86 Commits

Author SHA1 Message Date
Yuvi9587
3704fece2b Update main_window.py 2025-08-04 04:53:52 -07:00
Yuvi9587
bdb7ac93c4 Update readme.md 2025-08-03 09:16:25 -07:00
Yuvi9587
76d4a3ea8a Update main_window.py 2025-08-03 09:15:01 -07:00
Yuvi9587
ccc7804505 Update readme.md 2025-08-03 09:13:47 -07:00
Yuvi9587
4ee750c5d4 Update drive_downloader.py 2025-08-03 09:11:27 -07:00
Yuvi9587
e9be13c4e3 Update readme.md 2025-08-03 09:07:29 -07:00
Yuvi9587
a5cb04ea6f Update features.md 2025-08-03 06:46:30 -07:00
Yuvi9587
842f18d70d Update features.md 2025-08-03 06:32:32 -07:00
Yuvi9587
fb3f0e8913 Update features.md 2025-08-03 06:11:05 -07:00
Yuvi9587
0758887154 Update features.md 2025-08-03 06:07:05 -07:00
Yuvi9587
e752d881e7 Update features.md 2025-08-03 06:01:32 -07:00
Yuvi9587
a776d1abe9 Update features.md 2025-08-03 06:01:15 -07:00
Yuvi9587
21d1ce4fa9 Commit 2025-08-03 05:46:51 -07:00
Yuvi9587
d5112a25ee Commit 2025-08-01 09:42:10 -07:00
Yuvi9587
791ce503ff Update main_window.py 2025-08-01 07:57:32 -07:00
Yuvi9587
e5b519d5ce Commit 2025-08-01 06:33:36 -07:00
Yuvi9587
9888ed0862 Update multipart_downloader.py 2025-07-30 22:28:05 -07:00
Yuvi9587
9e996bf682 Commit 2025-07-30 21:31:02 -07:00
Yuvi9587
e7a6a91542 commit 2025-07-30 19:30:50 -07:00
Yuvi9587
d7faccce18 Commit 2025-07-29 06:37:28 -07:00
Yuvi9587
a78c01c4f6 Update workers.py 2025-07-27 07:44:14 -07:00
Yuvi9587
6de9967e0b Commit 2025-07-27 07:18:08 -07:00
Yuvi9587
e3dd0e70b6 commit 2025-07-27 06:32:15 -07:00
Yuvi9587
9db89cfad0 Commit 2025-07-25 11:00:33 -07:00
Yuvi9587
0a6034a632 Update features.md 2025-07-25 10:49:51 -07:00
Yuvi9587
2da69e7017 Update features.md 2025-07-25 10:45:50 -07:00
Yuvi9587
3209770d00 Update LICENSE 2025-07-23 20:14:01 -07:00
Yuvi9587
337cdd342c Commit 2025-07-23 20:08:44 -07:00
Yuvi9587
d54b013bbc commit 2025-07-22 07:00:34 -07:00
Yuvi9587
2785fc1121 Update EmptyPopupDialog.py 2025-07-19 20:27:55 -07:00
Yuvi9587
fbdae61b80 Commit 2025-07-19 03:28:32 -07:00
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
Yuvi9587
539e76aa9e Delete workers.py 2025-07-15 21:09:16 -07:00
Yuvi9587
574d0d66b4 Commit 2025-07-15 21:08:11 -07:00
Yuvi9587
9e58a9d574 commit 2025-07-15 08:49:20 -07:00
Yuvi9587
d67de87a11 Commit 2025-07-15 07:14:40 -07:00
Yuvi9587
149f217f2f Commit 2025-07-15 07:05:36 -07:00
Yuvi9587
874902ad60 Commit 2025-07-15 06:54:31 -07:00
Yuvi9587
440cf60d90 Update MoreOptionsDialog.py 2025-07-14 20:18:04 -07:00
Yuvi9587
fb446a1e28 Commit 2025-07-14 20:17:48 -07:00
Yuvi9587
cfd869e05a Update main_window.py 2025-07-14 09:04:34 -07:00
Yuvi9587
b191776f65 Commit 2025-07-14 08:19:58 -07:00
Yuvi9587
f41f354737 Update main_window.py 2025-07-13 21:46:34 -07:00
Yuvi9587
6b57ee099d Commit 2025-07-13 21:45:30 -07:00
Yuvi9587
21ecb60cb5 commit 2025-07-13 20:21:17 -07:00
Yuvi9587
ee00019f2e Update workers.py 2025-07-13 18:42:56 -07:00
Yuvi9587
d49c739fe4 Commit 2025-07-13 10:36:52 -07:00
Yuvi9587
dbdf82a079 Commit 2025-07-13 10:22:06 -07:00
Yuvi9587
f0bf74da16 Update readme.md 2025-07-11 01:30:07 -07:00
Yuvi9587
e8b655e492 Update readme.md 2025-07-11 01:28:48 -07:00
Yuvi9587
4f383910d2 Update readme.md 2025-07-11 01:26:57 -07:00
Yuvi9587
404c4ca59a commit 2025-07-11 01:24:56 -07:00
Yuvi9587
bcf26bea20 Commit 2025-07-11 01:24:12 -07:00
Yuvi9587
fa198c41c1 Commit 2025-07-10 09:59:51 -07:00
Yuvi9587
f214d2452e Update features.md 2025-07-08 13:14:46 +05:30
Yuvi9587
f39b510577 Update features.md 2025-07-08 13:03:24 +05:30
Yuvi9587
2c45c14696 Commit 2025-07-08 13:01:21 +05:30
Yuvi9587
aa2305c10e Commit 2025-07-07 14:10:52 +05:30
Yuvi9587
568c687f98 Update note.md 2025-07-06 17:34:04 +05:30
Yuvi9587
c8b77fb0d7 Commit 2025-07-05 06:02:21 +05:30
47 changed files with 7734 additions and 5596 deletions

2
.github/FUNDING.yml vendored
View File

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

24
LICENSE
View File

