48 Commits

Author SHA1 Message Date
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
Yuvi9587
539e76aa9e Delete workers.py 2025-07-15 21:09:16 -07:00
Yuvi9587
574d0d66b4 Commit 2025-07-15 21:08:11 -07:00
Yuvi9587
9e58a9d574 commit 2025-07-15 08:49:20 -07:00
Yuvi9587
d67de87a11 Commit 2025-07-15 07:14:40 -07:00
Yuvi9587
149f217f2f Commit 2025-07-15 07:05:36 -07:00
Yuvi9587
874902ad60 Commit 2025-07-15 06:54:31 -07:00
Yuvi9587
440cf60d90 Update MoreOptionsDialog.py 2025-07-14 20:18:04 -07:00
Yuvi9587
fb446a1e28 Commit 2025-07-14 20:17:48 -07:00
Yuvi9587
cfd869e05a Update main_window.py 2025-07-14 09:04:34 -07:00
Yuvi9587
b191776f65 Commit 2025-07-14 08:19:58 -07:00
Yuvi9587
f41f354737 Update main_window.py 2025-07-13 21:46:34 -07:00
Yuvi9587
6b57ee099d Commit 2025-07-13 21:45:30 -07:00
Yuvi9587
21ecb60cb5 commit 2025-07-13 20:21:17 -07:00
Yuvi9587
ee00019f2e Update workers.py 2025-07-13 18:42:56 -07:00
Yuvi9587
d49c739fe4 Commit 2025-07-13 10:36:52 -07:00
Yuvi9587
dbdf82a079 Commit 2025-07-13 10:22:06 -07:00
Yuvi9587
f0bf74da16 Update readme.md 2025-07-11 01:30:07 -07:00
Yuvi9587
e8b655e492 Update readme.md 2025-07-11 01:28:48 -07:00
Yuvi9587
4f383910d2 Update readme.md 2025-07-11 01:26:57 -07:00
Yuvi9587
404c4ca59a commit 2025-07-11 01:24:56 -07:00
Yuvi9587
bcf26bea20 Commit 2025-07-11 01:24:12 -07:00
38 changed files with 5279 additions and 4177 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1,3 @@
github: [Yuvi9587] github: [Yuvi9587]
ko_fi: yuvi427183
buy_me_a_coffee: yuvi9587

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@@ -0,0 +1,97 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,186 +5,188 @@ This guide provides a comprehensive overview of all user interface elements, inp
These are the primary controls you'll interact with to initiate and manage downloads. These are the primary controls you'll interact with to initiate and manage downloads.
### 1.1. Core Inputs ### 1.1. Core Inputs
**🔗 Creator/Post URL Input Field** **🔗 Creator/Post URL Input Field**  
- **Purpose**: Paste the URL of the content you want to download. - **Purpose**: Paste the URL of the content you want to download.  
- **Supported Sites**: Kemono.su, Coomer.party, Simpcity.su. - **Supported Sites**: Kemono.su, Coomer.party, Simpcity.su.  
- **Supported URL Types**: - **Supported URL Types**:  
- Creator pages (e.g., `https://kemono.su/patreon/user/12345`).   - Creator pages (e.g., `https://kemono.su/patreon/user/12345`).  
- Individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`).   - 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. - **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** **🎨 Creator Selection Button**  
- **Icon**: 🎨 (Artist Palette) - **Icon**: 🎨 (Artist Palette)  
- **Purpose**: Opens the "Creator Selection" dialog to browse and queue downloads from known creators. - **Purpose**: Opens the "Creator Selection" dialog to browse and queue downloads from known creators.  
- **Dialog Features**: - **Dialog Features**:  
- Loads creators from `creators.json`.   - Loads creators from `creators.json`.  
- **Search Bar**: Filter creators by name.   - **Search Bar**: Filter creators by name.  
- **Creator List**: Displays creators with their service (e.g., Patreon, Fanbox).   - **Creator List**: Displays creators with their service (e.g., Patreon, Fanbox).  
- **Selection**: Checkboxes to select one or more creators.   - **Selection**: Checkboxes to select one or more creators.  
- **Download Scope**: Organize downloads by Characters or Creators.   - **Download Scope**: Organize downloads by Characters or Creators.  
- **Add to Queue**: Adds selected creators or their posts to the download queue.   - **Add to Queue**: Adds selected creators or their posts to the download queue.
**Page Range (Start to End) Input Fields** **Page Range (Start to End) Input Fields**  
- **Purpose**: Specify a range of pages to fetch for creator URLs. - **Purpose**: Specify a range of pages to fetch for creator URLs.  
- **Usage**: Enter the starting and ending page numbers. - **Usage**: Enter the starting and ending page numbers.  
- **Behavior**: - **Behavior**:  
- If blank, all pages are processed.   - If blank, all pages are processed.  
- Disabled for single post URLs.   - Disabled for single post URLs.
**📁 Download Location Input Field & Browse Button** **📁 Download Location Input Field & Browse Button**  
- **Purpose**: Specify the main directory for downloaded files. - **Purpose**: Specify the main directory for downloaded files.  
- **Usage**: Type the path or click "Browse..." to select a folder. - **Usage**: Type the path or click "Browse..." to select a folder.  
- **Requirement**: Mandatory for all download operations. - **Requirement**: Mandatory for all download operations.
### 1.2. Action Buttons ### 1.2. Action Buttons
**⬇️ Start Download / 🔗 Extract Links Button** **⬇️ Start Download / 🔗 Extract Links Button**  
- **Purpose**: Initiates downloading or link extraction. - **Purpose**: Initiates downloading or link extraction.  
- **Behavior**: - **Behavior**:  
- Shows "🔗 Extract Links" if "Only Links" is selected.   - Shows "🔗 Extract Links" if "Only Links" is selected.  
- Otherwise, shows "⬇️ Start Download".   - Otherwise, shows "⬇️ Start Download".  
- Supports single-threaded or multi-threaded downloads based on settings.   - Supports single-threaded or multi-threaded downloads based on settings.
**🔄 Restore Download Button** **🔄 Restore Download Button**  
- **Visibility**: Appears if an incomplete session is detected on startup. - **Visibility**: Appears if an incomplete session is detected on startup.  
- **Purpose**: Resumes a previously interrupted download session. - **Purpose**: Resumes a previously interrupted download session.
**⏸️ Pause / ▶️ Resume Download Button** **⏸️ Pause / ▶️ Resume Download Button**  
- **Purpose**: Pause or resume the ongoing download. - **Purpose**: Pause or resume the ongoing download.  
- **Behavior**: Toggles between "Pause" and "Resume". Some UI settings can be changed while paused. - **Behavior**: Toggles between "Pause" and "Resume". Some UI settings can be changed while paused.
**❌ Cancel & Reset UI Button** **❌ Cancel & Reset UI Button**  
- **Purpose**: Stops the current operation and performs a "soft" reset. - **Purpose**: Stops the current operation and performs a "soft" reset.  
- **Behavior**: Halts background threads, preserves URL and Download Location inputs, resets other settings. - **Behavior**: Halts background threads, preserves URL and Download Location inputs, resets other settings.
**🔄 Reset Button (in the log area)** **🔄 Reset Button (in the log area)**  
- **Purpose**: Performs a "hard" reset when no operation is active. - **Purpose**: Performs a "hard" reset when no operation is active.  
- **Behavior**: Clears all inputs, resets options to default, and clears logs. - **Behavior**: Clears all inputs, resets options to default, and clears logs.
## 2. Filtering & Content Selection ## 2. Filtering & Content Selection
These options allow precise control over downloaded content. These options allow precise control over downloaded content.
### 2.1. Content Filtering ### 2.1. Content Filtering
**🎯 Filter by Character(s) Input Field** **🎯 Filter by Character(s) Input Field**  
- **Purpose**: Download content related to specific characters or series. - **Purpose**: Download content related to specific characters or series.  
- **Usage**: Enter comma-separated character names. - **Usage**: Enter comma-separated character names.  
- **Advanced Syntax**: - **Advanced Syntax**:  
- `Nami`: Simple filter.   - `Nami`: Simple filter.  
- `(Vivi, Ulti)`: Grouped filter. Matches posts with "Vivi" OR "Ulti". Creates a shared folder like `Vivi Ulti` if subfolders are enabled.   - `(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.   - `(Boa, Hancock)~`: Aliased filter. Treats "Boa" and "Hancock" as the same entity.
**Filter: [Type] Button (Character Filter Scope)** **Filter: [Type] Button (Character Filter Scope)**  
- **Purpose**: Defines where the character filter is applied. Cycles on click. - **Purpose**: Defines where the character filter is applied. Cycles on click.  
- **Options**: - **Options**:  
- **Filter: Title** (Default): Matches post titles.   - **Filter: Title** (Default): Matches post titles.  
- **Filter: Files**: Matches filenames.   - **Filter: Files**: Matches filenames.  
- **Filter: Both**: Checks title first, then filenames.   - **Filter: Both**: Checks title first, then filenames.  
- **Filter: Comments (Beta)**: Checks filenames, then post comments.   - **Filter: Comments (Beta)**: Checks filenames, then post comments.
**🚫 Skip with Words Input Field** **🚫 Skip with Words Input Field**  
- **Purpose**: Exclude posts/files with specified keywords (e.g., `WIP`, `sketch`). - **Purpose**: Exclude posts/files with specified keywords (e.g., `WIP`, `sketch`).
**Scope: [Type] Button (Skip Words Scope)** **Scope: [Type] Button (Skip Words Scope)**  
- **Purpose**: Defines where skip words are applied. Cycles on click. - **Purpose**: Defines where skip words are applied. Cycles on click.  
- **Options**: - **Options**:  
- **Scope: Posts** (Default): Skips posts if the title contains a skip word.   - **Scope: Posts** (Default): Skips posts if the title contains a skip word.  
- **Scope: Files**: Skips files if the filename contains a skip word.   - **Scope: Files**: Skips files if the filename contains a skip word.  
- **Scope: Both**: Applies both rules.   - **Scope: Both**: Applies both rules.
**✂️ Remove Words from Name Input Field** **✂️ Remove Words from Name Input Field**  
- **Purpose**: Remove unwanted text from filenames (e.g., `patreon`, `[HD]`). - **Purpose**: Remove unwanted text from filenames (e.g., `patreon`, `[HD]`).
### 2.2. File Type Filtering ### 2.2. File Type Filtering
**Filter Files (Radio Buttons)** **Filter Files (Radio Buttons)**  
- **Purpose**: Select file types to download. - **Purpose**: Select file types to download.  
- **Options**: - **Options**:  
- **All**: All file types.   - **All**: All file types.  
- **Images/GIFs**: Common image formats.   - **Images/GIFs**: Common image formats.  
- **Videos**: Common video formats.   - **Videos**: Common video formats.  
- **🎧 Only Audio**: Common audio formats.   - **🎧 Only Audio**: Common audio formats.  
- **📦 Only Archives**: Only `.zip` and `.rar` files.   - **📦 Only Archives**: Only `.zip` and `.rar` files.  
- **🔗 Only Links**: Extracts external links without downloading files.   - **🔗 Only Links**: Extracts external links without downloading files.
**Skip .zip / Skip .rar Checkboxes** **Skip .zip / Skip .rar Checkboxes**  
- **Purpose**: Skip downloading `.zip` or `.rar` files. - **Purpose**: Skip downloading `.zip` or `.rar` files.  
- **Behavior**: Disabled when "📦 Only Archives" is active. - **Behavior**: Disabled when "📦 Only Archives" is active.
## 3. Download Customization ## 3. Download Customization
Options to refine the download process and output. Options to refine the download process and output.
- **Download Thumbnails Only**: Downloads small preview images instead of full-resolution files. - **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. - **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). - **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). - **🗄️ 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 ## 4. 📖 Manga/Comic Mode
A mode for downloading creator feeds in chronological order, ideal for sequential content. 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). - **Activation**: Active when downloading a creator's entire feed (not a single post).  
- **Core Behavior**: Fetches all posts, processing from oldest to newest. - **Core Behavior**: Fetches all posts, processing from oldest to newest.  
- **Filename Style Toggle Button (in the log area)**: - **Filename Style Toggle Button (in the log area)**:  
- **Purpose**: Controls file naming in Manga Mode. Cycles on click.   - **Purpose**: Controls file naming in Manga Mode. Cycles on click.  
- **Options**:   - **Options**:  
- **Name: Post Title**: First file named after post title; others keep original names.     - **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: 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: 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: 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: Post ID**: Files named after post ID to avoid clashes.  
- **Name: Date + Title**: Combines post date and title for filenames.     - **Name: Date + Title**: Combines post date and title for filenames.
## 5. Folder Organization & Known.txt ## 5. Folder Organization & Known.txt
Controls for structuring downloaded content. Controls for structuring downloaded content.
- **Separate Folders by Name/Title Checkbox**: Enables automatic subfolder creation. - **Separate Folders by Name/Title Checkbox**: Enables automatic subfolder creation.  
- **Subfolder per Post Checkbox**: Creates subfolders for each post, named after the post title. - **Subfolder per Post Checkbox**: Creates subfolders for each post, named after the post title.  
- **Known.txt Management UI (Bottom Left)**: - **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.
- **Purpose**: Manages a local `Known.txt` file for series, characters, or terms used in folder creation. - **Known.txt Management UI (Bottom Left)**:  
- **List Display**: Shows primary names from `Known.txt`.   - **Purpose**: Manages a local `Known.txt` file for series, characters, or terms used in folder creation.  
- ** Add Button**: Adds names or groups (e.g., `(Character A, Alias B)~`).   - **List Display**: Shows primary names from `Known.txt`.  
- **⤵️ Add to Filter Button**: Select names from `Known.txt` for the character filter.   - ** Add Button**: Adds names or groups (e.g., `(Character A, Alias B)~`).  
- **🗑 Delete Selected Button**: Removes selected names from `Known.txt`.   - ** Add to Filter Button**: Select names from `Known.txt` for the character filter.  
- **Open Known.txt Button**: Opens the file in the default text editor.   - **🗑️ Delete Selected Button**: Removes selected names from `Known.txt`.  
- **❓ Help Button**: Opens this feature guide.   - **Open Known.txt Button**: Opens the file in the default text editor.  
- **📜 History Button**: Views recent download history.   - ** Help Button**: Opens this feature guide.  
  - **📜 History Button**: Views recent download history.
