mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
48 Commits
13c8380cca
...
v6.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33133eb275 | ||
|
|
3935cbeea4 | ||
|
|
8ba2a572fa | ||
|
|
8db40f03b6 | ||
|
|
742fe7685c | ||
|
|
e085d9a134 | ||
|
|
1cd03731c0 | ||
|
|
0bc8d7c692 | ||
|
|
3a9009e76e | ||
|
|
9a28e922b4 | ||
|
|
923a0ff61e | ||
|
|
e891a2a845 | ||
|
|
778b0219e2 | ||
|
|
3fc08d9ea7 | ||
|
|
af6a6add57 | ||
|
|
7737d32ef9 | ||
|
|
c08cbb6490 | ||
|
|
92a2e91624 | ||
|
|
11ea511a9d | ||
|
|
8abdb49ed8 | ||
|
|
0873dd1ce0 | ||
|
|
df5fbc1f73 | ||
|
|
5510f7f0c6 | ||
|
|
2f0593c450 | ||
|
|
e67adb6bdc | ||
|
|
d39081088c | ||
|
|
f303b8b020 | ||
|
|
539e76aa9e | ||
|
|
574d0d66b4 | ||
|
|
9e58a9d574 | ||
|
|
d67de87a11 | ||
|
|
149f217f2f | ||
|
|
874902ad60 | ||
|
|
440cf60d90 | ||
|
|
fb446a1e28 | ||
|
|
cfd869e05a | ||
|
|
b191776f65 | ||
|
|
f41f354737 | ||
|
|
6b57ee099d | ||
|
|
21ecb60cb5 | ||
|
|
ee00019f2e | ||
|
|
d49c739fe4 | ||
|
|
dbdf82a079 | ||
|
|
f0bf74da16 | ||
|
|
e8b655e492 | ||
|
|
4f383910d2 | ||
|
|
404c4ca59a | ||
|
|
bcf26bea20 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1,3 @@
|
|||||||
github: [Yuvi9587]
|
github: [Yuvi9587]
|
||||||
|
ko_fi: yuvi427183
|
||||||
|
buy_me_a_coffee: yuvi9587
|
||||||
BIN
Read/bmac.gif
Normal file
BIN
Read/bmac.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 434 KiB |
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal 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.
|
||||||
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
Binary file not shown.
262
features.md
262
features.md
@@ -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
196
readme.md
@@ -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">
|
||||||
|
|
||||||
|
[](features.md)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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).")
|
||||||
|
|||||||
3336
src/core/workers.py
3336
src/core/workers.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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.")
|
||||||
)
|
)
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 ))
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
)
|
||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -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 )
|
||||||
|
|||||||
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal file
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal 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}
|
||||||
@@ -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
|
||||||
96
src/ui/dialogs/MoreOptionsDialog.py
Normal file
96
src/ui/dialogs/MoreOptionsDialog.py
Normal 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("")
|
||||||
77
src/ui/dialogs/SinglePDF.py
Normal file
77
src/ui/dialogs/SinglePDF.py
Normal 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
|
||||||
155
src/ui/dialogs/SupportDialog.py
Normal file
155
src/ui/dialogs/SupportDialog.py
Normal 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("")
|
||||||
@@ -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
@@ -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
580
src/utils/resolution.py
Normal 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()
|
||||||
Reference in New Issue
Block a user