mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0bf74da16 | ||
|
|
e8b655e492 | ||
|
|
4f383910d2 | ||
|
|
404c4ca59a | ||
|
|
bcf26bea20 | ||
|
|
fa198c41c1 | ||
|
|
f214d2452e | ||
|
|
f39b510577 | ||
|
|
2c45c14696 | ||
|
|
aa2305c10e | ||
|
|
568c687f98 | ||
|
|
c8b77fb0d7 |
575
features.md
575
features.md
@@ -1,391 +1,192 @@
|
||||
# Kemono Downloader - Detailed Feature Guide
|
||||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Main Interface & Workflow
|
||||
|
||||
## 1. Main Interface & Workflow
|
||||
These are the primary controls you'll interact with to initiate and manage downloads.
|
||||
|
||||
### 1. Main Inputs
|
||||
|
||||
- **🔗 Kemono Creator/Post URL Input Field:**
|
||||
- **Purpose:** This is where you paste the URL of the content you want to download.
|
||||
- **Usage:** Supports full URLs for:
|
||||
- Kemono.su (and mirrors like kemono.party) creator pages (e.g., `https://kemono.su/patreon/user/12345`).
|
||||
- Kemono.su (and mirrors) individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`).
|
||||
- Coomer.party (and mirrors like coomer.su) creator pages.
|
||||
- Coomer.party (and mirrors) individual posts.
|
||||
- **Note:**
|
||||
- When **⭐ Favorite Mode** is active, this field is disabled and shows a "Favorite Mode active" message.
|
||||
- This field can also be populated with a placeholder message (e.g., "{count} items in queue from popup") if posts are added to the download queue directly from the 'Creator Selection' dialog's 'Fetched Posts' view.
|
||||
|
||||
- **🎨 Creator Selection Button:**
|
||||
- **Icon:** 🎨 (Artist Palette)
|
||||
- **Location:** Next to the URL input field.
|
||||
- **Purpose:** Opens the "Creator Selection" dialog to easily add multiple creators to the URL field.
|
||||
- **Dialog Features:**
|
||||
- Loads creators from your `creators.json` file (expected in the app's directory).
|
||||
- **Search Bar:** Filter the list of creators by name.
|
||||
- **Creator List:** Displays creators with their service (e.g., Patreon, Fanbox) and ID.
|
||||
- **Selection:** Checkboxes to select one or more creators.
|
||||
- **"Add Selected to URL" Button:** Adds the names of selected creators to the URL input field, comma-separated.
|
||||
- **"Fetch Posts" Button:** After selecting creators, click this to retrieve their latest posts. This will display a new pane within the dialog showing the fetched posts.
|
||||
- **"Download Scope" Radio Buttons (`Characters` / `Creators`):** Determines the folder structure for items added via this popup.
|
||||
- `Characters`: Assumes creator names are character names for folder organization.
|
||||
- `Creators`: Uses the actual creator names for folder organization.
|
||||
- **Fetched Posts View (Right Pane - Appears after clicking 'Fetch Posts'):**
|
||||
- **Posts Area Title Label:** Indicates loading status or number of fetched posts.
|
||||
- **Posts Search Input:** Allows filtering the list of fetched posts by title.
|
||||
- **Posts List Widget:** Displays posts fetched from the selected creators, often grouped by creator. Each post is checkable.
|
||||
- **Select All / Deselect All Buttons (for Posts):** Convenience buttons for selecting/deselecting all displayed fetched posts.
|
||||
- **"Add Selected Posts to Queue" Button:** Adds all checked posts from this view directly to the application's main download queue. The main URL input field will then show a message like "{count} items in queue from popup".
|
||||
- **"Close" Button (for Posts View):** Hides the fetched posts view and returns to the creator selection list, allowing you to use the 'Add Selected to URL' button if preferred.
|
||||
|
||||
- **Page Range (Start to End) Input Fields:**
|
||||
- **Purpose:** For creator URLs, specify a range of pages to fetch and process.
|
||||
- **Usage:** Enter the starting page number in the first field and the ending page number in the second.
|
||||
- **Behavior:**
|
||||
- If left blank, all pages for the creator are typically processed (or up to a reasonable limit).
|
||||
- Disabled for single post URLs or when **📖 Manga/Comic Mode** is active (as manga mode fetches all posts for chronological sorting).
|
||||
|
||||
- **📁 Download Location Input Field & Browse Button:**
|
||||
- **Purpose:** Specify the main directory where all downloaded files and folders will be saved.
|
||||
- **Usage:**
|
||||
- Type or paste the path directly into the field.
|
||||
- Click the **"Browse..."** button to open a system dialog to select a folder.
|
||||
- **Requirement:** This field must be filled unless you are using the "🔗 Only Links" filter mode.
|
||||
|
||||
### 2. Action Buttons
|
||||
|
||||
- **⬇️ Start Download / 🔗 Extract Links Button:**
|
||||
- **Purpose:** The primary action button to begin the downloading or link extraction process based on current settings.
|
||||
- **Behavior:**
|
||||
- If "🔗 Only Links" filter is selected, the button text changes to **"🔗 Extract Links"** and it will only gather external links from posts.
|
||||
- Otherwise, it reads **"⬇️ Start Download"** and initiates the content download.
|
||||
|
||||
- **⏸️ Pause / ▶️ Resume Download Button:**
|
||||
- **Purpose:** Temporarily halt or continue the ongoing download/extraction process.
|
||||
- **Behavior:**
|
||||
- When active, the button shows **"⏸️ Pause Download"**. Clicking it pauses the operation.
|
||||
- When paused, the button shows **"▶️ Resume Download"**. Clicking it resumes from where it left off.
|
||||
- Some UI settings can be changed while paused (e.g., filter adjustments), which will apply upon resuming.
|
||||
|
||||
- **❌ Cancel & Reset UI Button:**
|
||||
- **Purpose:** Immediately stops the current download/extraction operation and performs a "soft" reset of the UI.
|
||||
- **Behavior:**
|
||||
- Halts all active threads and processes.
|
||||
- Clears progress information and logs.
|
||||
- Preserves the content of the "🔗 Kemono Creator/Post URL" and "📁 Download Location" input fields. Other settings are reset to their defaults.
|
||||
|
||||
- **🔄 Reset Button (located in the log area):**
|
||||
- **Purpose:** Performs a "hard" reset of the UI when no operation is active.
|
||||
- **Behavior:**
|
||||
- Clears all input fields (including URL and Download Location).
|
||||
- Resets all filter settings and options to their default values.
|
||||
- Clears the log area.
|
||||
|
||||
---
|
||||
|
||||
## Filtering & Content Selection
|
||||
|
||||
These options allow you to precisely control what content is downloaded or skipped.
|
||||
|
||||
- **🎯 Filter by Character(s) Input Field:**
|
||||
- **Purpose:** Download content related to specific characters.
|
||||
- **Usage:** Enter character names, comma-separated.
|
||||
- **Advanced Syntax:**
|
||||
- `Nami`: Simple character filter. Matches "Nami".
|
||||
- `(Vivi, Ulti, Uta)`: Grouped characters. Matches "Vivi" OR "Ulti" OR "Uta". If "Separate Folders" is on, creates a shared folder for the session (e.g., "Vivi Ulti Uta"). Adds "Vivi", "Ulti", "Uta" as *separate* entries to `Known.txt` if new.
|
||||
- `(Boa, Hancock)~`: Aliased characters. Matches "Boa" OR "Hancock" but treats them as the same entity. If "Separate Folders" is on, creates a shared folder (e.g., "Boa Hancock"). Adds "Boa Hancock" as a *single group entry* to `Known.txt` if new, with "Boa" and "Hancock" as its aliases.
|
||||
|
||||
- **Filter: [Type] Button (Scope for Character Filter):**
|
||||
- **Location:** Next to the "Filter by Character(s)" input.
|
||||
- **Purpose:** Defines how the character filter is applied. Cycles through options on click.
|
||||
- **Options:**
|
||||
- `Filter: Files`: Checks individual filenames against the character filter. Only matching files from a post are downloaded.
|
||||
- `Filter: Title` (Default): Checks post titles against the character filter. If the title matches, all files from that post are downloaded.
|
||||
- `Filter: Both`: Checks the post title first. If no match, then checks individual filenames within that post.
|
||||
- `Filter: Comments (Beta)`: Checks filenames first. If no file match, then checks post comments/description. (Note: This may use more API requests).
|
||||
|
||||
- **🚫 Skip with Words Input Field:**
|
||||
- **Purpose:** Exclude posts or files containing specified keywords.
|
||||
- **Usage:** Enter words or phrases, comma-separated (e.g., `WIP, sketch, preview`).
|
||||
|
||||
- **Scope: [Type] Button (Scope for Skip with Words):**
|
||||
- **Location:** Next to the "Skip with Words" input.
|
||||
- **Purpose:** Defines how the skip words are applied. Cycles through options on click.
|
||||
- **Options:**
|
||||
- `Scope: Files`: Skips individual files if their names contain any of the skip words.
|
||||
- `Scope: Posts` (Default): Skips entire posts if their titles contain any of the skip words.
|
||||
- `Scope: Both`: Checks the post title first. If no skip words match, then checks individual filenames.
|
||||
|
||||
- **✂️ Remove Words from name Input Field:**
|
||||
- **Purpose:** Clean up downloaded filenames by removing specified unwanted words or phrases.
|
||||
- **Usage:** Enter words or phrases, comma-separated (e.g., `patreon, [HD], kemono`).
|
||||
|
||||
- **Filter Files (Radio Buttons):**
|
||||
- **Purpose:** Select the types of files to download.
|
||||
- **Options:**
|
||||
- `All`: Download all file types attached to posts.
|
||||
- `Images/GIFs`: Download only common image formats (JPG, PNG, GIF, WebP, etc.).
|
||||
- `Videos`: Download only common video formats (MP4, MOV, MKV, WebM, etc.).
|
||||
- `📦 Only Archives`: Exclusively download `.zip` and `.rar` files. This mode disables the "Skip .zip/.rar" checkboxes and the "Show External Links in Log" feature.
|
||||
- `🎧 Only Audio`: Download only common audio formats (MP3, WAV, FLAC, OGG, etc.).
|
||||
- `🔗 Only Links`: Do not download any files. Instead, extract and display external links found in post descriptions in the log area. The main action button changes to "🔗 Extract Links".
|
||||
|
||||
- **Skip .zip / Skip .rar Checkboxes:**
|
||||
- **Purpose:** Individually choose to skip downloading `.zip` files or `.rar` files.
|
||||
- **Behavior:** Disabled if the "📦 Only Archives" filter is active.
|
||||
|
||||
---
|
||||
|
||||
## Download Customization
|
||||
|
||||
Options to further refine the download process and output.
|
||||
|
||||
- **Download Thumbnails Only Checkbox:**
|
||||
- **Purpose:** Download only the small preview images (thumbnails) provided by the API, instead of full-resolution files.
|
||||
- **Behavior:** If "**Scan Content for Images**" is also active, this option's behavior changes: *only* images found by the content scan (embedded `<img>` tags) are downloaded as thumbnails (API thumbnails are ignored).
|
||||
|
||||
- **Scan Content for Images Checkbox:**
|
||||
- **Purpose:** Actively scan the HTML content of posts for `<img>` tags and direct image links. This is crucial for downloading images embedded in post descriptions that are not listed as direct attachments in the API response.
|
||||
- **Behavior:** Resolves relative image paths to absolute URLs for downloading.
|
||||
|
||||
- **Compress to WebP Checkbox:**
|
||||
- **Purpose:** Convert downloaded images to WebP format to potentially save disk space.
|
||||
- **Requirement:** Requires the `Pillow` library to be installed.
|
||||
- **Behavior:** Attempts to convert images larger than a certain threshold (e.g., 1.5MB) to WebP if the WebP version is significantly smaller. Original files are not kept if conversion is successful.
|
||||
|
||||
- **🗄️ Custom Folder Name (Single Post Only) Input Field:**
|
||||
- **Purpose:** When downloading a single post URL, allows you to specify a custom name for the folder where its contents will be saved.
|
||||
- **Visibility:** Only appears if:
|
||||
1. A single post URL is entered in the main URL field.
|
||||
2. The "**Separate Folders by Name/Title**" option is enabled.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Manga/Comic Mode
|
||||
|
||||
Specialized mode for downloading creator feeds in a way suitable for sequential reading, like manga or comics. This mode is implicitly active when downloading from a creator URL and certain filename styles are chosen.
|
||||
|
||||
- **Activation:** Primarily by downloading a creator's feed (not a single post) and selecting a relevant "Filename Style".
|
||||
- **Core Behavior:** Processes and downloads posts from the creator's feed in chronological order (oldest to newest). The "Page Range" input is typically disabled as all posts are fetched for correct sorting.
|
||||
|
||||
- **Filename Style Toggle Button (located in the log area):**
|
||||
- **Purpose:** Controls how files are named when downloading in a manga/comic-like fashion. Cycles through options on click.
|
||||
- **Options:**
|
||||
- `Name: Post Title` (Default for non-manga): The first file in a post is named after the post title; subsequent files in the *same post* keep their original names.
|
||||
- `Name: Original File`: All downloaded files attempt to keep their original filenames as provided by the server. An optional "Filename Prefix" input field appears.
|
||||
- `Name: Title+G.Num`: (Global Numbering) All files across all downloaded posts for the creator get a prefix from their respective post's title, followed by a global sequential number (e.g., `Chapter 1_001.jpg`, `Chapter 1_002.jpg`, `Chapter 2_003.jpg`). This ensures strict order across posts. Disables post-level multithreading for sequential numbering.
|
||||
- `Name: Date Based`: Files are named sequentially (e.g., `001.jpg`, `002.jpg`) based on the post's publication date. An optional "Filename Prefix" input field appears. Disables post-level multithreading.
|
||||
|
||||
- **Optional Filename Prefix Input Field (Manga Mode):**
|
||||
- **Visibility:** Appears when "Filename Style" is set to `Name: Original File` or `Name: Date Based`.
|
||||
- **Purpose:** Allows you to add a custom prefix to all filenames generated using these styles (e.g., `MySeries_001.jpg`).
|
||||
|
||||
---
|
||||
|
||||
## Folder Organization
|
||||
|
||||
Controls for how downloaded content is structured into folders.
|
||||
|
||||
- **Separate Folders by Name/Title Checkbox:**
|
||||
- **Purpose:** Creates subfolders within the main "Download Location" based on matching criteria.
|
||||
- **Behavior:**
|
||||
- If "**Filter by Character(s)**" is used, folders are named after the matched character(s)/group(s).
|
||||
- If no character filter matches (or no filter is active), but the post title matches an entry in `Known.txt`, a folder named after the `Known.txt` entry is created.
|
||||
- If neither of the above, and this option is checked, folders might be created based on post titles directly (behavior can vary).
|
||||
|
||||
- **Subfolder per Post Checkbox:**
|
||||
- **Purpose:** Creates an additional layer of subfolders, where each individual post's content goes into its own subfolder.
|
||||
- **Behavior:** Only active if "**Separate Folders by Name/Title**" is also checked. The post subfolder will be created *inside* the character/title folder. Folder names are typically derived from sanitized post titles or IDs.
|
||||
|
||||
- **`Known.txt` Management UI (Bottom Left of UI):**
|
||||
- **Purpose:** Manages a local list (`Known.txt` file in the app directory) of series, characters, or general terms used for automatic folder organization and character filter suggestions.
|
||||
- **Elements:**
|
||||
- **List Display:** Shows the primary names from your `Known.txt` file.
|
||||
- **Add New Input Field:** Enter a new name or group to add to `Known.txt`.
|
||||
- Simple Name: e.g., `My Series`
|
||||
- Group (creates separate entries in `Known.txt`): e.g., `(Vivi, Ulti, Uta)`
|
||||
- Group with Aliases (single entry in `Known.txt` with `~`): e.g., `(Boa, Hancock)~`
|
||||
- **➕ Add Button:** Adds the entry from the "Add New" field to `Known.txt` and refreshes the list.
|
||||
- **⤵️ Add to Filter Button:** Opens a dialog displaying all entries from `Known.txt` (with a search bar). Select one or more entries to add them to the "**🎯 Filter by Character(s)**" input field. Grouped names from `Known.txt` are added with the `~` syntax if applicable.
|
||||
- **🗑️ Delete Selected Button:** Removes the currently selected name(s) from the list display and from the `Known.txt` file.
|
||||
- **Open Known.txt Button:** Opens your `Known.txt` file in the system's default text editor for manual editing.
|
||||
- **❓ Help Button:** Opens a guide or tooltip explaining the app feature
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Favorite Mode (Kemono.su Only)
|
||||
|
||||
Download directly from your favorited artists and posts on Kemono.su.
|
||||
|
||||
- **Enable Checkbox ("⭐ Favorite Mode"):**
|
||||
- **Location:** Usually near the "🔗 Only Links" filter option.
|
||||
- **Purpose:** Switches the downloader to operate on your Kemono.su favorites.
|
||||
- **UI Changes upon Enabling:**
|
||||
- The "🔗 Kemono Creator/Post URL" input field is disabled/replaced with a "Favorite Mode active" message.
|
||||
- The main action buttons change to "**🖼️ Favorite Artists**" and "**📄 Favorite Posts**".
|
||||
- The "**🍪 Use Cookie**" option is automatically enabled and locked, as cookies are required to access your favorites.
|
||||
|
||||
- **🖼️ Favorite Artists Button & Dialog:**
|
||||
- **Purpose:** Fetches and allows you to download content from artists you have favorited on Kemono.su.
|
||||
- **Dialog Features:**
|
||||
- Fetches the list of your favorited artists.
|
||||
- **Search Bar:** Filter artists by name.
|
||||
- **Artist List:** Displays favorited artists.
|
||||
- **Select All / Deselect All:** Convenience buttons for selection.
|
||||
- **"Download Selected" Button:** Queues all posts from the selected artists for download, respecting current filter settings.
|
||||
|
||||
- **📄 Favorite Posts Button & Dialog:**
|
||||
- **Purpose:** Fetches and allows you to download specific posts you have favorited on Kemono.su.
|
||||
- **Dialog Features:**
|
||||
- Fetches the list of your favorited posts, usually grouped by artist and sorted by date.
|
||||
- **Search Bar:** Filter posts by title, creator name, ID, or service.
|
||||
- **Post List:** Displays favorited posts. Known names from your `Known.txt` may be highlighted in post titles for easier identification.
|
||||
- **Select All / Deselect All:** Convenience buttons for selection.
|
||||
- **"Download Selected" Button:** Queues the selected individual posts for download, respecting current filter settings.
|
||||
|
||||
- **Favorite Download Scope Button (Location may vary, often near Favorite Posts button):**
|
||||
- **Purpose:** Determines the folder structure for downloads initiated via Favorite Mode.
|
||||
- **Options:**
|
||||
- `Scope: Selected Location`: All selected favorites (artists or posts) are downloaded directly into the main "📁 Download Location". Global filters apply.
|
||||
- `Scope: Artist Folders`: A subfolder is created for each artist within the main "📁 Download Location" (e.g., `DownloadLocation/ArtistName/`). Content from that artist (whether a full artist download or specific favorited posts from them) goes into their respective subfolder. Filters apply within each artist's context.
|
||||
|
||||
---
|
||||
|
||||
## Advanced & Performance
|
||||
|
||||
- **🍪 Cookie Management:**
|
||||
- **Use Cookie Checkbox:** Enables the use of browser cookies for accessing content that might be restricted or require login (e.g., certain posts, Favorite Mode).
|
||||
- **Cookie Text Field:**
|
||||
- **Purpose:** Directly paste your cookie string.
|
||||
- **Format:** Standard HTTP cookie string format (e.g., `name1=value1; name2=value2`).
|
||||
- **Browse... Button (for Cookies):**
|
||||
- **Purpose:** Select a `cookies.txt` file from your system.
|
||||
- **Format:** Must be in Netscape cookie file format.
|
||||
- **Behavior:**
|
||||
- The text field takes precedence if filled.
|
||||
- If "Use Cookie" is checked and both the text field and browsed file path are empty, the application will attempt to automatically load a `cookies.txt` file from its root directory.
|
||||
|
||||
- **Use Multithreading Checkbox & Threads Input Field:**
|
||||
- **Purpose:** Enable and configure the number of simultaneous operations to potentially speed up downloads.
|
||||
- **Behavior:**
|
||||
- **Creator Feeds:** The "Threads" input controls how many posts are processed concurrently.
|
||||
- **Single Post URLs:** The "Threads" input controls how many files from that single post are downloaded concurrently.
|
||||
- **Note:** Setting too high a number might lead to API rate-limiting or instability.
|
||||
|
||||
- **Multi-part Download Toggle Button (located in the log area):**
|
||||
- **Purpose:** Enables/disables multi-segment downloading for individual large files.
|
||||
- **Options:**
|
||||
- `Multi-part: ON`: Large files are split into multiple parts that are downloaded simultaneously and then reassembled. Can significantly speed up downloads for single large files but may increase UI choppiness or log spam with many small files.
|
||||
- `Multi-part: OFF` (Default): Files are downloaded as a single stream.
|
||||
- **Behavior:** Disabled if "🔗 Only Links" or "📦 Only Archives" mode is active.
|
||||
|
||||
---
|
||||
|
||||
## Logging & Monitoring
|
||||
|
||||
- **📜 Progress Log / Extracted Links Log Area:**
|
||||
- **Purpose:** The main text area displaying detailed messages about the ongoing process.
|
||||
- **Content:** Shows download progress for each file, errors encountered, skipped items, summary information, or extracted links (if in "🔗 Only Links" mode).
|
||||
|
||||
- **👁️ / 🙈 Log View Toggle Button:**
|
||||
- **Purpose:** Switches the content displayed in the main log area.
|
||||
- **Views:**
|
||||
- `👁️ Progress Log` (Default): Shows all download activity, errors, and general progress messages.
|
||||
- `🙈 Missed Character Log`: Shows a list of key terms intelligently extracted from post titles or content that were skipped due to the "**🎯 Filter by Character(s)**" not matching. Useful for identifying characters you might want to add to your filter or `Known.txt`.
|
||||
|
||||
- **Show External Links in Log Checkbox & Panel:**
|
||||
- **Purpose:** If checked, a secondary, smaller log panel appears (usually below the main log) that specifically displays any external links (e.g., to Mega, Google Drive) found in post descriptions.
|
||||
- **Behavior:** Disabled if "🔗 Only Links" or "📦 Only Archives" mode is active (as "Only Links" uses the main log, and archives typically don't have such external links processed).
|
||||
|
||||
- **Export Links Button:**
|
||||
- **Visibility:** Appears when the "**🔗 Only Links**" filter mode is active.
|
||||
- **Purpose:** Saves all the links extracted and displayed in the main log area to a `.txt` file.
|
||||
|
||||
- **Progress Labels/Bars:**
|
||||
- **Purpose:** Provide a visual and textual representation of the download progress.
|
||||
- **Typically Includes:**
|
||||
- Overall post progress (e.g., "Post 5 of 20").
|
||||
- Individual file download status (e.g., "Downloading file.zip... 50% at 1.2 MB/s").
|
||||
- Summary statistics at the end of a session (total downloaded, skipped, failed).
|
||||
|
||||
---
|
||||
## Error Handling & Retries
|
||||
|
||||
- **🆘 Error Button (Main UI):**
|
||||
- **Location:** Typically near the main action buttons (e.g., Start, Pause, Cancel).
|
||||
- **Purpose:** Becomes active if files failed to download during the last session (and were not successfully retried). Clicking it opens the "Files Skipped Due to Errors" dialog.
|
||||
- **"Files Skipped Due to Errors" Dialog:**
|
||||
- **File List:** Displays a list of files that encountered download errors. Each entry shows the filename, the post it was from (title and ID).
|
||||
- **Checkboxes:** Allows selection of individual files from the list.
|
||||
- **"Select All" Button:** Checks all files in the list.
|
||||
- **"Retry Selected" Button:** Attempts to re-download all checked files.
|
||||
- **"Export URLs to .txt" Button:**
|
||||
- Opens an "Export Options" dialog.
|
||||
- **"Link per line (URL only)":** Exports only the direct download URL for each failed file, one URL per line.
|
||||
- **"Export with details (URL [Post, File info])":** Exports the URL followed by details like Post Title, Post ID, and Original Filename in brackets.
|
||||
- Prompts the user to save the generated `.txt` file.
|
||||
- **"OK" Button:** Closes the dialog.
|
||||
- **Note:** Files successfully retried or skipped due to hash match during a retry attempt are removed from this error list.
|
||||
---
|
||||
|
||||
## ⚙️ Application Settings
|
||||
|
||||
These settings allow you to customize the application's appearance and language.
|
||||
|
||||
- **⚙️ Settings Button (Icon may vary, e.g., a gear ⚙️):**
|
||||
- **Location:** Typically located in a persistent area of the UI, possibly near other global controls or in a menu.
|
||||
- **Purpose:** Opens the "Settings" dialog.
|
||||
- **Tooltip Example:** "Open application settings (Theme, Language, etc.)"
|
||||
|
||||
- **"Settings" Dialog:**
|
||||
- **Title:** "Settings"
|
||||
- **Purpose:** Provides options to configure application-wide preferences.
|
||||
- **Sections:**
|
||||
- **Appearance Group (`Appearance`):**
|
||||
- **Theme Toggle Buttons/Options:**
|
||||
- `Switch to Light Mode`
|
||||
- `Switch to Dark Mode`
|
||||
- **Purpose:** Allows users to switch between a light and dark visual theme for the application.
|
||||
- **Tooltips:** Provide guidance on switching themes.
|
||||
- **Language Settings Group (`Language Settings`):**
|
||||
- **Language Selection Dropdown/List:**
|
||||
- **Label:** "Language:"
|
||||
- **Options:** Includes, but not limited to:
|
||||
- English (`English`)
|
||||
- 日本語 (`日本語 (Japanese)`)
|
||||
- Français (French)
|
||||
- Español (Spanish)
|
||||
- Deutsch (German)
|
||||
- Русский (Russian)
|
||||
- 한국어 (Korean)
|
||||
- 简体中文 (Chinese Simplified)
|
||||
- **Purpose:** Allows users to change the display language of the application interface.
|
||||
- **Restart Prompt:** After changing the language, a dialog may appear:
|
||||
- **Title:** "Language Changed"
|
||||
- **Message:** "The language has been changed. A restart is required for all changes to take full effect."
|
||||
- **Informative Text:** "Would you like to restart the application now?"
|
||||
- **Buttons:** "Restart Now", "OK" (or similar to defer restart).
|
||||
- **"OK" Button:** Saves the changes made in the Settings dialog and closes it.
|
||||
---
|
||||
|
||||
## Other UI Elements
|
||||
|
||||
- **Retry Failed Downloads Prompt:**
|
||||
- **Trigger:** Appears at the end of a download session if there were files that failed to download due to recoverable errors (e.g., network interruption, IncompleteRead).
|
||||
- **Action:** Prompts the user if they want to attempt downloading the failed files again.
|
||||
|
||||
- **New Name Confirmation Dialog (for Character Filter & `Known.txt`):**
|
||||
- **Trigger:** When new, unrecognized names or groups are used in the "**🎯 Filter by Character(s)**" field that are not present in `Known.txt`.
|
||||
- **Action:** Prompts the user to confirm if they want to add these new names/groups to `Known.txt` with the appropriate formatting (simple, grouped, or aliased).
|
||||
|
||||
- **Onboarding Tour / Help Guide Button (❓):**
|
||||
- **Purpose:** Opens a built-in help guide or an onboarding tour that explains the basic functionalities and UI elements of the application. Often linked to this detailed feature guide.
|
||||
|
||||
---
|
||||
|
||||
This guide should cover all interactive elements of the Kemono Downloader. If you have further questions or discover elements not covered, please refer to the main `readme.md` or consider opening an issue on the project's repository.
|
||||
### 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).
|
||||
29
main.py
29
main.py
@@ -9,24 +9,26 @@ from PyQt5.QtWidgets import QApplication, QDialog
|
||||
from PyQt5.QtCore import QCoreApplication
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# These imports reflect the new, organized project structure.
|
||||
from src.ui.main_window import DownloaderApp
|
||||
from src.ui.dialogs.TourDialog import TourDialog
|
||||
from src.config.constants import CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN
|
||||
|
||||
# --- Define APP_BASE_DIR globally and make available early ---
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
APP_BASE_DIR = sys._MEIPASS
|
||||
else:
|
||||
APP_BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
# Optional: Set a global variable or pass it into modules if needed
|
||||
# Or re-export it via constants.py for cleaner imports
|
||||
|
||||
def handle_uncaught_exception(exc_type, exc_value, exc_traceback):
|
||||
"""
|
||||
Handles uncaught exceptions by logging them to a file for easier debugging,
|
||||
especially for bundled applications.
|
||||
"""
|
||||
# Determine the base directory for logging
|
||||
if getattr(sys, 'frozen', False):
|
||||
base_dir_for_log = os.path.dirname(sys.executable)
|
||||
else:
|
||||
base_dir_for_log = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
log_dir = os.path.join(base_dir_for_log, "logs")
|
||||
# Use APP_BASE_DIR to determine logging location
|
||||
log_dir = os.path.join(APP_BASE_DIR, "logs")
|
||||
log_file_path = os.path.join(log_dir, "uncaught_exceptions.log")
|
||||
|
||||
try:
|
||||
@@ -57,41 +59,35 @@ def main():
|
||||
|
||||
qt_app = QApplication(sys.argv)
|
||||
|
||||
# Create the main application window from its new module
|
||||
# Create the main application window
|
||||
downloader_app_instance = DownloaderApp()
|
||||
|
||||
# --- Window Sizing and Positioning ---
|
||||
# Logic moved from the old main.py to set an appropriate initial size
|
||||
primary_screen = QApplication.primaryScreen()
|
||||
if not primary_screen:
|
||||
# Fallback for systems with no primary screen detected
|
||||
downloader_app_instance.resize(1024, 768)
|
||||
else:
|
||||
available_geo = primary_screen.availableGeometry()
|
||||
screen_width = available_geo.width()
|
||||
screen_height = available_geo.height()
|
||||
|
||||
# Define minimums and desired ratios
|
||||
min_app_width, min_app_height = 960, 680
|
||||
desired_width_ratio, desired_height_ratio = 0.80, 0.85
|
||||
|
||||
app_width = max(min_app_width, int(screen_width * desired_width_ratio))
|
||||
app_height = max(min_app_height, int(screen_height * desired_height_ratio))
|
||||
|
||||
# Ensure the window is not larger than the screen
|
||||
app_width = min(app_width, screen_width)
|
||||
app_height = min(app_height, screen_height)
|
||||
|
||||
downloader_app_instance.resize(app_width, app_height)
|
||||
|
||||
# Show the main window and center it
|
||||
# Show and center the main window
|
||||
downloader_app_instance.show()
|
||||
if hasattr(downloader_app_instance, '_center_on_screen'):
|
||||
downloader_app_instance._center_on_screen()
|
||||
|
||||
# --- First-Run Welcome Tour ---
|
||||
# Check if the tour should be shown and run it.
|
||||
# This static method call keeps the logic clean and contained.
|
||||
if TourDialog.should_show_tour():
|
||||
tour_dialog = TourDialog(parent_app=downloader_app_instance)
|
||||
tour_dialog.exec_()
|
||||
@@ -102,7 +98,6 @@ def main():
|
||||
sys.exit(exit_code)
|
||||
|
||||
except SystemExit:
|
||||
# Allow sys.exit() to work as intended
|
||||
pass
|
||||
except Exception as e:
|
||||
print("--- CRITICAL APPLICATION STARTUP ERROR ---")
|
||||
|
||||
3
note.md
3
note.md
@@ -13,10 +13,9 @@ This project used to be one giant messy App Script. It worked, but it was hard t
|
||||
```
|
||||
KemonoDownloader/
|
||||
├── main.py # Where the app starts
|
||||
├── requirements.txt # List of Python libraries used
|
||||
├── assets/ # Icons and other static files
|
||||
│ └── Kemono.ico
|
||||
├── data/ # Stuff that gets saved (user config, cookies, etc.)
|
||||
├── data/
|
||||
│ └── creators.json
|
||||
├── logs/ # Error logs and other output
|
||||
│ └── uncaught_exceptions.log
|
||||
|
||||
188
readme.md
188
readme.md
@@ -1,46 +1,45 @@
|
||||
<h1 align="center">Kemono Downloader v5.5.0</h1>
|
||||
<h1 align="center">Kemono Downloader v6.0.0</h1>
|
||||
|
||||
<table align="center">
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read.png" alt="Default Mode" width="400"/><br>
|
||||
<img src="Read/Read.png" alt="Default Mode" width="400"><br>
|
||||
<strong>Default</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read1.png" alt="Favorite Mode" width="400"/><br>
|
||||
<strong>Favorite mode</strong>
|
||||
<img src="Read/Read1.png" alt="Favorite Mode" width="400"><br>
|
||||
<strong>Favorite Mode</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read2.png" alt="Single Post" width="400"/><br>
|
||||
<img src="Read/Read2.png" alt="Single Post" width="400"><br>
|
||||
<strong>Single Post</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"/><br>
|
||||
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"><br>
|
||||
<strong>Manga/Comic Mode</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** (and its mirrors like kemono.party) and **[Coomer.party](https://coomer.party)** (and its mirrors like coomer.su).
|
||||
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation, all within a modern and user-friendly graphical interface.
|
||||
|
||||
*This v5.0.0 release marks a significant feature milestone. Future updates are expected to be less frequent, focusing on maintenance and minor refinements.*
|
||||
*Update v5.2.0 introduces multi-language support, theme selection, and further UI refinements.*
|
||||
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation — all within a modern and user-friendly graphical interface.
|
||||
|
||||
<p align="center">
|
||||
<a href="features.md">
|
||||
<img alt="Features" src="https://img.shields.io/badge/📚%20Full%20Feature%20List-FFD700?style=for-the-badge&logoColor=black&color=FFD700">
|
||||
</a>
|
||||
<a href="LICENSE">
|
||||
<img alt="License" src="https://img.shields.io/badge/📝%20License-90EE90?style=for-the-badge&logoColor=black&color=90EE90">
|
||||
</a>
|
||||
<a href="note.md">
|
||||
<img alt="Note" src="https://img.shields.io/badge/⚠️%20Important%20Note-FFCCCB?style=for-the-badge&logoColor=black&color=FFCCCB">
|
||||
</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
[](features.md)
|
||||
[](LICENSE)
|
||||
[](note.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -48,77 +47,110 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
|
||||
|
||||
Kemono Downloader offers a range of features to streamline your content downloading experience:
|
||||
|
||||
- **User-Friendly Interface:** A modern PyQt5 GUI for easy navigation and operation.
|
||||
- **Flexible Downloading:**
|
||||
- Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors).
|
||||
- Supports creator pages (with page range selection) and individual post URLs.
|
||||
- Standard download controls: Start, Pause, Resume, and Cancel.
|
||||
- **Powerful Filtering:**
|
||||
- **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
|
||||
- **Keyword Skipping:** Skip posts or files based on specified keywords.
|
||||
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
|
||||
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
|
||||
- **Customizable Downloads:**
|
||||
- **Thumbnails Only:** Option to download only small preview images.
|
||||
- **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
|
||||
- **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
|
||||
- **Organized Output:**
|
||||
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
|
||||
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
|
||||
- **Manga/Comic Mode:**
|
||||
- Downloads posts from a creator's feed in chronological order (oldest to newest).
|
||||
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
|
||||
- **⭐ Favorite Mode:**
|
||||
- Directly download from your favorited artists and posts on Kemono.su.
|
||||
- Requires a valid cookie and adapts the UI for easy selection from your favorites.
|
||||
- Supports downloading into a single location or artist-specific subfolders.
|
||||
- **Performance & Advanced Options:**
|
||||
- **Cookie Support:** Use cookies (paste string or load from `cookies.txt`) to access restricted content.
|
||||
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
|
||||
- **Logging:**
|
||||
- A detailed progress log displays download activity, errors, and summaries.
|
||||
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
|
||||
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
|
||||
- **User-Friendly Interface:** A modern PyQt5 GUI for easy navigation and operation.
|
||||
|
||||
- **Flexible Downloading:**
|
||||
- Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors).
|
||||
- Supports creator pages (with page range selection) and individual post URLs.
|
||||
- Standard download controls: Start, Pause, Resume, and Cancel.
|
||||
|
||||
- **Powerful Filtering:**
|
||||
- **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
|
||||
- **Keyword Skipping:** Skip posts or files based on specified keywords.
|
||||
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
|
||||
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
|
||||
|
||||
- **Customizable Downloads:**
|
||||
- **Thumbnails Only:** Option to download only small preview images.
|
||||
- **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
|
||||
- **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
|
||||
|
||||
- **Organized Output:**
|
||||
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
|
||||
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
|
||||
|
||||
- **Manga/Comic Mode:**
|
||||
- Downloads posts from a creator's feed in chronological order (oldest to newest).
|
||||
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
|
||||
|
||||
- **⭐ Favorite Mode:**
|
||||
- Directly download from your favorited artists and posts on Kemono.su.
|
||||
- Requires a valid cookie and adapts the UI for easy selection from your favorites.
|
||||
- Supports downloading into a single location or artist-specific subfolders.
|
||||
|
||||
- **Performance & Advanced Options:**
|
||||
- **Cookie Support:** Use cookies (paste string or load from `cookies.txt`) to access restricted content.
|
||||
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
|
||||
|
||||
- **Logging:**
|
||||
- A detailed progress log displays download activity, errors, and summaries.
|
||||
|
||||
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
|
||||
|
||||
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
|
||||
|
||||
---
|
||||
|
||||
## ✨ What's New in v5.3.0
|
||||
- **Multi-Creator Post Fetching & Queuing:**
|
||||
- The **Creator Selection popup** (🎨 icon) has been significantly enhanced.
|
||||
- After selecting multiple creators, you can now click a new "**Fetch Posts**" button.
|
||||
- This will retrieve and display posts from all selected creators in a new view within the popup.
|
||||
- You can then browse these fetched posts (with search functionality) and select individual posts.
|
||||
- A new "**Add Selected Posts to Queue**" button allows you to add your chosen posts directly to the main download queue, streamlining the process of gathering content from multiple artists.
|
||||
- The traditional "**Add Selected to URL**" button is still available if you prefer to populate the main URL field with creator names.
|
||||
- **Improved Favorite Download Queue Handling:**
|
||||
- When items are added to the download queue from the Creator Selection popup, the main URL input field will now display a placeholder message (e.g., "{count} items in queue from popup").
|
||||
- The queue is now more robustly managed, especially when interacting with the main URL input field after items have been queued from the popup.
|
||||
## ✨ What's New in v6.0.0
|
||||
|
||||
This release focuses on providing more granular control over file organization and improving at-a-glance status monitoring.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Live Error Count on Button**
|
||||
The **"Error" button** now dynamically displays the number of failed files during a download. Instead of opening the dialog, you can quickly see a live count like `(3) Error`, helping you track issues at a glance.
|
||||
|
||||
- **Date Prefix for Post Subfolders**
|
||||
A new checkbox labeled **"Date Prefix"** is now available in the advanced settings.
|
||||
When enabled alongside **"Subfolder per Post"**, it prepends the post's upload date to the folder name (e.g., `2025-07-11 Post Title`).
|
||||
This makes your downloads sortable and easier to browse chronologically.
|
||||
|
||||
- **Keep Duplicates Within a Post**
|
||||
A **"Keep Duplicates"** option has been added to preserve all files from a post — even if some have the same name.
|
||||
Instead of skipping or overwriting, the downloader will save duplicates with numbered suffixes (e.g., `image.jpg`, `image_1.jpg`, etc.), which is especially useful when the same file name points to different media.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- The downloader now correctly renames large `.part` files when completed, avoiding leftover temp files.
|
||||
- The list of failed files shown in the Error Dialog is now saved and restored with your session — so no errors get lost if you close the app.
|
||||
- Your selected download location is remembered, even after pressing the **Reset** button.
|
||||
- The **Cancel** button is now enabled when restoring a pending session, so you can abort stuck jobs more easily.
|
||||
- Internal cleanup logs (like "Deleting post cache") are now excluded from the final download summary for clarity.
|
||||
|
||||
---
|
||||
|
||||
## ✨ What's New in v5.1.0
|
||||
- **Enhanced Error File Management**: The "Error" button now opens a dialog listing files that failed to download. This dialog includes:
|
||||
- An option to **retry selected** failed downloads.
|
||||
- A new **"Export URLs to .txt"** button, allowing users to save links of failed downloads either as "URL only" or "URL with details" (including post title, ID, and original filename).
|
||||
- Fixed a bug where files skipped during retry (due to existing hash match) were not correctly removed from the error list.
|
||||
- **Improved UI Stability**: Addressed issues with UI state management to more accurately reflect ongoing download activities (including retries and external link downloads). This prevents the "Cancel" button from becoming inactive prematurely while operations are still running.
|
||||
## 📅 Next Update Plans
|
||||
|
||||
## ✨ What's New in v5.2.0
|
||||
- **Multi-language Support:** The interface now supports multiple languages: English, Japanese, French, Spanish, German, Russian, Korean, and Chinese (Simplified). Select your preferred language in the new Settings dialog.
|
||||
- **Theme Selection:** Choose between Light and Dark application themes via the Settings dialog for a personalized viewing experience.
|
||||
- **Centralized Settings:** A new Settings dialog (accessible via a settings button, often with a gear icon) provides a dedicated space for language and appearance customizations.
|
||||
- **Internal Localization:** Introduced `languages.py` for managing UI translations, streamlining the addition of new languages by contributors.
|
||||
### 🔖 Post Tag Filtering (Planned for v6.1.0)
|
||||
|
||||
A powerful new **"Filter by Post Tags"** feature is planned:
|
||||
|
||||
- Filter and download content based on specific post tags.
|
||||
- Combine tag filtering with current filters (character, file type, etc.).
|
||||
- Use tag presets to automate frequent downloads.
|
||||
|
||||
This will provide **much greater control** over what gets downloaded, especially for creators who use tags consistently.
|
||||
|
||||
### 📁 Creator Download History (.json Save)
|
||||
|
||||
To streamline incremental downloads, a new system will allow the app to:
|
||||
|
||||
- Save a `.json` file with metadata about already-downloaded posts.
|
||||
- Compare that file on future runs, so only **new** posts are downloaded.
|
||||
- Avoids duplication and makes regular syncs fast and efficient.
|
||||
|
||||
Ideal for users managing large collections or syncing favorites regularly.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## 💻 Installation
|
||||
|
||||
### Requirements
|
||||
- Python 3.6 or higher
|
||||
- pip (Python package installer)
|
||||
|
||||
- Python 3.6 or higher
|
||||
- pip (Python package installer)
|
||||
|
||||
### Install Dependencies
|
||||
Open your terminal or command prompt and run:
|
||||
|
||||
```bash
|
||||
pip install PyQt5 requests Pillow mega.py
|
||||
|
||||
@@ -9,6 +9,7 @@ STYLE_ORIGINAL_NAME = "original_name"
|
||||
STYLE_DATE_BASED = "date_based"
|
||||
STYLE_DATE_POST_TITLE = "date_post_title"
|
||||
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
|
||||
STYLE_POST_ID = "post_id" # Add this line
|
||||
MANGA_DATE_PREFIX_DEFAULT = ""
|
||||
|
||||
# --- Download Scopes ---
|
||||
@@ -94,6 +95,7 @@ FOLDER_NAME_STOP_WORDS = {
|
||||
"me", "my", "net", "not", "of", "on", "or", "org", "our",
|
||||
"s", "she", "so", "the", "their", "they", "this",
|
||||
"to", "ve", "was", "we", "were", "with", "www", "you", "your",
|
||||
# add more according to need
|
||||
}
|
||||
|
||||
# Additional words to ignore specifically for creator-level downloads
|
||||
@@ -107,4 +109,5 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
|
||||
"oct", "october", "nov", "november", "dec", "december",
|
||||
"mon", "monday", "tue", "tuesday", "wed", "wednesday", "thu", "thursday",
|
||||
"fri", "friday", "sat", "saturday", "sun", "sunday"
|
||||
# add more according to need
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ class PostProcessorWorker:
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
manga_global_file_counter_ref =None ,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
):
|
||||
@@ -128,6 +130,8 @@ class PostProcessorWorker:
|
||||
self .override_output_dir =override_output_dir
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
|
||||
self.keep_in_post_duplicates = keep_in_post_duplicates
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
if self .compress_images and Image is None :
|
||||
@@ -167,6 +171,7 @@ class PostProcessorWorker:
|
||||
if self .dynamic_filter_holder :
|
||||
return self .dynamic_filter_holder .get_filters ()
|
||||
return self .filter_character_list_objects_initial
|
||||
|
||||
def _download_single_file (self ,file_info ,target_folder_path ,headers ,original_post_id_for_log ,skip_event ,
|
||||
post_title ="",file_index_in_post =0 ,num_files_in_this_post =1 ,
|
||||
manga_date_file_counter_ref =None ,
|
||||
@@ -273,6 +278,15 @@ class PostProcessorWorker:
|
||||
self .logger (f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename }'. Using original. Ref: {manga_global_file_counter_ref }")
|
||||
filename_to_save_in_main_path =cleaned_original_api_filename
|
||||
self .logger (f"⚠️ Manga mode (Title+GlobalNum Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path }' for post {original_post_id_for_log }.")
|
||||
elif self.manga_filename_style == STYLE_POST_ID:
|
||||
if original_post_id_for_log and original_post_id_for_log != 'unknown_id':
|
||||
base_name = str(original_post_id_for_log)
|
||||
# Always append the file index for consistency (e.g., xxxxxx_0, xxxxxx_1)
|
||||
filename_to_save_in_main_path = f"{base_name}_{file_index_in_post}{original_ext}"
|
||||
else:
|
||||
# Fallback if post_id is somehow not available
|
||||
self.logger(f"⚠️ Manga mode (Post ID Style): Post ID missing. Using cleaned original filename '{cleaned_original_api_filename}'.")
|
||||
filename_to_save_in_main_path = cleaned_original_api_filename
|
||||
elif self .manga_filename_style ==STYLE_DATE_POST_TITLE :
|
||||
published_date_str =self .post .get ('published')
|
||||
added_date_str =self .post .get ('added')
|
||||
@@ -543,58 +557,175 @@ class PostProcessorWorker:
|
||||
final_total_for_progress =total_size_bytes if download_successful_flag and total_size_bytes >0 else downloaded_size_bytes
|
||||
self ._emit_signal ('file_progress',api_original_filename ,(downloaded_size_bytes ,final_total_for_progress ))
|
||||
|
||||
if self .check_cancel ()or (skip_event and skip_event .is_set ())or (self .pause_event and self .pause_event .is_set ()and not download_successful_flag ):
|
||||
self .logger (f" ⚠️ Download process interrupted for {api_original_filename }.")
|
||||
if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ):
|
||||
try :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
|
||||
# Rescue download if an IncompleteRead error occurred but the file is complete
|
||||
if (not download_successful_flag and
|
||||
isinstance(last_exception_for_retry_later, http.client.IncompleteRead) and
|
||||
total_size_bytes > 0 and downloaded_part_file_path and os.path.exists(downloaded_part_file_path)):
|
||||
try:
|
||||
actual_size = os.path.getsize(downloaded_part_file_path)
|
||||
if actual_size == total_size_bytes:
|
||||
self.logger(f" ✅ Rescued '{api_original_filename}': IncompleteRead error occurred, but file size matches. Proceeding with save.")
|
||||
download_successful_flag = True
|
||||
# The hash must be recalculated now that we've verified the file
|
||||
md5_hasher = hashlib.md5()
|
||||
with open(downloaded_part_file_path, 'rb') as f_verify:
|
||||
for chunk in iter(lambda: f_verify.read(8192), b""): # Read in chunks
|
||||
md5_hasher.update(chunk)
|
||||
calculated_file_hash = md5_hasher.hexdigest()
|
||||
except Exception as rescue_exc:
|
||||
self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}")
|
||||
|
||||
if not download_successful_flag :
|
||||
self .logger (f"❌ Download failed for '{api_original_filename }' after {max_retries +1 } attempts.")
|
||||
if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag):
|
||||
self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.")
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try: 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
|
||||
|
||||
# This logic block now correctly handles all outcomes: success, failure, or rescued.
|
||||
if download_successful_flag:
|
||||
# --- This is the success path ---
|
||||
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
|
||||
|
||||
is_actually_incomplete_read =False
|
||||
if isinstance (last_exception_for_retry_later ,http .client .IncompleteRead ):
|
||||
is_actually_incomplete_read =True
|
||||
elif hasattr (last_exception_for_retry_later ,'__cause__')and isinstance (last_exception_for_retry_later .__cause__ ,http .client .IncompleteRead ):
|
||||
is_actually_incomplete_read =True
|
||||
with self.downloaded_file_hashes_lock:
|
||||
if calculated_file_hash in self.downloaded_file_hashes:
|
||||
self.logger(f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename}' (Hash: {calculated_file_hash[:8]}...).")
|
||||
with self.downloaded_files_lock: self.downloaded_files.add(filename_to_save_in_main_path)
|
||||
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 file for hash duplicate: {e_rem}")
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
elif last_exception_for_retry_later is not None :
|
||||
str_exc =str (last_exception_for_retry_later ).lower ()
|
||||
effective_save_folder = target_folder_path
|
||||
filename_after_styling_and_word_removal = filename_to_save_in_main_path
|
||||
|
||||
if "incompleteread"in str_exc or (isinstance (last_exception_for_retry_later ,tuple )and any ("incompleteread"in str (arg ).lower ()for arg in last_exception_for_retry_later if isinstance (arg ,(str ,Exception )))):
|
||||
is_actually_incomplete_read =True
|
||||
try:
|
||||
os.makedirs(effective_save_folder, exist_ok=True)
|
||||
except OSError as e:
|
||||
self.logger(f" ❌ Critical error creating directory '{effective_save_folder}': {e}. Skipping file '{api_original_filename}'.")
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try: os.remove(downloaded_part_file_path)
|
||||
except OSError: pass
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
if is_actually_incomplete_read :
|
||||
self .logger (f" Marking '{api_original_filename }' for potential retry later due to IncompleteRead.")
|
||||
retry_later_details ={
|
||||
'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 ,
|
||||
'manga_mode_active_for_file':self .manga_mode_active ,
|
||||
'manga_filename_style_for_file':self .manga_filename_style ,
|
||||
data_to_write_io = None
|
||||
filename_after_compression = filename_after_styling_and_word_removal
|
||||
is_img_for_compress_check = is_image(api_original_filename)
|
||||
|
||||
if is_img_for_compress_check and self.compress_images and Image and downloaded_size_bytes > (1.5 * 1024 * 1024):
|
||||
# ... (This block for image compression remains the same)
|
||||
self .logger (f" Compressing '{api_original_filename }' ({downloaded_size_bytes /(1024 *1024 ):.2f} MB)...")
|
||||
if self ._check_pause (f"Image compression for '{api_original_filename }'"):return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
img_content_for_pillow =None
|
||||
try :
|
||||
with open (downloaded_part_file_path ,'rb')as f_img_in :
|
||||
img_content_for_pillow =BytesIO (f_img_in .read ())
|
||||
with Image .open (img_content_for_pillow )as img_obj :
|
||||
if img_obj .mode =='P':img_obj =img_obj .convert ('RGBA')
|
||||
elif img_obj .mode not in ['RGB','RGBA','L']:img_obj =img_obj .convert ('RGB')
|
||||
compressed_output_io =BytesIO ()
|
||||
img_obj .save (compressed_output_io ,format ='WebP',quality =80 ,method =4 )
|
||||
compressed_size =compressed_output_io .getbuffer ().nbytes
|
||||
if compressed_size <downloaded_size_bytes *0.9 :
|
||||
self .logger (f" Compression success: {compressed_size /(1024 *1024 ):.2f} MB.")
|
||||
data_to_write_io =compressed_output_io
|
||||
data_to_write_io .seek (0 )
|
||||
base_name_orig ,_ =os .path .splitext (filename_after_compression )
|
||||
filename_after_compression =base_name_orig +'.webp'
|
||||
self .logger (f" Updated filename (compressed): {filename_after_compression }")
|
||||
else :
|
||||
self .logger (f" Compression skipped: WebP not significantly smaller.")
|
||||
if compressed_output_io :compressed_output_io .close ()
|
||||
except Exception as comp_e :
|
||||
self .logger (f"❌ Compression failed for '{api_original_filename }': {comp_e }. Saving original.")
|
||||
finally :
|
||||
if img_content_for_pillow :img_content_for_pillow .close ()
|
||||
|
||||
final_filename_on_disk = filename_after_compression
|
||||
temp_base, temp_ext = os.path.splitext(final_filename_on_disk)
|
||||
suffix_counter = 1
|
||||
while os.path.exists(os.path.join(effective_save_folder, final_filename_on_disk)):
|
||||
final_filename_on_disk = f"{temp_base}_{suffix_counter}{temp_ext}"
|
||||
suffix_counter += 1
|
||||
if final_filename_on_disk != filename_after_compression:
|
||||
self.logger(f" Applied numeric suffix in '{os.path.basename(effective_save_folder)}': '{final_filename_on_disk}' (was '{filename_after_compression}')")
|
||||
|
||||
if self._check_pause(f"File saving for '{final_filename_on_disk}'"):
|
||||
return 0, 1, final_filename_on_disk, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
final_save_path = os.path.join(effective_save_folder, final_filename_on_disk)
|
||||
try:
|
||||
if data_to_write_io:
|
||||
with open(final_save_path, 'wb') as f_out:
|
||||
time.sleep(0.05)
|
||||
f_out.write(data_to_write_io.getvalue())
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try:
|
||||
os.remove(downloaded_part_file_path)
|
||||
except OSError as e_rem:
|
||||
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
||||
else:
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
time.sleep(0.1)
|
||||
os.rename(downloaded_part_file_path, final_save_path)
|
||||
else:
|
||||
raise FileNotFoundError(f"Original .part file not found for saving: {downloaded_part_file_path}")
|
||||
|
||||
with self.downloaded_file_hashes_lock: self.downloaded_file_hashes.add(calculated_file_hash)
|
||||
with self.downloaded_files_lock: self.downloaded_files.add(filename_to_save_in_main_path)
|
||||
|
||||
final_filename_saved_for_return = final_filename_on_disk
|
||||
self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'")
|
||||
|
||||
downloaded_file_details = {
|
||||
'disk_filename': final_filename_saved_for_return,
|
||||
'post_title': post_title,
|
||||
'post_id': original_post_id_for_log,
|
||||
'upload_date_str': self.post.get('published') or self.post.get('added') or "N/A",
|
||||
'download_timestamp': time.time(),
|
||||
'download_path': effective_save_folder,
|
||||
'service': self.service,
|
||||
'user_id': self.user_id,
|
||||
'api_original_filename': api_original_filename,
|
||||
'folder_context_name': folder_context_name_for_history or os.path.basename(effective_save_folder)
|
||||
}
|
||||
return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER ,retry_later_details
|
||||
else :
|
||||
self .logger (f" Marking '{api_original_filename }' as permanently failed for this session.")
|
||||
permanent_failure_details ={
|
||||
'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 ,
|
||||
}
|
||||
return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION ,permanent_failure_details
|
||||
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
|
||||
self._emit_signal('file_successfully_downloaded', downloaded_file_details)
|
||||
time.sleep(0.05)
|
||||
|
||||
return 1, 0, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SUCCESS, None
|
||||
except Exception as save_err:
|
||||
self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}")
|
||||
if os.path.exists(final_save_path):
|
||||
try: os.remove(final_save_path)
|
||||
except OSError: self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
|
||||
return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
finally:
|
||||
if data_to_write_io and hasattr(data_to_write_io, 'close'):
|
||||
data_to_write_io.close()
|
||||
|
||||
else:
|
||||
# --- This is the failure path ---
|
||||
self.logger(f"❌ Download failed for '{api_original_filename}' after {max_retries + 1} attempts.")
|
||||
|
||||
is_actually_incomplete_read = False
|
||||
if isinstance(last_exception_for_retry_later, http.client.IncompleteRead):
|
||||
is_actually_incomplete_read = True
|
||||
elif hasattr(last_exception_for_retry_later, '__cause__') and isinstance(last_exception_for_retry_later.__cause__, http.client.IncompleteRead):
|
||||
is_actually_incomplete_read = True
|
||||
elif last_exception_for_retry_later is not None:
|
||||
str_exc = str(last_exception_for_retry_later).lower()
|
||||
if "incompleteread" in str_exc or (isinstance(last_exception_for_retry_later, tuple) and any("incompleteread" in str(arg).lower() for arg in last_exception_for_retry_later if isinstance(arg, (str, Exception)))):
|
||||
is_actually_incomplete_read = True
|
||||
|
||||
if is_actually_incomplete_read:
|
||||
self.logger(f" Marking '{api_original_filename}' for potential retry later due to IncompleteRead.")
|
||||
retry_later_details = { '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, 'manga_mode_active_for_file': self.manga_mode_active, 'manga_filename_style_for_file': self.manga_filename_style, }
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details
|
||||
else:
|
||||
self.logger(f" Marking '{api_original_filename}' as permanently failed for this session.")
|
||||
permanent_failure_details = { '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, }
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
|
||||
with self .downloaded_file_hashes_lock :
|
||||
if calculated_file_hash in self .downloaded_file_hashes :
|
||||
self .logger (f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename }' (Hash: {calculated_file_hash [:8 ]}...).")
|
||||
@@ -717,7 +848,6 @@ class PostProcessorWorker:
|
||||
if data_to_write_io and hasattr (data_to_write_io ,'close'):
|
||||
data_to_write_io .close ()
|
||||
|
||||
|
||||
def process (self ):
|
||||
if self ._check_pause (f"Post processing for ID {self .post .get ('id','N/A')}"):return 0 ,0 ,[],[],[],None
|
||||
if self .check_cancel ():return 0 ,0 ,[],[],[],None
|
||||
@@ -994,6 +1124,20 @@ class PostProcessorWorker:
|
||||
else :
|
||||
original_cleaned_post_title_for_sub =cleaned_post_title_for_sub
|
||||
|
||||
if self.use_date_prefix_for_subfolder:
|
||||
# Prioritize 'published' date, fall back to 'added' date
|
||||
published_date_str = self.post.get('published') or self.post.get('added')
|
||||
if published_date_str:
|
||||
try:
|
||||
# Extract just the date part (YYYY-MM-DD)
|
||||
date_prefix = published_date_str.split('T')[0]
|
||||
# Prepend the date to the folder name
|
||||
original_cleaned_post_title_for_sub = f"{date_prefix} {original_cleaned_post_title_for_sub}"
|
||||
self.logger(f" ℹ️ Applying date prefix to subfolder: '{original_cleaned_post_title_for_sub}'")
|
||||
except Exception as e:
|
||||
self.logger(f" ⚠️ Could not parse date '{published_date_str}' for prefix. Using original name. Error: {e}")
|
||||
else:
|
||||
self.logger(" ⚠️ 'Date Prefix' is checked, but post has no 'published' or 'added' date. Omitting prefix.")
|
||||
|
||||
base_path_for_post_subfolder =determined_post_save_path_for_history
|
||||
|
||||
@@ -1182,16 +1326,26 @@ class PostProcessorWorker:
|
||||
return 0 ,0 ,[],[],[],None
|
||||
files_to_download_info_list =[]
|
||||
processed_original_filenames_in_this_post =set ()
|
||||
for file_info in all_files_from_post_api :
|
||||
current_api_original_filename =file_info .get ('_original_name_for_log')
|
||||
if current_api_original_filename in processed_original_filenames_in_this_post :
|
||||
self .logger (f" -> Skip Duplicate Original Name (within post {post_id }): '{current_api_original_filename }' already processed/listed for this post.")
|
||||
total_skipped_this_post +=1
|
||||
else :
|
||||
files_to_download_info_list .append (file_info )
|
||||
if current_api_original_filename :
|
||||
processed_original_filenames_in_this_post .add (current_api_original_filename )
|
||||
if not files_to_download_info_list :
|
||||
|
||||
if self.keep_in_post_duplicates:
|
||||
# If we keep duplicates, just add every file to the list to be processed.
|
||||
# The downstream hash check and rename-on-collision logic will handle them.
|
||||
files_to_download_info_list.extend(all_files_from_post_api)
|
||||
self.logger(f" ℹ️ 'Keep Duplicates' is on. All {len(all_files_from_post_api)} files from post will be processed.")
|
||||
else:
|
||||
# This is the original logic that skips duplicates by name within a post.
|
||||
for file_info in all_files_from_post_api:
|
||||
current_api_original_filename = file_info.get('_original_name_for_log')
|
||||
if current_api_original_filename in processed_original_filenames_in_this_post:
|
||||
self.logger(f" -> Skip Duplicate Original Name (within post {post_id}): '{current_api_original_filename}' already processed/listed for this post.")
|
||||
total_skipped_this_post += 1
|
||||
else:
|
||||
files_to_download_info_list.append(file_info)
|
||||
if current_api_original_filename:
|
||||
processed_original_filenames_in_this_post.add(current_api_original_filename)
|
||||
|
||||
if not files_to_download_info_list:
|
||||
|
||||
self .logger (f" All files for post {post_id } were duplicate original names or skipped earlier.")
|
||||
return 0 ,total_skipped_this_post ,[],[],[],None
|
||||
|
||||
@@ -1341,12 +1495,24 @@ class PostProcessorWorker:
|
||||
with open(self.session_file_path, 'r', encoding='utf-8') as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
# Modify in memory
|
||||
if not isinstance(session_data.get('download_state', {}).get('processed_post_ids'), list):
|
||||
if 'download_state' not in session_data:
|
||||
session_data['download_state'] = {}
|
||||
if 'download_state' not in session_data:
|
||||
session_data['download_state'] = {}
|
||||
|
||||
# Add processed ID
|
||||
if not isinstance(session_data['download_state'].get('processed_post_ids'), list):
|
||||
session_data['download_state']['processed_post_ids'] = []
|
||||
session_data['download_state']['processed_post_ids'].append(self.post.get('id'))
|
||||
|
||||
# Add any permanent failures from this worker to the session file
|
||||
if permanent_failures_this_post:
|
||||
if not isinstance(session_data['download_state'].get('permanently_failed_files'), list):
|
||||
session_data['download_state']['permanently_failed_files'] = []
|
||||
# To avoid duplicates if the same post is somehow re-processed
|
||||
existing_failed_urls = {f.get('file_info', {}).get('url') for f in session_data['download_state']['permanently_failed_files']}
|
||||
for failure in permanent_failures_this_post:
|
||||
if failure.get('file_info', {}).get('url') not in existing_failed_urls:
|
||||
session_data['download_state']['permanently_failed_files'].append(failure)
|
||||
|
||||
# Write to temp file and then atomically replace
|
||||
temp_file_path = self.session_file_path + ".tmp"
|
||||
with open(temp_file_path, 'w', encoding='utf-8') as f_tmp:
|
||||
@@ -1434,6 +1600,8 @@ class DownloadThread (QThread ):
|
||||
use_cookie =False ,
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
cookie_text ="",
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
@@ -1486,6 +1654,8 @@ class DownloadThread (QThread ):
|
||||
self .manga_date_file_counter_ref =manga_date_file_counter_ref
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
|
||||
self.keep_in_post_duplicates = keep_in_post_duplicates
|
||||
self .manga_global_file_counter_ref =manga_global_file_counter_ref
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
@@ -1618,6 +1788,8 @@ class DownloadThread (QThread ):
|
||||
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,
|
||||
creator_download_folder_ignore_words =self .creator_download_folder_ignore_words ,
|
||||
session_file_path=self.session_file_path,
|
||||
session_lock=self.session_lock,
|
||||
|
||||
@@ -22,13 +22,17 @@ 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)
|
||||
base_dir = os.path.dirname(sys.executable)
|
||||
# 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/ to the project root
|
||||
# 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')
|
||||
@@ -36,7 +40,14 @@ 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):
|
||||
_app_icon_cache = QIcon(fallback_icon_path)
|
||||
return _app_icon_cache
|
||||
|
||||
print(f"Warning: Application icon not found at {icon_path}")
|
||||
_app_icon_cache = QIcon() # Return an empty icon as a fallback
|
||||
|
||||
return _app_icon_cache
|
||||
return _app_icon_cache
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import QUrl, QSize, Qt
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtGui import QIcon, QDesktopServices
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QScrollArea, QFrame, QWidget
|
||||
|
||||
@@ -92,171 +92,177 @@ class DownloaderApp (QWidget ):
|
||||
file_progress_signal =pyqtSignal (str ,object )
|
||||
|
||||
|
||||
def __init__ (self ):
|
||||
super ().__init__ ()
|
||||
self .settings =QSettings (CONFIG_ORGANIZATION_NAME ,CONFIG_APP_NAME_MAIN )
|
||||
if getattr (sys ,'frozen',False ):
|
||||
self .app_base_dir =os .path .dirname (sys .executable )
|
||||
else :
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
|
||||
|
||||
# --- CORRECT PATH DEFINITION ---
|
||||
# This block correctly determines the application's base directory whether
|
||||
# it's running from source or as a frozen executable.
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# Path for PyInstaller one-file bundle
|
||||
self.app_base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
# Path for running from source code
|
||||
self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
self .config_file =os .path .join (self .app_base_dir ,"appdata","Known.txt")
|
||||
|
||||
self .download_thread =None
|
||||
self .thread_pool =None
|
||||
self .cancellation_event =threading .Event ()
|
||||
self.session_file_path = os.path.join(self.app_base_dir, "appdata","session.json")
|
||||
# All file paths will now correctly use the single, correct app_base_dir
|
||||
self.config_file = os.path.join(self.app_base_dir, "appdata", "Known.txt")
|
||||
self.session_file_path = os.path.join(self.app_base_dir, "appdata", "session.json")
|
||||
self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json")
|
||||
|
||||
self.download_thread = None
|
||||
self.thread_pool = None
|
||||
self.cancellation_event = threading.Event()
|
||||
self.session_lock = threading.Lock()
|
||||
self.interrupted_session_data = None
|
||||
self.is_restore_pending = False
|
||||
self .external_link_download_thread =None
|
||||
self .pause_event =threading .Event ()
|
||||
self .active_futures =[]
|
||||
self .total_posts_to_process =0
|
||||
self .dynamic_character_filter_holder =DynamicFilterHolder ()
|
||||
self .processed_posts_count =0
|
||||
self .creator_name_cache ={}
|
||||
self .log_signal .emit (f"ℹ️ App base directory: {self .app_base_dir }")
|
||||
self.external_link_download_thread = None
|
||||
self.pause_event = threading.Event()
|
||||
self.active_futures = []
|
||||
self.total_posts_to_process = 0
|
||||
self.dynamic_character_filter_holder = DynamicFilterHolder()
|
||||
self.processed_posts_count = 0
|
||||
self.creator_name_cache = {}
|
||||
self.log_signal.emit(f"ℹ️ App base directory: {self.app_base_dir}")
|
||||
self.log_signal.emit(f"ℹ️ Persistent history file path set to: {self.persistent_history_file}")
|
||||
|
||||
self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json")
|
||||
self .last_downloaded_files_details =deque (maxlen =3 )
|
||||
self .download_history_candidates =deque (maxlen =8 )
|
||||
self .log_signal .emit (f"ℹ️ Persistent history file path set to: {self .persistent_history_file }")
|
||||
self .final_download_history_entries =[]
|
||||
self .favorite_download_queue =deque ()
|
||||
self .is_processing_favorites_queue =False
|
||||
self .download_counter =0
|
||||
self .favorite_download_queue =deque ()
|
||||
self .permanently_failed_files_for_dialog =[]
|
||||
self .last_link_input_text_for_queue_sync =""
|
||||
self .is_fetcher_thread_running =False
|
||||
self ._restart_pending =False
|
||||
self .is_processing_favorites_queue =False
|
||||
self .download_history_log =deque (maxlen =50 )
|
||||
self .skip_counter =0
|
||||
self .all_kept_original_filenames =[]
|
||||
self .cancellation_message_logged_this_session =False
|
||||
self .favorite_scope_toggle_button =None
|
||||
self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
|
||||
# --- The rest of your __init__ method continues from here ---
|
||||
self.last_downloaded_files_details = deque(maxlen=3)
|
||||
self.download_history_candidates = deque(maxlen=8)
|
||||
self.final_download_history_entries = []
|
||||
self.favorite_download_queue = deque()
|
||||
self.is_processing_favorites_queue = False
|
||||
self.download_counter = 0
|
||||
self.permanently_failed_files_for_dialog = []
|
||||
self.last_link_input_text_for_queue_sync = ""
|
||||
self.is_fetcher_thread_running = False
|
||||
self._restart_pending = False
|
||||
self.download_history_log = deque(maxlen=50)
|
||||
self.skip_counter = 0
|
||||
self.all_kept_original_filenames = []
|
||||
self.cancellation_message_logged_this_session = False
|
||||
self.favorite_scope_toggle_button = None
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
|
||||
self.manga_mode_checkbox = None
|
||||
self.selected_cookie_filepath = None
|
||||
self.retryable_failed_files_info = []
|
||||
self.is_paused = False
|
||||
self.worker_to_gui_queue = queue.Queue()
|
||||
self.gui_update_timer = QTimer(self)
|
||||
self.actual_gui_signals = PostProcessorSignals()
|
||||
self.worker_signals = PostProcessorSignals()
|
||||
self.prompt_mutex = QMutex()
|
||||
self._add_character_response = None
|
||||
self._original_scan_content_tooltip = ("If checked, the downloader will scan the HTML content of posts for image URLs (from <img> tags or direct links).\n"
|
||||
"now This includes resolving relative paths from <img> tags to full URLs.\n"
|
||||
"Relative paths in <img> tags (e.g., /data/image.jpg) will be resolved to full URLs.\n"
|
||||
"Useful for cases where images are in the post description but not in the API's file/attachment list.")
|
||||
self.downloaded_files = set()
|
||||
self.downloaded_files_lock = threading.Lock()
|
||||
self.downloaded_file_hashes = set()
|
||||
self.downloaded_file_hashes_lock = threading.Lock()
|
||||
self.show_external_links = False
|
||||
self.external_link_queue = deque()
|
||||
self._is_processing_external_link_queue = False
|
||||
self._current_link_post_title = None
|
||||
self.extracted_links_cache = []
|
||||
self.manga_rename_toggle_button = None
|
||||
self.favorite_mode_checkbox = None
|
||||
self.url_or_placeholder_stack = None
|
||||
self.url_input_widget = None
|
||||
self.url_placeholder_widget = None
|
||||
self.favorite_action_buttons_widget = None
|
||||
self.favorite_mode_artists_button = None
|
||||
self.favorite_mode_posts_button = None
|
||||
self.standard_action_buttons_widget = None
|
||||
self.bottom_action_buttons_stack = None
|
||||
self.main_log_output = None
|
||||
self.external_log_output = None
|
||||
self.log_splitter = None
|
||||
self.main_splitter = None
|
||||
self.reset_button = None
|
||||
self.progress_log_label = None
|
||||
self.log_verbosity_toggle_button = None
|
||||
self.missed_character_log_output = None
|
||||
self.log_view_stack = None
|
||||
self.current_log_view = 'progress'
|
||||
self.link_search_input = None
|
||||
self.link_search_button = None
|
||||
self.export_links_button = None
|
||||
self.radio_only_links = None
|
||||
self.radio_only_archives = None
|
||||
self.missed_title_key_terms_count = {}
|
||||
self.missed_title_key_terms_examples = {}
|
||||
self.logged_summary_for_key_term = set()
|
||||
self.STOP_WORDS = set(["a", "an", "the", "is", "was", "were", "of", "for", "with", "in", "on", "at", "by", "to", "and", "or", "but", "i", "you", "he", "she", "it", "we", "they", "my", "your", "his", "her", "its", "our", "their", "com", "net", "org", "www"])
|
||||
self.already_logged_bold_key_terms = set()
|
||||
self.missed_key_terms_buffer = []
|
||||
self.char_filter_scope_toggle_button = None
|
||||
self.skip_words_scope = SKIP_SCOPE_POSTS
|
||||
self.char_filter_scope = CHAR_SCOPE_TITLE
|
||||
self.manga_filename_style = self.settings.value(MANGA_FILENAME_STYLE_KEY, STYLE_POST_TITLE, type=str)
|
||||
self.current_theme = self.settings.value(THEME_KEY, "dark", type=str)
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.allow_multipart_download_setting = False
|
||||
self.use_cookie_setting = False
|
||||
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
|
||||
self.cookie_text_setting = ""
|
||||
self.current_selected_language = self.settings.value(LANGUAGE_KEY, "en", type=str)
|
||||
|
||||
self .manga_mode_checkbox =None
|
||||
print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}")
|
||||
|
||||
self .selected_cookie_filepath =None
|
||||
self .retryable_failed_files_info =[]
|
||||
try:
|
||||
base_path_for_icon = ""
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
base_path_for_icon = sys._MEIPASS
|
||||
else:
|
||||
base_path_for_icon = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
icon_path_for_window = os.path.join(base_path_for_icon, 'assets', 'Kemono.ico')
|
||||
|
||||
if os.path.exists(icon_path_for_window):
|
||||
self.setWindowIcon(QIcon(icon_path_for_window))
|
||||
else:
|
||||
if getattr(sys, 'frozen', False):
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
fallback_icon_path = os.path.join(executable_dir, 'assets', 'Kemono.ico')
|
||||
if os.path.exists(fallback_icon_path):
|
||||
self.setWindowIcon(QIcon(fallback_icon_path))
|
||||
else:
|
||||
self.log_signal.emit(f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window} or {fallback_icon_path}")
|
||||
else:
|
||||
self.log_signal.emit(f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window}")
|
||||
except Exception as e_icon_app:
|
||||
self.log_signal.emit(f"❌ Error setting main window icon in DownloaderApp init: {e_icon_app}")
|
||||
|
||||
self .is_paused =False
|
||||
self .worker_to_gui_queue =queue .Queue ()
|
||||
self .gui_update_timer =QTimer (self )
|
||||
self .actual_gui_signals =PostProcessorSignals ()
|
||||
|
||||
self .worker_signals =PostProcessorSignals ()
|
||||
self .prompt_mutex =QMutex ()
|
||||
self ._add_character_response =None
|
||||
|
||||
self ._original_scan_content_tooltip =("If checked, the downloader will scan the HTML content of posts for image URLs (from <img> tags or direct links).\n"
|
||||
"now This includes resolving relative paths from <img> tags to full URLs.\n"
|
||||
"Relative paths in <img> tags (e.g., /data/image.jpg) will be resolved to full URLs.\n"
|
||||
"Useful for cases where images are in the post description but not in the API's file/attachment list.")
|
||||
|
||||
self .downloaded_files =set ()
|
||||
self .downloaded_files_lock =threading .Lock ()
|
||||
self .downloaded_file_hashes =set ()
|
||||
self .downloaded_file_hashes_lock =threading .Lock ()
|
||||
|
||||
self .show_external_links =False
|
||||
self .external_link_queue =deque ()
|
||||
self ._is_processing_external_link_queue =False
|
||||
self ._current_link_post_title =None
|
||||
self .extracted_links_cache =[]
|
||||
self .manga_rename_toggle_button =None
|
||||
self .favorite_mode_checkbox =None
|
||||
self .url_or_placeholder_stack =None
|
||||
self .url_input_widget =None
|
||||
self .url_placeholder_widget =None
|
||||
self .favorite_action_buttons_widget =None
|
||||
self .favorite_mode_artists_button =None
|
||||
self .favorite_mode_posts_button =None
|
||||
self .standard_action_buttons_widget =None
|
||||
self .bottom_action_buttons_stack =None
|
||||
self .main_log_output =None
|
||||
self .external_log_output =None
|
||||
self .log_splitter =None
|
||||
self .main_splitter =None
|
||||
self .reset_button =None
|
||||
self .progress_log_label =None
|
||||
self .log_verbosity_toggle_button =None
|
||||
|
||||
self .missed_character_log_output =None
|
||||
self .log_view_stack =None
|
||||
self .current_log_view ='progress'
|
||||
|
||||
self .link_search_input =None
|
||||
self .link_search_button =None
|
||||
self .export_links_button =None
|
||||
self .radio_only_links =None
|
||||
self .radio_only_archives =None
|
||||
self .missed_title_key_terms_count ={}
|
||||
self .missed_title_key_terms_examples ={}
|
||||
self .logged_summary_for_key_term =set ()
|
||||
self .STOP_WORDS =set (["a","an","the","is","was","were","of","for","with","in","on","at","by","to","and","or","but","i","you","he","she","it","we","they","my","your","his","her","its","our","their","com","net","org","www"])
|
||||
self .already_logged_bold_key_terms =set ()
|
||||
self .missed_key_terms_buffer =[]
|
||||
self .char_filter_scope_toggle_button =None
|
||||
self .skip_words_scope =SKIP_SCOPE_POSTS
|
||||
self .char_filter_scope =CHAR_SCOPE_TITLE
|
||||
self .manga_filename_style =self .settings .value (MANGA_FILENAME_STYLE_KEY ,STYLE_POST_TITLE ,type =str )
|
||||
self .current_theme =self .settings .value (THEME_KEY ,"dark",type =str )
|
||||
self .only_links_log_display_mode =LOG_DISPLAY_LINKS
|
||||
self .mega_download_log_preserved_once =False
|
||||
self .allow_multipart_download_setting =False
|
||||
self .use_cookie_setting =False
|
||||
self .scan_content_images_setting =self .settings .value (SCAN_CONTENT_IMAGES_KEY ,False ,type =bool )
|
||||
self .cookie_text_setting =""
|
||||
self .current_selected_language =self .settings .value (LANGUAGE_KEY ,"en",type =str )
|
||||
|
||||
print (f"ℹ️ Known.txt will be loaded/saved at: {self .config_file }")
|
||||
try :
|
||||
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
|
||||
|
||||
base_dir_for_icon =sys ._MEIPASS
|
||||
else :
|
||||
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
icon_path_for_window =os .path .join (app_base_dir ,'assets','Kemono.ico')
|
||||
if os .path .exists (icon_path_for_window ):
|
||||
self .setWindowIcon (QIcon (icon_path_for_window ))
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window } (tried in DownloaderApp init)")
|
||||
except Exception as e_icon_app :
|
||||
self .log_signal .emit (f"❌ Error setting main window icon in DownloaderApp init: {e_icon_app }")
|
||||
|
||||
self .url_label_widget =None
|
||||
self .download_location_label_widget =None
|
||||
|
||||
self .remove_from_filename_label_widget =None
|
||||
self .skip_words_label_widget =None
|
||||
|
||||
self .setWindowTitle ("Kemono Downloader v5.5.0")
|
||||
|
||||
self .init_ui ()
|
||||
self ._connect_signals ()
|
||||
self .log_signal .emit ("ℹ️ Local API server functionality has been removed.")
|
||||
self .log_signal .emit ("ℹ️ 'Skip Current File' button has been removed.")
|
||||
if hasattr (self ,'character_input'):
|
||||
self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)..."))
|
||||
self .log_signal .emit (f"ℹ️ Manga filename style loaded: '{self .manga_filename_style }'")
|
||||
self .log_signal .emit (f"ℹ️ Skip words scope loaded: '{self .skip_words_scope }'")
|
||||
self .log_signal .emit (f"ℹ️ Character filter scope set to default: '{self .char_filter_scope }'")
|
||||
self .log_signal .emit (f"ℹ️ Multi-part download defaults to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
|
||||
self .log_signal .emit (f"ℹ️ Cookie text defaults to: Empty on launch")
|
||||
self .log_signal .emit (f"ℹ️ 'Use Cookie' setting defaults to: Disabled on launch")
|
||||
self .log_signal .emit (f"ℹ️ Scan post content for images defaults to: {'Enabled'if self .scan_content_images_setting else 'Disabled'}")
|
||||
self .log_signal .emit (f"ℹ️ Application language loaded: '{self .current_selected_language .upper ()}' (UI may not reflect this yet).")
|
||||
self ._retranslate_main_ui ()
|
||||
self ._load_persistent_history ()
|
||||
self ._load_saved_download_location ()
|
||||
self._update_button_states_and_connections() # Initial button state setup
|
||||
self.url_label_widget = None
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v6.0.0")
|
||||
self.init_ui()
|
||||
self._connect_signals()
|
||||
self.log_signal.emit("ℹ️ Local API server functionality has been removed.")
|
||||
self.log_signal.emit("ℹ️ 'Skip Current File' button has been removed.")
|
||||
if hasattr(self, 'character_input'):
|
||||
self.character_input.setToolTip(self._tr("character_input_tooltip", "Enter character names (comma-separated)..."))
|
||||
self.log_signal.emit(f"ℹ️ Manga filename style loaded: '{self.manga_filename_style}'")
|
||||
self.log_signal.emit(f"ℹ️ Skip words scope loaded: '{self.skip_words_scope}'")
|
||||
self.log_signal.emit(f"ℹ️ Character filter scope set to default: '{self.char_filter_scope}'")
|
||||
self.log_signal.emit(f"ℹ️ Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'}")
|
||||
self.log_signal.emit(f"ℹ️ Cookie text defaults to: Empty on launch")
|
||||
self.log_signal.emit(f"ℹ️ 'Use Cookie' setting defaults to: Disabled on launch")
|
||||
self.log_signal.emit(f"ℹ️ Scan post content for images defaults to: {'Enabled' if self.scan_content_images_setting else 'Disabled'}")
|
||||
self.log_signal.emit(f"ℹ️ Application language loaded: '{self.current_selected_language.upper()}' (UI may not reflect this yet).")
|
||||
self._retranslate_main_ui()
|
||||
self._load_persistent_history()
|
||||
self._load_saved_download_location()
|
||||
self._update_button_states_and_connections()
|
||||
self._check_for_interrupted_session()
|
||||
|
||||
|
||||
def get_checkbox_map(self):
|
||||
"""Returns a mapping of checkbox attribute names to their corresponding settings key."""
|
||||
return {
|
||||
@@ -268,6 +274,8 @@ class DownloaderApp (QWidget ):
|
||||
'use_subfolder_per_post_checkbox': 'use_post_subfolders',
|
||||
'use_multithreading_checkbox': 'use_multithreading',
|
||||
'external_links_checkbox': 'show_external_links',
|
||||
'keep_duplicates_checkbox': 'keep_in_post_duplicates',
|
||||
'date_prefix_checkbox': 'use_date_prefix_for_subfolder',
|
||||
'manga_mode_checkbox': 'manga_mode_active',
|
||||
'scan_content_images_checkbox': 'scan_content_for_images',
|
||||
'use_cookie_checkbox': 'use_cookie',
|
||||
@@ -336,6 +344,12 @@ class DownloaderApp (QWidget ):
|
||||
if "ui_settings" not in session_data or "download_state" not in session_data:
|
||||
raise ValueError("Invalid session file structure.")
|
||||
|
||||
failed_files_from_session = session_data.get('download_state', {}).get('permanently_failed_files', [])
|
||||
if failed_files_from_session:
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
self.permanently_failed_files_for_dialog.extend(failed_files_from_session)
|
||||
self.log_signal.emit(f"ℹ️ Restored {len(failed_files_from_session)} failed file entries from the previous session.")
|
||||
|
||||
self.interrupted_session_data = session_data
|
||||
self.log_signal.emit("ℹ️ Incomplete download session found. UI updated for restore.")
|
||||
self._prepare_ui_for_restore()
|
||||
@@ -416,11 +430,13 @@ class DownloaderApp (QWidget ):
|
||||
self.pause_btn.setEnabled(True)
|
||||
self.pause_btn.clicked.connect(self.restore_download)
|
||||
self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download."))
|
||||
self.cancel_btn.setEnabled(True)
|
||||
|
||||
self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
|
||||
self.cancel_btn.setEnabled(False) # Nothing to cancel yet
|
||||
self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
|
||||
# --- START: CORRECTED CANCEL BUTTON LOGIC ---
|
||||
self.cancel_btn.setText(self._tr("discard_session_button_text", "🗑️ Discard Session"))
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui)
|
||||
self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI."))
|
||||
|
||||
elif is_download_active:
|
||||
# State: Downloading / Paused
|
||||
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
|
||||
@@ -851,20 +867,40 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
|
||||
|
||||
def save_known_names (self ):
|
||||
global KNOWN_NAMES
|
||||
try :
|
||||
with open (self .config_file ,'w',encoding ='utf-8')as f :
|
||||
for entry in KNOWN_NAMES :
|
||||
if entry ["is_group"]:
|
||||
f .write (f"({', '.join (sorted (entry ['aliases'],key =str .lower ))})\n")
|
||||
else :
|
||||
f .write (entry ["name"]+'\n')
|
||||
if hasattr (self ,'log_signal'):self .log_signal .emit (f"💾 Saved {len (KNOWN_NAMES )} known entries to {self .config_file }")
|
||||
except Exception as e :
|
||||
log_msg =f"❌ Error saving config '{self .config_file }': {e }"
|
||||
if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg )
|
||||
QMessageBox .warning (self ,"Config Save Error",f"Could not save list to {self .config_file }:\n{e }")
|
||||
def save_known_names(self):
|
||||
"""
|
||||
Saves the current list of known names (KNOWN_NAMES) to the config file.
|
||||
This version includes a fix to ensure the destination directory exists
|
||||
before attempting to write the file, preventing crashes in new installations.
|
||||
"""
|
||||
global KNOWN_NAMES
|
||||
try:
|
||||
# --- FIX STARTS HERE ---
|
||||
# Get the directory path from the full file path.
|
||||
config_dir = os.path.dirname(self.config_file)
|
||||
# Create the directory if it doesn't exist. 'exist_ok=True' prevents
|
||||
# an error if the directory is already there.
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
# --- FIX ENDS HERE ---
|
||||
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
for entry in KNOWN_NAMES:
|
||||
if entry["is_group"]:
|
||||
# For groups, write the aliases in a sorted, comma-separated format inside parentheses.
|
||||
f.write(f"({', '.join(sorted(entry['aliases'], key=str.lower))})\n")
|
||||
else:
|
||||
# For single entries, write the name on its own line.
|
||||
f.write(entry["name"] + '\n')
|
||||
|
||||
if hasattr(self, 'log_signal'):
|
||||
self.log_signal.emit(f"💾 Saved {len(KNOWN_NAMES)} known entries to {self.config_file}")
|
||||
|
||||
except Exception as e:
|
||||
# If any error occurs during saving, log it and show a warning popup.
|
||||
log_msg = f"❌ Error saving config '{self.config_file}': {e}"
|
||||
if hasattr(self, 'log_signal'):
|
||||
self.log_signal.emit(log_msg)
|
||||
QMessageBox.warning(self, "Config Save Error", f"Could not save list to {self.config_file}:\n{e}")
|
||||
|
||||
def closeEvent (self ,event ):
|
||||
self .save_known_names ()
|
||||
@@ -1180,6 +1216,11 @@ class DownloaderApp (QWidget ):
|
||||
self .compress_images_checkbox .setToolTip ("Compress images > 1.5MB to WebP format (requires Pillow).")
|
||||
row1_layout .addWidget (self .compress_images_checkbox )
|
||||
|
||||
self.keep_duplicates_checkbox = QCheckBox("Keep Duplicates")
|
||||
self.keep_duplicates_checkbox.setChecked(False)
|
||||
self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.\nUnique files will be renamed with a suffix; identical files will still be skipped by hash.")
|
||||
row1_layout.addWidget(self.keep_duplicates_checkbox)
|
||||
|
||||
row1_layout .addStretch (1 )
|
||||
checkboxes_group_layout .addLayout (row1_layout )
|
||||
|
||||
@@ -1197,6 +1238,11 @@ class DownloaderApp (QWidget ):
|
||||
self .use_subfolder_per_post_checkbox .toggled .connect (self .update_ui_for_subfolders )
|
||||
advanced_row1_layout .addWidget (self .use_subfolder_per_post_checkbox )
|
||||
|
||||
self.date_prefix_checkbox = QCheckBox("Date Prefix")
|
||||
self.date_prefix_checkbox.setChecked(False)
|
||||
self.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date (e.g., YYYY-MM-DD Post Title).")
|
||||
advanced_row1_layout.addWidget(self.date_prefix_checkbox)
|
||||
|
||||
self .use_cookie_checkbox =QCheckBox ("Use Cookie")
|
||||
self .use_cookie_checkbox .setChecked (self .use_cookie_setting )
|
||||
|
||||
@@ -1526,25 +1572,28 @@ class DownloaderApp (QWidget ):
|
||||
self .final_download_history_entries =[]
|
||||
self ._save_persistent_history ()
|
||||
|
||||
def _save_persistent_history (self ):
|
||||
|
||||
def _save_persistent_history(self):
|
||||
"""Saves download history to a persistent file."""
|
||||
self .log_signal .emit (f"📜 Attempting to save history to: {self .persistent_history_file }")
|
||||
try :
|
||||
history_dir =os .path .dirname (self .persistent_history_file )
|
||||
self .log_signal .emit (f" History directory: {history_dir }")
|
||||
if not os .path .exists (history_dir ):
|
||||
os .makedirs (history_dir ,exist_ok =True )
|
||||
self .log_signal .emit (f" Created history directory: {history_dir }")
|
||||
self.log_signal.emit(f"📜 Attempting to save history to: {self.persistent_history_file}")
|
||||
try:
|
||||
history_dir = os.path.dirname(self.persistent_history_file)
|
||||
self.log_signal.emit(f" History directory: {history_dir}")
|
||||
if not os.path.exists(history_dir):
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
self.log_signal.emit(f" Created history directory: {history_dir}")
|
||||
|
||||
history_data = {
|
||||
"last_downloaded_files": list(self.last_downloaded_files_details),
|
||||
"first_processed_posts": self.final_download_history_entries
|
||||
}
|
||||
with open (self .persistent_history_file ,'w',encoding ='utf-8')as f :
|
||||
json .dump (history_data ,f ,indent =2 )
|
||||
self .log_signal .emit (f"✅ Saved {len (self .final_download_history_entries )} history entries to: {self .persistent_history_file }")
|
||||
except Exception as e :
|
||||
self .log_signal .emit (f"❌ Error saving persistent history to {self .persistent_history_file }: {e }")
|
||||
with open(self.persistent_history_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(history_data, f, indent=2)
|
||||
self.log_signal.emit(f"✅ Saved {len(self.final_download_history_entries)} history entries to: {self.persistent_history_file}")
|
||||
except Exception as e:
|
||||
self.log_signal.emit(f"❌ Error saving persistent history to {self.persistent_history_file}: {e}")
|
||||
|
||||
|
||||
def _load_creator_name_cache_from_json (self ):
|
||||
"""Loads creator id-name-service mappings from creators.json into self.creator_name_cache."""
|
||||
self .log_signal .emit ("ℹ️ Attempting to load creators.json for creator name cache.")
|
||||
@@ -2692,6 +2741,13 @@ class DownloaderApp (QWidget ):
|
||||
if not can_enable_subfolder_per_post_checkbox :
|
||||
self .use_subfolder_per_post_checkbox .setChecked (False )
|
||||
|
||||
if hasattr(self, 'date_prefix_checkbox'):
|
||||
# The Date Prefix checkbox should only be enabled if "Subfolder per Post" is both enabled and checked
|
||||
can_enable_date_prefix = self.use_subfolder_per_post_checkbox.isEnabled() and self.use_subfolder_per_post_checkbox.isChecked()
|
||||
self.date_prefix_checkbox.setEnabled(can_enable_date_prefix)
|
||||
if not can_enable_date_prefix:
|
||||
self.date_prefix_checkbox.setChecked(False)
|
||||
|
||||
self .update_custom_folder_visibility ()
|
||||
|
||||
|
||||
@@ -2751,7 +2807,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
elif self .manga_filename_style ==STYLE_DATE_BASED :
|
||||
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_based_text","Name: Date Based"))
|
||||
|
||||
|
||||
elif self .manga_filename_style ==STYLE_POST_ID: # Add this block
|
||||
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_id_text","Name: Post ID"))
|
||||
|
||||
elif self .manga_filename_style ==STYLE_DATE_POST_TITLE :
|
||||
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_post_title_text","Name: Date + Title"))
|
||||
@@ -2763,6 +2821,8 @@ class DownloaderApp (QWidget ):
|
||||
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
|
||||
|
||||
|
||||
# In main_window.py
|
||||
|
||||
def _toggle_manga_filename_style (self ):
|
||||
current_style =self .manga_filename_style
|
||||
new_style =""
|
||||
@@ -2775,7 +2835,9 @@ class DownloaderApp (QWidget ):
|
||||
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
|
||||
new_style =STYLE_DATE_BASED
|
||||
elif current_style ==STYLE_DATE_BASED :
|
||||
new_style =STYLE_POST_TITLE
|
||||
new_style =STYLE_POST_ID # Change this line
|
||||
elif current_style ==STYLE_POST_ID: # Add this block
|
||||
new_style =STYLE_POST_TITLE
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
|
||||
new_style =STYLE_POST_TITLE
|
||||
@@ -3531,6 +3593,7 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self .retryable_failed_files_info .clear ()
|
||||
self .permanently_failed_files_for_dialog .clear ()
|
||||
self._update_error_button_count()
|
||||
|
||||
manga_date_file_counter_ref_for_thread =None
|
||||
if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only :
|
||||
@@ -3580,6 +3643,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
if not extract_links_only :
|
||||
log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}")
|
||||
if use_subfolders and self.use_subfolder_per_post_checkbox.isChecked():
|
||||
use_date_prefix = self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False
|
||||
log_messages.append(f" ↳ Date Prefix for Post Subfolders: {'Enabled' if use_date_prefix else 'Disabled'}")
|
||||
if use_subfolders :
|
||||
if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'")
|
||||
if actual_filters_to_use_for_run :
|
||||
@@ -3589,14 +3655,15 @@ class DownloaderApp (QWidget ):
|
||||
log_messages .append (f" Folder Naming: Automatic (based on title/known names)")
|
||||
|
||||
|
||||
log_messages .extend ([
|
||||
f" File Type Filter: {user_selected_filter_text } (Backend processing as: {backend_filter_mode })",
|
||||
f" Skip Archives: {'.zip'if effective_skip_zip else ''}{', 'if effective_skip_zip and effective_skip_rar else ''}{'.rar'if effective_skip_rar else ''}{'None (Archive Mode)'if backend_filter_mode =='archive'else ('None'if not (effective_skip_zip or effective_skip_rar )else '')}",
|
||||
f" Skip Words (posts/files): {', '.join (skip_words_list )if skip_words_list else 'None'}",
|
||||
f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
|
||||
f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
|
||||
f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
|
||||
f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
|
||||
keep_duplicates = self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False
|
||||
log_messages.extend([
|
||||
f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})",
|
||||
f" Keep In-Post Duplicates: {'Enabled' if keep_duplicates else 'Disabled'}",
|
||||
f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}",
|
||||
f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
|
||||
f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
|
||||
f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
|
||||
f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
|
||||
])
|
||||
log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}")
|
||||
else :
|
||||
@@ -3683,6 +3750,8 @@ class DownloaderApp (QWidget ):
|
||||
'session_file_path': self.session_file_path,
|
||||
'session_lock': self.session_lock,
|
||||
'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run ,
|
||||
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False,
|
||||
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False,
|
||||
}
|
||||
|
||||
args_template ['override_output_dir']=override_output_dir
|
||||
@@ -3782,6 +3851,7 @@ class DownloaderApp (QWidget ):
|
||||
dialog .exec_ ()
|
||||
def _handle_retry_from_error_dialog (self ,selected_files_to_retry ):
|
||||
self ._start_failed_files_retry_session (files_to_retry_list =selected_files_to_retry )
|
||||
self._update_error_button_count()
|
||||
|
||||
def _handle_retryable_file_failure (self ,list_of_retry_details ):
|
||||
"""Appends details of files that failed but might be retryable later."""
|
||||
@@ -3793,6 +3863,7 @@ class DownloaderApp (QWidget ):
|
||||
if list_of_permanent_failure_details :
|
||||
self .permanently_failed_files_for_dialog .extend (list_of_permanent_failure_details )
|
||||
self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
|
||||
self._update_error_button_count()
|
||||
|
||||
def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
|
||||
"""Helper to prepare and submit a single post processing task to the thread pool."""
|
||||
@@ -4035,21 +4106,57 @@ class DownloaderApp (QWidget ):
|
||||
num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 )
|
||||
|
||||
|
||||
ppw_expected_keys =[
|
||||
'post_data','download_root','known_names','filter_character_list','unwanted_keywords',
|
||||
'filter_mode','skip_zip','skip_rar','use_subfolders','use_post_subfolders',
|
||||
'target_post_id_from_initial_url','custom_folder_name','compress_images','emitter','pause_event',
|
||||
'download_thumbnails','service','user_id','api_url_input',
|
||||
'cancellation_event','downloaded_files','downloaded_file_hashes',
|
||||
'downloaded_files_lock','downloaded_file_hashes_lock','remove_from_filename_words_list','dynamic_character_filter_holder',
|
||||
'skip_words_list','skip_words_scope','char_filter_scope',
|
||||
'show_external_links','extract_links_only','allow_multipart_download','use_cookie','cookie_text',
|
||||
'app_base_dir','selected_cookie_file','override_output_dir',
|
||||
'num_file_threads','skip_current_file_flag','manga_date_file_counter_ref','scan_content_for_images',
|
||||
'manga_mode_active','manga_filename_style','manga_date_prefix',
|
||||
'manga_global_file_counter_ref'
|
||||
,'creator_download_folder_ignore_words'
|
||||
, 'session_file_path', 'session_lock'
|
||||
ppw_expected_keys = [
|
||||
'post_data',
|
||||
'download_root',
|
||||
'known_names',
|
||||
'filter_character_list',
|
||||
'unwanted_keywords',
|
||||
'filter_mode',
|
||||
'skip_zip',
|
||||
'skip_rar',
|
||||
'use_subfolders',
|
||||
'use_post_subfolders',
|
||||
'target_post_id_from_initial_url',
|
||||
'custom_folder_name',
|
||||
'compress_images',
|
||||
'emitter',
|
||||
'pause_event',
|
||||
'download_thumbnails',
|
||||
'service',
|
||||
'user_id',
|
||||
'api_url_input',
|
||||
'cancellation_event',
|
||||
'downloaded_files',
|
||||
'downloaded_file_hashes',
|
||||
'downloaded_files_lock',
|
||||
'downloaded_file_hashes_lock',
|
||||
'remove_from_filename_words_list',
|
||||
'dynamic_character_filter_holder',
|
||||
'skip_words_list',
|
||||
'skip_words_scope',
|
||||
'char_filter_scope',
|
||||
'show_external_links',
|
||||
'extract_links_only',
|
||||
'allow_multipart_download',
|
||||
'use_cookie',
|
||||
'cookie_text',
|
||||
'app_base_dir',
|
||||
'selected_cookie_file',
|
||||
'override_output_dir',
|
||||
'num_file_threads',
|
||||
'skip_current_file_flag',
|
||||
'manga_date_file_counter_ref',
|
||||
'scan_content_for_images',
|
||||
'manga_mode_active',
|
||||
'manga_filename_style',
|
||||
'manga_date_prefix',
|
||||
'use_date_prefix_for_subfolder',
|
||||
'keep_in_post_duplicates',
|
||||
'manga_global_file_counter_ref',
|
||||
'creator_download_folder_ignore_words',
|
||||
'session_file_path',
|
||||
'session_lock'
|
||||
]
|
||||
|
||||
ppw_optional_keys_with_defaults ={
|
||||
@@ -4159,6 +4266,7 @@ class DownloaderApp (QWidget ):
|
||||
self ._add_to_history_candidates (history_data_from_worker )
|
||||
if permanent_failures_from_post :
|
||||
self .permanently_failed_files_for_dialog .extend (permanent_failures_from_post )
|
||||
self._update_error_button_count()
|
||||
self ._add_to_history_candidates (history_data_from_worker )
|
||||
with self .downloaded_files_lock :
|
||||
self .download_counter +=downloaded_files_from_future
|
||||
@@ -4872,6 +4980,7 @@ class DownloaderApp (QWidget ):
|
||||
# --- Reset UI and all state ---
|
||||
self.log_signal.emit("🔄 Resetting application state to defaults...")
|
||||
self._reset_ui_to_defaults()
|
||||
self._load_saved_download_location()
|
||||
self.main_log_output.clear()
|
||||
self.external_log_output.clear()
|
||||
if self.missed_character_log_output:
|
||||
@@ -4906,6 +5015,7 @@ class DownloaderApp (QWidget ):
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
self._update_error_button_count()
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
|
||||
self._update_favorite_scope_button_text()
|
||||
self.retryable_failed_files_info.clear()
|
||||
@@ -4941,7 +5051,6 @@ class DownloaderApp (QWidget ):
|
||||
"""Resets all UI elements and relevant state to their default values."""
|
||||
# Clear all text fields
|
||||
self.link_input.clear()
|
||||
self.dir_input.clear()
|
||||
self.custom_folder_input.clear()
|
||||
self.character_input.clear()
|
||||
self.skip_words_input.clear()
|
||||
@@ -5124,6 +5233,19 @@ class DownloaderApp (QWidget ):
|
||||
self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
|
||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
|
||||
|
||||
def _update_error_button_count(self):
|
||||
"""Updates the Error button text to show the count of failed files."""
|
||||
if not hasattr(self, 'error_btn'):
|
||||
return
|
||||
|
||||
count = len(self.permanently_failed_files_for_dialog)
|
||||
base_text = self._tr("error_button_text", "Error")
|
||||
|
||||
if count > 0:
|
||||
self.error_btn.setText(f"({count}) {base_text}")
|
||||
else:
|
||||
self.error_btn.setText(base_text)
|
||||
|
||||
def _toggle_multipart_mode (self ):
|
||||
if not self .allow_multipart_download_setting :
|
||||
msg_box =QMessageBox (self )
|
||||
|
||||
Reference in New Issue
Block a user