## 6. ⭐ Favorite Mode (Kemono.su Only) ## 6. ⭐ Favorite Mode (Kemono.su Only)
Download from favorited artists/posts on Kemono.su. Download from favorited artists/posts on Kemono.su.
- **Enable Checkbox ("⭐ Favorite Mode")**: - **Enable Checkbox ("⭐ Favorite Mode")**:  
- Switches to Favorite Mode.   - Switches to Favorite Mode.  
- Disables the main URL input.   - Disables the main URL input.  
- Changes action buttons to "Favorite Artists" and "Favorite Posts".   - Changes action buttons to "Favorite Artists" and "Favorite Posts".  
- Requires cookies.   - Requires cookies.  
- **🖼️ Favorite Artists Button**: Select and download from favorited artists. - **🖼️ Favorite Artists Button**: Select and download from favorited artists.  
- **📄 Favorite Posts Button**: Select and download specific favorited posts. - **📄 Favorite Posts Button**: Select and download specific favorited posts.  
- **Favorite Download Scope Button**: - **Favorite Download Scope Button**:  
- **Scope: Selected Location**: Downloads favorites to the main directory.   - **Scope: Selected Location**: Downloads favorites to the main directory.  
- **Scope: Artist Folders**: Creates subfolders per artist.   - **Scope: Artist Folders**: Creates subfolders per artist.
## 7. Advanced Settings & Performance ## 7. Advanced Settings & Performance
- **🍪 Cookie Management**: - **🍪 Cookie Management**:  
- **Use Cookie Checkbox**: Enables cookies for restricted content.   - **Use Cookie Checkbox**: Enables cookies for restricted content.  
- **Cookie Text Field**: Paste cookie string.   - **Cookie Text Field**: Paste cookie string.  
- **Browse... Button**: Select a `cookies.txt` file (Netscape format).   - **Browse... Button**: Select a `cookies.txt` file (Netscape format).  
- **Use Multithreading Checkbox & Threads Input**: - **Use Multithreading Checkbox & Threads Input**:  
- **Purpose**: Configures simultaneous operations.   - **Purpose**: Configures simultaneous operations.  
- **Behavior**: Sets concurrent post processing (creator feeds) or file downloads (single posts).   - **Behavior**: Sets concurrent post processing (creator feeds) or file downloads (single posts).  
- **Multi-part Download Toggle Button**: - **Multi-part Download Toggle Button**:  
- **Purpose**: Enables/disables multi-segment downloading for large files.   - **Purpose**: Enables/disables multi-segment downloading for large files.  
- **Note**: Best for large files; less efficient for small files.   - **Note**: Best for large files; less efficient for small files.
## 8. Logging, Monitoring & Error Handling ## 8. Logging, Monitoring & Error Handling
- **📜 Progress Log Area**: Displays messages, progress, and errors. - **📜 Progress Log Area**: Displays messages, progress, and errors.  
- **👁️ / 🙈 Log View Toggle Button**: Switches between Progress Log and Missed Character Log (skipped posts). - **👁️ / 🙈 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. - **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. - **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. - **Download Extracted Links Button**: Downloads files from supported external links in "Only Links" mode.  
- **🆘 Error Button & Dialog**: - **🆘 Error Button & Dialog**:  
- **Purpose**: Active if files fail to download.   - **Purpose**: Active if files fail to download. The button will display a live count of failed files (e.g., **(3) Error**).  
- **Dialog Features**:   - **Dialog Features**:  
- Lists failed files.     - Lists failed files.  
- Retry failed downloads.     - Retry failed downloads.  
- Export failed URLs to a text file.     - Export failed URLs to a text file.
## 9. Application Settings (⚙️) ## 9. Application Settings (⚙️)
- **Appearance**: Switch between Light and Dark themes. - **Appearance**: Switch between Light and Dark themes.  
- **Language**: Change UI language (restart required). - **Language**: Change UI language (restart required).

196
readme.md
View File

@@ -1,46 +1,46 @@
<h1 align="center">Kemono Downloader v5.6.0</h1> <h1 align="center">Kemono Downloader v6.0.0</h1>
<table align="center"> <div align="center">
<table>
<tr> <tr>
<td align="center"> <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> <strong>Default</strong>
</td> </td>
<td align="center"> <td align="center">
<img src="Read/Read1.png" alt="Favorite Mode" width="400"/><br> <img src="Read/Read1.png" alt="Favorite Mode" width="400"><br>
<strong>Favorite mode</strong> <strong>Favorite Mode</strong>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center"> <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> <strong>Single Post</strong>
</td> </td>
<td align="center"> <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> <strong>Manga/Comic Mode</strong>
</td> </td>
</tr> </tr>
</table> </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). 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.* 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.
*Update v5.2.0 introduces multi-language support, theme selection, and further UI refinements.*
<div align="center">
[![](https://img.shields.io/badge/📚%20Full%20Feature%20List-FFD700?style=for-the-badge&logoColor=black&color=FFD700)](features.md)
[![](https://img.shields.io/badge/📝%20License-90EE90?style=for-the-badge&logoColor=black&color=90EE90)](LICENSE)
[![](https://img.shields.io/badge/⚠️%20Important%20Note-FFCCCB?style=for-the-badge&logoColor=black&color=FFCCCB)](note.md)
</div>
<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>
--- ---
@@ -48,77 +48,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: 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. - **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). - **Flexible Downloading:**
- Supports creator pages (with page range selection) and individual post URLs. - Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors).
- Standard download controls: Start, Pause, Resume, and Cancel. - Supports creator pages (with page range selection) and individual post URLs.
- **Powerful Filtering:** - Standard download controls: Start, Pause, Resume, and Cancel.
- **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. - **Powerful Filtering:**
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames. - **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only. - **Keyword Skipping:** Skip posts or files based on specified keywords.
- **Customizable Downloads:** - **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
- **Thumbnails Only:** Option to download only small preview images. - **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
- **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). - **Customizable Downloads:**
- **Organized Output:** - **Thumbnails Only:** Option to download only small preview images.
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles. - **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post. - **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
- **Manga/Comic Mode:**
- Downloads posts from a creator's feed in chronological order (oldest to newest). - **Organized Output:**
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering). - **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
- **⭐ Favorite Mode:** - **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
- 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. - **Manga/Comic Mode:**
- Supports downloading into a single location or artist-specific subfolders. - Downloads posts from a creator's feed in chronological order (oldest to newest).
- **Performance & Advanced Options:** - Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
- **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. - **⭐ Favorite Mode:**
- **Logging:** - Directly download from your favorited artists and posts on Kemono.su.
- A detailed progress log displays download activity, errors, and summaries. - Requires a valid cookie and adapts the UI for easy selection from your favorites.
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified). - Supports downloading into a single location or artist-specific subfolders.
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
- **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 ## ✨ What's New in v6.0.0
- **Multi-Creator Post Fetching & Queuing:**
- The **Creator Selection popup** (🎨 icon) has been significantly enhanced. This release focuses on providing more granular control over file organization and improving at-a-glance status monitoring.
- 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. ### New Features
- 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. - **Live Error Count on Button**
- The traditional "**Add Selected to URL**" button is still available if you prefer to populate the main URL field with creator names. 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.
- **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"). - **Date Prefix for Post Subfolders**
- The queue is now more robustly managed, especially when interacting with the main URL input field after items have been queued from the popup. 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 ## 📅 Next Update Plans
- **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.
## ✨ What's New in v5.2.0 ### 🔖 Post Tag Filtering (Planned for v6.1.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. A powerful new **"Filter by Post Tags"** feature is planned:
- **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. - 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 ### Requirements
- Python 3.6 or higher
- pip (Python package installer) - Python 3.6 or higher
- pip (Python package installer)
### Install Dependencies ### Install Dependencies
Open your terminal or command prompt and run:
```bash ```bash
pip install PyQt5 requests Pillow mega.py pip install PyQt5 requests Pillow mega.py
@@ -176,4 +209,9 @@ This project is under the Custom Licence
</a> </a>
</table> </table>
👉 See [features.md](features.md) for the full feature list. <p align="center">
<a href="https://buymeacoffee.com/yuvi9587">
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
</a>
</p>

View File

@@ -57,6 +57,8 @@ THEME_KEY = "currentThemeV2"
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1" SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
LANGUAGE_KEY = "currentLanguageV1" LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1" DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
# --- UI Constants and Identifiers --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"
@@ -70,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
# --- File Type Extensions --- # --- File Type Extensions ---
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {
@@ -111,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"fri", "friday", "sat", "saturday", "sun", "sunday" "fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need # add more according to need
} }
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