@@ -1,11 +1,21 @@
Custom License - No Commercial Use MIT License
Copyright [Yuvi9587] [2025] Copyright (c) [2025] [Yuvi9587]
Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for **non-commercial purposes only**, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The above copyright notice and this permission notice shall be included in all
2. Proper credit must be given to the original author in any public use, distribution, or derivative works. copies or substantial portions of the Software.
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND... THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,391 +1,391 @@
# Kemono Downloader - Detailed Feature Guide <div>
<h1>Kemono Downloader - Comprehensive Feature Guide</h1>
This guide provides a comprehensive overview of all user interface elements, input fields, buttons, popups, and functionalities available in the Kemono Downloader. <p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
<hr>
---
<h2><strong>1. URL Input (🔗)</strong></h2>
## Main Interface & Workflow <p>This is the primary input field where you specify the content you want to download.</p>
These are the primary controls you'll interact with to initiate and manage downloads. <p><strong>Functionality:</strong></p>
<ul>
### 1. Main Inputs <li><strong>Creator URL:</strong> A link to a creator's main page (e.g., https://kemono.su/patreon/user/12345). Downloads all posts from the creator.</li>
<li><strong>Post URL:</strong> A direct link to a specific post (e.g., .../post/98765). Downloads only the specified post.</li>
- **🔗 Kemono Creator/Post URL Input Field:** </ul>
- **Purpose:** This is where you paste the URL of the content you want to download.
- **Usage:** Supports full URLs for: <p><strong>Interaction with Other Features:</strong> The content of this field influences "Manga Mode" and "Page Range". "Page Range" is enabled only with a creator URL.</p>
- Kemono.su (and mirrors like kemono.party) creator pages (e.g., `https://kemono.su/patreon/user/12345`).
- Kemono.su (and mirrors) individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`). <hr>
- Coomer.party (and mirrors like coomer.su) creator pages.
- Coomer.party (and mirrors) individual posts. <h2><strong>2. Creator Selection & Update (🎨)</strong></h2>
- **Note:** <p>The color palette emoji button opens the Creator Selection & Update dialog. This allows managing and downloading from a local creator database.</p>
- When **⭐ Favorite Mode** is active, this field is disabled and shows a "Favorite Mode active" message.
- This field can also be populated with a placeholder message (e.g., "{count} items in queue from popup") if posts are added to the download queue directly from the 'Creator Selection' dialog's 'Fetched Posts' view. <p><strong>Functionality:</strong></p>
<ul>
- **🎨 Creator Selection Button:** <li><strong>Creator Browser:</strong> Loads a list from <code>creators.json</code>. Search by name, service, or paste a URL to find creators.</li>
- **Icon:** 🎨 (Artist Palette) <li><strong>Batch Selection:</strong> Select multiple creators and click "Add Selected" to add them to the batch download session.</li>
- **Location:** Next to the URL input field. <li><strong>Update Checker:</strong> Use a saved profile (.json) to download only new content based on previously fetched posts.</li>
- **Purpose:** Opens the "Creator Selection" dialog to easily add multiple creators to the URL field. <li><strong>Post Fetching & Filtering:</strong> "Fetch Posts" loads post titles, allowing you to choose specific posts for download.</li>
- **Dialog Features:** </ul>
- Loads creators from your `creators.json` file (expected in the app's directory).
- **Search Bar:** Filter the list of creators by name. <hr>
- **Creator List:** Displays creators with their service (e.g., Patreon, Fanbox) and ID.
- **Selection:** Checkboxes to select one or more creators. <h2><strong>3. Download Location Input (📁)</strong></h2>
- **"Add Selected to URL" Button:** Adds the names of selected creators to the URL input field, comma-separated. <p>This input defines the destination directory for downloaded files.</p>
- **"Fetch Posts" Button:** After selecting creators, click this to retrieve their latest posts. This will display a new pane within the dialog showing the fetched posts.
- **"Download Scope" Radio Buttons (`Characters` / `Creators`):** Determines the folder structure for items added via this popup. <p><strong>Functionality:</strong></p>
- `Characters`: Assumes creator names are character names for folder organization. <ul>
- `Creators`: Uses the actual creator names for folder organization. <li><strong>Manual Entry:</strong> Enter or paste the folder path.</li>
- **Fetched Posts View (Right Pane - Appears after clicking 'Fetch Posts'):** <li><strong>Browse Button:</strong> Opens a system dialog to choose a folder.</li>
- **Posts Area Title Label:** Indicates loading status or number of fetched posts. <li><strong>Directory Creation:</strong> If the folder doesn't exist, the app can create it after user confirmation.</li>
- **Posts Search Input:** Allows filtering the list of fetched posts by title. </ul>
- **Posts List Widget:** Displays posts fetched from the selected creators, often grouped by creator. Each post is checkable.
- **Select All / Deselect All Buttons (for Posts):** Convenience buttons for selecting/deselecting all displayed fetched posts. <hr>
- **"Add Selected Posts to Queue" Button:** Adds all checked posts from this view directly to the application's main download queue. The main URL input field will then show a message like "{count} items in queue from popup".
- **"Close" Button (for Posts View):** Hides the fetched posts view and returns to the creator selection list, allowing you to use the 'Add Selected to URL' button if preferred. <h2><strong>4. Filter by Character(s) & Scope Button</strong></h2>
<p>Used to download content for specific characters or series and organize them into subfolders.</p>
- **Page Range (Start to End) Input Fields:**
- **Purpose:** For creator URLs, specify a range of pages to fetch and process. <p><strong>Input Field (Filter by Character(s)):</strong></p>
- **Usage:** Enter the starting page number in the first field and the ending page number in the second. <ul>
- **Behavior:** <li>Enter comma-separated names (e.g., <code>Tifa, Aerith</code>).</li>
- If left blank, all pages for the creator are typically processed (or up to a reasonable limit). <li>Group aliases using parentheses (e.g., <code>(Cloud, Zack)</code>).</li>
- Disabled for single post URLs or when **📖 Manga/Comic Mode** is active (as manga mode fetches all posts for chronological sorting). <li>Names are matched against titles, filenames, or comments.</li>
<li>If "Separate Folders by Known.txt" is enabled, the name becomes the subfolder name.</li>
- **📁 Download Location Input Field & Browse Button:** </ul>
- **Purpose:** Specify the main directory where all downloaded files and folders will be saved.
- **Usage:** <p><strong>Scope Button Modes:</strong></p>
- Type or paste the path directly into the field. <ul>
- Click the **"Browse..."** button to open a system dialog to select a folder. <li><strong>Filter: Title</strong> (default) Match names in post titles only.</li>
- **Requirement:** This field must be filled unless you are using the "🔗 Only Links" filter mode. <li><strong>Filter: Files</strong> Match names in filenames only.</li>
<li><strong>Filter: Both</strong> Try title match first, then filenames.</li>
### 2. Action Buttons <li><strong>Filter: Comments</strong> Try filenames first, then post comments if no match.</li>
</ul>
- **⬇️ Start Download / 🔗 Extract Links Button:**
- **Purpose:** The primary action button to begin the downloading or link extraction process based on current settings. <hr>
- **Behavior:**
- If "🔗 Only Links" filter is selected, the button text changes to **"🔗 Extract Links"** and it will only gather external links from posts. <h2><strong>5. Skip with Words & Scope Button</strong></h2>
- Otherwise, it reads **"⬇️ Start Download"** and initiates the content download. <p>Prevents downloading content based on keywords.</p>
- **⏸️ Pause / ▶️ Resume Download Button:** <p><strong>Input Field (Skip with Words):</strong></p>
- **Purpose:** Temporarily halt or continue the ongoing download/extraction process. <ul>
- **Behavior:** <li>Enter comma-separated keywords (e.g., <code>WIP, sketch, preview</code>).</li>
- When active, the button shows **"⏸️ Pause Download"**. Clicking it pauses the operation. <li>Matching is case-insensitive.</li>
- When paused, the button shows **"▶️ Resume Download"**. Clicking it resumes from where it left off. <li>If a keyword matches, the file or post is skipped.</li>
- Some UI settings can be changed while paused (e.g., filter adjustments), which will apply upon resuming. </ul>
- **❌ Cancel & Reset UI Button:** <p><strong>Scope Button Modes:</strong></p>
- **Purpose:** Immediately stops the current download/extraction operation and performs a "soft" reset of the UI. <ul>
- **Behavior:** <li><strong>Scope: Posts</strong> (default) Skips post if title contains a keyword.</li>
- Halts all active threads and processes. <li><strong>Scope: Files</strong> Skips individual files with keyword matches.</li>
- Clears progress information and logs. <li><strong>Scope: Both</strong> Skips entire post if title matches, otherwise filters individual files.</li>
- Preserves the content of the "🔗 Kemono Creator/Post URL" and "📁 Download Location" input fields. Other settings are reset to their defaults. </ul>
</div>
- **🔄 Reset Button (located in the log area):** <div>
- **Purpose:** Performs a "hard" reset of the UI when no operation is active. <h2><strong>Filter File Section (Radio Buttons)</strong></h2>
- **Behavior:** <p>This section uses a group of radio buttons to control the primary download mode, dictating which types of files are targeted. Only one of these modes can be active at a time.</p>
- Clears all input fields (including URL and Download Location).
- Resets all filter settings and options to their default values. <ul>
- Clears the log area. <li>
<strong>All:</strong> Default mode. Downloads every file and attachment provided by the API, regardless of type.
--- </li>
<li>
## Filtering & Content Selection <strong>Images/GIFs:</strong> Filters for common image formats (<code>.jpg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>), skipping non-image files.
</li>
These options allow you to precisely control what content is downloaded or skipped. <li>
<strong>Videos:</strong> Filters for common video formats like <code>.mp4</code>, <code>.webm</code>, and <code>.mov</code>, skipping all others.
- **🎯 Filter by Character(s) Input Field:** </li>
- **Purpose:** Download content related to specific characters. <li>
- **Usage:** Enter character names, comma-separated. <strong>Only Archives:</strong> Downloads only archive files (<code>.zip</code>, <code>.rar</code>). Disables "Compress to WebP" and unchecks "Skip Archives".
- **Advanced Syntax:** </li>
- `Nami`: Simple character filter. Matches "Nami". <li>
- `(Vivi, Ulti, Uta)`: Grouped characters. Matches "Vivi" OR "Ulti" OR "Uta". If "Separate Folders" is on, creates a shared folder for the session (e.g., "Vivi Ulti Uta"). Adds "Vivi", "Ulti", "Uta" as *separate* entries to `Known.txt` if new. <strong>Only Audio:</strong> Filters for common audio formats like <code>.mp3</code>, <code>.wav</code>, and <code>.flac</code>.
- `(Boa, Hancock)~`: Aliased characters. Matches "Boa" OR "Hancock" but treats them as the same entity. If "Separate Folders" is on, creates a shared folder (e.g., "Boa Hancock"). Adds "Boa Hancock" as a *single group entry* to `Known.txt` if new, with "Boa" and "Hancock" as its aliases. </li>
<li>
- **Filter: [Type] Button (Scope for Character Filter):** <strong>Only Links:</strong> Extracts external hyperlinks from post descriptions (e.g., Mega, Google Drive) and displays them in the log. Disables all download options.
- **Location:** Next to the "Filter by Character(s)" input. </li>
- **Purpose:** Defines how the character filter is applied. Cycles through options on click. <li>
- **Options:** <strong>More:</strong> Opens the "More Options" dialog to download text-based content instead of media files.
- `Filter: Files`: Checks individual filenames against the character filter. Only matching files from a post are downloaded. <ul>
- `Filter: Title` (Default): Checks post titles against the character filter. If the title matches, all files from that post are downloaded. <li><strong>Scope:</strong> Choose to extract from post description or comments.</li>
- `Filter: Both`: Checks the post title first. If no match, then checks individual filenames within that post. <li><strong>Export Format:</strong> Save text as PDF, DOCX, or TXT.</li>
- `Filter: Comments (Beta)`: Checks filenames first. If no file match, then checks post comments/description. (Note: This may use more API requests). <li><strong>Single PDF:</strong> Optionally compile all text into one PDF.</li>
</ul>
- **🚫 Skip with Words Input Field:** </li>
- **Purpose:** Exclude posts or files containing specified keywords. </ul>
- **Usage:** Enter words or phrases, comma-separated (e.g., `WIP, sketch, preview`).
<hr>
- **Scope: [Type] Button (Scope for Skip with Words):**
- **Location:** Next to the "Skip with Words" input. <h2><strong>Check Box Buttons</strong></h2>
- **Purpose:** Defines how the skip words are applied. Cycles through options on click. <p>These checkboxes provide additional toggles to refine the download behavior and enable special features.</p>
- **Options:**
- `Scope: Files`: Skips individual files if their names contain any of the skip words. <ul>
- `Scope: Posts` (Default): Skips entire posts if their titles contain any of the skip words. <li>
- `Scope: Both`: Checks the post title first. If no skip words match, then checks individual filenames. <strong>⭐ Favorite Mode:</strong> Changes workflow to download from your personal favorites. Disables the URL input.
<ul>
- **✂️ Remove Words from name Input Field:** <li><strong>Favorite Artists:</strong> Opens a dialog to select from your favorited creators.</li>
- **Purpose:** Clean up downloaded filenames by removing specified unwanted words or phrases. <li><strong>Favorite Posts:</strong> Opens a dialog to select from your favorited posts on Kemono and Coomer.</li>
- **Usage:** Enter words or phrases, comma-separated (e.g., `patreon, [HD], kemono`). </ul>
</li>
- **Filter Files (Radio Buttons):** <li>
- **Purpose:** Select the types of files to download. <strong>Skip Archives:</strong> When checked, archive files (<code>.zip</code>, <code>.rar</code>) are ignored. Disabled in "Only Archives" mode.
- **Options:** </li>
- `All`: Download all file types attached to posts. <li>
- `Images/GIFs`: Download only common image formats (JPG, PNG, GIF, WebP, etc.). <strong>Download Thumbnail Only:</strong> Saves only thumbnail previews, not full-resolution files. Enables "Scan Content for Images".
- `Videos`: Download only common video formats (MP4, MOV, MKV, WebM, etc.). </li>
- `📦 Only Archives`: Exclusively download `.zip` and `.rar` files. This mode disables the "Skip .zip/.rar" checkboxes and the "Show External Links in Log" feature. <li>
- `🎧 Only Audio`: Download only common audio formats (MP3, WAV, FLAC, OGG, etc.). <strong>Scan Content for Images:</strong> Parses post HTML for embedded images not listed in the API. Looks for <code>&lt;img&gt;</code> tags and direct image links.
- `🔗 Only Links`: Do not download any files. Instead, extract and display external links found in post descriptions in the log area. The main action button changes to "🔗 Extract Links". </li>
<li>
- **Skip .zip / Skip .rar Checkboxes:** <strong>Compress to WebP:</strong> Converts large images (over 1.5 MB) to WebP format using the Pillow library for space-saving.
- **Purpose:** Individually choose to skip downloading `.zip` files or `.rar` files. </li>
- **Behavior:** Disabled if the "📦 Only Archives" filter is active. <li>
<strong>Keep Duplicates:</strong> Provides control over duplicate handling via the "Duplicate Handling Options" dialog.
--- <ul>
<li><strong>Skip by Hash:</strong> Default skip identical files.</li>
## Download Customization <li><strong>Keep Everything:</strong> Save all files regardless of duplication.</li>
<li><strong>Limit:</strong> Set a limit on how many copies of the same file are saved. A limit of <code>0</code> means no limit.</li>
Options to further refine the download process and output. </ul>
</li>
- **Download Thumbnails Only Checkbox:** </ul>
- **Purpose:** Download only the small preview images (thumbnails) provided by the API, instead of full-resolution files. </div>
- **Behavior:** If "**Scan Content for Images**" is also active, this option's behavior changes: *only* images found by the content scan (embedded `<img>` tags) are downloaded as thumbnails (API thumbnails are ignored). <h2><strong>Folder Organization Checkboxes</strong></h2>
<ul>
- **Scan Content for Images Checkbox:** <li>
- **Purpose:** Actively scan the HTML content of posts for `<img>` tags and direct image links. This is crucial for downloading images embedded in post descriptions that are not listed as direct attachments in the API response. <strong>Separate folders by Known.txt:</strong> Automatically organizes downloads into folders based on name matches.
- **Behavior:** Resolves relative image paths to absolute URLs for downloading. <ul>
<li>Uses "Filter by Character(s)" input first, if available.</li>
- **Compress to WebP Checkbox:** <li>Then checks names in <code>Known.txt</code>.</li>
- **Purpose:** Convert downloaded images to WebP format to potentially save disk space. <li>Falls back to extracting from post title.</li>
- **Requirement:** Requires the `Pillow` library to be installed. </ul>
- **Behavior:** Attempts to convert images larger than a certain threshold (e.g., 1.5MB) to WebP if the WebP version is significantly smaller. Original files are not kept if conversion is successful. </li>
<li>
- **🗄️ Custom Folder Name (Single Post Only) Input Field:** <strong>Subfolder per post:</strong> Creates a unique folder per post, using the posts title.
- **Purpose:** When downloading a single post URL, allows you to specify a custom name for the folder where its contents will be saved. <ul>
- **Visibility:** Only appears if: <li>Prevents mixing files from multiple posts.</li>
1. A single post URL is entered in the main URL field. <li>Can be combined with Known.txt-based folders.</li>
2. The "**Separate Folders by Name/Title**" option is enabled. <li>Ensures uniqueness (e.g., <code>My Post Title_1</code>).</li>
<li>Automatically removes empty folders.</li>
--- </ul>
</li>
## 📖 Manga/Comic Mode <li>
<strong>Date prefix:</strong> Enabled only with "Subfolder per post". Prepends the post date (e.g., <code>2025-08-03 My Post Title</code>) for chronological sorting.
Specialized mode for downloading creator feeds in a way suitable for sequential reading, like manga or comics. This mode is implicitly active when downloading from a creator URL and certain filename styles are chosen. </li>
</ul>
- **Activation:** Primarily by downloading a creator's feed (not a single post) and selecting a relevant "Filename Style".
- **Core Behavior:** Processes and downloads posts from the creator's feed in chronological order (oldest to newest). The "Page Range" input is typically disabled as all posts are fetched for correct sorting. <h2><strong>General Functionality Checkboxes</strong></h2>
<ul>
- **Filename Style Toggle Button (located in the log area):** <li>
- **Purpose:** Controls how files are named when downloading in a manga/comic-like fashion. Cycles through options on click. <strong>Use cookie:</strong> Enables login-based access via cookies.
- **Options:** <ul>
- `Name: Post Title` (Default for non-manga): The first file in a post is named after the post title; subsequent files in the *same post* keep their original names. <li>Paste cookie string directly, or browse to select a <code>cookies.txt</code> file.</li>
- `Name: Original File`: All downloaded files attempt to keep their original filenames as provided by the server. An optional "Filename Prefix" input field appears. <li>Cookies are used in all authenticated API requests.</li>
- `Name: Title+G.Num`: (Global Numbering) All files across all downloaded posts for the creator get a prefix from their respective post's title, followed by a global sequential number (e.g., `Chapter 1_001.jpg`, `Chapter 1_002.jpg`, `Chapter 2_003.jpg`). This ensures strict order across posts. Disables post-level multithreading for sequential numbering. </ul>
- `Name: Date Based`: Files are named sequentially (e.g., `001.jpg`, `002.jpg`) based on the post's publication date. An optional "Filename Prefix" input field appears. Disables post-level multithreading. </li>
<li>
- **Optional Filename Prefix Input Field (Manga Mode):** <strong>Use Multithreading:</strong> Enables parallel downloading of posts.
- **Visibility:** Appears when "Filename Style" is set to `Name: Original File` or `Name: Date Based`. <ul>
- **Purpose:** Allows you to add a custom prefix to all filenames generated using these styles (e.g., `MySeries_001.jpg`). <li>Specify the number of worker threads (e.g., 10).</li>
<li>Disabled for Manga Mode and Only Links mode.</li>
--- </ul>
</li>
## Folder Organization <li>
<strong>Show external links in log:</strong> Adds a secondary log that displays links (e.g., Mega, Dropbox) found in post text.
Controls for how downloaded content is structured into folders. </li>
<li>
- **Separate Folders by Name/Title Checkbox:** <strong>Manga/Comic mode:</strong> Sorts posts chronologically before download.
- **Purpose:** Creates subfolders within the main "Download Location" based on matching criteria. <ul>
- **Behavior:** <li>Ensures correct page order for comics/manga.</li>
- If "**Filter by Character(s)**" is used, folders are named after the matched character(s)/group(s). </ul>
- If no character filter matches (or no filter is active), but the post title matches an entry in `Known.txt`, a folder named after the `Known.txt` entry is created. <strong>Scope Button (Name: ...):</strong> Controls filename style:
- If neither of the above, and this option is checked, folders might be created based on post titles directly (behavior can vary). <ul>
<li><strong>Name: Post Title</strong> — e.g., <code>Chapter-1.jpg</code></li>
- **Subfolder per Post Checkbox:** <li><strong>Name: Date + Original</strong> — e.g., <code>2025-08-03_filename.png</code></li>
- **Purpose:** Creates an additional layer of subfolders, where each individual post's content goes into its own subfolder. <li><strong>Name: Date + Title</strong> — e.g., <code>2025-08-03_Chapter-1.jpg</code></li>
- **Behavior:** Only active if "**Separate Folders by Name/Title**" is also checked. The post subfolder will be created *inside* the character/title folder. Folder names are typically derived from sanitized post titles or IDs. <li><strong>Name: Title+G.Num</strong> — e.g., <code>Page_001.jpg</code></li>
<li><strong>Name: Date Based</strong> — e.g., <code>001.jpg</code>, with optional prefix</li>
- **`Known.txt` Management UI (Bottom Left of UI):** <li><strong>Name: Post ID</strong> — uses unique post ID as filename</li>
- **Purpose:** Manages a local list (`Known.txt` file in the app directory) of series, characters, or general terms used for automatic folder organization and character filter suggestions. </ul>
- **Elements:** </li>
- **List Display:** Shows the primary names from your `Known.txt` file. </ul>
- **Add New Input Field:** Enter a new name or group to add to `Known.txt`. <h2><strong>Start Download</strong></h2>
- Simple Name: e.g., `My Series` <ul>
- Group (creates separate entries in `Known.txt`): e.g., `(Vivi, Ulti, Uta)` <li>
- Group with Aliases (single entry in `Known.txt` with `~`): e.g., `(Boa, Hancock)~` <strong>Default State ("⬇️ Start Download"):</strong> When idle, this button gathers all current settings (URL, filters, checkboxes, etc.) and begins the download process via the DownloadManager.
- ** Add Button:** Adds the entry from the "Add New" field to `Known.txt` and refreshes the list. </li>
- **⤵️ Add to Filter Button:** Opens a dialog displaying all entries from `Known.txt` (with a search bar). Select one or more entries to add them to the "**🎯 Filter by Character(s)**" input field. Grouped names from `Known.txt` are added with the `~` syntax if applicable. <li>
- **🗑️ Delete Selected Button:** Removes the currently selected name(s) from the list display and from the `Known.txt` file. <strong>Restore State:</strong> If an interrupted session is detected, the tooltip will indicate that starting a new download will discard previous session progress.
- **Open Known.txt Button:** Opens your `Known.txt` file in the system's default text editor for manual editing. </li>
- **❓ Help Button:** Opens a guide or tooltip explaining the app feature <li>
<strong>Update Mode (Phase 1 - "🔄 Check For Updates"):</strong> If a creator profile is loaded, clicking this button will fetch the creator's posts and compare them against your saved profile to identify new content.
--- </li>
<li>
## ⭐ Favorite Mode (Kemono.su Only) <strong>Update Mode (Phase 2 - "⬇️ Start Download (X new)"):</strong> After new posts are found, the button text updates to reflect the number. Clicking it downloads only the new content.
</li>
Download directly from your favorited artists and posts on Kemono.su. </ul>
- **Enable Checkbox ("⭐ Favorite Mode"):** <h2><strong>Pause / Resume Download</strong></h2>
- **Location:** Usually near the "🔗 Only Links" filter option. <ul>
- **Purpose:** Switches the downloader to operate on your Kemono.su favorites. <li>
- **UI Changes upon Enabling:** <strong>While Downloading:</strong> The button toggles between:
- The "🔗 Kemono Creator/Post URL" input field is disabled/replaced with a "Favorite Mode active" message. <ul>
- The main action buttons change to "**🖼️ Favorite Artists**" and "**📄 Favorite Posts**". <li><strong>"⏸️ Pause Download":</strong> Sets a <code>pause_event</code>, which tells all worker threads to halt their current task and wait.</li>
- The "**🍪 Use Cookie**" option is automatically enabled and locked, as cookies are required to access your favorites. <li><strong>"▶️ Resume Download":</strong> Clears the <code>pause_event</code>, allowing threads to resume their work.</li>
</ul>
- **🖼️ Favorite Artists Button & Dialog:** </li>
- **Purpose:** Fetches and allows you to download content from artists you have favorited on Kemono.su. <li>
- **Dialog Features:** <strong>While Idle:</strong> The button is disabled.
- Fetches the list of your favorited artists. </li>
- **Search Bar:** Filter artists by name. <li>
- **Artist List:** Displays favorited artists. <strong>Restore State:</strong> Changes to "🔄 Restore Download", which resumes the last session from saved data.
- **Select All / Deselect All:** Convenience buttons for selection. </li>
- **"Download Selected" Button:** Queues all posts from the selected artists for download, respecting current filter settings. </ul>
- **📄 Favorite Posts Button & Dialog:** <h2><strong>Cancel & Reset UI</strong></h2>
- **Purpose:** Fetches and allows you to download specific posts you have favorited on Kemono.su. <ul>
- **Dialog Features:** <li>
- Fetches the list of your favorited posts, usually grouped by artist and sorted by date. <strong>Functionality:</strong> Stops downloads gracefully using a <code>cancellation_event</code>. Threads finish current tasks before shutting down.
- **Search Bar:** Filter posts by title, creator name, ID, or service. </li>
- **Post List:** Displays favorited posts. Known names from your `Known.txt` may be highlighted in post titles for easier identification. <li>
- **Select All / Deselect All:** Convenience buttons for selection. <strong>The Soft Reset:</strong> After cancellation is confirmed by background threads, the UI resets via the <code>download_finished</code> function. Input fields (URL and Download Location) are preserved for convenience.
- **"Download Selected" Button:** Queues the selected individual posts for download, respecting current filter settings. </li>
<li>
- **Favorite Download Scope Button (Location may vary, often near Favorite Posts button):** <strong>Restore State:</strong> Changes to "🗑️ Discard Session", which deletes <code>session.json</code> and resets the UI.
- **Purpose:** Determines the folder structure for downloads initiated via Favorite Mode. </li>
- **Options:** <li>
- `Scope: Selected Location`: All selected favorites (artists or posts) are downloaded directly into the main "📁 Download Location". Global filters apply. <strong>Update State:</strong> Changes to "🗑️ Clear Selection", unloading the selected creator profile and returning to normal UI state.
- `Scope: Artist Folders`: A subfolder is created for each artist within the main "📁 Download Location" (e.g., `DownloadLocation/ArtistName/`). Content from that artist (whether a full artist download or specific favorited posts from them) goes into their respective subfolder. Filters apply within each artist's context. </li>
</ul>
---
<h2><strong>Error Button</strong></h2>
## Advanced & Performance <ul>
<li>
- **🍪 Cookie Management:** <strong>Error Counter:</strong> Shows how many files failed to download (e.g., <code>(3) Error</code>). Disabled if there are no errors.
- **Use Cookie Checkbox:** Enables the use of browser cookies for accessing content that might be restricted or require login (e.g., certain posts, Favorite Mode). </li>
- **Cookie Text Field:** <li>
- **Purpose:** Directly paste your cookie string. <strong>Error Dialog:</strong> Clicking opens the "Files Skipped Due to Errors" dialog (defined in <code>ErrorFilesDialog.py</code>), listing all failed files.
- **Format:** Standard HTTP cookie string format (e.g., `name1=value1; name2=value2`). </li>
- **Browse... Button (for Cookies):** <li>
- **Purpose:** Select a `cookies.txt` file from your system. <strong>Dialog Features:</strong>
- **Format:** Must be in Netscape cookie file format. <ul>
- **Behavior:** <li><strong>View Failed Files:</strong> Shows filenames and related post info.</li>
- The text field takes precedence if filled. <li><strong>Select and Retry:</strong> Retry selected failed files in a focused download session.</li>
- If "Use Cookie" is checked and both the text field and browsed file path are empty, the application will attempt to automatically load a `cookies.txt` file from its root directory. <li><strong>Export URLs:</strong> Save a <code>.txt</code> file of direct download links. Optionally include post metadata with each URL.</li>
</ul>
- **Use Multithreading Checkbox & Threads Input Field:** </li>
- **Purpose:** Enable and configure the number of simultaneous operations to potentially speed up downloads. </ul>
- **Behavior:** <h2><strong>"Known Area" and its Controls</strong></h2>
- **Creator Feeds:** The "Threads" input controls how many posts are processed concurrently. <p>This section, located on the right side of the main window, manages your personal name database (<code>Known.txt</code>), which the app uses to organize downloads into subfolders.</p>
- **Single Post URLs:** The "Threads" input controls how many files from that single post are downloaded concurrently.
- **Note:** Setting too high a number might lead to API rate-limiting or instability. <ul>
<li>
- **Multi-part Download Toggle Button (located in the log area):** <strong>Open Known.txt:</strong> Opens the <code>Known.txt</code> file in your system's default text editor for manual editing, such as bulk changes or cleanup.
- **Purpose:** Enables/disables multi-segment downloading for individual large files. </li>
- **Options:** <li>
- `Multi-part: ON`: Large files are split into multiple parts that are downloaded simultaneously and then reassembled. Can significantly speed up downloads for single large files but may increase UI choppiness or log spam with many small files. <strong>Search character input:</strong> A live search filter that hides any list items not matching your input text. Useful for quickly locating specific names in large lists.
- `Multi-part: OFF` (Default): Files are downloaded as a single stream. </li>
- **Behavior:** Disabled if "🔗 Only Links" or "📦 Only Archives" mode is active. <li>
<strong>Known Series/Characters Area:</strong> Displays all names currently stored in your <code>Known.txt</code>. These names are used when "Separate folders by Known.txt" is enabled.
--- </li>
<li>
## Logging & Monitoring <strong>Input at bottom & Add button:</strong> Type a new character or series name into the input field, then click " Add". The app checks for duplicates, updates the list, and saves to <code>Known.txt</code>.
</li>
- **📜 Progress Log / Extracted Links Log Area:** <li>
- **Purpose:** The main text area displaying detailed messages about the ongoing process. <strong>Add to Filter:</strong> Opens a dialog showing all entries from <code>Known.txt</code> with checkboxes. You can select one or more to auto-fill the "Filter by Character(s)" field at the top of the app.
- **Content:** Shows download progress for each file, errors encountered, skipped items, summary information, or extracted links (if in "🔗 Only Links" mode). </li>
<li>
- **👁️ / 🙈 Log View Toggle Button:** <strong>Delete Selected:</strong> Select one or more entries from the list and click "🗑️ Delete Selected" to remove them from the app and update <code>Known.txt</code> accordingly.
- **Purpose:** Switches the content displayed in the main log area. </li>
- **Views:** </ul>
- `👁️ Progress Log` (Default): Shows all download activity, errors, and general progress messages.
- `🙈 Missed Character Log`: Shows a list of key terms intelligently extracted from post titles or content that were skipped due to the "**🎯 Filter by Character(s)**" not matching. Useful for identifying characters you might want to add to your filter or `Known.txt`. <h2><strong>Other Buttons</strong></h2>
<ul>
- **Show External Links in Log Checkbox & Panel:** <li>
- **Purpose:** If checked, a secondary, smaller log panel appears (usually below the main log) that specifically displays any external links (e.g., to Mega, Google Drive) found in post descriptions. <strong>(?_?) mark button (Help Guide):</strong> Opens a multi-page help dialog with step-by-step instructions and explanations for all app features. Useful for new users.
- **Behavior:** Disabled if "🔗 Only Links" or "📦 Only Archives" mode is active (as "Only Links" uses the main log, and archives typically don't have such external links processed). </li>
<li>
- **Export Links Button:** <strong>History Button:</strong> Opens the Download History dialog (from <code>DownloadHistoryDialog.py</code>), showing:
- **Visibility:** Appears when the "**🔗 Only Links**" filter mode is active. <ul>
- **Purpose:** Saves all the links extracted and displayed in the main log area to a `.txt` file. <li>Recently downloaded files</li>
<li>The first few posts processed in the last session</li>
- **Progress Labels/Bars:** </ul>
- **Purpose:** Provide a visual and textual representation of the download progress. This allows for a quick review of recent activity.
- **Typically Includes:** </li>
- Overall post progress (e.g., "Post 5 of 20"). <li>
- Individual file download status (e.g., "Downloading file.zip... 50% at 1.2 MB/s"). <strong>Settings Button:</strong> Opens the Settings dialog (from <code>FutureSettingsDialog.py</code>), where you can change app-wide settings such as theme (light/dark) and language.
- Summary statistics at the end of a session (total downloaded, skipped, failed). </li>
<li>
--- <strong>Support Button:</strong> Opens the Support dialog (from <code>SupportDialog.py</code>), which includes developer info, source links, and donation platforms like Ko-fi or Patreon.
## Error Handling & Retries </li>
</ul>
- **🆘 Error Button (Main UI):** <h2><strong>Log Area Controls</strong></h2>
- **Location:** Typically near the main action buttons (e.g., Start, Pause, Cancel). <p>These controls are located around the main log panel and offer tools for managing downloads, configuring advanced options, and resetting the application.</p>
- **Purpose:** Becomes active if files failed to download during the last session (and were not successfully retried). Clicking it opens the "Files Skipped Due to Errors" dialog.
- **"Files Skipped Due to Errors" Dialog:** <ul>
- **File List:** Displays a list of files that encountered download errors. Each entry shows the filename, the post it was from (title and ID). <li>
- **Checkboxes:** Allows selection of individual files from the list. <strong>Multi-part: OFF</strong><br>
- **"Select All" Button:** Checks all files in the list. This button acts as both a status indicator and a configuration panel for multi-part downloading (parallel downloading of large files).
- **"Retry Selected" Button:** Attempts to re-download all checked files. <ul>
- **"Export URLs to .txt" Button:** <li><strong>Function:</strong> Opens the <code>Multipart Download Options</code> dialog (defined in <code>MultipartScopeDialog.py</code>).</li>
- Opens an "Export Options" dialog. <li><strong>Scope Options:</strong> Choose between "Videos Only", "Archives Only", or "Both".</li>
- **"Link per line (URL only)":** Exports only the direct download URL for each failed file, one URL per line. <li><strong>Number of parts:</strong> Set how many simultaneous connections to use (216).</li>
- **"Export with details (URL [Post, File info])":** Exports the URL followed by details like Post Title, Post ID, and Original Filename in brackets. <li><strong>Minimum file size:</strong> Set a threshold (MB) below which files are downloaded normally.</li>
- Prompts the user to save the generated `.txt` file. <li><strong>Status:</strong> After applying settings, the button's text updates (e.g., <code>Multi-part: Both</code>); otherwise, it resets to <code>Multi-part: OFF</code>.</li>
- **"OK" Button:** Closes the dialog. </ul>
- **Note:** Files successfully retried or skipped due to hash match during a retry attempt are removed from this error list. </li>
---
<li>
## ⚙️ Application Settings <strong>👁️ Eye Emoji Button (Log View Toggle)</strong><br>
Switches between two views in the log panel:
These settings allow you to customize the application's appearance and language. <ul>
<li><strong>👁️ Progress Log View:</strong> Shows real-time download progress, status messages, and errors.</li>
- **⚙️ Settings Button (Icon may vary, e.g., a gear ⚙️):** <li><strong>🚫 Missed Character View:</strong> Displays names detected in posts that didnt match the current filter — useful for updating <code>Known.txt</code>.</li>
- **Location:** Typically located in a persistent area of the UI, possibly near other global controls or in a menu. </ul>
- **Purpose:** Opens the "Settings" dialog. </li>
- **Tooltip Example:** "Open application settings (Theme, Language, etc.)"
<li>
- **"Settings" Dialog:** <strong>Reset Button</strong><br>
- **Title:** "Settings" Performs a full "soft reset" of the UI when the application is idle.
- **Purpose:** Provides options to configure application-wide preferences. <ul>
- **Sections:** <li>Clears all inputs (except saved Download Location)</li>
- **Appearance Group (`Appearance`):** <li>Resets checkboxes, buttons, and logs</li>
- **Theme Toggle Buttons/Options:** <li>Clears counters, queues, and restores the UI to its default state</li>
- `Switch to Light Mode` <li><strong>Note:</strong> This is different from <em>Cancel & Reset UI</em>, which halts active downloads</li>
- `Switch to Dark Mode` </ul>
- **Purpose:** Allows users to switch between a light and dark visual theme for the application. </li>
- **Tooltips:** Provide guidance on switching themes. </ul>
- **Language Settings Group (`Language Settings`):**
- **Language Selection Dropdown/List:** <h3><strong>The Progress Log and "Only Links" Mode Controls</strong></h3>
- **Label:** "Language:"
- **Options:** Includes, but not limited to: <ul>
- English (`English`) <li>
- 日本語 (`日本語 (Japanese)`) <strong>Standard Mode (Progress Log)</strong><br>
- Français (French) This is the default behavior. The <code>main_log_output</code> field displays:
- Español (Spanish) <ul>
- Deutsch (German) <li>Post processing steps</li>
- Русский (Russian) <li>Download/skipped file notifications</li>
- 한국어 (Korean) <li>Error messages</li>
- 简体中文 (Chinese Simplified) <li>Session summaries</li>
- **Purpose:** Allows users to change the display language of the application interface. </ul>
- **Restart Prompt:** After changing the language, a dialog may appear: </li>
- **Title:** "Language Changed"
- **Message:** "The language has been changed. A restart is required for all changes to take full effect." <li>
- **Informative Text:** "Would you like to restart the application now?" <strong>"Only Links" Mode</strong><br>
- **Buttons:** "Restart Now", "OK" (or similar to defer restart). When enabled, the log panel switches modes and reveals new controls.
- **"OK" Button:** Saves the changes made in the Settings dialog and closes it. <ul>
--- <li><strong>📜 Extracted Links Log:</strong> Replaces progress info with a list of found external links (e.g., Mega, Dropbox).</li>
<li><strong>Export Links Button:</strong> Saves the extracted links to a <code>.txt</code> file.</li>
## Other UI Elements <li><strong>Download Button:</strong> Opens the <code>Download Selected External Links</code> dialog (from <code>DownloadExtractedLinksDialog.py</code>), where you can:
<ul>
- **Retry Failed Downloads Prompt:** <li>View all supported external links</li>
- **Trigger:** Appears at the end of a download session if there were files that failed to download due to recoverable errors (e.g., network interruption, IncompleteRead). <li>Select which ones to download</li>
- **Action:** Prompts the user if they want to attempt downloading the failed files again. <li>Begin download directly from cloud services</li>
</ul>
- **New Name Confirmation Dialog (for Character Filter & `Known.txt`):** </li>
- **Trigger:** When new, unrecognized names or groups are used in the "**🎯 Filter by Character(s)**" field that are not present in `Known.txt`. <li><strong>Links View Button:</strong> Toggles log display between:
- **Action:** Prompts the user to confirm if they want to add these new names/groups to `Known.txt` with the appropriate formatting (simple, grouped, or aliased). <ul>
<li><strong>🔗 Links View:</strong> Shows all extracted links</li>
- **Onboarding Tour / Help Guide Button (❓):** <li><strong>⬇️ Progress View:</strong> Shows download progress from external services (e.g., Mega)</li>
- **Purpose:** Opens a built-in help guide or an onboarding tour that explains the basic functionalities and UI elements of the application. Often linked to this detailed feature guide. </ul>
</li>
--- </ul>
</li>
This guide should cover all interactive elements of the Kemono Downloader. If you have further questions or discover elements not covered, please refer to the main `readme.md` or consider opening an issue on the project's repository. </ul>

29
main.py
View File

@@ -9,24 +9,26 @@ from PyQt5.QtWidgets import QApplication, QDialog
from PyQt5.QtCore import QCoreApplication from PyQt5.QtCore import QCoreApplication
# --- Local Application Imports --- # --- Local Application Imports ---
# These imports reflect the new, organized project structure.
from src.ui.main_window import DownloaderApp from src.ui.main_window import DownloaderApp
from src.ui.dialogs.TourDialog import TourDialog from src.ui.dialogs.TourDialog import TourDialog
from src.config.constants import CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN from src.config.constants import CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN
# --- Define APP_BASE_DIR globally and make available early ---
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
APP_BASE_DIR = sys._MEIPASS
else:
APP_BASE_DIR = os.path.abspath(os.path.dirname(__file__))
# Optional: Set a global variable or pass it into modules if needed
# Or re-export it via constants.py for cleaner imports
def handle_uncaught_exception(exc_type, exc_value, exc_traceback): def handle_uncaught_exception(exc_type, exc_value, exc_traceback):
""" """
Handles uncaught exceptions by logging them to a file for easier debugging, Handles uncaught exceptions by logging them to a file for easier debugging,
especially for bundled applications. especially for bundled applications.
""" """
# Determine the base directory for logging # Use APP_BASE_DIR to determine logging location
if getattr(sys, 'frozen', False): log_dir = os.path.join(APP_BASE_DIR, "logs")
base_dir_for_log = os.path.dirname(sys.executable)
else:
base_dir_for_log = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(base_dir_for_log, "logs")
log_file_path = os.path.join(log_dir, "uncaught_exceptions.log") log_file_path = os.path.join(log_dir, "uncaught_exceptions.log")
try: try:
@@ -57,41 +59,35 @@ def main():
qt_app = QApplication(sys.argv) qt_app = QApplication(sys.argv)
# Create the main application window from its new module # Create the main application window
downloader_app_instance = DownloaderApp() downloader_app_instance = DownloaderApp()
# --- Window Sizing and Positioning --- # --- Window Sizing and Positioning ---
# Logic moved from the old main.py to set an appropriate initial size
primary_screen = QApplication.primaryScreen() primary_screen = QApplication.primaryScreen()
if not primary_screen: if not primary_screen:
# Fallback for systems with no primary screen detected
downloader_app_instance.resize(1024, 768) downloader_app_instance.resize(1024, 768)
else: else:
available_geo = primary_screen.availableGeometry() available_geo = primary_screen.availableGeometry()
screen_width = available_geo.width() screen_width = available_geo.width()
screen_height = available_geo.height() screen_height = available_geo.height()
# Define minimums and desired ratios
min_app_width, min_app_height = 960, 680 min_app_width, min_app_height = 960, 680
desired_width_ratio, desired_height_ratio = 0.80, 0.85 desired_width_ratio, desired_height_ratio = 0.80, 0.85
app_width = max(min_app_width, int(screen_width * desired_width_ratio)) app_width = max(min_app_width, int(screen_width * desired_width_ratio))
app_height = max(min_app_height, int(screen_height * desired_height_ratio)) app_height = max(min_app_height, int(screen_height * desired_height_ratio))
# Ensure the window is not larger than the screen
app_width = min(app_width, screen_width) app_width = min(app_width, screen_width)
app_height = min(app_height, screen_height) app_height = min(app_height, screen_height)
downloader_app_instance.resize(app_width, app_height) downloader_app_instance.resize(app_width, app_height)
# Show the main window and center it # Show and center the main window
downloader_app_instance.show() downloader_app_instance.show()
if hasattr(downloader_app_instance, '_center_on_screen'): if hasattr(downloader_app_instance, '_center_on_screen'):
downloader_app_instance._center_on_screen() downloader_app_instance._center_on_screen()
# --- First-Run Welcome Tour --- # --- First-Run Welcome Tour ---
# Check if the tour should be shown and run it.
# This static method call keeps the logic clean and contained.
if TourDialog.should_show_tour(): if TourDialog.should_show_tour():
tour_dialog = TourDialog(parent_app=downloader_app_instance) tour_dialog = TourDialog(parent_app=downloader_app_instance)
tour_dialog.exec_() tour_dialog.exec_()
@@ -102,7 +98,6 @@ def main():
sys.exit(exit_code) sys.exit(exit_code)
except SystemExit: except SystemExit:
# Allow sys.exit() to work as intended
pass pass
except Exception as e: except Exception as e:
print("--- CRITICAL APPLICATION STARTUP ERROR ---") print("--- CRITICAL APPLICATION STARTUP ERROR ---")

View File

@@ -13,10 +13,9 @@ This project used to be one giant messy App Script. It worked, but it was hard t
``` ```
KemonoDownloader/ KemonoDownloader/
├── main.py # Where the app starts ├── main.py # Where the app starts
├── requirements.txt # List of Python libraries used
├── assets/ # Icons and other static files ├── assets/ # Icons and other static files
│ └── Kemono.ico │ └── Kemono.ico
├── data/ # Stuff that gets saved (user config, cookies, etc.) ├── data/
│ └── creators.json │ └── creators.json
├── logs/ # Error logs and other output ├── logs/ # Error logs and other output
│ └── uncaught_exceptions.log │ └── uncaught_exceptions.log

157
readme.md
View File

@@ -1,127 +1,105 @@
<h1 align="center">Kemono Downloader v5.5.0</h1> <h1 align="center">Kemono Downloader </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.*
<p align="center"> <div 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>
--- [![](https://img.shields.io/badge/📚%20Full%20Feature%20List-FFD700?style=for-the-badge&logoColor=black&color=FFD700)](features.md)
[![](https://img.shields.io/badge/📝%20License-90EE90?style=for-the-badge&logoColor=black&color=90EE90)](LICENSE)
[![](https://img.shields.io/badge/⚠️%20Important%20Note-FFCCCB?style=for-the-badge&logoColor=black&color=FFCCCB)](note.md)
## Feature Overview </div>
Kemono Downloader offers a range of features to streamline your content downloading experience: <h2><strong>Core Capabilities Overview</strong></h2>
- **User-Friendly Interface:** A modern PyQt5 GUI for easy navigation and operation. <h3><strong>High-Performance Downloading</strong></h3>
- **Flexible Downloading:** <ul>
- Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors). <li><strong>Multi-threading:</strong> Processes multiple posts simultaneously to greatly accelerate downloads from large creator profiles.</li>
- Supports creator pages (with page range selection) and individual post URLs. <li><strong>Multi-part Downloading:</strong> Splits large files into chunks and downloads them in parallel to maximize speed.</li>
- Standard download controls: Start, Pause, Resume, and Cancel. <li><strong>Resilience:</strong> Supports pausing, resuming, and restoring downloads after crashes or interruptions.</li>
- **Powerful Filtering:** </ul>
- **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
- **Keyword Skipping:** Skip posts or files based on specified keywords.
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
- **Customizable Downloads:**
- **Thumbnails Only:** Option to download only small preview images.
- **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
- **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
- **Organized Output:**
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
- **Manga/Comic Mode:**
- Downloads posts from a creator's feed in chronological order (oldest to newest).
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
- **⭐ Favorite Mode:**
- Directly download from your favorited artists and posts on Kemono.su.
- Requires a valid cookie and adapts the UI for easy selection from your favorites.
- Supports downloading into a single location or artist-specific subfolders.
- **Performance & Advanced Options:**
- **Cookie Support:** Use cookies (paste string or load from `cookies.txt`) to access restricted content.
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
- **Logging:**
- A detailed progress log displays download activity, errors, and summaries.
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
--- <h3><strong>Advanced Filtering & Content Control</strong></h3>
<ul>
<li><strong>Content Type Filtering:</strong> Select whether to download all files or limit to images, videos, audio, or archives only.</li>
<li><strong>Keyword Skipping:</strong> Automatically skips posts or files containing certain keywords (e.g., "WIP", "sketch").</li>
<li><strong>Character Filtering:</strong> Restricts downloads to posts that match specific character or series names.</li>
</ul>
## ✨ What's New in v5.3.0 <h3><strong>File Organization & Renaming</strong></h3>
- **Multi-Creator Post Fetching & Queuing:** <ul>
- The **Creator Selection popup** (🎨 icon) has been significantly enhanced. <li><strong>Automated Subfolders:</strong> Automatically organizes downloaded files into subdirectories based on character names or per post.</li>
- After selecting multiple creators, you can now click a new "**Fetch Posts**" button. <li><strong>Advanced File Renaming:</strong> Flexible renaming options, especially in Manga Mode, including:
- This will retrieve and display posts from all selected creators in a new view within the popup. <ul>
- You can then browse these fetched posts (with search functionality) and select individual posts. <li><strong>Post Title:</strong> Uses the post's title (e.g., <code>Chapter-One.jpg</code>).</li>
- 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. <li><strong>Date + Original Name:</strong> Prepends the publication date to the original filename.</li>
- The traditional "**Add Selected to URL**" button is still available if you prefer to populate the main URL field with creator names. <li><strong>Date + Title:</strong> Combines the date with the post title.</li>
- **Improved Favorite Download Queue Handling:** <li><strong>Sequential Numbering (Date Based):</strong> Simple sequence numbers (e.g., <code>001.jpg</code>, <code>002.jpg</code>).</li>
- 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"). <li><strong>Title + Global Numbering:</strong> Uses post title with a globally incrementing number across the session.</li>
- The queue is now more robustly managed, especially when interacting with the main URL input field after items have been queued from the popup. <li><strong>Post ID:</strong> Names files using the posts unique ID.</li>
</ul>
</li>
</ul>
--- <h3><strong>Specialized Modes</strong></h3>
<ul>
<li><strong>Manga/Comic Mode:</strong> Sorts posts chronologically before downloading to ensure pages appear in the correct sequence.</li>
<li><strong>Favorite Mode:</strong> Connects to your account and downloads from your favorites list (artists or posts).</li>
<li><strong>Link Extraction Mode:</strong> Extracts external links from posts for export or targeted downloading.</li>
<li><strong>Text Extraction Mode:</strong> Saves post descriptions or comment sections as <code>PDF</code>, <code>DOCX</code>, or <code>TXT</code> files.</li>
</ul>
## ✨ What's New in v5.1.0 <h3><strong>Utility & Advanced Features</strong></h3>
- **Enhanced Error File Management**: The "Error" button now opens a dialog listing files that failed to download. This dialog includes: <ul>
- An option to **retry selected** failed downloads. <li><strong>Cookie Support:</strong> Enables access to subscriber-only content via browser session cookies.</li>
- 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). <li><strong>Duplicate Detection:</strong> Prevents saving duplicate files using content-based comparison, with configurable limits.</li>
- Fixed a bug where files skipped during retry (due to existing hash match) were not correctly removed from the error list. <li><strong>Image Compression:</strong> Automatically converts large images to <code>.webp</code> to reduce disk usage.</li>
- **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. <li><strong>Creator Management:</strong> Built-in creator browser and update checker for downloading only new posts from saved profiles.</li>
<li><strong>Error Handling:</strong> Tracks failed downloads and provides a retry dialog with options to export or redownload missing files.</li>
</ul>
## ✨ What's New in v5.2.0 ## 💻 Installation
- **Multi-language Support:** The interface now supports multiple languages: English, Japanese, French, Spanish, German, Russian, Korean, and Chinese (Simplified). Select your preferred language in the new Settings dialog.
- **Theme Selection:** Choose between Light and Dark application themes via the Settings dialog for a personalized viewing experience.
- **Centralized Settings:** A new Settings dialog (accessible via a settings button, often with a gear icon) provides a dedicated space for language and appearance customizations.
- **Internal Localization:** Introduced `languages.py` for managing UI translations, streamlining the addition of new languages by contributors.
---
## Installation
### Requirements ### Requirements
- Python 3.6 or higher - Python 3.6 or higher
- pip (Python package installer) - 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 fpdf2 python-docx
``` ```
### Running the Application ### Running the Application
@@ -164,7 +142,7 @@ Feel free to fork this repo and submit pull requests for bug fixes, new features
## License ## License
This project is under the Custom Licence This project is under the MIT Licence
## Star History ## Star History
@@ -176,4 +154,9 @@ This project is under the Custom Licence
</a> </a>
</table> </table>
👉 See [features.md](features.md) for the full feature list. <p align="center">
<a href="https://buymeacoffee.com/yuvi9587">
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
</a>
</p>

View File

@@ -9,6 +9,7 @@ STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" STYLE_DATE_BASED = "date_based"
STYLE_DATE_POST_TITLE = "date_post_title" STYLE_DATE_POST_TITLE = "date_post_title"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
STYLE_POST_ID = "post_id" # Add this line
MANGA_DATE_PREFIX_DEFAULT = "" MANGA_DATE_PREFIX_DEFAULT = ""
# --- Download Scopes --- # --- Download Scopes ---
@@ -56,6 +57,9 @@ 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"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
# --- UI Constants and Identifiers --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"
@@ -69,7 +73,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 = {
@@ -94,6 +98,7 @@ FOLDER_NAME_STOP_WORDS = {
"me", "my", "net", "not", "of", "on", "or", "org", "our", "me", "my", "net", "not", "of", "on", "or", "org", "our",
"s", "she", "so", "the", "their", "they", "this", "s", "she", "so", "the", "their", "they", "this",
"to", "ve", "was", "we", "were", "with", "www", "you", "your", "to", "ve", "was", "we", "were", "with", "www", "you", "your",
# add more according to need
} }
# Additional words to ignore specifically for creator-level downloads # Additional words to ignore specifically for creator-level downloads
@@ -107,4 +112,9 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"oct", "october", "nov", "november", "dec", "december", "oct", "october", "nov", "november", "dec", "december",
"mon", "monday", "tue", "tuesday", "wed", "wednesday", "thu", "thursday", "mon", "monday", "tue", "tuesday", "wed", "wednesday", "thu", "thursday",
"fri", "friday", "sat", "saturday", "sun", "sunday" "fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need
} }
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

@@ -1,12 +1,8 @@
# --- 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 ---
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 +11,21 @@ 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.")
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}'
paginated_url = f'{api_url_base}?o={offset}'
max_retries = 3 max_retries = 3
retry_delay = 5 retry_delay = 5
@@ -52,22 +33,17 @@ 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) 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 +52,44 @@ 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:
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)
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 +107,250 @@ 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
): ):
headers ={ headers = {
'User-Agent':'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
'Accept':'application/json' 'Accept': 'application/json'
} }
if processed_post_ids is None:
processed_post_ids = set()
else:
processed_post_ids = set(processed_post_ids)
service ,user_id ,target_post_id =extract_post_info (api_url_input ) service, user_id, target_post_id = extract_post_info(api_url_input)
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Download_from_api cancelled at start.") logger(" Download_from_api cancelled at start.")
return return
parsed_input_url_for_domain =urlparse (api_url_input ) parsed_input_url_for_domain = urlparse(api_url_input)
api_domain =parsed_input_url_for_domain .netloc api_domain = parsed_input_url_for_domain.netloc
if not any (d in api_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']):
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 # --- START: MODIFIED LOGIC ---
api_base_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }" # This list is updated to include the new .cr and .st mirrors for validation.
page_size =50 if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
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 =[] # --- END: MODIFIED LOGIC ---
current_offset_manga =0
if start_page and start_page >1 : cookies_for_api = None
current_offset_manga =(start_page -1 )*page_size if use_cookie and app_base_dir:
logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).") cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
elif start_page : if target_post_id:
logger (f" Manga Mode: Starting fetch from page 1 (offset 0).") if target_post_id in processed_post_ids:
if end_page : logger(f" Skipping already processed target post ID: {target_post_id}")
logger (f" Manga Mode: Will fetch up to page {end_page }.") return
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()
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
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 break
time .sleep (0.5 ) time.sleep(0.5)
if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.") if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Manga mode post fetching cancelled.") logger(" Manga mode post fetching cancelled.")
break break
current_page_num_manga =(current_offset_manga //page_size )+1 current_page_num_manga = (current_offset_manga // page_size) + 1
if end_page and current_page_num_manga >end_page : if end_page and current_page_num_manga > end_page:
logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.") logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break break
try : try:
posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) 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 ): if not isinstance(posts_batch_manga, list):
logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.") logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break break
if not posts_batch_manga : if not posts_batch_manga:
logger ("✅ Reached end of posts (Manga Mode fetch all).") logger("✅ Reached end of posts (Manga Mode fetch all).")
if start_page and not end_page and current_page_num_manga <start_page : 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 }.") 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 : 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 }).") logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break break
all_posts_for_manga_mode .extend (posts_batch_manga ) all_posts_for_manga_mode.extend(posts_batch_manga)
current_offset_manga +=page_size
time .sleep (0.6 ) logger(f"MANGA_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}")
except RuntimeError as e :
if "cancelled by user"in str (e ).lower (): current_offset_manga += page_size
logger (f" Manga mode pagination stopped due to cancellation: {e }") time.sleep(0.6)
else : except RuntimeError as e:
logger (f"{e }\n Aborting manga mode pagination.") 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 break
except Exception as e : except Exception as e:
logger (f"❌ Unexpected error during manga mode fetch: {e }") logger(f"❌ Unexpected error during manga mode fetch: {e}")
traceback .print_exc () traceback.print_exc()
break break
if cancellation_event and cancellation_event .is_set ():return
if all_posts_for_manga_mode : if cancellation_event and cancellation_event.is_set(): return
logger (f" Manga Mode: Fetched {len (all_posts_for_manga_mode )} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple (post ): if all_posts_for_manga_mode:
published_date_str =post .get ('published') logger(f"MANGA_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}")
added_date_str =post .get ('added')
post_id_str =post .get ('id',"0") if all_posts_for_manga_mode:
primary_sort_val ="0000-00-00T00:00:00" if processed_post_ids:
if published_date_str : original_count = len(all_posts_for_manga_mode)
primary_sort_val =published_date_str all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
elif added_date_str : skipped_count = original_count - len(all_posts_for_manga_mode)
logger (f" ⚠️ Post ID {post_id_str } missing 'published' date, using 'added' date '{added_date_str }' for primary sorting.") if skipped_count > 0:
primary_sort_val =added_date_str logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
else :
logger (f" ⚠️ Post ID {post_id_str } missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).") logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
secondary_sort_val =0 def sort_key_tuple(post):
try : published_date_str = post.get('published')
secondary_sort_val =int (post_id_str ) added_date_str = post.get('added')
except ValueError : post_id_str = post.get('id', "0")
logger (f" ⚠️ Post ID '{post_id_str }' is not a valid integer for secondary sorting, using 0.") primary_sort_val = "0000-00-00T00:00:00"
return (primary_sort_val ,secondary_sort_val ) if published_date_str:
all_posts_for_manga_mode .sort (key =sort_key_tuple ) primary_sort_val = published_date_str
for i in range (0 ,len (all_posts_for_manga_mode ),page_size ): elif added_date_str:
if cancellation_event and cancellation_event .is_set (): logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.")
logger (" Manga mode post yielding cancelled.") 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 break
yield all_posts_for_manga_mode [i :i +page_size ] yield all_posts_for_manga_mode[i:i + page_size]
return return
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).")
current_page_num = 1
current_offset = 0
processed_target_post_flag = False
if start_page and start_page > 1 and not target_post_id:
current_offset = (start_page - 1) * page_size
current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
while True:
if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching loop cancelled while paused.")
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching loop cancelled.")
break
if target_post_id and processed_target_post_flag:
break
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.")
break
try:
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):
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
break
except RuntimeError as e:
if "cancelled by user" in str(e).lower():
logger(f" Pagination stopped due to cancellation: {e}")
else:
logger(f"{e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).")
break
except Exception as e:
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc()
break
if processed_post_ids:
original_count = len(posts_batch)
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch)
if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check ==STYLE_DATE_POST_TITLE ): if not posts_batch:
logger (f" Manga Mode (Style: {STYLE_DATE_POST_TITLE }): Processing posts in default API order (newest first).") if target_post_id and not processed_target_post_flag:
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
current_page_num =1 elif not target_post_id:
current_offset =0 if current_page_num == (start_page or 1):
processed_target_post_flag =False logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
if start_page and start_page >1 and not target_post_id : else:
current_offset =(start_page -1 )*page_size logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
current_page_num =start_page
logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).")
while True :
if pause_event and pause_event .is_set ():
logger (" Post fetching loop paused...")
while pause_event .is_set ():
if cancellation_event and cancellation_event .is_set ():
logger (" Post fetching loop cancelled while paused.")
break break
time .sleep (0.5 ) if target_post_id and not processed_target_post_flag:
if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.") matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
if cancellation_event and cancellation_event .is_set (): if matching_post:
logger (" Post fetching loop cancelled.") logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
break yield [matching_post]
if target_post_id and processed_target_post_flag : processed_target_post_flag = True
break elif not target_post_id:
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.")
break
try :
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 ):
logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).")
break
except RuntimeError as e :
if "cancelled by user"in str (e ).lower ():
logger (f" Pagination stopped due to cancellation: {e }")
else :
logger (f"{e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).")
break
except Exception as e :
logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }")
traceback .print_exc ()
break
if not posts_batch :
if target_post_id and not processed_target_post_flag :
logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).")
elif not target_post_id :
if current_page_num ==(start_page or 1 ):
logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).")
else :
logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).")
break
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 matching_post :
logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).")
yield [matching_post ]
processed_target_post_flag =True
elif not target_post_id :
yield posts_batch yield posts_batch
if processed_target_post_flag : if processed_target_post_flag:
break break
current_offset +=page_size current_offset += page_size
current_page_num +=1 current_page_num += 1
time .sleep (0.6 ) time.sleep(0.6)
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()): 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).") logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

View File

@@ -1,19 +1,14 @@
# --- Standard Library Imports ---
import threading import threading
import time import time
import os import os
import json import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future from concurrent.futures import ThreadPoolExecutor, as_completed, Future
# --- Local Application Imports ---
# These imports reflect the new, organized project structure.
from .api_client import download_from_api from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread from .workers import PostProcessorWorker
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING, STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING,
MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES, MAX_THREADS
POST_WORKER_BATCH_DELAY_SECONDS
) )
from ..utils.file_utils import clean_folder_name from ..utils.file_utils import clean_folder_name
@@ -36,8 +31,6 @@ class DownloadManager:
self.progress_queue = progress_queue self.progress_queue = progress_queue
self.thread_pool = None self.thread_pool = None
self.active_futures = [] self.active_futures = []
# --- Session State ---
self.cancellation_event = threading.Event() self.cancellation_event = threading.Event()
self.pause_event = threading.Event() self.pause_event = threading.Event()
self.is_running = False self.is_running = False
@@ -47,6 +40,10 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
self.creator_profiles_dir = None
self.current_creator_name_for_profile = None
self.current_creator_profile_path = None
self.session_file_path = None
def _log(self, message): def _log(self, message):
"""Puts a progress message into the queue for the UI.""" """Puts a progress message into the queue for the UI."""
@@ -65,7 +62,16 @@ class DownloadManager:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
# --- Reset state for the new session --- self.session_file_path = config.get('session_file_path')
creator_profile_data = self._setup_creator_profile(config)
# Save settings to profile at the start of the session
if self.current_creator_profile_path:
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
self.pause_event.clear() self.pause_event.clear()
@@ -76,7 +82,6 @@ class DownloadManager:
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
is_single_post = bool(config.get('target_post_id_from_initial_url')) is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True) use_multithreading = config.get('use_multithreading', True)
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
@@ -84,112 +89,114 @@ class DownloadManager:
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
# Start a separate thread to manage fetching and queuing to the thread pool
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data), args=(config, restore_data, creator_profile_data),
daemon=True daemon=True
) )
fetcher_thread.start() fetcher_thread.start()
else: else:
# For single posts or sequential manga mode, use a single worker thread # Single-threaded mode does not use the manager's complex logic
# which is simpler and ensures order. self._log(" Manager is handing off to a single-threaded worker...")
self._start_single_threaded_session(config) # The single-threaded worker will manage its own lifecycle and signals.
# The manager's role for this session is effectively over.
self.is_running = False # Allow another session to start if needed
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...")
# The original DownloadThread is now a pure Python thread, not a QThread. def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
# We run its `run` method in a standard Python thread.
self.worker_thread = threading.Thread(
target=self._run_single_worker,
args=(config,),
daemon=True
)
self.worker_thread.start()
def _run_single_worker(self, config):
"""Target function for the single-worker thread."""
try:
# Pass the queue directly to the worker for it to send updates
worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread
except Exception as e:
self._log(f"❌ CRITICAL ERROR in single-worker thread: {e}")
self._log(traceback.format_exc())
finally:
self.is_running = False
def _fetch_and_queue_posts_for_pool(self, config, restore_data):
""" """
Fetches all posts from the API and submits them as tasks to a thread pool. Fetches posts from the API in batches and submits them as tasks to a thread pool.
This method runs in its own dedicated thread to avoid blocking. This method runs in its own dedicated thread to avoid blocking the UI.
It provides immediate feedback as soon as the first batch of posts is found.
""" """
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
# Fetch posts session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set()
# In a real implementation, this would call `api_client.download_from_api` profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
if restore_data: processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data and 'all_posts_data' in restore_data:
# This logic for session restore remains as it relies on a pre-fetched list
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids] posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids]
self.total_posts = len(all_posts) self.total_posts = len(all_posts)
self.processed_posts = len(processed_ids) self.processed_posts = len(processed_ids)
self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.") self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.")
else:
posts_to_process = self._get_all_posts(config)
self.total_posts = len(posts_to_process)
self.processed_posts = 0
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
if not posts_to_process: if not posts_to_process:
self._log("✅ No new posts to process.") self._log("✅ No new posts to process from restored session.")
return return
# Submit tasks to the pool
for post_data in posts_to_process: for post_data in posts_to_process:
if self.cancellation_event.is_set(): break
worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
else:
# --- START: REFACTORED STREAMING LOGIC ---
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=list(processed_ids)
)
self.total_posts = 0
self.processed_posts = 0
# Process posts in batches as they are yielded by the API client
for batch in post_generator:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
self._log(" Post fetching cancelled.")
break break
# Each PostProcessorWorker gets the queue to send its own updates
# Filter out any posts that might have been processed since the start
posts_in_batch_to_process = [p for p in batch if p.get('id') not in processed_ids]
if not posts_in_batch_to_process:
continue
# Update total count and immediately inform the UI
self.total_posts += len(posts_in_batch_to_process)
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
for post_data in posts_in_batch_to_process:
if self.cancellation_event.is_set(): break
worker = PostProcessorWorker(post_data, config, self.progress_queue) worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process) future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result) future.add_done_callback(self._handle_future_result)
self.active_futures.append(future) self.active_futures.append(future)
if self.total_posts == 0 and not self.cancellation_event.is_set():
self._log("✅ No new posts found to process.")
except Exception as e: except Exception as e:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc()) self._log(traceback.format_exc())
finally: finally:
# Wait for all submitted tasks to complete before shutting down
if self.thread_pool: if self.thread_pool:
self.thread_pool.shutdown(wait=True) self.thread_pool.shutdown(wait=True)
self.is_running = False self.is_running = False
self._log("🏁 All processing tasks have completed.") self._log("🏁 All processing tasks have completed or been cancelled.")
# Emit final signal
self.progress_queue.put({ self.progress_queue.put({
'type': 'finished', 'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
}) })
def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client."""
all_posts = []
# This generator yields batches of posts
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
# ... pass other relevant config keys ...
cancellation_event=self.cancellation_event,
pause_event=self.pause_event
)
for batch in post_generator:
all_posts.extend(batch)
return all_posts
def _handle_future_result(self, future: Future): def _handle_future_result(self, future: Future):
"""Callback executed when a worker task completes.""" """Callback executed when a worker task completes."""
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
@@ -203,39 +210,76 @@ class DownloadManager:
self.total_skips += 1 self.total_skips += 1
else: else:
result = future.result() result = future.result()
# Unpack result tuple from the worker
(dl_count, skip_count, kept_originals, (dl_count, skip_count, kept_originals,
retryable, permanent, history) = result retryable, permanent, history) = result
self.total_downloads += dl_count self.total_downloads += dl_count
self.total_skips += skip_count self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals) self.all_kept_original_filenames.extend(kept_originals)
# Queue up results for UI to handle
if retryable: if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)}) self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent: if permanent:
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)}) self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
if history: if history:
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)}) self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
post_id = history.get('post_id')
if post_id and self.current_creator_profile_path:
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(profile_data)
except Exception as e: except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}") self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped self.total_skips += 1 # Count errored posts as skipped
# Update overall progress
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
if not self.current_creator_name_for_profile:
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
return {}
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(self.creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
if os.path.exists(self.current_creator_profile_path):
try:
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, data):
"""Saves the provided data to the current creator's profile file."""
if not self.current_creator_profile_path:
return
try:
temp_path = self.current_creator_profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, self.current_creator_profile_path)
except OSError as e:
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
def cancel_session(self): def cancel_session(self):
"""Cancels the current running session.""" """Cancels the current running session."""
if not self.is_running: if not self.is_running:
return return
if self.cancellation_event.is_set():
self._log(" Cancellation already in progress.")
return
self._log("⚠️ Cancellation requested by user...") self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set() self.cancellation_event.set()
# For single thread mode, the worker checks the event
# For multi-thread mode, shut down the pool
if self.thread_pool: if self.thread_pool:
# Don't wait, just cancel pending futures and let the fetcher thread exit self._log(" Signaling all worker threads to stop and shutting down pool...")
self.thread_pool.shutdown(wait=False, cancel_futures=True) self.thread_pool.shutdown(wait=False)
self.is_running = False

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -3,33 +3,30 @@ import os
import re import re
import traceback import traceback
import json import json
import base64
import time
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
# --- Third-Party Library Imports ---
import requests import requests
try: try:
from mega import Mega from Crypto.Cipher import AES
MEGA_AVAILABLE = True PYCRYPTODOME_AVAILABLE = True
except ImportError: except ImportError:
MEGA_AVAILABLE = False PYCRYPTODOME_AVAILABLE = False
try: try:
import gdown import gdown
GDOWN_AVAILABLE = True GDRIVE_AVAILABLE = True
except ImportError: except ImportError:
GDOWN_AVAILABLE = False GDRIVE_AVAILABLE = False
# --- Helper Functions --- MEGA_API_URL = "https://g.api.mega.co.nz"
def _get_filename_from_headers(headers): def _get_filename_from_headers(headers):
""" """
Extracts a filename from the Content-Disposition header. Extracts a filename from the Content-Disposition header.
(This is from your original file and is kept for Dropbox downloads)
Args:
headers (dict): A dictionary of HTTP response headers.
Returns:
str or None: The extracted filename, or None if not found.
""" """
cd = headers.get('content-disposition') cd = headers.get('content-disposition')
if not cd: if not cd:
@@ -37,97 +34,205 @@ def _get_filename_from_headers(headers):
fname_match = re.findall('filename="?([^"]+)"?', cd) fname_match = re.findall('filename="?([^"]+)"?', cd)
if fname_match: if fname_match:
# Sanitize the filename to prevent directory traversal issues
# and remove invalid characters for most filesystems.
sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip()) sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip())
return sanitized_name return sanitized_name
return None return None
# --- Main Service Downloader Functions --- # --- NEW: Helper functions for Mega decryption ---
def download_mega_file(mega_link, download_path=".", logger_func=print): def urlb64_to_b64(s):
""" """Converts a URL-safe base64 string to a standard base64 string."""
Downloads a file from a public Mega.nz link. s = s.replace('-', '+').replace('_', '/')
s += '=' * (-len(s) % 4)
return s
Args: def b64_to_bytes(s):
mega_link (str): The public Mega.nz link to the file. """Decodes a URL-safe base64 string to bytes."""
download_path (str): The directory to save the downloaded file. return base64.b64decode(urlb64_to_b64(s))
logger_func (callable): Function to use for logging.
"""
if not MEGA_AVAILABLE:
logger_func("❌ Error: mega.py library is not installed. Cannot download from Mega.")
logger_func(" Please install it: pip install mega.py")
raise ImportError("mega.py library not found.")
logger_func(f" [Mega] Initializing Mega client...") def bytes_to_hex(b):
"""Converts bytes to a hex string."""
return b.hex()
def hex_to_bytes(h):
"""Converts a hex string to bytes."""
return bytes.fromhex(h)
def hrk2hk(hex_raw_key):
"""Derives the final AES key from the raw key components for Mega."""
key_part1 = int(hex_raw_key[0:16], 16)
key_part2 = int(hex_raw_key[16:32], 16)
key_part3 = int(hex_raw_key[32:48], 16)
key_part4 = int(hex_raw_key[48:64], 16)
final_key_part1 = key_part1 ^ key_part3
final_key_part2 = key_part2 ^ key_part4
return f'{final_key_part1:016x}{final_key_part2:016x}'
def decrypt_at(at_b64, key_bytes):
"""Decrypts the 'at' attribute to get file metadata."""
at_bytes = b64_to_bytes(at_b64)
iv = b'\0' * 16
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
decrypted_at = cipher.decrypt(at_bytes)
return decrypted_at.decode('utf-8').strip('\0').replace('MEGA', '')
# --- NEW: Core Logic for Mega Downloads ---
def get_mega_file_info(file_id, file_key, session, logger_func):
"""Fetches file metadata and the temporary download URL from the Mega API."""
try: try:
mega_client = Mega() hex_raw_key = bytes_to_hex(b64_to_bytes(file_key))
m = mega_client.login() hex_key = hrk2hk(hex_raw_key)
logger_func(f" [Mega] Attempting to download from: {mega_link}") key_bytes = hex_to_bytes(hex_key)
if not os.path.exists(download_path): # Request file attributes
os.makedirs(download_path, exist_ok=True) payload = [{"a": "g", "p": file_id}]
logger_func(f" [Mega] Created download directory: {download_path}") response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
response.raise_for_status()
res_json = response.json()
# The download_url method handles file info fetching and saving internally. if isinstance(res_json, list) and isinstance(res_json[0], int) and res_json[0] < 0:
downloaded_file_path = m.download_url(mega_link, dest_path=download_path) logger_func(f" [Mega] ❌ API Error: {res_json[0]}. The link may be invalid or removed.")
return None
if downloaded_file_path and os.path.exists(downloaded_file_path): file_size = res_json[0]['s']
logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}") at_b64 = res_json[0]['at']
else:
raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}")
except Exception as e: # Decrypt attributes to get the file name
logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}") at_dec_json_str = decrypt_at(at_b64, key_bytes)
traceback.print_exc(limit=2) at_dec_json = json.loads(at_dec_json_str)
raise # Re-raise the exception to be handled by the calling worker file_name = at_dec_json['n']
def download_gdrive_file(gdrive_link, download_path=".", logger_func=print): # Request the temporary download URL
""" payload = [{"a": "g", "g": 1, "p": file_id}]
Downloads a file from a public Google Drive link using the gdown library. response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
response.raise_for_status()
res_json = response.json()
dl_temp_url = res_json[0]['g']
Args: return {
gdrive_link (str): The public Google Drive link to the file. 'file_name': file_name,
download_path (str): The directory to save the downloaded file. 'file_size': file_size,
logger_func (callable): Function to use for logging. 'dl_url': dl_temp_url,
""" 'hex_raw_key': hex_raw_key
if not GDOWN_AVAILABLE: }
logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.") except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as e:
logger_func(" Please install it: pip install gdown") logger_func(f" [Mega] ❌ Failed to get file info: {e}")
raise ImportError("gdown library not found.") return None
def download_and_decrypt_mega_file(info, download_path, logger_func):
"""Downloads the file and decrypts it chunk by chunk, reporting progress."""
file_name = info['file_name']
file_size = info['file_size']
dl_url = info['dl_url']
hex_raw_key = info['hex_raw_key']
final_path = os.path.join(download_path, file_name)
if os.path.exists(final_path) and os.path.getsize(final_path) == file_size:
logger_func(f" [Mega] File '{file_name}' already exists with the correct size. Skipping.")
return
# Prepare for decryption
key = hex_to_bytes(hrk2hk(hex_raw_key))
iv_hex = hex_raw_key[32:48] + '0000000000000000'
iv_bytes = hex_to_bytes(iv_hex)
cipher = AES.new(key, AES.MODE_CTR, initial_value=iv_bytes, nonce=b'')
logger_func(f" [GDrive] Attempting to download: {gdrive_link}")
try: try:
if not os.path.exists(download_path): with requests.get(dl_url, stream=True, timeout=(15, 300)) as r:
os.makedirs(download_path, exist_ok=True) r.raise_for_status()
logger_func(f" [GDrive] Created download directory: {download_path}") downloaded_bytes = 0
last_log_time = time.time()
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats. with open(final_path, 'wb') as f:
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True) for chunk in r.iter_content(chunk_size=8192):
if not chunk:
continue
decrypted_chunk = cipher.decrypt(chunk)
f.write(decrypted_chunk)
downloaded_bytes += len(chunk)
if output_file_path and os.path.exists(output_file_path): # Log progress every second
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}") current_time = time.time()
else: if current_time - last_log_time > 1:
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}") progress_percent = (downloaded_bytes / file_size) * 100 if file_size > 0 else 0
logger_func(f" [Mega] Downloading '{file_name}': {downloaded_bytes/1024/1024:.2f}MB / {file_size/1024/1024:.2f}MB ({progress_percent:.1f}%)")
last_log_time = current_time
logger_func(f" [Mega] ✅ Successfully downloaded '{file_name}' to '{download_path}'")
except requests.RequestException as e:
logger_func(f" [Mega] ❌ Download failed for '{file_name}': {e}")
except IOError as e:
logger_func(f" [Mega] ❌ Could not write to file '{final_path}': {e}")
except Exception as e: except Exception as e:
logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}") logger_func(f" [Mega] ❌ An unexpected error occurred during download/decryption: {e}")
traceback.print_exc(limit=2)
raise
# --- REPLACEMENT Main Service Downloader Function for Mega ---
def download_mega_file(mega_url, download_path, logger_func=print):
"""
Downloads a file from a Mega.nz URL using direct requests and decryption.
This replaces the old mega.py implementation.
"""
if not PYCRYPTODOME_AVAILABLE:
logger_func("❌ Mega download failed: 'pycryptodome' library is not installed. Please run: pip install pycryptodome")
return
logger_func(f" [Mega] Initializing download for: {mega_url}")
# Regex to capture file ID and key from both old and new URL formats
match = re.search(r'mega(?:\.co)?\.nz/(?:file/|#!)?([a-zA-Z0-9]+)(?:#|!)([a-zA-Z0-9_.-]+)', mega_url)
if not match:
logger_func(f" [Mega] ❌ Error: Invalid Mega URL format.")
return
file_id = match.group(1)
file_key = match.group(2)
session = requests.Session()
session.headers.update({'User-Agent': 'Kemono-Downloader-PyQt/1.0'})
file_info = get_mega_file_info(file_id, file_key, session, logger_func)
if not file_info:
logger_func(f" [Mega] ❌ Failed to get file info. The link may be invalid or expired. Aborting.")
return
logger_func(f" [Mega] File found: '{file_info['file_name']}' (Size: {file_info['file_size'] / 1024 / 1024:.2f} MB)")
download_and_decrypt_mega_file(file_info, download_path, logger_func)
# --- ORIGINAL Functions for Google Drive and Dropbox (Unchanged) ---
def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return
try:
logger_func(f" [G-Drive] Starting download for: {url}")
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e:
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
""" """
Downloads a file from a public Dropbox link by modifying the URL for direct download. Downloads a file from a public Dropbox link by modifying the URL for direct download.
Args:
dropbox_link (str): The public Dropbox link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
""" """
logger_func(f" [Dropbox] Attempting to download: {dropbox_link}") logger_func(f" [Dropbox] Attempting to download: {dropbox_link}")
# Modify the Dropbox URL to force a direct download instead of showing the preview page.
parsed_url = urlparse(dropbox_link) parsed_url = urlparse(dropbox_link)
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)
query_params['dl'] = ['1'] query_params['dl'] = ['1']
@@ -144,13 +249,11 @@ def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r: with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r:
r.raise_for_status() r.raise_for_status()
# Determine filename from headers or URL
filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file" filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file"
full_save_path = os.path.join(download_path, filename) full_save_path = os.path.join(download_path, filename)
logger_func(f" [Dropbox] Starting download of '{filename}'...") logger_func(f" [Dropbox] Starting download of '{filename}'...")
# Write file to disk in chunks
with open(full_save_path, 'wb') as f: with open(full_save_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)

View File

@@ -1,4 +1,5 @@
# --- Standard Library Imports --- # --- Standard Library Imports ---
# --- Standard Library Imports ---
import os import os
import time import time
import hashlib import hashlib
@@ -10,28 +11,49 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
# --- Third-Party Library Imports --- # --- Third-Party Library Imports ---
import requests import requests
MULTIPART_DOWNLOADER_AVAILABLE = True
# --- Module Constants --- # --- Module Constants ---
CHUNK_DOWNLOAD_RETRY_DELAY = 2 CHUNK_DOWNLOAD_RETRY_DELAY = 2
MAX_CHUNK_DOWNLOAD_RETRIES = 1 MAX_CHUNK_DOWNLOAD_RETRIES = 1
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk
# Flag to indicate if this module and its dependencies are available.
# This was missing and caused the ImportError.
MULTIPART_DOWNLOADER_AVAILABLE = True
def _download_individual_chunk( def _download_individual_chunk(
chunk_url, temp_file_path, start_byte, end_byte, headers, chunk_url, chunk_temp_file_path, start_byte, end_byte, headers,
part_num, total_parts, progress_data, cancellation_event, part_num, total_parts, progress_data, cancellation_event,
skip_event, pause_event, global_emit_time_ref, cookies_for_chunk, skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
logger_func, emitter=None, api_original_filename=None logger_func, emitter=None, api_original_filename=None
): ):
""" """
Downloads a single segment (chunk) of a larger file. This function is Downloads a single segment (chunk) of a larger file to its own unique part file.
intended to be run in a separate thread by a ThreadPoolExecutor. This function is intended to be run in a separate thread by a ThreadPoolExecutor.
It handles retries, pauses, and cancellations for its specific chunk. It handles retries, pauses, and cancellations for its specific chunk. If a
download fails, the partial chunk file is removed, allowing a clean retry later.
Args:
chunk_url (str): The URL to download the file from.
chunk_temp_file_path (str): The unique path to save this specific chunk
(e.g., 'my_video.mp4.part0').
start_byte (int): The starting byte for the Range header.
end_byte (int): The ending byte for the Range header.
headers (dict): The HTTP headers to use for the request.
part_num (int): The index of this chunk (e.g., 0 for the first part).
total_parts (int): The total number of chunks for the entire file.
progress_data (dict): A thread-safe dictionary for sharing progress.
cancellation_event (threading.Event): Event to signal cancellation.
skip_event (threading.Event): Event to signal skipping the file.
pause_event (threading.Event): Event to signal pausing the download.
global_emit_time_ref (list): A mutable list with one element (a timestamp)
to rate-limit UI updates.
cookies_for_chunk (dict): Cookies to use for the request.
logger_func (function): A function to log messages.
emitter (queue.Queue or QObject): Emitter for sending progress to the UI.
api_original_filename (str): The original filename for UI display.
Returns:
tuple: A tuple containing (bytes_downloaded, success_flag).
""" """
# --- Pre-download checks for control events --- # --- Pre-download checks for control events ---
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
@@ -49,6 +71,11 @@ def _download_individual_chunk(
time.sleep(0.2) time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.") logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
# Set this chunk's status to 'active' before starting the download.
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = True
try:
# Prepare headers for the specific byte range of this chunk # Prepare headers for the specific byte range of this chunk
chunk_headers = headers.copy() chunk_headers = headers.copy()
if end_byte != -1: if end_byte != -1:
@@ -76,8 +103,9 @@ def _download_individual_chunk(
response.raise_for_status() response.raise_for_status()
# --- Data Writing Loop --- # --- Data Writing Loop ---
with open(temp_file_path, 'r+b') as f: # We open the unique chunk file in write-binary ('wb') mode.
f.seek(start_byte) # No more seeking is required.
with open(chunk_temp_file_path, 'wb') as f:
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER): for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
return bytes_this_chunk, False return bytes_this_chunk, False
@@ -117,7 +145,7 @@ def _download_individual_chunk(
elif hasattr(emitter, 'file_progress_signal'): elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy) emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
# If we reach here, the download for this chunk was successful # If we get here, the download for this chunk is successful
return bytes_this_chunk, True return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
@@ -129,23 +157,49 @@ def _download_individual_chunk(
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}") logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False return bytes_this_chunk, False
# If the retry loop finishes without a successful download
return bytes_this_chunk, False return bytes_this_chunk, False
finally:
# This block runs whether the download succeeded or failed
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0.0
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename, def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,
emitter_for_multipart, cookies_for_chunk_session, emitter_for_multipart, cookies_for_chunk_session,
cancellation_event, skip_event, logger_func, pause_event): cancellation_event, skip_event, logger_func, pause_event):
logger_func(f"⬇️ Initializing Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)") """
temp_file_path = save_path + ".part" Manages a resilient, multipart file download by saving each chunk to a separate file.
try: This function orchestrates the download process by:
with open(temp_file_path, 'wb') as f_temp: 1. Checking for already completed chunk files to resume a previous download.
if total_size > 0: 2. Submitting only the missing chunks to a thread pool for parallel download.
f_temp.truncate(total_size) 3. Assembling the final file from the individual chunks upon successful completion.
except IOError as e: 4. Cleaning up temporary chunk files after assembly.
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}") 5. Leaving completed chunks on disk if the download fails, allowing for a future resume.
return False, 0, None, None
Args:
file_url (str): The URL of the file to download.
save_path (str): The final desired path for the downloaded file (e.g., 'my_video.mp4').
total_size (int): The total size of the file in bytes.
num_parts (int): The number of parts to split the download into.
headers (dict): HTTP headers for the download requests.
api_original_filename (str): The original filename for UI progress display.
emitter_for_multipart (queue.Queue or QObject): Emitter for UI signals.
cookies_for_chunk_session (dict): Cookies for the download requests.
cancellation_event (threading.Event): Event to signal cancellation.
skip_event (threading.Event): Event to signal skipping the file.
logger_func (function): A function for logging messages.
pause_event (threading.Event): Event to signal pausing the download.
Returns:
tuple: A tuple containing (success_flag, total_bytes_downloaded, md5_hash, file_handle).
The file_handle will be for the final assembled file if successful, otherwise None.
"""
logger_func(f"⬇️ Initializing Resumable Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
# Calculate the byte range for each chunk
chunk_size_calc = total_size // num_parts chunk_size_calc = total_size // num_parts
chunks_ranges = [] chunks_ranges = []
for i in range(num_parts): for i in range(num_parts):
@@ -153,76 +207,119 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1 end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
if start <= end: if start <= end:
chunks_ranges.append((start, end)) chunks_ranges.append((start, end))
elif total_size == 0 and i == 0: elif total_size == 0 and i == 0: # Handle zero-byte files
chunks_ranges.append((0, -1)) chunks_ranges.append((0, -1))
# Calculate the expected size of each chunk
chunk_actual_sizes = [] chunk_actual_sizes = []
for start, end in chunks_ranges: for start, end in chunks_ranges:
if end == -1 and start == 0: chunk_actual_sizes.append(end - start + 1 if end != -1 else 0)
chunk_actual_sizes.append(0)
else:
chunk_actual_sizes.append(end - start + 1)
if not chunks_ranges and total_size > 0: if not chunks_ranges and total_size > 0:
logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting multipart.") logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting.")
if os.path.exists(temp_file_path): os.remove(temp_file_path)
return False, 0, None, None return False, 0, None, None
# --- Resumption Logic: Check for existing complete chunks ---
chunks_to_download = []
total_bytes_resumed = 0
for i, (start, end) in enumerate(chunks_ranges):
chunk_part_path = f"{save_path}.part{i}"
expected_chunk_size = chunk_actual_sizes[i]
if os.path.exists(chunk_part_path) and os.path.getsize(chunk_part_path) == expected_chunk_size:
logger_func(f" [Chunk {i + 1}/{num_parts}] Resuming with existing complete chunk file.")
total_bytes_resumed += expected_chunk_size
else:
chunks_to_download.append({'index': i, 'start': start, 'end': end})
# Setup the shared progress data structure
progress_data = { progress_data = {
'total_file_size': total_size, 'total_file_size': total_size,
'total_downloaded_so_far': 0, 'total_downloaded_so_far': total_bytes_resumed,
'chunks_status': [ 'chunks_status': [],
{'id': i, 'downloaded': 0, 'total': chunk_actual_sizes[i] if i < len(chunk_actual_sizes) else 0, 'active': False, 'speed_bps': 0.0}
for i in range(num_parts)
],
'lock': threading.Lock(), 'lock': threading.Lock(),
'last_global_emit_time': [time.time()] 'last_global_emit_time': [time.time()]
} }
for i in range(num_parts):
is_resumed = not any(c['index'] == i for c in chunks_to_download)
progress_data['chunks_status'].append({
'id': i,
'downloaded': chunk_actual_sizes[i] if is_resumed else 0,
'total': chunk_actual_sizes[i],
'active': False,
'speed_bps': 0.0
})
# --- Download Phase ---
chunk_futures = [] chunk_futures = []
all_chunks_successful = True all_chunks_successful = True
total_bytes_from_chunks = 0 total_bytes_from_threads = 0
with ThreadPoolExecutor(max_workers=num_parts, thread_name_prefix=f"MPChunk_{api_original_filename[:10]}_") as chunk_pool: with ThreadPoolExecutor(max_workers=num_parts, thread_name_prefix=f"MPChunk_{api_original_filename[:10]}_") as chunk_pool:
for i, (start, end) in enumerate(chunks_ranges): for chunk_info in chunks_to_download:
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break if cancellation_event and cancellation_event.is_set():
chunk_futures.append(chunk_pool.submit( all_chunks_successful = False
_download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path, break
i, start, end = chunk_info['index'], chunk_info['start'], chunk_info['end']
chunk_part_path = f"{save_path}.part{i}"
future = chunk_pool.submit(
_download_individual_chunk,
chunk_url=file_url,
chunk_temp_file_path=chunk_part_path,
start_byte=start, end_byte=end, headers=headers, part_num=i, total_parts=num_parts, start_byte=start, end_byte=end, headers=headers, part_num=i, total_parts=num_parts,
progress_data=progress_data, cancellation_event=cancellation_event, skip_event=skip_event, global_emit_time_ref=progress_data['last_global_emit_time'], progress_data=progress_data, cancellation_event=cancellation_event,
pause_event=pause_event, cookies_for_chunk=cookies_for_chunk_session, logger_func=logger_func, emitter=emitter_for_multipart, skip_event=skip_event, global_emit_time_ref=progress_data['last_global_emit_time'],
pause_event=pause_event, cookies_for_chunk=cookies_for_chunk_session,
logger_func=logger_func, emitter=emitter_for_multipart,
api_original_filename=api_original_filename api_original_filename=api_original_filename
)) )
chunk_futures.append(future)
for future in as_completed(chunk_futures): for future in as_completed(chunk_futures):
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break if cancellation_event and cancellation_event.is_set():
bytes_downloaded_this_chunk, success_this_chunk = future.result()
total_bytes_from_chunks += bytes_downloaded_this_chunk
if not success_this_chunk:
all_chunks_successful = False all_chunks_successful = False
bytes_downloaded, success = future.result()
total_bytes_from_threads += bytes_downloaded
if not success:
all_chunks_successful = False
total_bytes_final = total_bytes_resumed + total_bytes_from_threads
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.") logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
all_chunks_successful = False all_chunks_successful = False
if emitter_for_multipart:
with progress_data['lock']:
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
if isinstance(emitter_for_multipart, queue.Queue):
emitter_for_multipart.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter_for_multipart, 'file_progress_signal'):
emitter_for_multipart.file_progress_signal.emit(api_original_filename, status_list_copy)
if all_chunks_successful and (total_bytes_from_chunks == total_size or total_size == 0): # --- Assembly and Cleanup Phase ---
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}") if all_chunks_successful and (total_bytes_final == total_size or total_size == 0):
logger_func(f" ✅ All {num_parts} chunks complete. Assembling final file...")
md5_hasher = hashlib.md5() md5_hasher = hashlib.md5()
with open(temp_file_path, 'rb') as f_hash: try:
for buf in iter(lambda: f_hash.read(4096*10), b''): with open(save_path, 'wb') as final_file:
md5_hasher.update(buf) for i in range(num_parts):
chunk_part_path = f"{save_path}.part{i}"
with open(chunk_part_path, 'rb') as chunk_file:
content = chunk_file.read()
final_file.write(content)
md5_hasher.update(content)
calculated_hash = md5_hasher.hexdigest() calculated_hash = md5_hasher.hexdigest()
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb') logger_func(f" ✅ Assembly successful for '{api_original_filename}'. Total bytes: {total_bytes_final}")
return True, total_bytes_final, calculated_hash, open(save_path, 'rb')
except Exception as e:
logger_func(f" ❌ Critical error during file assembly: {e}. Cleaning up.")
return False, total_bytes_final, None, None
finally:
# Cleanup all individual chunk files after successful assembly
for i in range(num_parts):
chunk_part_path = f"{save_path}.part{i}"
if os.path.exists(chunk_part_path):
try:
os.remove(chunk_part_path)
except OSError as e:
logger_func(f" ⚠️ Failed to remove temp part file '{chunk_part_path}': {e}")
else: else:
logger_func(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_from_chunks}/{total_size}. Cleaning up.") # If download failed, we do NOT clean up, allowing for resumption later
if os.path.exists(temp_file_path): logger_func(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_final}/{total_size}. Partial chunks saved for future resumption.")
try: os.remove(temp_file_path) return False, total_bytes_final, None, None
except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}")
return False, total_bytes_from_chunks, None, None

View File

@@ -1,13 +1,7 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- 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,13 +16,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
# Determine the project's base directory, whether running from source or as a bundled app app_base_dir = ""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# The application is frozen (e.g., with PyInstaller) app_base_dir = os.path.dirname(sys.executable)
base_dir = os.path.dirname(sys.executable)
else: else:
# The application is running from a .py file
# This path navigates up from src/ui/ 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')
@@ -36,6 +28,12 @@ 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 getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
if os.path.exists(fallback_icon_path):
_app_icon_cache = QIcon(fallback_icon_path)
return _app_icon_cache
print(f"Warning: Application icon not found at {icon_path}") print(f"Warning: Application icon not found at {icon_path}")
_app_icon_cache = QIcon() # Return an empty icon as a fallback _app_icon_cache = QIcon() # Return an empty icon as a fallback

View File

@@ -1,18 +1,10 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout QPushButton, QVBoxLayout
) )
# --- 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
# --- Constants for Dialog Choices ---
# These were moved from main.py to be self-contained within this module's context.
CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
self.parent_app = parent_app self.parent_app = parent_app
self.setModal(True) self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list self.new_filter_objects_list = new_filter_objects_list
# Default choice if the dialog is closed without a button press
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- 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 768 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0 scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350 base_min_w, base_min_h = 480, 350
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()
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
self.names_list_widget = QListWidget() self.names_list_widget = QListWidget()
self._populate_list() self._populate_list()
main_layout.addWidget(self.names_list_widget) main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout() selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items) self.select_all_button.clicked.connect(self._select_all_items)
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
selection_buttons_layout.addWidget(self.deselect_all_button) selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch() selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout) main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton() self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected) self.add_selected_button.clicked.connect(self._accept_add_selected)
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
sensible default if no items are selected but the "Add" button is clicked. sensible default if no items are selected but the "Add" button is clicked.
""" """
super().exec_() super().exec_()
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
if isinstance(self.user_choice, list) and not self.user_choice: if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice

View File

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

View File

@@ -1,27 +1,18 @@
# --- Standard Library Imports ---
from collections import defaultdict from collections import defaultdict
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
) )
# --- 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):
""" """
A dialog to select and initiate the download for extracted, supported links A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox. from external cloud services like Mega, Google Drive, and Dropbox.
""" """
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list) download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None): def __init__(self, links_data, parent_app, parent=None):
@@ -36,29 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.links_data = links_data self.links_data = links_data
self.parent_app = parent_app self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Set window size dynamically based on the parent window's size base_width, base_height = 600, 450
if parent: self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
parent_width = parent.width() self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -76,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection) self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list() self._populate_list()
layout.addWidget(self.links_list_widget) layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked)) self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
@@ -108,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower()) sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles: for post_title_key in sorted_post_titles:
# Add a non-selectable header for each post
header_item = QListWidgetItem(f"{post_title_key}") header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags) header_item.setFlags(Qt.NoItemFlags)
font = header_item.font() font = header_item.font()
@@ -116,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
font.setPointSize(font.pointSize() + 1) font.setPointSize(font.pointSize() + 1)
header_item.setFont(font) header_item.setFont(font)
self.links_list_widget.addItem(header_item) self.links_list_widget.addItem(header_item)
# Add checkable items for each link within that post
for link_info_data in grouped_links[post_title_key]: for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper() platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})" display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
@@ -144,16 +114,16 @@ class DownloadExtractedLinksDialog(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."""
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 and hasattr(self.parent_app, 'get_dark_theme'): if is_dark_theme:
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
# Set header text color based on theme else:
self.setStyleSheet("")
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
if not item.flags() & Qt.ItemIsUserCheckable: if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color) item.setForeground(header_color)

View File

@@ -1,18 +1,15 @@
# --- Standard Library Imports ---
import os import os
import time import time
import json import json
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea, QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox, QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox QFileDialog, QMessageBox
) )
# --- 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,18 +20,15 @@ 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
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:
# Patch left pane (files)
for entry in self.last_3_downloaded_entries: for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'): if not entry.get('creator_display_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', '')) user_id = str(entry.get('user_id', ''))
key = (service, user_id) key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series')) entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
# Patch right pane (posts)
for entry in self.first_processed_entries: for entry in self.first_processed_entries:
if not entry.get('creator_name'): if not entry.get('creator_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
@@ -158,6 +152,14 @@ class DownloadHistoryDialog (QDialog ):
return get_translation (self .parent_app .current_selected_language ,key ,default_text ) return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text return default_text
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _save_history_to_txt (self ): def _save_history_to_txt (self ):
if not self .last_3_downloaded_entries and not self .first_processed_entries : if not self .last_3_downloaded_entries and not self .first_processed_entries :
QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"), QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"),

View File

@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
QSplitter, QProgressBar, QWidget QSplitter, QProgressBar, QWidget, QFileDialog
) )
# --- Local Application Imports --- # --- Local Application Imports ---
@@ -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,18 +140,19 @@ 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 ()
if app_icon and not app_icon .isNull (): if app_icon and not app_icon .isNull ():
self .setWindowIcon (app_icon ) self .setWindowIcon (app_icon )
self.update_profile_data = None
self.update_creator_name = None
self .selected_creators_for_queue =[] self .selected_creators_for_queue =[]
self .globally_selected_creators ={} self .globally_selected_creators ={}
self .fetched_posts_data ={} self .fetched_posts_data ={}
@@ -204,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
self .scope_button .clicked .connect (self ._toggle_scope_mode ) self .scope_button .clicked .connect (self ._toggle_scope_mode )
left_bottom_buttons_layout .addWidget (self .scope_button ) left_bottom_buttons_layout .addWidget (self .scope_button )
left_pane_layout .addLayout (left_bottom_buttons_layout ) left_pane_layout .addLayout (left_bottom_buttons_layout )
self.update_button = QPushButton()
self.update_button.clicked.connect(self._handle_update_check)
left_bottom_buttons_layout.addWidget(self.update_button)
self .right_pane_widget =QWidget () self .right_pane_widget =QWidget ()
@@ -289,9 +295,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 ))
@@ -309,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
except AttributeError : except AttributeError :
pass pass
def _handle_update_check(self):
"""Opens a dialog to select a creator profile and loads it for an update session."""
appdata_dir = os.path.join(self.app_base_dir, "appdata")
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
if not os.path.isdir(profiles_dir):
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
return
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
if filepath:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'creator_url' not in data or 'processed_post_ids' not in data:
raise ValueError("Invalid profile format.")
self.update_profile_data = data
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
self.accept() # Close the dialog and signal success
except Exception as e:
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
def _handle_fetch_posts_click (self ): def _handle_fetch_posts_click (self ):
selected_creators =list (self .globally_selected_creators .values ()) selected_creators =list (self .globally_selected_creators .values ())
print(f"[DEBUG] Selected creators for fetch: {selected_creators}") print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
@@ -364,6 +400,7 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected")) self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected"))
self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts")) self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
self ._update_scope_button_text_and_tooltip () self ._update_scope_button_text_and_tooltip ()
self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title...")) self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
@@ -923,15 +960,19 @@ class EmptyPopupDialog (QDialog ):
self .parent_app .log_signal .emit (f" Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.") self .parent_app .log_signal .emit (f" Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.")
# --- START: MODIFIED LOGIC ---
# Removed the blockSignals(True/False) calls to allow the main window's UI to update correctly.
if self .parent_app .link_input : if self .parent_app .link_input :
self .parent_app .link_input .blockSignals (True )
self .parent_app .link_input .setText ( self .parent_app .link_input .setText (
self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts ) self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts )
) )
self .parent_app .link_input .blockSignals (False )
self .parent_app .link_input .setPlaceholderText ( self .parent_app .link_input .setPlaceholderText (
self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue ) self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue )
) )
# --- END: MODIFIED LOGIC ---
self.selected_creators_for_queue.clear()
self .accept () self .accept ()
else : else :
QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"), QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
@@ -949,9 +990,6 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setEnabled (True ) self .add_selected_button .setEnabled (True )
self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection")) self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
def _get_domain_for_service (self ,service_name ): def _get_domain_for_service (self ,service_name ):
"""Determines the base domain for a given service.""" """Determines the base domain for a given service."""
service_lower =service_name .lower () service_lower =service_name .lower ()

View File

@@ -10,7 +10,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase. # Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog from .ExportOptionsDialog import ExportOptionsDialog
from ...utils.resolution import get_dark_theme
class ErrorFilesDialog(QDialog): class ErrorFilesDialog(QDialog):
""" """
@@ -42,13 +42,15 @@ 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 # --- START OF FIX ---
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 # Get the user-defined scale factor from the parent application.
scale_factor = screen_height / 1080.0 scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_min_w, base_min_h = 500, 300
scaled_min_w = int(base_min_w * scale_factor) # Define base dimensions and apply the correct scale factor.
scaled_min_h = int(base_min_h * scale_factor) base_width, base_height = 550, 400
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -132,9 +134,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."""

View File

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

View File

@@ -16,7 +16,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...utils.resolution import get_dark_theme
class FavoriteArtistsDialog (QDialog ): class FavoriteArtistsDialog (QDialog ):
"""Dialog to display and select favorite artists.""" """Dialog to display and select favorite artists."""
@@ -37,13 +37,13 @@ class FavoriteArtistsDialog (QDialog ):
self ._init_ui () self ._init_ui ()
self ._fetch_favorite_artists () self ._fetch_favorite_artists ()
def _get_domain_for_service (self ,service_name ): def _get_domain_for_service(self, service_name):
service_lower =service_name .lower () service_lower = service_name.lower()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'} coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans'}
if service_lower in coomer_primary_services : if service_lower in coomer_primary_services:
return "coomer.su" return "coomer.st" # Use the new domain
else : else:
return "kemono.su" return "kemono.cr" # Use the new domain
def _tr (self ,key ,default_text =""): def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language.""" """Helper to get translation based on current app language."""
@@ -126,6 +126,41 @@ 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']:
# --- Kemono Check with Fallback ---
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.cr"
)
if not kemono_cookies:
self._logger("No cookies for kemono.cr, trying fallback kemono.su...")
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 Check with Fallback ---
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.st"
)
if not coomer_cookies:
self._logger("No cookies for coomer.st, trying fallback coomer.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"
@@ -134,9 +169,12 @@ class FavoriteArtistsDialog (QDialog ):
errors_occurred =[] errors_occurred =[]
any_cookies_loaded_successfully_for_any_source =False any_cookies_loaded_successfully_for_any_source =False
api_sources =[ kemono_cr_fav_url = "https://kemono.cr/api/v1/account/favorites?type=artist"
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"}, coomer_st_fav_url = "https://coomer.st/api/v1/account/favorites?type=artist"
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
api_sources = [
{"name": "Kemono.cr", "url": kemono_cr_fav_url, "domain": "kemono.cr"},
{"name": "Coomer.st", "url": coomer_st_fav_url, "domain": "coomer.st"}
] ]
for source in api_sources : for source in api_sources :
@@ -144,20 +182,41 @@ class FavoriteArtistsDialog (QDialog ):
self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name'])) self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
QCoreApplication .processEvents () QCoreApplication .processEvents ()
cookies_dict_for_source =None cookies_dict_for_source = None
if self .cookies_config ['use_cookie']: if self.cookies_config['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request ( primary_domain = source['domain']
True , fallback_domain = None
self .cookies_config ['cookie_text'], if primary_domain == "kemono.cr":
self .cookies_config ['selected_cookie_file'], fallback_domain = "kemono.su"
self .cookies_config ['app_base_dir'], elif primary_domain == "coomer.st":
self ._logger , fallback_domain = "coomer.su"
target_domain =source ['domain']
# First, try the primary domain
cookies_dict_for_source = 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=primary_domain
) )
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True # If no cookies found, try the fallback domain
else : if not cookies_dict_for_source and fallback_domain:
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.") self._logger(f"Warning ({source['name']}): No cookies found for '{primary_domain}'. Trying fallback '{fallback_domain}'...")
cookies_dict_for_source = 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=fallback_domain
)
if cookies_dict_for_source:
any_cookies_loaded_successfully_for_any_source = True
else:
self._logger(f"Warning ({source['name']}): Cookies enabled but could not be loaded for this source (including fallbacks). Fetch might fail.")
try : try :
headers ={'User-Agent':'Mozilla/5.0'} headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 ) response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
@@ -208,7 +267,7 @@ class FavoriteArtistsDialog (QDialog ):
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source : if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source.")) 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 cookies loaded for any source. Showing help dialog.") self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self ) cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ () cookie_help_dialog .exec_ ()
self .download_button .setEnabled (False ) self .download_button .setEnabled (False )
if not fetched_any_successfully : if not fetched_any_successfully :

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import html import html
import os import os
import sys import sys
@@ -8,8 +7,6 @@ import traceback
import json import json
import re import re
from collections import defaultdict from collections import defaultdict
# --- Third-Party Library Imports ---
import requests import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -17,15 +14,12 @@ from PyQt5.QtWidgets import (
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox QWidget, QCheckBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation 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
# 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."""
@@ -40,28 +34,30 @@ class FavoritePostsFetcherThread (QThread ):
self .target_domain_preference =target_domain_preference self .target_domain_preference =target_domain_preference
self .cancellation_event =threading .Event () self .cancellation_event =threading .Event ()
self .error_key_map ={ self .error_key_map ={
"Kemono.su":"kemono_su", "kemono.cr":"kemono_su",
"Coomer.su":"coomer_su" "coomer.st":"coomer_su"
} }
def _logger (self ,message ): def _logger (self ,message ):
self .parent_logger_func (f"[FavPostsFetcherThread] {message }") self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
def run (self ): def run(self):
kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post" kemono_su_fav_posts_url = "https://kemono.su/api/v1/account/favorites?type=post"
coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post" coomer_su_fav_posts_url = "https://coomer.su/api/v1/account/favorites?type=post"
kemono_cr_fav_posts_url = "https://kemono.cr/api/v1/account/favorites?type=post"
coomer_st_fav_posts_url = "https://coomer.st/api/v1/account/favorites?type=post"
all_fetched_posts_temp =[] all_fetched_posts_temp = []
error_messages_for_summary =[] error_messages_for_summary = []
fetched_any_successfully =False fetched_any_successfully = False
any_cookies_loaded_successfully_for_any_source =False any_cookies_loaded_successfully_for_any_source = False
self .status_update .emit ("key_fetching_fav_post_list_init") self.status_update.emit("key_fetching_fav_post_list_init")
self .progress_bar_update .emit (0 ,0 ) self.progress_bar_update.emit(0, 0)
api_sources =[ api_sources = [
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"}, {"name": "Kemono.cr", "url": kemono_cr_fav_posts_url, "domain": "kemono.cr"},
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"} {"name": "Coomer.st", "url": coomer_st_fav_posts_url, "domain": "coomer.st"}
] ]
api_sources_to_try =[] api_sources_to_try =[]
@@ -82,20 +78,41 @@ class FavoritePostsFetcherThread (QThread ):
if self .cancellation_event .is_set (): if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING") self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
return return
cookies_dict_for_source =None cookies_dict_for_source = None
if self .cookies_config ['use_cookie']: if self.cookies_config['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request ( primary_domain = source['domain']
True , fallback_domain = None
self .cookies_config ['cookie_text'], if primary_domain == "kemono.cr":
self .cookies_config ['selected_cookie_file'], fallback_domain = "kemono.su"
self .cookies_config ['app_base_dir'], elif primary_domain == "coomer.st":
self ._logger , fallback_domain = "coomer.su"
target_domain =source ['domain']
# First, try the primary domain
cookies_dict_for_source = 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=primary_domain
) )
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True # If no cookies found, try the fallback domain
else : if not cookies_dict_for_source and fallback_domain:
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.") self._logger(f"Warning ({source['name']}): No cookies found for '{primary_domain}'. Trying fallback '{fallback_domain}'...")
cookies_dict_for_source = 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=fallback_domain
)
if cookies_dict_for_source:
any_cookies_loaded_successfully_for_any_source = True
else:
self._logger(f"Warning ({source['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})") self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_')) source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
@@ -415,14 +432,14 @@ class FavoritePostsDialog (QDialog ):
if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC": if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
status_label_text_key ="fav_posts_cookies_required_error" status_label_text_key ="fav_posts_cookies_required_error"
self ._logger (f"Cookie error: {status_key }. Showing help dialog.") self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self ) cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ () cookie_help_dialog .exec_ ()
elif status_key =="KEY_AUTH_FAILED": elif status_key =="KEY_AUTH_FAILED":
status_label_text_key ="fav_posts_auth_failed_title" status_label_text_key ="fav_posts_auth_failed_title"
self ._logger (f"Auth error: {status_key }. Showing help dialog.") self ._logger (f"Auth error: {status_key }. Showing help dialog.")
QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"), QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part )) self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
cookie_help_dialog =CookieHelpDialog (self ) cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ () cookie_help_dialog .exec_ ()
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS": elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
status_label_text_key ="fav_posts_no_posts_found_status" status_label_text_key ="fav_posts_no_posts_found_status"

View File

@@ -1,170 +1,271 @@
# --- 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, QCheckBox
) )
# --- 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, SAVE_CREATOR_JSON_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_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, 360 # 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)
# --- Download Settings --- main_layout.addWidget(self.interface_group_box)
self.download_settings_group_box = QGroupBox()
download_settings_layout = QVBoxLayout(self.download_settings_group_box) # --- Group 2: Download & Window Settings ---
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) # --- START: MODIFIED LOGIC ---
download_settings_layout.addWidget(self.save_path_button) self.save_path_button.clicked.connect(self._save_cookie_and_path)
layout.addWidget(self.download_settings_group_box) # --- END: MODIFIED LOGIC ---
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
layout.addStretch(1) # Save Creator.json Checkbox
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1)
# --- 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 _load_checkbox_states(self):
"""Loads the initial state for all checkboxes from settings."""
self.save_creator_json_checkbox.blockSignals(True)
# Default to True so the feature is on by default for users
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
self.save_creator_json_checkbox.setChecked(should_save)
self.save_creator_json_checkbox.blockSignals(False)
def _creator_json_setting_changed(self, state):
"""Saves the state of the 'Save Creator.json' checkbox."""
is_checked = state == Qt.Checked
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
self.parent_app.settings.sync()
def _tr(self, key, default_text=""): def _tr(self, key, default_text=""):
"""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()
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path")) # Group Box Titles
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions.")) 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:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# --- START: MODIFIED LOGIC ---
# Buttons and Controls
self._update_theme_toggle_button_text()
self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path"))
self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK")) self.ok_button.setText(self._tr("ok_button", "OK"))
# --- END: MODIFIED LOGIC ---
# Populate dropdowns
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._load_checkbox_states()
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)
@@ -179,24 +280,43 @@ class FutureSettingsDialog(QDialog):
if msg_box.clickedButton() == restart_button: if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application() self.parent_app._request_restart_application()
def _save_download_path(self): def _save_cookie_and_path(self):
"""Saves the current download path from the main window to settings.""" """Saves the current download path and/or cookie settings from the main window."""
path_saved = False
cookie_saved = False
# --- Save Download Path Logic ---
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)
path_saved = True
# --- Save Cookie Logic ---
if hasattr(self.parent_app, 'use_cookie_checkbox'):
use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
cookie_content = self.parent_app.cookie_text_input.text().strip()
if use_cookie and cookie_content:
self.parent_app.settings.setValue(USE_COOKIE_KEY, True)
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, cookie_content)
cookie_saved = True
else: # Also save the 'off' state
self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "")
self.parent_app.settings.sync() self.parent_app.settings.sync()
QMessageBox.information(self,
self._tr("settings_save_path_success_title", "Path Saved"), # --- User Feedback ---
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path)) if path_saved and cookie_saved:
message = self._tr("settings_save_both_success", "Download location and cookie settings saved.")
elif path_saved:
message = self._tr("settings_save_path_only_success", "Download location saved. No cookie settings were active to save.")
elif cookie_saved:
message = self._tr("settings_save_cookie_only_success", "Cookie settings saved. Download location was not set.")
else: else:
QMessageBox.warning(self, QMessageBox.warning(self, self._tr("settings_save_nothing_title", "Nothing to Save"),
self._tr("settings_save_path_invalid_title", "Invalid Path"), self._tr("settings_save_nothing_message", "The download location is not a valid directory and no cookie was active."))
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path)) return
else:
QMessageBox.warning(self, QMessageBox.information(self, self._tr("settings_save_success_title", "Settings Saved"), message)
self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
else:
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")

View File

@@ -1,34 +1,32 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- 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, QListWidget, QFrame, QWidget, QScrollArea
) )
# --- 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()
@@ -42,151 +40,153 @@ 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)
class HelpGuideDialog (QDialog ): class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide.""" """A multi-page dialog for displaying the feature guide with a navigation list."""
def __init__ (self ,steps_data ,parent_app ,parent =None ): def __init__(self, steps_data, parent_app, parent=None):
super ().__init__ (parent ) super().__init__(parent)
self .current_step =0 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(800 * scale), int(650 * scale))
dialog_font_size = int(11 * scale)
current_theme_style ="" current_theme_style = ""
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark": 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 = get_dark_theme(scale)
current_theme_style =self .parent_app .get_dark_theme () else:
# Basic light theme fallback
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; }}
"""
self.setStyleSheet(current_theme_style)
self._init_ui()
if self.parent_app:
self.move(self.parent_app.geometry().center() - self.rect().center())
self .setStyleSheet (current_theme_style if current_theme_style else """ def _tr(self, key, default_text=""):
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 ()
if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language.""" """Helper to get translation based on current app 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 _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(15, 15, 15, 15)
main_layout.setSpacing(10)
def _init_ui (self ): # Title
main_layout =QVBoxLayout (self ) title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
main_layout .setContentsMargins (0 ,0 ,0 ,0 ) scale = getattr(self.parent_app, 'scale_factor', 1.0)
main_layout .setSpacing (0 ) title_font_size = int(16 * scale)
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
self .stacked_widget =QStackedWidget () # Content Layout (Navigation + Stacked Pages)
main_layout .addWidget (self .stacked_widget ,1 ) content_layout = QHBoxLayout()
main_layout.addLayout(content_layout, 1)
self .tour_steps_widgets =[] self.nav_list = QListWidget()
for title ,content in self .steps_data : self.nav_list.setFixedWidth(int(220 * scale))
step_widget =TourStepWidget (title ,content ) self.nav_list.setStyleSheet(f"""
self .tour_steps_widgets .append (step_widget ) QListWidget {{
self .stacked_widget .addWidget (step_widget ) background-color: #2E2E2E;
border: 1px solid #4A4A4A;
border-radius: 4px;
font-size: {int(11 * scale)}pt;
}}
QListWidget::item {{
padding: 10px;
border-bottom: 1px solid #4A4A4A;
}}
QListWidget::item:selected {{
background-color: #87CEEB;
color: #2E2E2E;
font-weight: bold;
}}
""")
content_layout.addWidget(self.nav_list)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) self.stacked_widget = QStackedWidget()
content_layout.addWidget(self.stacked_widget)
buttons_layout =QHBoxLayout () for title_key, content_key in self.steps_data:
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 ) title = self._tr(title_key, title_key)
buttons_layout .setSpacing (10 ) content = self._tr(content_key, f"Content for {content_key} not found.")
self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back")) self.nav_list.addItem(title)
self .back_button .clicked .connect (self ._previous_step )
self .back_button .setEnabled (False )
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): step_widget = TourStepWidget(title, content, scale=scale)
assets_base_dir =sys ._MEIPASS self.stacked_widget.addWidget(step_widget)
else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") self.nav_list.currentRowChanged.connect(self.stacked_widget.setCurrentIndex)
instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png") if self.nav_list.count() > 0:
discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png") self.nav_list.setCurrentRow(0)
self .github_button =QPushButton (QIcon (github_icon_path ),"") # Footer Layout (Social links and Close button)
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") footer_layout = QHBoxLayout()
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") footer_layout.setContentsMargins(0, 10, 0, 0)
icon_size =QSize (24 ,24 ) # Social Media Icons
self .github_button .setIconSize (icon_size ) if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
self .instagram_button .setIconSize (icon_size ) assets_base_dir = sys._MEIPASS
self .Discord_button .setIconSize (icon_size ) else:
assets_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next")) github_icon_path = os.path.join(assets_base_dir, "assets", "github.png")
self .next_button .clicked .connect (self ._next_step_action ) instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png")
self .next_button .setDefault (True ) discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png")
self .github_button .clicked .connect (self ._open_github_link )
self .instagram_button .clicked .connect (self ._open_instagram_link )
self .Discord_button .clicked .connect (self ._open_Discord_link )
self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
self.github_button = QPushButton(QIcon(github_icon_path), "")
self.instagram_button = QPushButton(QIcon(instagram_icon_path), "")
self.discord_button = QPushButton(QIcon(discord_icon_path), "")
social_layout =QHBoxLayout () icon_dim = int(24 * scale)
social_layout .setSpacing (10 ) icon_size = QSize(icon_dim, icon_dim)
social_layout .addWidget (self .github_button )
social_layout .addWidget (self .instagram_button )
social_layout .addWidget (self .Discord_button )
while buttons_layout .count (): for button, tooltip_key, url in [
item =buttons_layout .takeAt (0 ) (self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi9587"),
if item .widget (): (self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
item .widget ().setParent (None ) (self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
elif item .layout (): ]:
pass button.setIconSize(icon_size)
buttons_layout .addLayout (social_layout ) button.setToolTip(self._tr(tooltip_key))
buttons_layout .addStretch (1 ) button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
buttons_layout .addWidget (self .back_button ) button.setStyleSheet("background-color: transparent; border: none;")
buttons_layout .addWidget (self .next_button ) button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
main_layout .addLayout (buttons_layout ) footer_layout.addWidget(button)
self ._update_button_states ()
def _next_step_action (self ): footer_layout.addStretch(1)
if self .current_step <len (self .tour_steps_widgets )-1 :
self .current_step +=1
self .stacked_widget .setCurrentIndex (self .current_step )
else :
self .accept ()
self ._update_button_states ()
def _previous_step (self ): self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
if self .current_step >0 : self.finish_button.clicked.connect(self.accept)
self .current_step -=1 footer_layout.addWidget(self.finish_button)
self .stacked_widget .setCurrentIndex (self .current_step )
self ._update_button_states ()
def _update_button_states (self ): main_layout.addLayout(footer_layout)
if self .current_step ==len (self .tour_steps_widgets )-1 :
self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
else :
self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
self .back_button .setEnabled (self .current_step >0 )
def _open_github_link (self ):
QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
def _open_instagram_link (self ):
QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
def _open_Discord_link (self ):
QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))

View File

@@ -0,0 +1,107 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
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)
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)
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_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)
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)
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -8,13 +8,12 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class KnownNamesFilterDialog(QDialog): class KnownNamesFilterDialog(QDialog):
""" """
A dialog to select names from the Known.txt list to add to the main A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads. to reuse their saved names and groups for filtering downloads.
""" """
@@ -38,13 +37,16 @@ class KnownNamesFilterDialog(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 # --- START OF FIX ---
screen_geometry = QApplication.primaryScreen().availableGeometry() # Get the user-defined scale factor from the parent application
# instead of calculating an independent one.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450 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)) # --- END OF FIX ---
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 +104,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."""

View File

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

View File

@@ -0,0 +1,118 @@
# multipart_scope_dialog.py
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton, QDialogButtonBox, QButtonGroup,
QLabel, QLineEdit, QHBoxLayout, QFrame
)
from PyQt5.QtGui import QIntValidator
from PyQt5.QtCore import Qt
# It's good practice to get this constant from the source
# but for this example, we will define it here.
MAX_PARTS = 16
class MultipartScopeDialog(QDialog):
"""
A dialog to let the user select the scope, number of parts, and minimum size for multipart downloads.
"""
SCOPE_VIDEOS = 'videos'
SCOPE_ARCHIVES = 'archives'
SCOPE_BOTH = 'both'
def __init__(self, current_scope='both', current_parts=4, current_min_size_mb=100, parent=None):
super().__init__(parent)
self.setWindowTitle("Multipart Download Options")
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
self.setMinimumWidth(350)
# Main Layout
layout = QVBoxLayout(self)
# --- Options Group for Scope ---
self.options_group_box = QGroupBox("Apply multipart downloads to:")
options_layout = QVBoxLayout()
# ... (Radio buttons and button group code remains unchanged) ...
self.radio_videos = QRadioButton("Videos Only")
self.radio_archives = QRadioButton("Archives Only (.zip, .rar, etc.)")
self.radio_both = QRadioButton("Both Videos and Archives")
if current_scope == self.SCOPE_VIDEOS:
self.radio_videos.setChecked(True)
elif current_scope == self.SCOPE_ARCHIVES:
self.radio_archives.setChecked(True)
else:
self.radio_both.setChecked(True)
self.button_group = QButtonGroup(self)
self.button_group.addButton(self.radio_videos)
self.button_group.addButton(self.radio_archives)
self.button_group.addButton(self.radio_both)
options_layout.addWidget(self.radio_videos)
options_layout.addWidget(self.radio_archives)
options_layout.addWidget(self.radio_both)
self.options_group_box.setLayout(options_layout)
layout.addWidget(self.options_group_box)
# --- START: MODIFIED Download Settings Group ---
self.settings_group_box = QGroupBox("Download settings:")
settings_layout = QVBoxLayout()
# Layout for Parts count
parts_layout = QHBoxLayout()
self.parts_label = QLabel("Number of download parts per file:")
self.parts_input = QLineEdit(str(current_parts))
self.parts_input.setValidator(QIntValidator(2, MAX_PARTS, self))
self.parts_input.setFixedWidth(40)
self.parts_input.setToolTip(f"Set the number of concurrent connections per file (2-{MAX_PARTS}).")
parts_layout.addWidget(self.parts_label)
parts_layout.addStretch()
parts_layout.addWidget(self.parts_input)
settings_layout.addLayout(parts_layout)
# Layout for Minimum Size
size_layout = QHBoxLayout()
self.size_label = QLabel("Minimum file size for multipart (MB):")
self.size_input = QLineEdit(str(current_min_size_mb))
self.size_input.setValidator(QIntValidator(10, 10000, self)) # Min 10MB, Max ~10GB
self.size_input.setFixedWidth(40)
self.size_input.setToolTip("Files smaller than this will use a normal, single-part download.")
size_layout.addWidget(self.size_label)
size_layout.addStretch()
size_layout.addWidget(self.size_input)
settings_layout.addLayout(size_layout)
self.settings_group_box.setLayout(settings_layout)
layout.addWidget(self.settings_group_box)
# --- END: MODIFIED Download Settings Group ---
# OK and Cancel Buttons
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)
def get_selected_scope(self):
# ... (This method remains unchanged) ...
if self.radio_videos.isChecked():
return self.SCOPE_VIDEOS
if self.radio_archives.isChecked():
return self.SCOPE_ARCHIVES
return self.SCOPE_BOTH
def get_selected_parts(self):
# ... (This method remains unchanged) ...
try:
parts = int(self.parts_input.text())
return max(2, min(parts, MAX_PARTS))
except (ValueError, TypeError):
return 4
def get_selected_min_size(self):
"""Returns the selected minimum size in MB as an integer."""
try:
size = int(self.size_input.text())
return max(10, min(size, 10000)) # Enforce valid range
except (ValueError, TypeError):
return 100 # Return a safe default

114
src/ui/dialogs/SinglePDF.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import re
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
# --- FIX: Move the class definition inside the try block ---
class PDF(FPDF):
"""Custom PDF class to handle headers and footers."""
def header(self):
pass
def footer(self):
self.set_y(-15)
if self.font_family:
self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
except ImportError:
FPDF_AVAILABLE = False
# If the import fails, FPDF and PDF will not be defined,
# but the program won't crash here.
FPDF = None
PDF = None
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
"""
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
return False
if not posts_data:
logger(" No text content was collected to create a PDF.")
return False
pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font_family = 'Arial'
pdf.add_page()
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
for i, post in enumerate(posts_data):
if i > 0:
if 'content' in post:
pdf.add_page()
elif 'comments' in post:
pdf.ln(10)
pdf.cell(0, 0, '', border='T')
pdf.ln(10)
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if 'comments' in post and post['comments']:
comments_list = post['comments']
for comment_index, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(w=0, h=7, txt=body)
if comment_index < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
try:
pdf.output(output_filename)
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
return True
except Exception as e:
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
return False

View File

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

View File

@@ -1,17 +1,13 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
) )
# --- 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
) )
@@ -57,8 +53,6 @@ class TourDialog(QDialog):
""" """
tour_finished_normally = pyqtSignal() tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal() tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV19"
@@ -97,8 +91,6 @@ class TourDialog(QDialog):
self.stacked_widget = QStackedWidget() self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1) main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
steps_content = [ steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"), ("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"), ("tour_dialog_step2_title", "tour_dialog_step2_content"),
@@ -119,8 +111,6 @@ class TourDialog(QDialog):
self.stacked_widget.addWidget(step_widget) self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!")) self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
# --- Bottom Controls ---
bottom_controls_layout = QVBoxLayout() bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12) bottom_controls_layout.setSpacing(12)
@@ -150,8 +140,9 @@ class TourDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }") self.setStyleSheet("QDialog { background-color: #f0f0f0; }")

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -196,10 +196,9 @@ def get_link_platform(url):
if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x' if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x'
if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite' if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite'
if 'pixiv.net' in domain: return 'pixiv' if 'pixiv.net' in domain: return 'pixiv'
if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono' if 'kemono.su' in domain or 'kemono.party' in domain or 'kemono.cr' in domain: return 'kemono'
if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer' if 'coomer.su' in domain or 'coomer.party' in domain or 'coomer.st' in domain: return 'coomer'
# Fallback to a generic name for other domains
parts = domain.split('.') parts = domain.split('.')
if len(parts) >= 2: if len(parts) >= 2:
return parts[-2] return parts[-2]

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

@@ -0,0 +1,587 @@
# 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)
# --- REORDERED CHECKBOXES ---
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
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("<EFBFBD>")
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)
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()