@@ -1,12 +1,10 @@
# --- Standard Library Imports ---
import time import time
import traceback import traceback
from urllib.parse import urlparse from urllib.parse import urlparse
import json # Ensure json is imported
# --- Third-Party Library Imports ---
import requests import requests
# --- Local Application Imports --- # (Keep the rest of your imports)
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_POST_TITLE STYLE_DATE_POST_TITLE
@@ -15,36 +13,24 @@ from ..config.constants import (
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None): def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
""" """
Fetches a single page of posts from the API with retry logic. Fetches a single page of posts from the API with robust retry logic.
NEW: Requests only essential fields to keep the response size small and reliable.
Args:
api_url_base (str): The base URL for the user's posts.
headers (dict): The request headers.
offset (int): The offset for pagination.
logger (callable): Function to log messages.
cancellation_event (threading.Event): Event to signal cancellation.
pause_event (threading.Event): Event to signal pause.
cookies_dict (dict): A dictionary of cookies to include in the request.
Returns:
list: A list of post data dictionaries from the API.
Raises:
RuntimeError: If the fetch fails after all retries or encounters a non-retryable error.
""" """
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger(" Fetch cancelled before request.")
raise RuntimeError("Fetch operation cancelled by user.") raise RuntimeError("Fetch operation cancelled by user.")
if pause_event and pause_event.is_set(): if pause_event and pause_event.is_set():
logger(" Post fetching paused...") logger(" Post fetching paused...")
while pause_event.is_set(): while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger(" Post fetching cancelled while paused.") raise RuntimeError("Fetch operation cancelled by user while paused.")
raise RuntimeError("Fetch operation cancelled by user.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") logger(" Post fetching resumed.")
paginated_url = f'{api_url_base}?o={offset}' # --- MODIFICATION: Added `fields` to the URL to request only metadata ---
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
max_retries = 3 max_retries = 3
retry_delay = 5 retry_delay = 5
@@ -52,22 +38,18 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Fetch operation cancelled by user during retry loop.") raise RuntimeError("Fetch operation cancelled by user during retry loop.")
log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})" log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})"
if attempt > 0: if attempt > 0:
log_message += f" (Attempt {attempt + 1}/{max_retries})" log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message) logger(log_message)
try: try:
response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict) # We can now remove the streaming logic as the response will be small and fast.
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status() response.raise_for_status()
if 'application/json' not in response.headers.get('Content-Type', '').lower():
logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}")
return []
return response.json() return response.json()
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: except requests.exceptions.RequestException as e:
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}") logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = retry_delay * (2 ** attempt) delay = retry_delay * (2 ** attempt)
@@ -76,18 +58,46 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
continue continue
else: else:
logger(f" ❌ Failed to fetch page after {max_retries} attempts.") logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
raise RuntimeError(f"Timeout or connection error fetching offset {offset}") raise RuntimeError(f"Network error fetching offset {offset}")
except requests.exceptions.RequestException as e: except json.JSONDecodeError as e:
err_msg = f"Error fetching offset {offset}: {e}" logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}")
if e.response is not None: if attempt < max_retries - 1:
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})" delay = retry_delay * (2 ** attempt)
raise RuntimeError(err_msg) logger(f" Retrying in {delay} seconds...")
except ValueError as e: # JSON decode error time.sleep(delay)
raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}") continue
else:
raise RuntimeError(f"JSONDecodeError fetching offset {offset}")
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.") raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
"""
--- NEW FUNCTION ---
Fetches the full data, including the 'content' field, for a single post.
"""
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...")
try:
# Use streaming here as a precaution for single posts that are still very large.
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status()
response_body = b""
for chunk in response.iter_content(chunk_size=8192):
response_body += chunk
full_post_data = json.loads(response_body)
# The API sometimes wraps the post in a list, handle that.
if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0]
return full_post_data
except Exception as e:
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
return None
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None): def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
"""Fetches all comments for a specific post.""" """Fetches all comments for a specific post."""
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
@@ -105,217 +115,248 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
except ValueError as e: except ValueError as e:
raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}") raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}")
def download_from_api ( def download_from_api(
api_url_input , api_url_input,
logger =print , logger=print,
start_page =None , start_page=None,
end_page =None , end_page=None,
manga_mode =False , manga_mode=False,
cancellation_event =None , cancellation_event=None,
pause_event =None , pause_event=None,
use_cookie =False , use_cookie=False,
cookie_text ="", cookie_text="",
selected_cookie_file =None , selected_cookie_file=None,
app_base_dir =None , app_base_dir=None,
manga_filename_style_for_sort_check =None manga_filename_style_for_sort_check=None,
processed_post_ids=None # --- ADD THIS ARGUMENT ---
): ):
headers ={ headers = {
'User-Agent':'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
'Accept':'application/json' 'Accept': 'application/json'
} }
service ,user_id ,target_post_id =extract_post_info (api_url_input ) # --- ADD THIS BLOCK ---
# Ensure processed_post_ids is a set for fast lookups
if processed_post_ids is None:
processed_post_ids = set()
else:
processed_post_ids = set(processed_post_ids)
# --- END OF ADDITION ---
if cancellation_event and cancellation_event .is_set (): service, user_id, target_post_id = extract_post_info(api_url_input)
logger (" Download_from_api cancelled at start.")
return
parsed_input_url_for_domain =urlparse (api_url_input ) if cancellation_event and cancellation_event.is_set():
api_domain =parsed_input_url_for_domain .netloc logger(" Download_from_api cancelled at start.")
if not any (d in api_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']): return
logger (f"⚠️ Unrecognized domain '{api_domain }' from input URL. Defaulting to kemono.su for API calls.")
api_domain ="kemono.su"
cookies_for_api =None
if use_cookie and app_base_dir :
cookies_for_api =prepare_cookies_for_request (use_cookie ,cookie_text ,selected_cookie_file ,app_base_dir ,logger ,target_domain =api_domain )
if target_post_id :
direct_post_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{target_post_id }"
logger (f" Attempting direct fetch for target post: {direct_post_api_url }")
try :
direct_response =requests .get (direct_post_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_for_api )
direct_response .raise_for_status ()
direct_post_data =direct_response .json ()
if isinstance (direct_post_data ,list )and direct_post_data :
direct_post_data =direct_post_data [0 ]
if isinstance (direct_post_data ,dict )and 'post'in direct_post_data and isinstance (direct_post_data ['post'],dict ):
direct_post_data =direct_post_data ['post']
if isinstance (direct_post_data ,dict )and direct_post_data .get ('id')==target_post_id :
logger (f" ✅ Direct fetch successful for post {target_post_id }.")
yield [direct_post_data ]
return
else :
response_type =type (direct_post_data ).__name__
response_snippet =str (direct_post_data )[:200 ]
logger (f" ⚠️ Direct fetch for post {target_post_id } returned unexpected data (Type: {response_type }, Snippet: '{response_snippet }'). Falling back to pagination.")
except requests .exceptions .RequestException as e :
logger (f" ⚠️ Direct fetch failed for post {target_post_id }: {e }. Falling back to pagination.")
except Exception as e :
logger (f" ⚠️ Unexpected error during direct fetch for post {target_post_id }: {e }. Falling back to pagination.")
if not service or not user_id :
logger (f"❌ Invalid URL or could not extract service/user: {api_url_input }")
return
if target_post_id and (start_page or end_page ):
logger ("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
is_manga_mode_fetch_all_and_sort_oldest_first =manga_mode and (manga_filename_style_for_sort_check !=STYLE_DATE_POST_TITLE )and not target_post_id parsed_input_url_for_domain = urlparse(api_url_input)
api_base_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }" api_domain = parsed_input_url_for_domain.netloc
page_size =50 if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
if is_manga_mode_fetch_all_and_sort_oldest_first : logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
logger (f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...") api_domain = "kemono.su"
all_posts_for_manga_mode =[] cookies_for_api = None
current_offset_manga =0 if use_cookie and app_base_dir:
if start_page and start_page >1 : cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
current_offset_manga =(start_page -1 )*page_size if target_post_id:
logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).") # --- ADD THIS CHECK FOR RESTORE ---
elif start_page : if target_post_id in processed_post_ids:
logger (f" Manga Mode: Starting fetch from page 1 (offset 0).") logger(f" Skipping already processed target post ID: {target_post_id}")
if end_page : return
logger (f" Manga Mode: Will fetch up to page {end_page }.") # --- END OF ADDITION ---
while True : direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
if pause_event and pause_event .is_set (): logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
logger (" Manga mode post fetching paused...") try:
while pause_event .is_set (): direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
if cancellation_event and cancellation_event .is_set (): direct_response.raise_for_status()
logger (" Manga mode post fetching cancelled while paused.") direct_post_data = direct_response.json()
break if isinstance(direct_post_data, list) and direct_post_data:
time .sleep (0.5 ) direct_post_data = direct_post_data[0]
if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.") if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
if cancellation_event and cancellation_event .is_set (): direct_post_data = direct_post_data['post']
logger (" Manga mode post fetching cancelled.") if isinstance(direct_post_data, dict) and direct_post_data.get('id') == target_post_id:
break logger(f" ✅ Direct fetch successful for post {target_post_id}.")
current_page_num_manga =(current_offset_manga //page_size )+1 yield [direct_post_data]
if end_page and current_page_num_manga >end_page : return
logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.") else:
break response_type = type(direct_post_data).__name__
try : response_snippet = str(direct_post_data)[:200]
posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) logger(f" ⚠️ Direct fetch for post {target_post_id} returned unexpected data (Type: {response_type}, Snippet: '{response_snippet}'). Falling back to pagination.")
if not isinstance (posts_batch_manga ,list ): except requests.exceptions.RequestException as e:
logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.") logger(f" ⚠️ Direct fetch failed for post {target_post_id}: {e}. Falling back to pagination.")
break except Exception as e:
if not posts_batch_manga : logger(f" ⚠️ Unexpected error during direct fetch for post {target_post_id}: {e}. Falling back to pagination.")
logger ("✅ Reached end of posts (Manga Mode fetch all).") if not service or not user_id:
if start_page and not end_page and current_page_num_manga <start_page : logger(f"❌ Invalid URL or could not extract service/user: {api_url_input}")
logger (f" Manga Mode: No posts found on or after specified start page {start_page }.") return
elif end_page and current_page_num_manga <=end_page and not all_posts_for_manga_mode : if target_post_id and (start_page or end_page):
logger (f" Manga Mode: No posts found within the specified page range ({start_page or 1 }-{end_page }).") logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
break
all_posts_for_manga_mode .extend (posts_batch_manga )
current_offset_manga +=page_size
time .sleep (0.6 )
except RuntimeError as e :
if "cancelled by user"in str (e ).lower ():
logger (f" Manga mode pagination stopped due to cancellation: {e }")
else :
logger (f"{e }\n Aborting manga mode pagination.")
break
except Exception as e :
logger (f"❌ Unexpected error during manga mode fetch: {e }")
traceback .print_exc ()
break
if cancellation_event and cancellation_event .is_set ():return
if all_posts_for_manga_mode :
logger (f" Manga Mode: Fetched {len (all_posts_for_manga_mode )} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple (post ):
published_date_str =post .get ('published')
added_date_str =post .get ('added')
post_id_str =post .get ('id',"0")
primary_sort_val ="0000-00-00T00:00:00"
if published_date_str :
primary_sort_val =published_date_str
elif added_date_str :
logger (f" ⚠️ Post ID {post_id_str } missing 'published' date, using 'added' date '{added_date_str }' for primary sorting.")
primary_sort_val =added_date_str
else :
logger (f" ⚠️ Post ID {post_id_str } missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
secondary_sort_val =0
try :
secondary_sort_val =int (post_id_str )
except ValueError :
logger (f" ⚠️ Post ID '{post_id_str }' is not a valid integer for secondary sorting, using 0.")
return (primary_sort_val ,secondary_sort_val )
all_posts_for_manga_mode .sort (key =sort_key_tuple )
for i in range (0 ,len (all_posts_for_manga_mode ),page_size ):
if cancellation_event and cancellation_event .is_set ():
logger (" Manga mode post yielding cancelled.")
break
yield all_posts_for_manga_mode [i :i +page_size ]
return
is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
page_size = 50
if is_manga_mode_fetch_all_and_sort_oldest_first:
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
all_posts_for_manga_mode = []
current_offset_manga = 0
if start_page and start_page > 1:
current_offset_manga = (start_page - 1) * page_size
logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).")
elif start_page:
logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
if end_page:
logger(f" Manga Mode: Will fetch up to page {end_page}.")
while True:
if pause_event and pause_event.is_set():
logger(" Manga mode post fetching paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled while paused.")
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled.")
break
current_page_num_manga = (current_offset_manga // page_size) + 1
if end_page and current_page_num_manga > end_page:
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break
try:
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(posts_batch_manga, list):
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break
if not posts_batch_manga:
logger("✅ Reached end of posts (Manga Mode fetch all).")
if start_page and not end_page and current_page_num_manga < start_page:
logger(f" Manga Mode: No posts found on or after specified start page {start_page}.")
elif end_page and current_page_num_manga <= end_page and not all_posts_for_manga_mode:
logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break
all_posts_for_manga_mode.extend(posts_batch_manga)
current_offset_manga += page_size
time.sleep(0.6)
except RuntimeError as e:
if "cancelled by user" in str(e).lower():
logger(f" Manga mode pagination stopped due to cancellation: {e}")
else:
logger(f"{e}\n Aborting manga mode pagination.")
break
except Exception as e:
logger(f"❌ Unexpected error during manga mode fetch: {e}")
traceback.print_exc()
break
if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode:
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
if processed_post_ids:
original_count = len(all_posts_for_manga_mode)
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(all_posts_for_manga_mode)
if skipped_count > 0:
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
# --- END OF ADDITION ---
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple(post):
published_date_str = post.get('published')
added_date_str = post.get('added')
post_id_str = post.get('id', "0")
primary_sort_val = "0000-00-00T00:00:00"
if published_date_str:
primary_sort_val = published_date_str
elif added_date_str:
logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.")
primary_sort_val = added_date_str
else:
logger(f" ⚠️ Post ID {post_id_str} missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
secondary_sort_val = 0
try:
secondary_sort_val = int(post_id_str)
except ValueError:
logger(f" ⚠️ Post ID '{post_id_str}' is not a valid integer for secondary sorting, using 0.")
return (primary_sort_val, secondary_sort_val)
all_posts_for_manga_mode.sort(key=sort_key_tuple)
for i in range(0, len(all_posts_for_manga_mode), page_size):
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post yielding cancelled.")
break
yield all_posts_for_manga_mode[i:i + page_size]
return
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check ==STYLE_DATE_POST_TITLE ): if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE):
logger (f" Manga Mode (Style: {STYLE_DATE_POST_TITLE }): Processing posts in default API order (newest first).") logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).")
current_page_num =1 current_page_num = 1
current_offset =0 current_offset = 0
processed_target_post_flag =False processed_target_post_flag = False
if start_page and start_page >1 and not target_post_id : if start_page and start_page > 1 and not target_post_id:
current_offset =(start_page -1 )*page_size current_offset = (start_page - 1) * page_size
current_page_num =start_page current_page_num = start_page
logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).") logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
while True : while True:
if pause_event and pause_event .is_set (): if pause_event and pause_event.is_set():
logger (" Post fetching loop paused...") logger(" Post fetching loop paused...")
while pause_event .is_set (): while pause_event.is_set():
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Post fetching loop cancelled while paused.") logger(" Post fetching loop cancelled while paused.")
break break
time .sleep (0.5 ) time.sleep(0.5)
if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.") if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Post fetching loop cancelled.") logger(" Post fetching loop cancelled.")
break break
if target_post_id and processed_target_post_flag : if target_post_id and processed_target_post_flag:
break break
if not target_post_id and end_page and current_page_num >end_page : if not target_post_id and end_page and current_page_num > end_page:
logger (f"✅ Reached specified end page ({end_page }) for creator feed. Stopping.") logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break break
try : try:
posts_batch =fetch_posts_paginated (api_base_url ,headers ,current_offset ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance (posts_batch ,list ): if not isinstance(posts_batch, list):
logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).") logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
break break
except RuntimeError as e : except RuntimeError as e:
if "cancelled by user"in str (e ).lower (): if "cancelled by user" in str(e).lower():
logger (f" Pagination stopped due to cancellation: {e }") logger(f" Pagination stopped due to cancellation: {e}")
else : else:
logger (f"{e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).") logger(f"{e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).")
break break
except Exception as e : except Exception as e:
logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }") logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback .print_exc () traceback.print_exc()
break break
if not posts_batch :
if target_post_id and not processed_target_post_flag : # --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).") if processed_post_ids:
elif not target_post_id : original_count = len(posts_batch)
if current_page_num ==(start_page or 1 ): posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).") skipped_count = original_count - len(posts_batch)
else : if skipped_count > 0:
logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).") logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
break # --- END OF ADDITION ---
if target_post_id and not processed_target_post_flag :
matching_post =next ((p for p in posts_batch if str (p .get ('id'))==str (target_post_id )),None ) if not posts_batch:
if matching_post : if target_post_id and not processed_target_post_flag:
logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).") logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
yield [matching_post ] elif not target_post_id:
processed_target_post_flag =True if current_page_num == (start_page or 1):
elif not target_post_id : logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
yield posts_batch else:
if processed_target_post_flag : logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
break break
current_offset +=page_size if target_post_id and not processed_target_post_flag:
current_page_num +=1 matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
time .sleep (0.6 ) if matching_post:
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()): logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
logger (f"❌ Target post {target_post_id } could not be found after checking all relevant pages (final check after loop).") yield [matching_post]
processed_target_post_flag = True
elif not target_post_id:
yield posts_batch
if processed_target_post_flag:
break
current_offset += page_size
current_page_num += 1
time.sleep(0.6)
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,6 @@ import sys
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
# --- Asset Management ---
# This global variable will cache the icon so we don't have to load it from disk every time.
_app_icon_cache = None _app_icon_cache = None
def get_app_icon_object(): def get_app_icon_object():
@@ -22,17 +19,11 @@ def get_app_icon_object():
if _app_icon_cache and not _app_icon_cache.isNull(): if _app_icon_cache and not _app_icon_cache.isNull():
return _app_icon_cache return _app_icon_cache
# Declare a single variable to hold the base directory path.
app_base_dir = "" app_base_dir = ""
# Determine the project's base directory, whether running from source or as a bundled app
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# The application is frozen (e.g., with PyInstaller).
# The base directory is the one containing the executable.
app_base_dir = os.path.dirname(sys.executable) app_base_dir = os.path.dirname(sys.executable)
else: else:
# The application is running from a .py file.
# This path navigates up from src/ui/assets.py to the project root.
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico') icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
@@ -40,7 +31,6 @@ def get_app_icon_object():
if os.path.exists(icon_path): if os.path.exists(icon_path):
_app_icon_cache = QIcon(icon_path) _app_icon_cache = QIcon(icon_path)
else: 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'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico') fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
if os.path.exists(fallback_icon_path): if os.path.exists(fallback_icon_path):

View File

@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class CookieHelpDialog(QDialog): class CookieHelpDialog(QDialog):
""" """

View File

@@ -9,11 +9,9 @@ from PyQt5.QtWidgets import (
) )
# --- Local Application Imports --- # --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadExtractedLinksDialog(QDialog): class DownloadExtractedLinksDialog(QDialog):
""" """
@@ -42,21 +40,15 @@ class DownloadExtractedLinksDialog(QDialog):
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically based on the parent window's size # --- START OF FIX ---
if parent: # Get the user-defined scale factor from the parent application.
parent_width = parent.width() scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400 # Define base dimensions and apply the correct scale factor.
scaled_min_w = int(base_min_w * scale_factor) base_width, base_height = 600, 450
scaled_min_h = int(base_min_h * scale_factor) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(scaled_min_w, scaled_min_h) # --- END OF FIX ---
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -141,19 +133,25 @@ class DownloadExtractedLinksDialog(QDialog):
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All")) self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.download_button.setText(self._tr("download_selected_button_text", "Download Selected")) self.download_button.setText(self._tr("download_selected_button_text", "Download Selected"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel")) self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
is_dark_theme = self.parent() and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark" is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme:
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
if is_dark_theme and hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
# Set header text color based on theme # Set header text color based on theme
header_color = Qt.cyan if is_dark_theme else Qt.blue header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()): for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i) item = self.links_list_widget.item(i)
# Headers are not checkable # Headers are not checkable (they have no checkable flag)
if not item.flags() & Qt.ItemIsUserCheckable: if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color) item.setForeground(header_color)
@@ -180,4 +178,4 @@ class DownloadExtractedLinksDialog(QDialog):
self, self,
self._tr("no_selection_title", "No Selection"), self._tr("no_selection_title", "No Selection"),
self._tr("no_selection_message_links", "Please select at least one link to download.") self._tr("no_selection_message_links", "Please select at least one link to download.")
) )

View File

@@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadHistoryDialog (QDialog ): class DownloadHistoryDialog (QDialog ):
@@ -23,7 +24,7 @@ class DownloadHistoryDialog (QDialog ):
self .last_3_downloaded_entries =last_3_downloaded_entries self .last_3_downloaded_entries =last_3_downloaded_entries
self .first_processed_entries =first_processed_entries self .first_processed_entries =first_processed_entries
self .setModal (True ) self .setModal (True )
self._apply_theme()
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available # Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
creator_name_cache = getattr(parent_app, 'creator_name_cache', None) creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache: if creator_name_cache:
@@ -158,6 +159,14 @@ class DownloadHistoryDialog (QDialog ):
return get_translation (self .parent_app .current_selected_language ,key ,default_text ) return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text return default_text
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _save_history_to_txt (self ): def _save_history_to_txt (self ):
if not self .last_3_downloaded_entries and not self .first_processed_entries : if not self .last_3_downloaded_entries and not self .first_processed_entries :
QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"), QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"),

View File

@@ -21,6 +21,7 @@ from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...core.api_client import download_from_api from ...core.api_client import download_from_api
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
from ...utils.resolution import get_dark_theme
class PostsFetcherThread (QThread ): class PostsFetcherThread (QThread ):
@@ -129,6 +130,7 @@ class PostsFetcherThread (QThread ):
self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators.")) self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators."))
self .finished_signal .emit () self .finished_signal .emit ()
class EmptyPopupDialog (QDialog ): class EmptyPopupDialog (QDialog ):
"""A simple empty popup dialog.""" """A simple empty popup dialog."""
SCOPE_CHARACTERS ="Characters" SCOPE_CHARACTERS ="Characters"
@@ -138,13 +140,12 @@ class EmptyPopupDialog (QDialog ):
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ): def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
super ().__init__ (parent ) super ().__init__ (parent )
self .setMinimumSize (400 ,300 ) self.parent_app = parent_app_ref
screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
scale_factor =screen_height /768.0
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
self .parent_app =parent_app_ref scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
self .current_scope_mode =self .SCOPE_CHARACTERS
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
self.current_scope_mode = self.SCOPE_CREATORS
self .app_base_dir =app_base_dir self .app_base_dir =app_base_dir
app_icon =get_app_icon_object () app_icon =get_app_icon_object ()
@@ -289,9 +290,14 @@ class EmptyPopupDialog (QDialog ):
self ._retranslate_ui () self ._retranslate_ui ()
if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark": if self.parent_app and self.parent_app.current_theme == "dark":
self .setStyleSheet (self .parent_app .get_dark_theme ()) # Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor )) self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor ))
@@ -997,4 +1003,4 @@ class EmptyPopupDialog (QDialog ):
else : else :
if unique_key in self .globally_selected_creators : if unique_key in self .globally_selected_creators :
del self .globally_selected_creators [unique_key ] del self .globally_selected_creators [unique_key ]
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))

View File

@@ -10,7 +10,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase. # Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog from .ExportOptionsDialog import ExportOptionsDialog
from ...utils.resolution import get_dark_theme
class ErrorFilesDialog(QDialog): class ErrorFilesDialog(QDialog):
""" """
@@ -42,13 +42,11 @@ class ErrorFilesDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 1080.0 base_width, base_height = 550, 400
base_min_w, base_min_h = 500, 300 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
scaled_min_w = int(base_min_w * scale_factor) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -132,9 +130,14 @@ class ErrorFilesDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
if hasattr(self.parent_app, 'get_dark_theme'): # Get the scale factor from the parent app
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _select_all_items(self): def _select_all_items(self):
"""Checks all items in the list.""" """Checks all items in the list."""
@@ -227,4 +230,4 @@ class ErrorFilesDialog(QDialog):
self, self,
self._tr("error_files_export_error_title", "Export Error"), self._tr("error_files_export_error_title", "Export Error"),
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e)) self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
) )

View File

@@ -10,7 +10,7 @@ from PyQt5.QtWidgets import (
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan. # get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class ExportOptionsDialog(QDialog): class ExportOptionsDialog(QDialog):
""" """

View File

@@ -16,7 +16,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...utils.resolution import get_dark_theme
class FavoriteArtistsDialog (QDialog ): class FavoriteArtistsDialog (QDialog ):
"""Dialog to display and select favorite artists.""" """Dialog to display and select favorite artists."""
@@ -126,6 +126,21 @@ class FavoriteArtistsDialog (QDialog ):
self .artist_list_widget .setVisible (show ) self .artist_list_widget .setVisible (show )
def _fetch_favorite_artists (self ): def _fetch_favorite_artists (self ):
if self.cookies_config['use_cookie']:
# Check if we can load cookies for at least one of the services.
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
if not kemono_cookies and not coomer_cookies:
# If cookies are enabled but none could be loaded, show help and stop.
self.status_label.setText(self._tr("fav_artists_cookies_required_status", "Error: Cookies enabled but could not be loaded for any source."))
self._logger("Error: Cookies enabled but no valid cookies were loaded. Showing help dialog.")
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog.exec_()
self.download_button.setEnabled(False)
return # Stop further execution
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist" kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist" coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"

View File

@@ -25,7 +25,7 @@ from ...utils.network_utils import prepare_cookies_for_request
# Corrected Import: Import CookieHelpDialog directly from its own module # Corrected Import: Import CookieHelpDialog directly from its own module
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api from ...core.api_client import download_from_api
from ...utils.resolution import get_dark_theme
class FavoritePostsFetcherThread (QThread ): class FavoritePostsFetcherThread (QThread ):
"""Worker thread to fetch favorite posts and creator names.""" """Worker thread to fetch favorite posts and creator names."""

View File

@@ -1,171 +1,246 @@
# --- Standard Library Imports --- # --- Standard Library Imports ---
import os import os
import json
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox QGroupBox, QComboBox, QMessageBox, QGridLayout
) )
# --- Local Application Imports --- # --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...config.constants import ( from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY
) )
class FutureSettingsDialog(QDialog): class FutureSettingsDialog(QDialog):
""" """
A dialog for managing application-wide settings like theme, language, A dialog for managing application-wide settings like theme, language,
and saving the default download path. and display options, with an organized layout.
""" """
def __init__(self, parent_app_ref, parent=None): def __init__(self, parent_app_ref, parent=None):
"""
Initializes the dialog.
Args:
parent_app_ref (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent) super().__init__(parent)
self.parent_app = parent_app_ref self.parent_app = parent_app_ref
self.setModal(True) self.setModal(True)
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 scale_factor = screen_height / 800.0
scale_factor = screen_height / 768.0 base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
base_min_w, base_min_h = 380, 250
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
def _init_ui(self): def _init_ui(self):
"""Initializes all UI components and layouts for the dialog.""" """Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
# --- Appearance Settings --- # --- Group 1: Interface Settings ---
self.appearance_group_box = QGroupBox() self.interface_group_box = QGroupBox()
appearance_layout = QVBoxLayout(self.appearance_group_box) interface_layout = QGridLayout(self.interface_group_box)
# Theme
self.theme_label = QLabel()
self.theme_toggle_button = QPushButton() self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme) self.theme_toggle_button.clicked.connect(self._toggle_theme)
appearance_layout.addWidget(self.theme_toggle_button) interface_layout.addWidget(self.theme_label, 0, 0)
layout.addWidget(self.appearance_group_box) interface_layout.addWidget(self.theme_toggle_button, 0, 1)
# --- Language Settings --- # UI Scale
self.language_group_box = QGroupBox() self.ui_scale_label = QLabel()
language_group_layout = QVBoxLayout(self.language_group_box) self.ui_scale_combo_box = QComboBox()
self.language_selection_layout = QHBoxLayout() self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
interface_layout.addWidget(self.ui_scale_label, 1, 0)
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
# Language
self.language_label = QLabel() self.language_label = QLabel()
self.language_selection_layout.addWidget(self.language_label)
self.language_combo_box = QComboBox() self.language_combo_box = QComboBox()
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed) self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
self.language_selection_layout.addWidget(self.language_combo_box, 1) interface_layout.addWidget(self.language_label, 2, 0)
language_group_layout.addLayout(self.language_selection_layout) interface_layout.addWidget(self.language_combo_box, 2, 1)
layout.addWidget(self.language_group_box)
main_layout.addWidget(self.interface_group_box)
# --- Download Settings ---
self.download_settings_group_box = QGroupBox() # --- Group 2: Download & Window Settings ---
download_settings_layout = QVBoxLayout(self.download_settings_group_box) self.download_window_group_box = QGroupBox()
download_window_layout = QGridLayout(self.download_window_group_box)
# Window Size (Resolution)
self.window_size_label = QLabel()
self.resolution_combo_box = QComboBox()
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
download_window_layout.addWidget(self.window_size_label, 0, 0)
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
# Default Path
self.default_path_label = QLabel()
self.save_path_button = QPushButton() self.save_path_button = QPushButton()
self.save_path_button.clicked.connect(self._save_download_path) self.save_path_button.clicked.connect(self._save_download_path)
download_settings_layout.addWidget(self.save_path_button) download_window_layout.addWidget(self.default_path_label, 1, 0)
layout.addWidget(self.download_settings_group_box) download_window_layout.addWidget(self.save_path_button, 1, 1)
layout.addStretch(1) main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1)
# --- OK Button --- # --- OK Button ---
self.ok_button = QPushButton() self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept) self.ok_button.clicked.connect(self.accept)
layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom) main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _tr(self, key, default_text=""): def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app: if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text) return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text return default_text
def _retranslate_ui(self): def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("settings_dialog_title", "Settings")) self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
self.appearance_group_box.setTitle(self._tr("appearance_group_title", "Appearance"))
self.language_group_box.setTitle(self._tr("language_group_title", "Language Settings"))
self.download_settings_group_box.setTitle(self._tr("settings_download_group_title", "Download Settings"))
self.language_label.setText(self._tr("language_label", "Language:"))
self._update_theme_toggle_button_text()
self._populate_language_combo_box()
# Group Box Titles
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
# Interface Group Labels
self.theme_label.setText(self._tr("theme_label", "Theme:"))
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
self.language_label.setText(self._tr("language_label", "Language:"))
# Download & Window Group Labels
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
# Buttons and Controls
self._update_theme_toggle_button_text()
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path")) self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions.")) self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK")) self.ok_button.setText(self._tr("ok_button", "OK"))
# Populate dropdowns
self._populate_display_combo_boxes()
self._populate_language_combo_box()
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" if self.parent_app and self.parent_app.current_theme == "dark":
if self.parent_app.current_theme == "dark": scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(self.parent_app.get_dark_theme()) self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("") self.setStyleSheet("")
def _update_theme_toggle_button_text(self): def _update_theme_toggle_button_text(self):
"""Updates the theme button text and tooltip based on the current theme."""
if self.parent_app.current_theme == "dark": if self.parent_app.current_theme == "dark":
self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode")) self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_light", "Change the application appearance to light."))
else: else:
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode")) self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_dark", "Change the application appearance to dark."))
def _toggle_theme(self): def _toggle_theme(self):
"""Toggles the application theme and updates the UI."""
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark" new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
self.parent_app.apply_theme(new_theme) self.parent_app.settings.setValue(THEME_KEY, new_theme)
self._retranslate_ui() self.parent_app.settings.sync()
self.parent_app.current_theme = new_theme
self._apply_theme() self._apply_theme()
if hasattr(self.parent_app, '_apply_theme_and_restart_prompt'):
self.parent_app._apply_theme_and_restart_prompt()
def _populate_display_combo_boxes(self):
self.resolution_combo_box.blockSignals(True)
self.resolution_combo_box.clear()
resolutions = [
("Auto", self._tr("auto_resolution", "Auto (System Default)")),
("1280x720", "1280 x 720"),
("1600x900", "1600 x 900"),
("1920x1080", "1920 x 1080 (Full HD)"),
("2560x1440", "2560 x 1440 (2K)"),
("3840x2160", "3840 x 2160 (4K)")
]
current_res = self.parent_app.settings.value(RESOLUTION_KEY, "Auto")
for res_key, res_name in resolutions:
self.resolution_combo_box.addItem(res_name, res_key)
if current_res == res_key:
self.resolution_combo_box.setCurrentIndex(self.resolution_combo_box.count() - 1)
self.resolution_combo_box.blockSignals(False)
self.ui_scale_combo_box.blockSignals(True)
self.ui_scale_combo_box.clear()
scales = [
(0.5, "50%"),
(0.7, "70%"),
(0.9, "90%"),
(1.0, "100% (Default)"),
(1.25, "125%"),
(1.50, "150%"),
(1.75, "175%"),
(2.0, "200%")
]
current_scale = float(self.parent_app.settings.value(UI_SCALE_KEY, 1.0))
for scale_val, scale_name in scales:
self.ui_scale_combo_box.addItem(scale_name, scale_val)
if abs(current_scale - scale_val) < 0.01:
self.ui_scale_combo_box.setCurrentIndex(self.ui_scale_combo_box.count() - 1)
self.ui_scale_combo_box.blockSignals(False)
def _display_setting_changed(self):
selected_res = self.resolution_combo_box.currentData()
selected_scale = self.ui_scale_combo_box.currentData()
self.parent_app.settings.setValue(RESOLUTION_KEY, selected_res)
self.parent_app.settings.setValue(UI_SCALE_KEY, selected_scale)
self.parent_app.settings.sync()
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("display_change_title", "Display Settings Changed"))
msg_box.setText(self._tr("language_change_message", "A restart is required for these changes to take effect."))
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
msg_box.setDefaultButton(ok_button)
msg_box.exec_()
if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application()
def _populate_language_combo_box(self): def _populate_language_combo_box(self):
"""Populates the language dropdown with available languages."""
self.language_combo_box.blockSignals(True) self.language_combo_box.blockSignals(True)
self.language_combo_box.clear() self.language_combo_box.clear()
languages = [ languages = [
("en","English"), ("en", "English"), ("ja", "日本語 (Japanese)"), ("fr", "Français (French)"),
("ja","日本語 (Japanese)"), ("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
("fr","Français (French)"), ("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
("de","Deutsch (German)"), ("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
("es","Español (Spanish)"),
("pt","Português (Portuguese)"),
("ru","Русский (Russian)"),
("zh_CN","简体中文 (Simplified Chinese)"),
("zh_TW","繁體中文 (Traditional Chinese)"),
("ko","한국어 (Korean)")
] ]
current_lang = self.parent_app.current_selected_language
for lang_code, lang_name in languages: for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code) self.language_combo_box.addItem(lang_name, lang_code)
if self.parent_app.current_selected_language == lang_code: if current_lang == lang_code:
self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1) self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
self.language_combo_box.blockSignals(False) self.language_combo_box.blockSignals(False)
def _language_selection_changed(self, index): def _language_selection_changed(self, index):
"""Handles the user selecting a new language."""
selected_lang_code = self.language_combo_box.itemData(index) selected_lang_code = self.language_combo_box.itemData(index)
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language: if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
self.parent_app.current_selected_language = selected_lang_code
self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code) self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
self.parent_app.settings.sync() self.parent_app.settings.sync()
self.parent_app.current_selected_language = selected_lang_code
self._retranslate_ui() self._retranslate_ui()
if hasattr(self.parent_app, '_retranslate_main_ui'):
self.parent_app._retranslate_main_ui()
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information) msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed")) msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed"))
@@ -180,23 +255,21 @@ class FutureSettingsDialog(QDialog):
self.parent_app._request_restart_application() self.parent_app._request_restart_application()
def _save_download_path(self): def _save_download_path(self):
"""Saves the current download path from the main window to settings."""
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input: if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip() current_path = self.parent_app.dir_input.text().strip()
if current_path: if current_path and os.path.isdir(current_path):
if os.path.isdir(current_path): self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) self.parent_app.settings.sync()
self.parent_app.settings.sync() QMessageBox.information(self,
QMessageBox.information(self, self._tr("settings_save_path_success_title", "Path Saved"),
self._tr("settings_save_path_success_title", "Path Saved"), self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path)) elif not current_path:
else: QMessageBox.warning(self,
QMessageBox.warning(self,
self._tr("settings_save_path_invalid_title", "Invalid Path"),
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
else:
QMessageBox.warning(self,
self._tr("settings_save_path_empty_title", "Empty Path"), self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty.")) self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
else:
QMessageBox.warning(self,
self._tr("settings_save_path_invalid_title", "Invalid Path"),
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
else: else:
QMessageBox.critical(self, "Error", "Could not access download path input from main application.") QMessageBox.critical(self, "Error", "Could not access download path input from main application.")

View File

@@ -4,7 +4,7 @@ import sys
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget QStackedWidget, QScrollArea, QFrame, QWidget
@@ -13,24 +13,27 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class TourStepWidget(QWidget): class TourStepWidget(QWidget):
""" """
A custom widget representing a single step or page in the feature guide. A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding content. It neatly formats a title and its corresponding content.
""" """
def __init__(self, title_text, content_text, parent=None): def __init__(self, title_text, content_text, parent=None, scale=1.0):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) layout.setSpacing(10)
title_font_size = int(14 * scale)
content_font_size = int(11 * scale)
title_label = QLabel(title_text) title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label) layout.addWidget(title_label)
scroll_area = QScrollArea() scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True) scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setFrameShape(QFrame.NoFrame)
@@ -42,8 +45,8 @@ class TourStepWidget(QWidget):
content_label.setWordWrap(True) content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText) content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True) # Allow opening links in the content content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label) scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1) layout.addWidget(scroll_area, 1)
@@ -56,27 +59,38 @@ class HelpGuideDialog (QDialog ):
self .steps_data =steps_data self .steps_data =steps_data
self .parent_app =parent_app self .parent_app =parent_app
app_icon =get_app_icon_object () scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
self .setModal (True ) self.setModal(True)
self .setFixedSize (650 ,600 ) self.resize(int(650 * scale), int(600 * scale))
dialog_font_size = int(11 * scale)
current_theme_style = ""
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale)
else:
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
QPushButton {{
background-color: #E1E1E1;
color: #1E1E1E;
border: 1px solid #ADADAD;
padding: {int(8*scale)}px {int(15*scale)}px;
border-radius: 4px;
min-height: {int(25*scale)}px;
font-size: {dialog_font_size}pt;
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
"""
current_theme_style ="" self.setStyleSheet(current_theme_style)
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
if hasattr (self .parent_app ,'get_dark_theme'):
current_theme_style =self .parent_app .get_dark_theme ()
self .setStyleSheet (current_theme_style if current_theme_style else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self ._init_ui () self ._init_ui ()
if self .parent_app : if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
@@ -97,10 +111,11 @@ class HelpGuideDialog (QDialog ):
main_layout .addWidget (self .stacked_widget ,1 ) main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[] self .tour_steps_widgets =[]
for title ,content in self .steps_data : scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
step_widget =TourStepWidget (title ,content ) for title, content in self.steps_data:
self .tour_steps_widgets .append (step_widget ) step_widget = TourStepWidget(title, content, scale=scale)
self .stacked_widget .addWidget (step_widget ) self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
@@ -115,7 +130,6 @@ class HelpGuideDialog (QDialog ):
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS assets_base_dir =sys ._MEIPASS
else : else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
@@ -126,7 +140,9 @@ class HelpGuideDialog (QDialog ):
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
icon_size =QSize (24 ,24 ) scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size ) self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size ) self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size ) self .Discord_button .setIconSize (icon_size )

View File

@@ -0,0 +1,122 @@
# KeepDuplicatesDialog.py
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
class KeepDuplicatesDialog(QDialog):
"""A dialog to choose the duplicate handling method, with a limit option."""
def __init__(self, current_mode, current_limit, parent=None):
super().__init__(parent)
self.parent_app = parent
self.selected_mode = current_mode
self.limit = current_limit
self._init_ui()
self._retranslate_ui()
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
# Set the initial state based on current settings
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
else:
self.radio_skip_by_hash.setChecked(True)
self.limit_input.setEnabled(False)
def _init_ui(self):
"""Initializes the UI components."""
main_layout = QVBoxLayout(self)
info_label = QLabel()
info_label.setWordWrap(True)
main_layout.addWidget(info_label)
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
# --- Skip by Hash Option ---
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
# --- Keep Everything Option with Limit Input ---
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
keep_everything_layout.addWidget(self.radio_keep_everything)
keep_everything_layout.addStretch(1)
self.limit_label = QLabel()
self.limit_input = QLineEdit()
self.limit_input.setValidator(QIntValidator(0, 99))
self.limit_input.setFixedWidth(50)
keep_everything_layout.addWidget(self.limit_label)
keep_everything_layout.addWidget(self.limit_input)
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
# --- OK and Cancel buttons ---
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
button_layout.addStretch(1)
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
# --- Connections ---
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -8,13 +8,12 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class KnownNamesFilterDialog(QDialog): class KnownNamesFilterDialog(QDialog):
""" """
A dialog to select names from the Known.txt list to add to the main A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads. to reuse their saved names and groups for filtering downloads.
""" """
@@ -40,11 +39,10 @@ class KnownNamesFilterDialog(QDialog):
# Set window size dynamically # Set window size dynamically
screen_geometry = QApplication.primaryScreen().availableGeometry() screen_geometry = QApplication.primaryScreen().availableGeometry()
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_width, base_height = 460, 450 base_width, base_height = 460, 450
scale_factor_h = screen_geometry.height() / 1080.0 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor))
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -102,8 +100,14 @@ class KnownNamesFilterDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme()) # Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _populate_list_widget(self): def _populate_list_widget(self):
"""Populates the list widget with the known names.""" """Populates the list widget with the known names."""
@@ -147,4 +151,4 @@ class KnownNamesFilterDialog(QDialog):
def get_selected_entries(self): def get_selected_entries(self):
"""Returns the list of known name entries selected by the user.""" """Returns the list of known name entries selected by the user."""
return self.selected_entries_to_return return self.selected_entries_to_return

View File

@@ -0,0 +1,96 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
)
from PyQt5.QtCore import Qt
from ...utils.resolution import get_dark_theme
class MoreOptionsDialog(QDialog):
"""
A dialog for selecting a scope, export format, and single PDF option.
"""
SCOPE_CONTENT = "content"
SCOPE_COMMENTS = "comments"
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("More Options")
self.setMinimumWidth(350)
# ... (Layout and other widgets remain the same) ...
layout = QVBoxLayout(self)
self.description_label = QLabel("Please choose the scope for the action:")
layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments (Not Working)")
self.radio_button_group.addButton(self.radio_content)
self.radio_button_group.addButton(self.radio_comments)
layout.addWidget(self.radio_content)
layout.addWidget(self.radio_comments)
if current_scope == self.SCOPE_COMMENTS:
self.radio_comments.setChecked(True)
else:
self.radio_content.setChecked(True)
export_layout = QHBoxLayout()
export_label = QLabel("Export as:")
self.format_combo = QComboBox()
self.format_combo.addItems(["PDF", "DOCX", "TXT"])
if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]:
self.format_combo.setCurrentText(current_format.upper())
else:
self.format_combo.setCurrentText("PDF")
export_layout.addWidget(export_label)
export_layout.addWidget(self.format_combo)
export_layout.addStretch()
layout.addLayout(export_layout)
# --- UPDATED: Single PDF Checkbox ---
self.single_pdf_checkbox = QCheckBox("Single PDF")
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
self.single_pdf_checkbox.setChecked(single_pdf_checked)
layout.addWidget(self.single_pdf_checkbox)
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
self.setLayout(layout)
self._apply_theme()
def update_single_pdf_checkbox_state(self, text):
"""Enable the Single PDF checkbox only if the format is PDF."""
is_pdf = (text.upper() == "PDF")
self.single_pdf_checkbox.setEnabled(is_pdf)
if not is_pdf:
self.single_pdf_checkbox.setChecked(False)
def get_selected_scope(self):
if self.radio_comments.isChecked():
return self.SCOPE_COMMENTS
return self.SCOPE_CONTENT
def get_selected_format(self):
return self.format_combo.currentText().lower()
def get_single_pdf_state(self):
"""Returns the state of the Single PDF checkbox."""
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")

View File

@@ -0,0 +1,77 @@
# SinglePDF.py
import os
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
except ImportError:
FPDF_AVAILABLE = False
class PDF(FPDF):
"""Custom PDF class to handle headers and footers."""
def header(self):
# No header
pass
def footer(self):
# Position at 1.5 cm from bottom
self.set_y(-15)
self.set_font('DejaVu', '', 8)
# Page number
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
"""
Creates a single PDF from a list of post titles and content.
Args:
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
output_filename (str): The full path for the output PDF file.
font_path (str): Path to the DejaVuSans.ttf font file.
logger (function, optional): A function to log progress and errors. Defaults to print.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
return False
if not posts_data:
logger(" No text content was collected to create a PDF.")
return False
pdf = PDF()
try:
if not os.path.exists(font_path):
raise RuntimeError("Font file not found.")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
logger(" PDF may not support all characters. Falling back to default Arial font.")
pdf.set_font('Arial', '', 12)
pdf.set_font('Arial', 'B', 16)
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
for post in posts_data:
pdf.add_page()
# Post Title
pdf.set_font('DejaVu', 'B', 16)
# vvv THIS LINE IS CORRECTED vvv
# We explicitly set align='L' and remove the incorrect positional arguments.
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5) # Add a little space after the title
# Post Content
pdf.set_font('DejaVu', '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
try:
pdf.output(output_filename)
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
return True
except Exception as e:
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
return False

View File

@@ -0,0 +1,155 @@
# src/ui/dialogs/SupportDialog.py
# --- Standard Library Imports ---
import sys
import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QPixmap
# --- Local Application Imports ---
from ...utils.resolution import get_dark_theme
# --- Helper function for robust asset loading ---
def get_asset_path(filename):
"""
Gets the absolute path to a file in the assets folder,
handling both development and frozen (PyInstaller) environments.
"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running in a normal Python environment from src/ui/dialogs/
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base_path, 'assets', filename)
class SupportDialog(QDialog):
"""
A dialog to show support and donation options.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout
main_layout = QVBoxLayout(self)
main_layout.setSpacing(15)
# Title Label
title_label = QLabel("Thank You for Your Support!")
font = title_label.font()
font.setPointSize(14)
font.setBold(True)
title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
# Informational Text
info_label = QLabel(
"If you find this application useful, please consider supporting its development. "
"Your contribution helps cover costs and encourages future updates and features."
)
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(info_label)
# Separator
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(line)
# --- Donation Options Layout (using a grid for icons and text) ---
options_layout = QGridLayout()
options_layout.setSpacing(18)
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally
options_layout.setColumnStretch(3, 1)
link_font = self.font()
link_font.setPointSize(12)
link_font.setBold(True)
scale = getattr(self.parent_app, 'scale_factor', 1.0)
icon_size = int(32 * scale)
# --- Ko-fi ---
kofi_icon_label = QLabel()
kofi_pixmap = QPixmap(get_asset_path("kofi.png"))
if not kofi_pixmap.isNull():
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
kofi_text_label = QLabel(
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">'
'☕ Buy me a Ko-fi'
'</a>'
)
kofi_text_label.setOpenExternalLinks(True)
kofi_text_label.setFont(link_font)
options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- GitHub Sponsors ---
github_icon_label = QLabel()
github_pixmap = QPixmap(get_asset_path("github_sponsors.png"))
if not github_pixmap.isNull():
github_icon_label.setPixmap(github_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
github_text_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #EA4AAA; text-decoration: none;">'
'💜 Sponsor on GitHub'
'</a>'
)
github_text_label.setOpenExternalLinks(True)
github_text_label.setFont(link_font)
options_layout.addWidget(github_icon_label, 1, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(github_text_label, 1, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- Buy Me a Coffee (New) ---
bmac_icon_label = QLabel()
bmac_pixmap = QPixmap(get_asset_path("bmac.png"))
if not bmac_pixmap.isNull():
bmac_icon_label.setPixmap(bmac_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
bmac_text_label = QLabel(
'<a href="https://buymeacoffee.com/yuvi9587" style="color: #FFDD00; text-decoration: none;">'
'🍺 Buy Me a Coffee'
'</a>'
)
bmac_text_label.setOpenExternalLinks(True)
bmac_text_label.setFont(link_font)
options_layout.addWidget(bmac_icon_label, 2, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(bmac_text_label, 2, 2, Qt.AlignLeft | Qt.AlignVCenter)
main_layout.addLayout(options_layout)
# Close Button
self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject)
main_layout.addWidget(self.button_box)
self.setLayout(main_layout)
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")

View File

@@ -12,6 +12,7 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
from ...config.constants import ( from ...config.constants import (
CONFIG_ORGANIZATION_NAME CONFIG_ORGANIZATION_NAME
) )
@@ -150,8 +151,9 @@ class TourDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }") self.setStyleSheet("QDialog { background-color: #f0f0f0; }")

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
# Sets of file extensions for quick type checking # Sets of file extensions for quick type checking
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

580
src/utils/resolution.py Normal file
View File

@@ -0,0 +1,580 @@
# src/ui/utils/resolution.py
# --- Standard Library Imports ---
import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QSplitter, QScrollArea, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QStackedWidget, QButtonGroup, QRadioButton, QCheckBox,
QListWidget, QTextEdit, QApplication
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIntValidator, QFont # <-- Import QFont
# --- Local Application Imports ---
# Assuming execution from project root
from ..config.constants import *
def setup_ui(main_app):
"""
Initializes and scales the user interface for the DownloaderApp.
Args:
main_app: The instance of the main DownloaderApp.
"""
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
main_app.scale_factor = scale
default_font = QApplication.font()
base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font)
default_font = QApplication.font()
base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font)
# --- END: Improved Scaling Logic ---
main_app.main_splitter = QSplitter(Qt.Horizontal)
# --- Use a scroll area for the left panel for consistency ---
left_scroll_area = QScrollArea()
left_scroll_area.setWidgetResizable(True)
left_scroll_area.setFrameShape(QFrame.NoFrame)
left_panel_widget = QWidget()
left_layout = QVBoxLayout(left_panel_widget)
left_scroll_area.setWidget(left_panel_widget)
right_panel_widget = QWidget()
right_layout = QVBoxLayout(right_panel_widget)
left_layout.setContentsMargins(10, 10, 10, 10)
right_layout.setContentsMargins(10, 10, 10, 10)
apply_theme_to_app(main_app, main_app.current_theme, initial_load=True)
# --- URL and Page Range ---
main_app.url_input_widget = QWidget()
url_input_layout = QHBoxLayout(main_app.url_input_widget)
url_input_layout.setContentsMargins(0, 0, 0, 0)
main_app.url_label_widget = QLabel()
url_input_layout.addWidget(main_app.url_label_widget)
main_app.link_input = QLineEdit()
main_app.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
main_app.link_input.textChanged.connect(main_app.update_custom_folder_visibility)
url_input_layout.addWidget(main_app.link_input, 1)
main_app.empty_popup_button = QPushButton("🎨")
special_font_size = int(9.5 * scale)
main_app.empty_popup_button.setStyleSheet(f"""
padding: {4*scale}px {6*scale}px;
font-size: {special_font_size}pt;
""")
main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup)
url_input_layout.addWidget(main_app.empty_popup_button)
main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:"))
main_app.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;")
url_input_layout.addWidget(main_app.page_range_label)
main_app.start_page_input = QLineEdit()
main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start"))
main_app.start_page_input.setFixedWidth(int(50 * scale))
main_app.start_page_input.setValidator(QIntValidator(1, 99999))
url_input_layout.addWidget(main_app.start_page_input)
main_app.to_label = QLabel(main_app._tr("page_range_to_label_text", "to"))
url_input_layout.addWidget(main_app.to_label)
main_app.end_page_input = QLineEdit()
main_app.end_page_input.setPlaceholderText(main_app._tr("end_page_input_placeholder", "End"))
main_app.end_page_input.setFixedWidth(int(50 * scale))
main_app.end_page_input.setToolTip(main_app._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number..."))
main_app.end_page_input.setValidator(QIntValidator(1, 99999))
url_input_layout.addWidget(main_app.end_page_input)
main_app.url_placeholder_widget = QWidget()
placeholder_layout = QHBoxLayout(main_app.url_placeholder_widget)
placeholder_layout.setContentsMargins(0, 0, 0, 0)
main_app.fav_mode_active_label = QLabel(main_app._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active..."))
main_app.fav_mode_active_label.setAlignment(Qt.AlignCenter)
placeholder_layout.addWidget(main_app.fav_mode_active_label)
main_app.url_or_placeholder_stack = QStackedWidget()
main_app.url_or_placeholder_stack.addWidget(main_app.url_input_widget)
main_app.url_or_placeholder_stack.addWidget(main_app.url_placeholder_widget)
left_layout.addWidget(main_app.url_or_placeholder_stack)
# --- Download Location ---
main_app.download_location_label_widget = QLabel()
left_layout.addWidget(main_app.download_location_label_widget)
dir_layout = QHBoxLayout()
main_app.dir_input = QLineEdit()
main_app.dir_input.setPlaceholderText("Select folder where downloads will be saved")
main_app.dir_button = QPushButton("Browse...")
main_app.dir_button.clicked.connect(main_app.browse_directory)
dir_layout.addWidget(main_app.dir_input, 1)
dir_layout.addWidget(main_app.dir_button)
left_layout.addLayout(dir_layout)
# --- Filters and Custom Folder Container ---
main_app.filters_and_custom_folder_container_widget = QWidget()
filters_and_custom_folder_layout = QHBoxLayout(main_app.filters_and_custom_folder_container_widget)
filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0)
filters_and_custom_folder_layout.setSpacing(10)
main_app.character_filter_widget = QWidget()
character_filter_v_layout = QVBoxLayout(main_app.character_filter_widget)
character_filter_v_layout.setContentsMargins(0, 0, 0, 0)
character_filter_v_layout.setSpacing(2)
main_app.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
character_filter_v_layout.addWidget(main_app.character_label)
char_input_and_button_layout = QHBoxLayout()
char_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
char_input_and_button_layout.setSpacing(10)
main_app.character_input = QLineEdit()
main_app.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)")
char_input_and_button_layout.addWidget(main_app.character_input, 3)
main_app.char_filter_scope_toggle_button = QPushButton()
main_app._update_char_filter_scope_button_text()
char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1)
character_filter_v_layout.addLayout(char_input_and_button_layout)
# --- Custom Folder Widget Definition ---
main_app.custom_folder_widget = QWidget()
custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget)
custom_folder_v_layout.setContentsMargins(0, 0, 0, 0)
custom_folder_v_layout.setSpacing(2)
main_app.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):")
main_app.custom_folder_input = QLineEdit()
main_app.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder")
custom_folder_v_layout.addWidget(main_app.custom_folder_label)
custom_folder_v_layout.addWidget(main_app.custom_folder_input)
main_app.custom_folder_widget.setVisible(False)
filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1)
filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1)
left_layout.addWidget(main_app.filters_and_custom_folder_container_widget)
# --- Word Manipulation Container ---
word_manipulation_container_widget = QWidget()
word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget)
word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0)
word_manipulation_outer_layout.setSpacing(15)
skip_words_widget = QWidget()
skip_words_vertical_layout = QVBoxLayout(skip_words_widget)
skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
skip_words_vertical_layout.setSpacing(2)
main_app.skip_words_label_widget = QLabel()
skip_words_vertical_layout.addWidget(main_app.skip_words_label_widget)
skip_input_and_button_layout = QHBoxLayout()
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
skip_input_and_button_layout.setSpacing(10)
main_app.skip_words_input = QLineEdit()
main_app.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
skip_input_and_button_layout.addWidget(main_app.skip_words_input, 1)
main_app.skip_scope_toggle_button = QPushButton()
main_app._update_skip_scope_button_text()
skip_input_and_button_layout.addWidget(main_app.skip_scope_toggle_button, 0)
skip_words_vertical_layout.addLayout(skip_input_and_button_layout)
word_manipulation_outer_layout.addWidget(skip_words_widget, 7)
remove_words_widget = QWidget()
remove_words_vertical_layout = QVBoxLayout(remove_words_widget)
remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
remove_words_vertical_layout.setSpacing(2)
main_app.remove_from_filename_label_widget = QLabel()
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_label_widget)
main_app.remove_from_filename_input = QLineEdit()
main_app.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD")
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_input)
word_manipulation_outer_layout.addWidget(remove_words_widget, 3)
left_layout.addWidget(word_manipulation_container_widget)
# --- File Filter Layout ---
file_filter_layout = QVBoxLayout()
file_filter_layout.setContentsMargins(0, 10, 0, 0)
file_filter_layout.addWidget(QLabel("Filter Files:"))
radio_button_layout = QHBoxLayout()
radio_button_layout.setSpacing(10)
main_app.radio_group = QButtonGroup(main_app)
main_app.radio_all = QRadioButton("All")
main_app.radio_images = QRadioButton("Images/GIFs")
main_app.radio_videos = QRadioButton("Videos")
main_app.radio_only_archives = QRadioButton("📦 Only Archives")
main_app.radio_only_audio = QRadioButton("🎧 Only Audio")
main_app.radio_only_links = QRadioButton("🔗 Only Links")
main_app.radio_more = QRadioButton("More")
main_app.radio_all.setChecked(True)
for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]:
main_app.radio_group.addButton(btn)
radio_button_layout.addWidget(btn)
main_app.favorite_mode_checkbox = QCheckBox()
main_app.favorite_mode_checkbox.setChecked(False)
radio_button_layout.addWidget(main_app.favorite_mode_checkbox)
radio_button_layout.addStretch(1)
file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout)
# --- Checkboxes Group ---
checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10)
row1_layout = QHBoxLayout()
row1_layout.setSpacing(10)
main_app.skip_zip_checkbox = QCheckBox("Skip archives")
main_app.skip_zip_checkbox.setToolTip("Skip Common Archives (Eg.. Zip, Rar, 7z)")
main_app.skip_zip_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_zip_checkbox)
main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
row1_layout.addWidget(main_app.download_thumbnails_checkbox)
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
main_app.scan_content_images_checkbox.setChecked(main_app.scan_content_images_setting)
row1_layout.addWidget(main_app.scan_content_images_checkbox)
main_app.compress_images_checkbox = QCheckBox("Compress to WebP")
main_app.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
row1_layout.addWidget(main_app.compress_images_checkbox)
main_app.keep_duplicates_checkbox = QCheckBox("Keep Duplicates")
main_app.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.")
row1_layout.addWidget(main_app.keep_duplicates_checkbox)
row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout)
# --- Advanced Settings ---
advanced_settings_label = QLabel("⚙️ Advanced Settings:")
checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
main_app.cookie_text_input.setPlaceholderText("if no Select cookies.txt)")
main_app.cookie_text_input.setText(main_app.cookie_text_setting)
advanced_row1_layout.addWidget(main_app.use_cookie_checkbox)
advanced_row1_layout.addWidget(main_app.cookie_text_input, 2)
main_app.cookie_browse_button = QPushButton("Browse...")
main_app.cookie_browse_button.setFixedWidth(int(80 * scale))
advanced_row1_layout.addWidget(main_app.cookie_browse_button)
advanced_row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row1_layout)
advanced_row2_layout = QHBoxLayout()
advanced_row2_layout.setSpacing(10)
multithreading_layout = QHBoxLayout()
multithreading_layout.setContentsMargins(0, 0, 0, 0)
main_app.use_multithreading_checkbox = QCheckBox("Use Multithreading")
main_app.use_multithreading_checkbox.setChecked(True)
multithreading_layout.addWidget(main_app.use_multithreading_checkbox)
main_app.thread_count_label = QLabel("Threads:")
multithreading_layout.addWidget(main_app.thread_count_label)
main_app.thread_count_input = QLineEdit("4")
main_app.thread_count_input.setFixedWidth(int(40 * scale))
main_app.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS))
multithreading_layout.addWidget(main_app.thread_count_input)
advanced_row2_layout.addLayout(multithreading_layout)
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
left_layout.addLayout(checkboxes_group_layout)
# --- Action Buttons ---
main_app.standard_action_buttons_widget = QWidget()
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
btn_layout.setContentsMargins(0, 10, 0, 0)
btn_layout.setSpacing(10)
main_app.download_btn = QPushButton("⬇️ Start Download")
font = main_app.download_btn.font()
font.setBold(True)
main_app.download_btn.setFont(font)
main_app.download_btn.clicked.connect(main_app.start_download)
main_app.pause_btn = QPushButton("⏸️ Pause Download")
main_app.pause_btn.setEnabled(False)
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
main_app.cancel_btn = QPushButton("❌ Cancel & Reset UI")
main_app.cancel_btn.setEnabled(False)
main_app.cancel_btn.clicked.connect(main_app.cancel_download_button_action)
main_app.error_btn = QPushButton("Error")
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
main_app.error_btn.setEnabled(True)
btn_layout.addWidget(main_app.download_btn)
btn_layout.addWidget(main_app.pause_btn)
btn_layout.addWidget(main_app.cancel_btn)
btn_layout.addWidget(main_app.error_btn)
main_app.favorite_action_buttons_widget = QWidget()
favorite_buttons_layout = QHBoxLayout(main_app.favorite_action_buttons_widget)
main_app.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists")
main_app.favorite_mode_posts_button = QPushButton("📄 Favorite Posts")
main_app.favorite_scope_toggle_button = QPushButton()
favorite_buttons_layout.addWidget(main_app.favorite_mode_artists_button)
favorite_buttons_layout.addWidget(main_app.favorite_mode_posts_button)
favorite_buttons_layout.addWidget(main_app.favorite_scope_toggle_button)
main_app.bottom_action_buttons_stack = QStackedWidget()
main_app.bottom_action_buttons_stack.addWidget(main_app.standard_action_buttons_widget)
main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget)
left_layout.addWidget(main_app.bottom_action_buttons_stack)
left_layout.addSpacing(10)
# --- Known Names Layout ---
known_chars_label_layout = QHBoxLayout()
known_chars_label_layout.setSpacing(10)
main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
known_chars_label_layout.addWidget(main_app.known_chars_label)
main_app.open_known_txt_button = QPushButton("Open Known.txt")
main_app.open_known_txt_button.setFixedWidth(int(120 * scale))
known_chars_label_layout.addWidget(main_app.open_known_txt_button)
main_app.character_search_input = QLineEdit()
main_app.character_search_input.setPlaceholderText("Search characters...")
known_chars_label_layout.addWidget(main_app.character_search_input, 1)
left_layout.addLayout(known_chars_label_layout)
main_app.character_list = QListWidget()
main_app.character_list.setSelectionMode(QListWidget.ExtendedSelection)
left_layout.addWidget(main_app.character_list, 1)
char_manage_layout = QHBoxLayout()
char_manage_layout.setSpacing(10)
main_app.new_char_input = QLineEdit()
main_app.new_char_input.setPlaceholderText("Add new show/character name")
main_app.add_char_button = QPushButton(" Add")
main_app.add_to_filter_button = QPushButton("⤵️ Add to Filter")
main_app.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.")
main_app.delete_char_button = QPushButton("🗑️ Delete Selected")
main_app.delete_char_button.setToolTip("Delete the selected name(s)...")
main_app.add_char_button.clicked.connect(main_app._handle_ui_add_new_character)
main_app.new_char_input.returnPressed.connect(main_app.add_char_button.click)
main_app.delete_char_button.clicked.connect(main_app.delete_selected_character)
char_manage_layout.addWidget(main_app.new_char_input, 2)
char_manage_layout.addWidget(main_app.add_char_button, 0)
main_app.known_names_help_button = QPushButton("?")
main_app.known_names_help_button.setFixedWidth(int(45 * scale))
main_app.known_names_help_button.clicked.connect(main_app._show_feature_guide)
main_app.history_button = QPushButton("📜")
main_app.history_button.setFixedWidth(int(45 * scale))
main_app.history_button.setToolTip(main_app._tr("history_button_tooltip_text", "View download history"))
main_app.future_settings_button = QPushButton("⚙️")
main_app.future_settings_button.setFixedWidth(int(45 * scale))
main_app.future_settings_button.clicked.connect(main_app._show_future_settings_dialog)
main_app.support_button = QPushButton("❤️ Support")
main_app.support_button.setFixedWidth(int(100 * scale))
main_app.support_button.setToolTip("Support the application developer.")
char_manage_layout.addWidget(main_app.add_to_filter_button, 1)
char_manage_layout.addWidget(main_app.delete_char_button, 1)
char_manage_layout.addWidget(main_app.known_names_help_button, 0)
char_manage_layout.addWidget(main_app.history_button, 0)
char_manage_layout.addWidget(main_app.future_settings_button, 0)
char_manage_layout.addWidget(main_app.support_button, 0)
left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0)
# --- Right Panel (Logs) ---
right_panel_widget.setLayout(right_layout)
log_title_layout = QHBoxLayout()
main_app.progress_log_label = QLabel("📜 Progress Log:")
log_title_layout.addWidget(main_app.progress_log_label)
log_title_layout.addStretch(1)
main_app.link_search_input = QLineEdit()
main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("🔍")
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)
main_app.manga_rename_toggle_button = QPushButton()
main_app.manga_rename_toggle_button.setVisible(False)
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
main_app._update_manga_filename_style_button_text()
log_title_layout.addWidget(main_app.manga_rename_toggle_button)
main_app.manga_date_prefix_input = QLineEdit()
main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames")
main_app.manga_date_prefix_input.setVisible(False)
log_title_layout.addWidget(main_app.manga_date_prefix_input)
main_app.multipart_toggle_button = QPushButton()
main_app.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.")
main_app.multipart_toggle_button.setFixedWidth(int(130 * scale))
main_app._update_multipart_toggle_button_text()
log_title_layout.addWidget(main_app.multipart_toggle_button)
main_app.EYE_ICON = "\U0001F441"
main_app.CLOSED_EYE_ICON = "\U0001F648"
main_app.log_verbosity_toggle_button = QPushButton(main_app.EYE_ICON)
main_app.log_verbosity_toggle_button.setFixedWidth(int(45 * scale))
main_app.log_verbosity_toggle_button.setStyleSheet(f"font-size: {11 * scale}pt; padding: {4 * scale}px {2 * scale}px;")
log_title_layout.addWidget(main_app.log_verbosity_toggle_button)
main_app.reset_button = QPushButton("🔄 Reset")
main_app.reset_button.setFixedWidth(int(80 * scale))
log_title_layout.addWidget(main_app.reset_button)
right_layout.addLayout(log_title_layout)
main_app.log_splitter = QSplitter(Qt.Vertical)
main_app.log_view_stack = QStackedWidget()
main_app.main_log_output = QTextEdit()
main_app.main_log_output.setReadOnly(True)
main_app.main_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.main_log_output)
main_app.missed_character_log_output = QTextEdit()
main_app.missed_character_log_output.setReadOnly(True)
main_app.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.missed_character_log_output)
main_app.external_log_output = QTextEdit()
main_app.external_log_output.setReadOnly(True)
main_app.external_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.external_log_output.hide()
main_app.log_splitter.addWidget(main_app.log_view_stack)
main_app.log_splitter.addWidget(main_app.external_log_output)
main_app.log_splitter.setSizes([main_app.height(), 0])
right_layout.addWidget(main_app.log_splitter, 1)
export_button_layout = QHBoxLayout()
export_button_layout.addStretch(1)
main_app.export_links_button = QPushButton(main_app._tr("export_links_button_text", "Export Links"))
main_app.export_links_button.setFixedWidth(int(100 * scale))
main_app.export_links_button.setEnabled(False)
main_app.export_links_button.setVisible(False)
export_button_layout.addWidget(main_app.export_links_button)
main_app.download_extracted_links_button = QPushButton(main_app._tr("download_extracted_links_button_text", "Download"))
main_app.download_extracted_links_button.setFixedWidth(int(100 * scale))
main_app.download_extracted_links_button.setEnabled(False)
main_app.download_extracted_links_button.setVisible(False)
export_button_layout.addWidget(main_app.download_extracted_links_button)
main_app.log_display_mode_toggle_button = QPushButton()
main_app.log_display_mode_toggle_button.setFixedWidth(int(120 * scale))
main_app.log_display_mode_toggle_button.setVisible(False)
export_button_layout.addWidget(main_app.log_display_mode_toggle_button)
right_layout.addLayout(export_button_layout)
main_app.progress_label = QLabel("Progress: Idle")
main_app.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(main_app.progress_label)
main_app.file_progress_label = QLabel("")
main_app.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.")
main_app.file_progress_label.setWordWrap(True)
main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(main_app.file_progress_label)
# --- Final Assembly ---
main_app.main_splitter.addWidget(left_scroll_area)
main_app.main_splitter.addWidget(right_panel_widget)
if main_app.width() >= 1920:
# For wider resolutions, give more space to the log panel (right).
main_app.main_splitter.setStretchFactor(0, 4)
main_app.main_splitter.setStretchFactor(1, 6)
else:
# Default for lower resolutions, giving more space to controls (left).
main_app.main_splitter.setStretchFactor(0, 7)
main_app.main_splitter.setStretchFactor(1, 3)
top_level_layout = QHBoxLayout(main_app)
top_level_layout.setContentsMargins(0, 0, 0, 0)
top_level_layout.addWidget(main_app.main_splitter)
# --- Initial UI State Updates ---
main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked())
main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked())
main_app.update_multithreading_label(main_app.thread_count_input.text())
main_app.update_page_range_enabled_state()
if main_app.manga_mode_checkbox:
main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked())
if hasattr(main_app, 'link_input'):
main_app.link_input.textChanged.connect(lambda: main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked() if main_app.manga_mode_checkbox else False))
main_app._load_creator_name_cache_from_json()
main_app.load_known_names_from_util()
main_app._update_cookie_input_visibility(main_app.use_cookie_checkbox.isChecked() if hasattr(main_app, 'use_cookie_checkbox') else False)
main_app._handle_multithreading_toggle(main_app.use_multithreading_checkbox.isChecked())
if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton():
main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True)
main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled)
main_app._update_manga_filename_style_button_text()
main_app._update_skip_scope_button_text()
main_app._update_char_filter_scope_button_text()
main_app._update_multithreading_for_date_mode()
if hasattr(main_app, 'download_thumbnails_checkbox'):
main_app._handle_thumbnail_mode_change(main_app.download_thumbnails_checkbox.isChecked())
if hasattr(main_app, 'favorite_mode_checkbox'):
main_app._handle_favorite_mode_toggle(False)
def get_dark_theme(scale=1):
"""
Generates the stylesheet for the dark theme, scaled by the given factor.
"""
# Adjust base font size for better readability
font_size_base = 9.5
font_size_small_base = 8.5
# Apply scaling
font_size = int(font_size_base * scale)
font_size_small = int(font_size_small_base * scale)
line_edit_padding = int(5 * scale)
button_padding_v = int(5 * scale)
button_padding_h = int(12 * scale)
tooltip_padding = int(4 * scale)
indicator_size = int(14 * scale)
return f"""
QWidget {{
background-color: #2E2E2E;
color: #E0E0E0;
font-family: Segoe UI, Arial, sans-serif;
font-size: {font_size}pt;
}}
QLineEdit, QListWidget, QTextEdit {{
background-color: #3C3F41;
border: 1px solid #5A5A5A;
padding: {line_edit_padding}px;
color: #F0F0F0;
border-radius: 4px;
font-size: {font_size}pt;
}}
QTextEdit {{
font-family: Consolas, Courier New, monospace;
}}
QPushButton {{
background-color: #555;
color: #F0F0F0;
border: 1px solid #6A6A6A;
padding: {button_padding_v}px {button_padding_h}px;
border-radius: 4px;
}}
QPushButton:hover {{ background-color: #656565; border: 1px solid #7A7A7A; }}
QPushButton:pressed {{ background-color: #4A4A4A; }}
QPushButton:disabled {{ background-color: #404040; color: #888; border-color: #555; }}
QLabel {{ font-weight: bold; color: #C0C0C0; }}
QRadioButton, QCheckBox {{ spacing: {int(5 * scale)}px; color: #E0E0E0; }}
QRadioButton::indicator, QCheckBox::indicator {{ width: {indicator_size}px; height: {indicator_size}px; }}
QListWidget {{ alternate-background-color: #353535; }}
QListWidget::item:selected {{ background-color: #007ACC; color: #FFFFFF; }}
QToolTip {{
background-color: #4A4A4A;
color: #F0F0F0;
border: 1px solid #6A6A6A;
padding: {tooltip_padding}px;
border-radius: 3px;
font-size: {font_size}pt;
}}
QSplitter::handle {{ background-color: #5A5A5A; }}
QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }}
QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }}
"""
def apply_theme_to_app(main_app, theme_name, initial_load=False):
"""
Applies the selected theme and scaling to the main application window.
"""
main_app.current_theme = theme_name
if not initial_load:
main_app.settings.setValue(THEME_KEY, theme_name)
main_app.settings.sync()
if theme_name == "dark":
scale = getattr(main_app, 'scale_factor', 1)
main_app.setStyleSheet(get_dark_theme(scale))
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Dark Mode.")
else:
main_app.setStyleSheet("")
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Light Mode.")
main_app.update()