mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdb7ac93c4 | ||
|
|
76d4a3ea8a | ||
|
|
ccc7804505 | ||
|
|
4ee750c5d4 | ||
|
|
e9be13c4e3 | ||
|
|
a5cb04ea6f | ||
|
|
842f18d70d | ||
|
|
fb3f0e8913 | ||
|
|
0758887154 | ||
|
|
e752d881e7 | ||
|
|
a776d1abe9 | ||
|
|
21d1ce4fa9 | ||
|
|
d5112a25ee | ||
|
|
791ce503ff | ||
|
|
e5b519d5ce | ||
|
|
9888ed0862 | ||
|
|
9e996bf682 | ||
|
|
e7a6a91542 | ||
|
|
d7faccce18 | ||
|
|
a78c01c4f6 | ||
|
|
6de9967e0b | ||
|
|
e3dd0e70b6 |
534
features.md
534
features.md
@@ -1,147 +1,391 @@
|
||||
<div>
|
||||
<h1>Kemono Downloader - Comprehensive Feature Guide</h1>
|
||||
<p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
|
||||
<hr>
|
||||
<h2><strong>Main Window: Core Functionality</strong></h2>
|
||||
<p>The application is divided into a configuration panel on the left and a status/log panel on the right.</p>
|
||||
<h3><strong>Primary Inputs (Top-Left)</strong></h3>
|
||||
<ul>
|
||||
<li><strong>URL Input Field</strong>: This is the starting point for most downloads. You can paste a URL for a specific post or for an entire creator's feed. The application's behavior adapts based on the URL type.</li>
|
||||
<li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can:
|
||||
<ul>
|
||||
<li><strong>Search and Queue</strong>: Search for creators and check multiple names. Clicking "Add Selected" populates the main input field, preparing a batch download.</li>
|
||||
<li><strong>Check for Updates</strong>: Select a single creator's saved profile. This loads their information and switches the main download button to "Check for Updates" mode, allowing you to download only new content since your last session.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Download Location</strong>: The primary folder where all content will be saved. The <strong>Browse...</strong> button lets you select this folder from your computer.</li>
|
||||
<li><strong>Page Range (Start/End)</strong>: These fields activate only for creator feed URLs. They allow you to download a specific slice of a creator's history (e.g., pages 5 through 10) instead of their entire feed.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Filtering & Naming (Left Panel)</strong></h2>
|
||||
<p>These features give you precise control over what gets downloaded and how it's named and organized.</p>
|
||||
<ul>
|
||||
<li><strong>Filter by Character(s)</strong>: A powerful tool to download content featuring specific characters. You can enter multiple names separated by commas.
|
||||
<ul>
|
||||
<li><strong>Filter: [Scope] Button</strong>: This button changes how the character filter works:
|
||||
<ul>
|
||||
<li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li>
|
||||
<li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li>
|
||||
<li><strong>Both</strong>: Combines the "Title" and "Files" logic.</li>
|
||||
<li><strong>Comments (Beta)</strong>: Downloads a post if a character's name is mentioned in the comments section.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>).
|
||||
<ul>
|
||||
<li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works:
|
||||
<ul>
|
||||
<li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li>
|
||||
<li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li>
|
||||
<li><strong>Both</strong>: Applies both levels of skipping.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
|
||||
</ul>
|
||||
<h3><strong>File Type Filter (Radio Buttons)</strong></h3>
|
||||
<p>This section lets you choose the kind of content you want:</p>
|
||||
<ul>
|
||||
<li><strong>All, Images/GIFs, Videos, 🎧 Only Audio, 📦 Only Archives</strong>: These options filter the downloads to only include the selected file types.</li>
|
||||
<li><strong>🔗 Only Links</strong>: This special mode doesn't download any files. Instead, it scans post descriptions and lists all external links (like Mega, Google Drive) in the log panel.</li>
|
||||
<li><strong>More</strong>: Opens a dialog for text-only downloads. You can choose to save post <strong>descriptions</strong> or <strong>comments</strong> as formatted <strong>PDF, DOCX, or TXT</strong> files. A key feature here is the <strong>"Single PDF"</strong> option, which compiles the text from all downloaded posts into one continuous, sorted PDF document.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2>
|
||||
<ul>
|
||||
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li>
|
||||
<li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li>
|
||||
<li><strong>Scan Content for Images</strong>: A crucial feature that scans the post's text content for embedded images that may not be listed in the API, ensuring a more complete download.</li>
|
||||
<li><strong>Compress to WebP</strong>: Saves disk space by automatically converting large images into the efficient WebP format.</li>
|
||||
<li><strong>Keep Duplicates</strong>: Opens a dialog to control how files with identical content are handled. The default is to skip duplicates, but you can choose to keep all of them or set a specific limit (e.g., "keep up to 2 copies of the same file").</li>
|
||||
<li><strong>Subfolder per Post</strong>: Organizes downloads by creating a unique folder for each post, named after the post's title.</li>
|
||||
<li><strong>Date Prefix</strong>: When "Subfolder per Post" is on, this adds the post's date to the beginning of the folder name (e.g., <code>2025-07-25 Post Title</code>).</li>
|
||||
<li><strong>Separate Folders by Known.txt</strong>: This enables the automatic folder organization system based on your "Known Names" list.</li>
|
||||
<li><strong>Use Cookie</strong>: Allows the application to use browser cookies to access content that might be behind a paywall or login. You can paste a cookie string directly or use <strong>Browse...</strong> to select a <code>cookies.txt</code> file.</li>
|
||||
<li><strong>Use Multithreading</strong>: Greatly speeds up downloads of creator feeds by processing multiple posts at once. The number of <strong>Threads</strong> can be configured.</li>
|
||||
<li><strong>Show External Links in Log</strong>: When checked, a secondary log panel appears at the bottom of the right side, dedicated to listing any external links found.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Known Names Management (Bottom-Left)</strong></h2>
|
||||
<p>This powerful feature automates the creation of organized, named folders.</p>
|
||||
<ul>
|
||||
<li><strong>Known Shows/Characters List</strong>: Displays all the names and groups you've saved.</li>
|
||||
<li><strong>Search...</strong>: Filters the list to quickly find a name.</li>
|
||||
<li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li>
|
||||
<li><strong>Add New Name</strong>:
|
||||
<ul>
|
||||
<li><strong>Single Name</strong>: Typing <code>Tifa Lockhart</code> and clicking <strong>➕ Add</strong> creates an entry that will match "Tifa Lockhart".</li>
|
||||
<li><strong>Group</strong>: Typing <code>(Boa, Hancock, Snake Princess)~</code> and clicking <strong>➕ Add</strong> creates a single entry named "Boa Hancock Snake Princess". The application will then look for "Boa," "Hancock," OR "Snake Princess" in titles/filenames and save any matches into that combined folder.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>⤵️ Add to Filter</strong>: Opens a dialog with your full Known Names list, allowing you to check multiple entries and add them all to the "Filter by Character(s)" field at once.</li>
|
||||
<li><strong>🗑️ Delete Selected</strong>: Removes highlighted names from your list.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Action Buttons & Status Controls</strong></h2>
|
||||
<ul>
|
||||
<li><strong>⬇️ Start Download / 🔗 Extract Links</strong>: The main action button. Its function is dynamic:
|
||||
<ul>
|
||||
<li><strong>Normal Mode</strong>: Starts the download based on the current settings.</li>
|
||||
<li><strong>Update Mode</strong>: After selecting a creator profile, this button changes to <strong>🔄 Check for Updates</strong>.</li>
|
||||
<li><strong>Update Confirmation</strong>: After new posts are found, it changes to <strong>⬇️ Start Download (X new)</strong>.</li>
|
||||
<li><strong>Link Extraction Mode</strong>: The text changes to <strong>🔗 Extract Links</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>⏸️ Pause / ▶️ Resume Download</strong>: Pauses the ongoing download, allowing you to change certain settings (like filters) on the fly. Click again to resume.</li>
|
||||
<li><strong>❌ Cancel & Reset UI</strong>: Immediately stops all download activity and resets the UI to a clean state, preserving your URL and Download Location inputs.</li>
|
||||
<li><strong>Error Button</strong>: If files fail to download, they are logged. This button opens a dialog listing all failed files and will show a count of errors (e.g., <strong>(5) Error</strong>). From the dialog, you can:
|
||||
<ul>
|
||||
<li>Select specific files to <strong>Retry</strong> downloading.</li>
|
||||
<li><strong>Export</strong> the list of failed URLs to a <code>.txt</code> file.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>🔄 Reset (Top-Right)</strong>: A hard reset that clears all logs and returns every single UI element to its default state.</li>
|
||||
<li><strong>⚙️ (Settings)</strong>: Opens the main Settings dialog.</li>
|
||||
<li><strong>📜 (History)</strong>: Opens the Download History dialog.</li>
|
||||
<li><strong>? (Help)</strong>: Opens a helpful guide explaining the application's features.</li>
|
||||
<li><strong>❤️ Support</strong>: Opens a dialog with information on how to support the developer.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Specialized Modes & Features</strong></h2>
|
||||
<h3><strong>⭐ Favorite Mode</strong></h3>
|
||||
<p>Activating this mode transforms the UI for managing saved collections:</p>
|
||||
<ul>
|
||||
<li>The URL input is disabled.</li>
|
||||
<li>The main action buttons are replaced with:
|
||||
<ul>
|
||||
<li><strong>🖼️ Favorite Artists</strong>: Opens a dialog to browse and queue downloads from your saved favorite creators.</li>
|
||||
<li><strong>📄 Favorite Posts</strong>: Opens a dialog to browse and queue downloads for specific saved favorite posts.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Scope: [Location] Button</strong>: Toggles where the favorited content is saved:
|
||||
<ul>
|
||||
<li><strong>Selected Location</strong>: Saves all content directly into the main "Download Location".</li>
|
||||
<li><strong>Artist Folders</strong>: Creates a subfolder for each artist inside the main "Download Location".</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3><strong>📖 Manga/Comic Mode</strong></h3>
|
||||
<p>This mode is designed for sequential content and has several effects:</p>
|
||||
<ul>
|
||||
<li><strong>Reverses Download Order</strong>: It fetches and downloads posts from <strong>oldest to newest</strong>.</li>
|
||||
<li><strong>Enables Special Naming</strong>: A <strong><code>Name: [Style]</code></strong> button appears, allowing you to choose how files are named to maintain their correct order (e.g., by Post Title, by Date, or simple sequential numbering like <code>001, 002, 003...</code>).</li>
|
||||
<li><strong>Disables Multithreading (for certain styles)</strong>: To guarantee perfect sequential numbering, multithreading for posts is automatically disabled for certain naming styles.</li>
|
||||
</ul>
|
||||
<h3><strong>Session & Error Management</strong></h3>
|
||||
<ul>
|
||||
<li><strong>Session Restore</strong>: If the application is closed unexpectedly during a download, it will detect the incomplete session on the next launch. The UI will present a <strong>🔄 Restore Download</strong> button to resume exactly where you left off. You can also choose to discard the session.</li>
|
||||
<li><strong>Update Checking</strong>: By selecting a creator profile via the <strong>🎨 Creator Selection Popup</strong>, you can run an update check. The application compares the posts on the server with your download history for that creator and will prompt you to download only the new content.</li>
|
||||
</ul>
|
||||
<h3><strong>Logging & Monitoring</strong></h3>
|
||||
<ul>
|
||||
<li><strong>Progress Log</strong>: The main log provides real-time feedback on the download process, including status messages, file saves, skips, and errors.</li>
|
||||
<li><strong>👁️ Log View Toggle</strong>: Switches the log view between the standard <strong>Progress Log</strong> and a <strong>Missed Character Log</strong>, which shows potential character names from posts that were skipped by your filters, helping you discover new names to add to your list.</li>
|
||||
</ul>
|
||||
<h1>Kemono Downloader - Comprehensive Feature Guide</h1>
|
||||
<p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
|
||||
<hr>
|
||||
|
||||
<h2><strong>1. URL Input (🔗)</strong></h2>
|
||||
<p>This is the primary input field where you specify the content you want to download.</p>
|
||||
|
||||
<p><strong>Functionality:</strong></p>
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2><strong>2. Creator Selection & Update (🎨)</strong></h2>
|
||||
<p>The color palette emoji button opens the Creator Selection & Update dialog. This allows managing and downloading from a local creator database.</p>
|
||||
|
||||
<p><strong>Functionality:</strong></p>
|
||||
<ul>
|
||||
<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>
|
||||
<li><strong>Batch Selection:</strong> Select multiple creators and click "Add Selected" to add them to the batch download session.</li>
|
||||
<li><strong>Update Checker:</strong> Use a saved profile (.json) to download only new content based on previously fetched posts.</li>
|
||||
<li><strong>Post Fetching & Filtering:</strong> "Fetch Posts" loads post titles, allowing you to choose specific posts for download.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2><strong>3. Download Location Input (📁)</strong></h2>
|
||||
<p>This input defines the destination directory for downloaded files.</p>
|
||||
|
||||
<p><strong>Functionality:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Manual Entry:</strong> Enter or paste the folder path.</li>
|
||||
<li><strong>Browse Button:</strong> Opens a system dialog to choose a folder.</li>
|
||||
<li><strong>Directory Creation:</strong> If the folder doesn't exist, the app can create it after user confirmation.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<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>
|
||||
|
||||
<p><strong>Input Field (Filter by Character(s)):</strong></p>
|
||||
<ul>
|
||||
<li>Enter comma-separated names (e.g., <code>Tifa, Aerith</code>).</li>
|
||||
<li>Group aliases using parentheses (e.g., <code>(Cloud, Zack)</code>).</li>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<p><strong>Scope Button Modes:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Filter: Title</strong> (default) – Match names in post titles only.</li>
|
||||
<li><strong>Filter: Files</strong> – Match names in filenames only.</li>
|
||||
<li><strong>Filter: Both</strong> – Try title match first, then filenames.</li>
|
||||
<li><strong>Filter: Comments</strong> – Try filenames first, then post comments if no match.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2><strong>5. Skip with Words & Scope Button</strong></h2>
|
||||
<p>Prevents downloading content based on keywords.</p>
|
||||
|
||||
<p><strong>Input Field (Skip with Words):</strong></p>
|
||||
<ul>
|
||||
<li>Enter comma-separated keywords (e.g., <code>WIP, sketch, preview</code>).</li>
|
||||
<li>Matching is case-insensitive.</li>
|
||||
<li>If a keyword matches, the file or post is skipped.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Scope Button Modes:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Scope: Posts</strong> (default) – Skips post if title contains a keyword.</li>
|
||||
<li><strong>Scope: Files</strong> – Skips individual files with keyword matches.</li>
|
||||
<li><strong>Scope: Both</strong> – Skips entire post if title matches, otherwise filters individual files.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2><strong>Filter File Section (Radio Buttons)</strong></h2>
|
||||
<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>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>All:</strong> Default mode. Downloads every file and attachment provided by the API, regardless of type.
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<li>
|
||||
<strong>Videos:</strong> Filters for common video formats like <code>.mp4</code>, <code>.webm</code>, and <code>.mov</code>, skipping all others.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Only Archives:</strong> Downloads only archive files (<code>.zip</code>, <code>.rar</code>). Disables "Compress to WebP" and unchecks "Skip Archives".
|
||||
</li>
|
||||
<li>
|
||||
<strong>Only Audio:</strong> Filters for common audio formats like <code>.mp3</code>, <code>.wav</code>, and <code>.flac</code>.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>More:</strong> Opens the "More Options" dialog to download text-based content instead of media files.
|
||||
<ul>
|
||||
<li><strong>Scope:</strong> Choose to extract from post description or comments.</li>
|
||||
<li><strong>Export Format:</strong> Save text as PDF, DOCX, or TXT.</li>
|
||||
<li><strong>Single PDF:</strong> Optionally compile all text into one PDF.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2><strong>Check Box Buttons</strong></h2>
|
||||
<p>These checkboxes provide additional toggles to refine the download behavior and enable special features.</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>⭐ Favorite Mode:</strong> Changes workflow to download from your personal favorites. Disables the URL input.
|
||||
<ul>
|
||||
<li><strong>Favorite Artists:</strong> Opens a dialog to select from your favorited creators.</li>
|
||||
<li><strong>Favorite Posts:</strong> Opens a dialog to select from your favorited posts on Kemono and Coomer.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Skip Archives:</strong> When checked, archive files (<code>.zip</code>, <code>.rar</code>) are ignored. Disabled in "Only Archives" mode.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Download Thumbnail Only:</strong> Saves only thumbnail previews, not full-resolution files. Enables "Scan Content for Images".
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scan Content for Images:</strong> Parses post HTML for embedded images not listed in the API. Looks for <code><img></code> tags and direct image links.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Compress to WebP:</strong> Converts large images (over 1.5 MB) to WebP format using the Pillow library for space-saving.
|
||||
</li>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h2><strong>Folder Organization Checkboxes</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Separate folders by Known.txt:</strong> Automatically organizes downloads into folders based on name matches.
|
||||
<ul>
|
||||
<li>Uses "Filter by Character(s)" input first, if available.</li>
|
||||
<li>Then checks names in <code>Known.txt</code>.</li>
|
||||
<li>Falls back to extracting from post title.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Subfolder per post:</strong> Creates a unique folder per post, using the post’s title.
|
||||
<ul>
|
||||
<li>Prevents mixing files from multiple posts.</li>
|
||||
<li>Can be combined with Known.txt-based folders.</li>
|
||||
<li>Ensures uniqueness (e.g., <code>My Post Title_1</code>).</li>
|
||||
<li>Automatically removes empty folders.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<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.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2><strong>General Functionality Checkboxes</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Use cookie:</strong> Enables login-based access via cookies.
|
||||
<ul>
|
||||
<li>Paste cookie string directly, or browse to select a <code>cookies.txt</code> file.</li>
|
||||
<li>Cookies are used in all authenticated API requests.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Use Multithreading:</strong> Enables parallel downloading of posts.
|
||||
<ul>
|
||||
<li>Specify the number of worker threads (e.g., 10).</li>
|
||||
<li>Disabled for Manga Mode and Only Links mode.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Show external links in log:</strong> Adds a secondary log that displays links (e.g., Mega, Dropbox) found in post text.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Manga/Comic mode:</strong> Sorts posts chronologically before download.
|
||||
<ul>
|
||||
<li>Ensures correct page order for comics/manga.</li>
|
||||
</ul>
|
||||
<strong>Scope Button (Name: ...):</strong> Controls filename style:
|
||||
<ul>
|
||||
<li><strong>Name: Post Title</strong> — e.g., <code>Chapter-1.jpg</code></li>
|
||||
<li><strong>Name: Date + Original</strong> — e.g., <code>2025-08-03_filename.png</code></li>
|
||||
<li><strong>Name: Date + Title</strong> — e.g., <code>2025-08-03_Chapter-1.jpg</code></li>
|
||||
<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>
|
||||
<li><strong>Name: Post ID</strong> — uses unique post ID as filename</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h2><strong>Start Download</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Restore State:</strong> If an interrupted session is detected, the tooltip will indicate that starting a new download will discard previous session progress.
|
||||
</li>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<h2><strong>Pause / Resume Download</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>While Downloading:</strong> The button toggles between:
|
||||
<ul>
|
||||
<li><strong>"⏸️ Pause Download":</strong> Sets a <code>pause_event</code>, which tells all worker threads to halt their current task and wait.</li>
|
||||
<li><strong>"▶️ Resume Download":</strong> Clears the <code>pause_event</code>, allowing threads to resume their work.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>While Idle:</strong> The button is disabled.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Restore State:</strong> Changes to "🔄 Restore Download", which resumes the last session from saved data.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2><strong>Cancel & Reset UI</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Functionality:</strong> Stops downloads gracefully using a <code>cancellation_event</code>. Threads finish current tasks before shutting down.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Restore State:</strong> Changes to "🗑️ Discard Session", which deletes <code>session.json</code> and resets the UI.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Update State:</strong> Changes to "🗑️ Clear Selection", unloading the selected creator profile and returning to normal UI state.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2><strong>Error Button</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Error Counter:</strong> Shows how many files failed to download (e.g., <code>(3) Error</code>). Disabled if there are no errors.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Error Dialog:</strong> Clicking opens the "Files Skipped Due to Errors" dialog (defined in <code>ErrorFilesDialog.py</code>), listing all failed files.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Dialog Features:</strong>
|
||||
<ul>
|
||||
<li><strong>View Failed Files:</strong> Shows filenames and related post info.</li>
|
||||
<li><strong>Select and Retry:</strong> Retry selected failed files in a focused download session.</li>
|
||||
<li><strong>Export URLs:</strong> Save a <code>.txt</code> file of direct download links. Optionally include post metadata with each URL.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h2><strong>"Known Area" and its Controls</strong></h2>
|
||||
<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>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<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>
|
||||
<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>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2><strong>Other Buttons</strong></h2>
|
||||
<ul>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>History Button:</strong> Opens the Download History dialog (from <code>DownloadHistoryDialog.py</code>), showing:
|
||||
<ul>
|
||||
<li>Recently downloaded files</li>
|
||||
<li>The first few posts processed in the last session</li>
|
||||
</ul>
|
||||
This allows for a quick review of recent activity.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</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.
|
||||
</li>
|
||||
</ul>
|
||||
<h2><strong>Log Area Controls</strong></h2>
|
||||
<p>These controls are located around the main log panel and offer tools for managing downloads, configuring advanced options, and resetting the application.</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Multi-part: OFF</strong><br>
|
||||
This button acts as both a status indicator and a configuration panel for multi-part downloading (parallel downloading of large files).
|
||||
<ul>
|
||||
<li><strong>Function:</strong> Opens the <code>Multipart Download Options</code> dialog (defined in <code>MultipartScopeDialog.py</code>).</li>
|
||||
<li><strong>Scope Options:</strong> Choose between "Videos Only", "Archives Only", or "Both".</li>
|
||||
<li><strong>Number of parts:</strong> Set how many simultaneous connections to use (2–16).</li>
|
||||
<li><strong>Minimum file size:</strong> Set a threshold (MB) below which files are downloaded normally.</li>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>👁️ Eye Emoji Button (Log View Toggle)</strong><br>
|
||||
Switches between two views in the log panel:
|
||||
<ul>
|
||||
<li><strong>👁️ Progress Log View:</strong> Shows real-time download progress, status messages, and errors.</li>
|
||||
<li><strong>🚫 Missed Character View:</strong> Displays names detected in posts that didn’t match the current filter — useful for updating <code>Known.txt</code>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>Reset Button</strong><br>
|
||||
Performs a full "soft reset" of the UI when the application is idle.
|
||||
<ul>
|
||||
<li>Clears all inputs (except saved Download Location)</li>
|
||||
<li>Resets checkboxes, buttons, and logs</li>
|
||||
<li>Clears counters, queues, and restores the UI to its default state</li>
|
||||
<li><strong>Note:</strong> This is different from <em>Cancel & Reset UI</em>, which halts active downloads</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3><strong>The Progress Log and "Only Links" Mode Controls</strong></h3>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Standard Mode (Progress Log)</strong><br>
|
||||
This is the default behavior. The <code>main_log_output</code> field displays:
|
||||
<ul>
|
||||
<li>Post processing steps</li>
|
||||
<li>Download/skipped file notifications</li>
|
||||
<li>Error messages</li>
|
||||
<li>Session summaries</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>"Only Links" Mode</strong><br>
|
||||
When enabled, the log panel switches modes and reveals new controls.
|
||||
<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>
|
||||
<li><strong>Download Button:</strong> Opens the <code>Download Selected External Links</code> dialog (from <code>DownloadExtractedLinksDialog.py</code>), where you can:
|
||||
<ul>
|
||||
<li>View all supported external links</li>
|
||||
<li>Select which ones to download</li>
|
||||
<li>Begin download directly from cloud services</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Links View Button:</strong> Toggles log display between:
|
||||
<ul>
|
||||
<li><strong>🔗 Links View:</strong> Shows all extracted links</li>
|
||||
<li><strong>⬇️ Progress View:</strong> Shows download progress from external services (e.g., Mega)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
145
readme.md
145
readme.md
@@ -1,4 +1,4 @@
|
||||
<h1 align="center">Kemono Downloader v6.0.0</h1>
|
||||
<h1 align="center">Kemono Downloader </h1>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -41,108 +41,53 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
|
||||
|
||||
</div>
|
||||
|
||||
<h2><strong>Core Capabilities Overview</strong></h2>
|
||||
|
||||
---
|
||||
<h3><strong>High-Performance Downloading</strong></h3>
|
||||
<ul>
|
||||
<li><strong>Multi-threading:</strong> Processes multiple posts simultaneously to greatly accelerate downloads from large creator profiles.</li>
|
||||
<li><strong>Multi-part Downloading:</strong> Splits large files into chunks and downloads them in parallel to maximize speed.</li>
|
||||
<li><strong>Resilience:</strong> Supports pausing, resuming, and restoring downloads after crashes or interruptions.</li>
|
||||
</ul>
|
||||
|
||||
## Feature Overview
|
||||
<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>
|
||||
|
||||
Kemono Downloader offers a range of features to streamline your content downloading experience:
|
||||
<h3><strong>File Organization & Renaming</strong></h3>
|
||||
<ul>
|
||||
<li><strong>Automated Subfolders:</strong> Automatically organizes downloaded files into subdirectories based on character names or per post.</li>
|
||||
<li><strong>Advanced File Renaming:</strong> Flexible renaming options, especially in Manga Mode, including:
|
||||
<ul>
|
||||
<li><strong>Post Title:</strong> Uses the post's title (e.g., <code>Chapter-One.jpg</code>).</li>
|
||||
<li><strong>Date + Original Name:</strong> Prepends the publication date to the original filename.</li>
|
||||
<li><strong>Date + Title:</strong> Combines the date with the post title.</li>
|
||||
<li><strong>Sequential Numbering (Date Based):</strong> Simple sequence numbers (e.g., <code>001.jpg</code>, <code>002.jpg</code>).</li>
|
||||
<li><strong>Title + Global Numbering:</strong> Uses post title with a globally incrementing number across the session.</li>
|
||||
<li><strong>Post ID:</strong> Names files using the post’s unique ID.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
- **User-Friendly Interface:** A modern PyQt5 GUI for easy navigation and operation.
|
||||
<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>
|
||||
|
||||
- **Flexible Downloading:**
|
||||
- Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors).
|
||||
- Supports creator pages (with page range selection) and individual post URLs.
|
||||
- Standard download controls: Start, Pause, Resume, and Cancel.
|
||||
|
||||
- **Powerful Filtering:**
|
||||
- **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
|
||||
- **Keyword Skipping:** Skip posts or files based on specified keywords.
|
||||
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
|
||||
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
|
||||
|
||||
- **Customizable Downloads:**
|
||||
- **Thumbnails Only:** Option to download only small preview images.
|
||||
- **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
|
||||
- **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
|
||||
|
||||
- **Organized Output:**
|
||||
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
|
||||
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
|
||||
|
||||
- **Manga/Comic Mode:**
|
||||
- Downloads posts from a creator's feed in chronological order (oldest to newest).
|
||||
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
|
||||
|
||||
- **⭐ Favorite Mode:**
|
||||
- Directly download from your favorited artists and posts on Kemono.su.
|
||||
- Requires a valid cookie and adapts the UI for easy selection from your favorites.
|
||||
- Supports downloading into a single location or artist-specific subfolders.
|
||||
|
||||
- **Performance & Advanced Options:**
|
||||
- **Cookie Support:** Use cookies (paste string or load from `cookies.txt`) to access restricted content.
|
||||
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
|
||||
|
||||
- **Logging:**
|
||||
- A detailed progress log displays download activity, errors, and summaries.
|
||||
|
||||
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
|
||||
|
||||
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
|
||||
|
||||
---
|
||||
|
||||
## ✨ What's New in v6.0.0
|
||||
|
||||
This release focuses on providing more granular control over file organization and improving at-a-glance status monitoring.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Live Error Count on Button**
|
||||
The **"Error" button** now dynamically displays the number of failed files during a download. Instead of opening the dialog, you can quickly see a live count like `(3) Error`, helping you track issues at a glance.
|
||||
|
||||
- **Date Prefix for Post Subfolders**
|
||||
A new checkbox labeled **"Date Prefix"** is now available in the advanced settings.
|
||||
When enabled alongside **"Subfolder per Post"**, it prepends the post's upload date to the folder name (e.g., `2025-07-11 Post Title`).
|
||||
This makes your downloads sortable and easier to browse chronologically.
|
||||
|
||||
- **Keep Duplicates Within a Post**
|
||||
A **"Keep Duplicates"** option has been added to preserve all files from a post — even if some have the same name.
|
||||
Instead of skipping or overwriting, the downloader will save duplicates with numbered suffixes (e.g., `image.jpg`, `image_1.jpg`, etc.), which is especially useful when the same file name points to different media.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- The downloader now correctly renames large `.part` files when completed, avoiding leftover temp files.
|
||||
- The list of failed files shown in the Error Dialog is now saved and restored with your session — so no errors get lost if you close the app.
|
||||
- Your selected download location is remembered, even after pressing the **Reset** button.
|
||||
- The **Cancel** button is now enabled when restoring a pending session, so you can abort stuck jobs more easily.
|
||||
- Internal cleanup logs (like "Deleting post cache") are now excluded from the final download summary for clarity.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Next Update Plans
|
||||
|
||||
### 🔖 Post Tag Filtering (Planned for v6.1.0)
|
||||
|
||||
A powerful new **"Filter by Post Tags"** feature is planned:
|
||||
|
||||
- Filter and download content based on specific post tags.
|
||||
- Combine tag filtering with current filters (character, file type, etc.).
|
||||
- Use tag presets to automate frequent downloads.
|
||||
|
||||
This will provide **much greater control** over what gets downloaded, especially for creators who use tags consistently.
|
||||
|
||||
### 📁 Creator Download History (.json Save)
|
||||
|
||||
To streamline incremental downloads, a new system will allow the app to:
|
||||
|
||||
- Save a `.json` file with metadata about already-downloaded posts.
|
||||
- Compare that file on future runs, so only **new** posts are downloaded.
|
||||
- Avoids duplication and makes regular syncs fast and efficient.
|
||||
|
||||
Ideal for users managing large collections or syncing favorites regularly.
|
||||
|
||||
---
|
||||
<h3><strong>Utility & Advanced Features</strong></h3>
|
||||
<ul>
|
||||
<li><strong>Cookie Support:</strong> Enables access to subscriber-only content via browser session cookies.</li>
|
||||
<li><strong>Duplicate Detection:</strong> Prevents saving duplicate files using content-based comparison, with configurable limits.</li>
|
||||
<li><strong>Image Compression:</strong> Automatically converts large images to <code>.webp</code> to reduce disk usage.</li>
|
||||
<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>
|
||||
|
||||
## 💻 Installation
|
||||
|
||||
@@ -154,7 +99,7 @@ Ideal for users managing large collections or syncing favorites regularly.
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install PyQt5 requests Pillow mega.py
|
||||
pip install PyQt5 requests Pillow mega.py fpdf2 python-docx
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
@@ -197,7 +142,7 @@ Feel free to fork this repo and submit pull requests for bug fixes, new features
|
||||
|
||||
## License
|
||||
|
||||
This project is under the Custom Licence
|
||||
This project is under the MIT Licence
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ def download_from_api(
|
||||
selected_cookie_file=None,
|
||||
app_base_dir=None,
|
||||
manga_filename_style_for_sort_check=None,
|
||||
processed_post_ids=None # --- ADD THIS ARGUMENT ---
|
||||
processed_post_ids=None
|
||||
):
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
@@ -139,9 +139,14 @@ def download_from_api(
|
||||
|
||||
parsed_input_url_for_domain = urlparse(api_url_input)
|
||||
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']):
|
||||
|
||||
# --- START: MODIFIED LOGIC ---
|
||||
# This list is updated to include the new .cr and .st mirrors for validation.
|
||||
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
|
||||
api_domain = "kemono.su"
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
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)
|
||||
@@ -220,6 +225,9 @@ def download_from_api(
|
||||
logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
|
||||
break
|
||||
all_posts_for_manga_mode.extend(posts_batch_manga)
|
||||
|
||||
logger(f"MANGA_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}")
|
||||
|
||||
current_offset_manga += page_size
|
||||
time.sleep(0.6)
|
||||
except RuntimeError as e:
|
||||
@@ -232,7 +240,12 @@ def download_from_api(
|
||||
logger(f"❌ Unexpected error during manga mode fetch: {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
if cancellation_event and cancellation_event.is_set(): return
|
||||
|
||||
if all_posts_for_manga_mode:
|
||||
logger(f"MANGA_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}")
|
||||
|
||||
if all_posts_for_manga_mode:
|
||||
if processed_post_ids:
|
||||
original_count = len(all_posts_for_manga_mode)
|
||||
|
||||
@@ -5,11 +5,10 @@ import json
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
||||
from .api_client import download_from_api
|
||||
from .workers import PostProcessorWorker, DownloadThread
|
||||
from .workers import PostProcessorWorker
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING,
|
||||
MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES,
|
||||
POST_WORKER_BATCH_DELAY_SECONDS
|
||||
MAX_THREADS
|
||||
)
|
||||
from ..utils.file_utils import clean_folder_name
|
||||
|
||||
@@ -44,6 +43,7 @@ class DownloadManager:
|
||||
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):
|
||||
"""Puts a progress message into the queue for the UI."""
|
||||
@@ -61,12 +61,16 @@ class DownloadManager:
|
||||
if self.is_running:
|
||||
self._log("❌ Cannot start a new session: A session is already in progress.")
|
||||
return
|
||||
|
||||
|
||||
self.session_file_path = config.get('session_file_path')
|
||||
creator_profile_data = self._setup_creator_profile(config)
|
||||
creator_profile_data['settings'] = config
|
||||
creator_profile_data.setdefault('processed_post_ids', [])
|
||||
self._save_creator_profile(creator_profile_data)
|
||||
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
|
||||
|
||||
# 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.cancellation_event.clear()
|
||||
@@ -77,6 +81,7 @@ class DownloadManager:
|
||||
self.total_downloads = 0
|
||||
self.total_skips = 0
|
||||
self.all_kept_original_filenames = []
|
||||
|
||||
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
||||
use_multithreading = config.get('use_multithreading', True)
|
||||
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
@@ -86,72 +91,99 @@ class DownloadManager:
|
||||
if should_use_multithreading_for_posts:
|
||||
fetcher_thread = threading.Thread(
|
||||
target=self._fetch_and_queue_posts_for_pool,
|
||||
args=(config, restore_data, creator_profile_data), # Add argument here
|
||||
args=(config, restore_data, creator_profile_data),
|
||||
daemon=True
|
||||
)
|
||||
fetcher_thread.start()
|
||||
else:
|
||||
self._start_single_threaded_session(config)
|
||||
# Single-threaded mode does not use the manager's complex logic
|
||||
self._log("ℹ️ Manager is handing off to a single-threaded worker...")
|
||||
# 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...")
|
||||
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:
|
||||
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):
|
||||
def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
|
||||
"""
|
||||
Fetches all posts from the API and submits them as tasks to a thread pool.
|
||||
This method runs in its own dedicated thread to avoid blocking.
|
||||
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 the UI.
|
||||
It provides immediate feedback as soon as the first batch of posts is found.
|
||||
"""
|
||||
try:
|
||||
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
|
||||
|
||||
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
|
||||
session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set()
|
||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||
processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||
|
||||
if restore_data:
|
||||
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']
|
||||
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]
|
||||
self.total_posts = len(all_posts)
|
||||
self.processed_posts = len(processed_ids)
|
||||
self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.")
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
|
||||
if not posts_to_process:
|
||||
self._log("✅ No new posts to process from restored session.")
|
||||
return
|
||||
|
||||
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:
|
||||
posts_to_process = self._get_all_posts(config)
|
||||
self.total_posts = len(posts_to_process)
|
||||
# --- 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
|
||||
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
|
||||
if not posts_to_process:
|
||||
self._log("✅ No new posts to process.")
|
||||
return
|
||||
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)
|
||||
|
||||
# Process posts in batches as they are yielded by the API client
|
||||
for batch in post_generator:
|
||||
if self.cancellation_event.is_set():
|
||||
self._log(" Post fetching cancelled.")
|
||||
break
|
||||
|
||||
# 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)
|
||||
future = self.thread_pool.submit(worker.process)
|
||||
future.add_done_callback(self._handle_future_result)
|
||||
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:
|
||||
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
|
||||
self._log(traceback.format_exc())
|
||||
@@ -164,28 +196,6 @@ class DownloadManager:
|
||||
'type': 'finished',
|
||||
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
|
||||
})
|
||||
|
||||
def _get_all_posts(self, config):
|
||||
"""Helper to fetch all posts using the API client."""
|
||||
all_posts = []
|
||||
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=config.get('processed_post_ids', [])
|
||||
)
|
||||
for batch in post_generator:
|
||||
all_posts.extend(batch)
|
||||
return all_posts
|
||||
|
||||
def _handle_future_result(self, future: Future):
|
||||
"""Callback executed when a worker task completes."""
|
||||
@@ -261,9 +271,15 @@ class DownloadManager:
|
||||
"""Cancels the current running session."""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
if self.cancellation_event.is_set():
|
||||
self._log("ℹ️ Cancellation already in progress.")
|
||||
return
|
||||
|
||||
self._log("⚠️ Cancellation requested by user...")
|
||||
self.cancellation_event.set()
|
||||
|
||||
if self.thread_pool:
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
self.is_running = False
|
||||
self._log(" Signaling all worker threads to stop and shutting down pool...")
|
||||
self.thread_pool.shutdown(wait=False)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
@@ -53,6 +54,24 @@ from ..utils.text_utils import (
|
||||
)
|
||||
from ..config.constants import *
|
||||
|
||||
def robust_clean_name(name):
|
||||
"""A more robust function to remove illegal characters for filenames and folders."""
|
||||
if not name:
|
||||
return ""
|
||||
# Removes illegal characters for Windows, macOS, and Linux: < > : " / \ | ? *
|
||||
# Also removes control characters (ASCII 0-31) which are invisible but invalid.
|
||||
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*]'
|
||||
cleaned_name = re.sub(illegal_chars_pattern, '', name)
|
||||
|
||||
# Remove leading/trailing spaces or periods, which can cause issues.
|
||||
cleaned_name = cleaned_name.strip(' .')
|
||||
|
||||
# If the name is empty after cleaning (e.g., it was only illegal chars),
|
||||
# provide a safe fallback name.
|
||||
if not cleaned_name:
|
||||
return "untitled_folder" # Or "untitled_file" depending on context
|
||||
return cleaned_name
|
||||
|
||||
class PostProcessorSignals (QObject ):
|
||||
progress_signal =pyqtSignal (str )
|
||||
file_download_status_signal =pyqtSignal (bool )
|
||||
@@ -63,7 +82,6 @@ class PostProcessorSignals (QObject ):
|
||||
worker_finished_signal = pyqtSignal(tuple)
|
||||
|
||||
class PostProcessorWorker:
|
||||
|
||||
def __init__(self, post_data, download_root, known_names,
|
||||
filter_character_list, emitter,
|
||||
unwanted_keywords, filter_mode, skip_zip,
|
||||
@@ -103,7 +121,10 @@ class PostProcessorWorker:
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None
|
||||
processed_post_ids=None,
|
||||
multipart_scope='both',
|
||||
multipart_parts_count=4,
|
||||
multipart_min_size_mb=100
|
||||
):
|
||||
self.post = post_data
|
||||
self.download_root = download_root
|
||||
@@ -165,7 +186,9 @@ class PostProcessorWorker:
|
||||
self.single_pdf_mode = single_pdf_mode
|
||||
self.project_root_dir = project_root_dir
|
||||
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else []
|
||||
|
||||
self.multipart_scope = multipart_scope
|
||||
self.multipart_parts_count = multipart_parts_count
|
||||
self.multipart_min_size_mb = multipart_min_size_mb
|
||||
if self.compress_images and Image is None:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
||||
self.compress_images = False
|
||||
@@ -199,8 +222,38 @@ class PostProcessorWorker:
|
||||
if self .dynamic_filter_holder :
|
||||
return self .dynamic_filter_holder .get_filters ()
|
||||
return self .filter_character_list_objects_initial
|
||||
|
||||
def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event,
|
||||
|
||||
def _find_valid_subdomain(self, url: str, max_subdomains: int = 4) -> str:
|
||||
"""
|
||||
Attempts to find a working subdomain for a Kemono/Coomer URL that returned a 403 error.
|
||||
Returns the original URL if no other valid subdomain is found.
|
||||
"""
|
||||
self.logger(f" probing for a valid subdomain...")
|
||||
parsed_url = urlparse(url)
|
||||
original_domain = parsed_url.netloc
|
||||
|
||||
for i in range(1, max_subdomains + 1):
|
||||
domain_parts = original_domain.split('.')
|
||||
if len(domain_parts) > 1:
|
||||
base_domain = ".".join(domain_parts[-2:])
|
||||
new_domain = f"n{i}.{base_domain}"
|
||||
else:
|
||||
continue
|
||||
|
||||
new_url = parsed_url._replace(netloc=new_domain).geturl()
|
||||
|
||||
try:
|
||||
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True) as resp:
|
||||
if resp.status_code == 200:
|
||||
self.logger(f" ✅ Valid subdomain found: {new_domain}")
|
||||
return new_url
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
self.logger(f" ⚠️ No other valid subdomain found. Sticking with the original.")
|
||||
return url
|
||||
|
||||
def _download_single_file(self, file_info, target_folder_path, post_page_url, original_post_id_for_log, skip_event,
|
||||
post_title="", file_index_in_post=0, num_files_in_this_post=1,
|
||||
manga_date_file_counter_ref=None,
|
||||
forced_filename_override=None,
|
||||
@@ -214,6 +267,11 @@ class PostProcessorWorker:
|
||||
if self.check_cancel() or (skip_event and skip_event.is_set()):
|
||||
return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
file_download_headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
||||
'Referer': post_page_url
|
||||
}
|
||||
|
||||
file_url = file_info.get('url')
|
||||
cookies_to_use_for_file = None
|
||||
if self.use_cookie:
|
||||
@@ -232,34 +290,28 @@ class PostProcessorWorker:
|
||||
self.logger(f" -> Skip File (Keyword in Original Name '{skip_word}'): '{api_original_filename}'. Scope: {self.skip_words_scope}")
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
cleaned_original_api_filename = clean_filename(api_original_filename)
|
||||
cleaned_original_api_filename = robust_clean_name(api_original_filename)
|
||||
original_filename_cleaned_base, original_ext = os.path.splitext(cleaned_original_api_filename)
|
||||
if not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else ''
|
||||
|
||||
if self.manga_mode_active:
|
||||
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||
# Get the post's publication or added date
|
||||
published_date_str = self.post.get('published')
|
||||
added_date_str = self.post.get('added')
|
||||
formatted_date_str = "nodate" # Fallback if no date is found
|
||||
|
||||
formatted_date_str = "nodate"
|
||||
date_to_use_str = published_date_str or added_date_str
|
||||
|
||||
if date_to_use_str:
|
||||
try:
|
||||
# Extract just the YYYY-MM-DD part from the timestamp
|
||||
formatted_date_str = date_to_use_str.split('T')[0]
|
||||
except Exception:
|
||||
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
|
||||
else:
|
||||
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
|
||||
|
||||
# Combine the date with the cleaned original filename
|
||||
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
|
||||
was_original_name_kept_flag = True
|
||||
elif self.manga_filename_style == STYLE_POST_TITLE:
|
||||
if post_title and post_title.strip():
|
||||
cleaned_post_title_base = clean_filename(post_title.strip())
|
||||
cleaned_post_title_base = robust_clean_name(post_title.strip())
|
||||
if num_files_in_this_post > 1:
|
||||
if file_index_in_post == 0:
|
||||
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
|
||||
@@ -280,7 +332,7 @@ class PostProcessorWorker:
|
||||
manga_date_file_counter_ref[0] += 1
|
||||
base_numbered_name = f"{counter_val_for_filename:03d}"
|
||||
if self.manga_date_prefix and self.manga_date_prefix.strip():
|
||||
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
|
||||
cleaned_prefix = robust_clean_name(self.manga_date_prefix.strip())
|
||||
if cleaned_prefix:
|
||||
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
|
||||
else:
|
||||
@@ -297,7 +349,7 @@ class PostProcessorWorker:
|
||||
with counter_lock:
|
||||
counter_val_for_filename = manga_global_file_counter_ref[0]
|
||||
manga_global_file_counter_ref[0] += 1
|
||||
cleaned_post_title_base_for_global = clean_filename(post_title.strip() if post_title and post_title.strip() else "post")
|
||||
cleaned_post_title_base_for_global = robust_clean_name(post_title.strip() if post_title and post_title.strip() else "post")
|
||||
filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}"
|
||||
else:
|
||||
self.logger(f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_global_file_counter_ref}")
|
||||
@@ -329,8 +381,8 @@ class PostProcessorWorker:
|
||||
self.logger(f" ⚠️ Post ID {original_post_id_for_log} missing both 'published' and 'added' dates for STYLE_DATE_POST_TITLE. Using 'nodate'.")
|
||||
|
||||
if post_title and post_title.strip():
|
||||
temp_cleaned_title = clean_filename(post_title.strip())
|
||||
if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_file"):
|
||||
temp_cleaned_title = robust_clean_name(post_title.strip())
|
||||
if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_folder"):
|
||||
self.logger(f"⚠️ Manga mode (Date+PostTitle Style): Post title for post {original_post_id_for_log} ('{post_title}') was empty or generic after cleaning. Using 'post' as title part.")
|
||||
cleaned_post_title_for_filename = "post"
|
||||
else:
|
||||
@@ -409,6 +461,33 @@ class PostProcessorWorker:
|
||||
unique_id_for_part_file = uuid.uuid4().hex[:8]
|
||||
unique_part_file_stem_on_disk = f"{temp_file_base_for_unique_part}_{unique_id_for_part_file}"
|
||||
max_retries = 3
|
||||
if not self.keep_in_post_duplicates:
|
||||
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
|
||||
if os.path.exists(final_save_path_check):
|
||||
try:
|
||||
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
||||
head_response.raise_for_status()
|
||||
expected_size = int(head_response.headers.get('Content-Length', -1))
|
||||
|
||||
actual_size = os.path.getsize(final_save_path_check)
|
||||
|
||||
if expected_size != -1 and actual_size == expected_size:
|
||||
self.logger(f" -> Skip (File Exists & Complete): '{filename_to_save_in_main_path}' is already on disk with the correct size.")
|
||||
try:
|
||||
md5_hasher = hashlib.md5()
|
||||
with open(final_save_path_check, 'rb') as f_verify:
|
||||
for chunk in iter(lambda: f_verify.read(8192), b""):
|
||||
md5_hasher.update(chunk)
|
||||
with self.downloaded_hash_counts_lock:
|
||||
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
|
||||
except Exception as hash_exc:
|
||||
self.logger(f" ⚠️ Could not hash existing file '{filename_to_save_in_main_path}' for session: {hash_exc}")
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
else:
|
||||
self.logger(f" ⚠️ File '{filename_to_save_in_main_path}' exists but is incomplete (Expected: {expected_size}, Actual: {actual_size}). Re-downloading.")
|
||||
except requests.RequestException as e:
|
||||
self.logger(f" ⚠️ Could not verify size of existing file '{filename_to_save_in_main_path}': {e}. Proceeding with download.")
|
||||
|
||||
retry_delay = 5
|
||||
downloaded_size_bytes = 0
|
||||
calculated_file_hash = None
|
||||
@@ -428,14 +507,44 @@ class PostProcessorWorker:
|
||||
if attempt_num_single_stream > 0:
|
||||
self.logger(f" Retrying download for '{api_original_filename}' (Overall Attempt {attempt_num_single_stream + 1}/{max_retries + 1})...")
|
||||
time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1)))
|
||||
|
||||
self._emit_signal('file_download_status', True)
|
||||
response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
|
||||
current_url_to_try = file_url
|
||||
|
||||
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
|
||||
if response.status_code == 403 and ('kemono.cr' in current_url_to_try or 'coomer.st' in current_url_to_try):
|
||||
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
|
||||
new_url = self._find_valid_subdomain(current_url_to_try)
|
||||
if new_url != current_url_to_try:
|
||||
self.logger(f" Retrying with new URL: {new_url}")
|
||||
file_url = new_url # Update the main file_url for subsequent retries
|
||||
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
|
||||
|
||||
response.raise_for_status()
|
||||
total_size_bytes = int(response.headers.get('Content-Length', 0))
|
||||
num_parts_for_file = min(self.num_file_threads, MAX_PARTS_FOR_MULTIPART_DOWNLOAD)
|
||||
num_parts_for_file = min(self.multipart_parts_count, MAX_PARTS_FOR_MULTIPART_DOWNLOAD)
|
||||
|
||||
file_is_eligible_by_scope = False
|
||||
if self.multipart_scope == 'videos':
|
||||
if is_video(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
elif self.multipart_scope == 'archives':
|
||||
if is_archive(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
elif self.multipart_scope == 'both':
|
||||
if is_video(api_original_filename) or is_archive(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
|
||||
min_size_in_bytes = self.multipart_min_size_mb * 1024 * 1024
|
||||
|
||||
attempt_multipart = (self.allow_multipart_download and MULTIPART_DOWNLOADER_AVAILABLE and
|
||||
num_parts_for_file > 1 and total_size_bytes > MIN_SIZE_FOR_MULTIPART_DOWNLOAD and
|
||||
file_is_eligible_by_scope and
|
||||
num_parts_for_file > 1 and total_size_bytes > min_size_in_bytes and
|
||||
'bytes' in response.headers.get('Accept-Ranges', '').lower())
|
||||
|
||||
if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break
|
||||
|
||||
if attempt_multipart:
|
||||
@@ -444,7 +553,7 @@ class PostProcessorWorker:
|
||||
response_for_this_attempt = None
|
||||
mp_save_path_for_unique_part_stem_arg = os.path.join(target_folder_path, f"{unique_part_file_stem_on_disk}{temp_file_ext_for_unique_part}")
|
||||
mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts(
|
||||
file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, headers, api_original_filename,
|
||||
file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, file_download_headers, api_original_filename,
|
||||
emitter_for_multipart=self.emitter, cookies_for_chunk_session=cookies_to_use_for_file,
|
||||
cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger,
|
||||
pause_event=self.pause_event
|
||||
@@ -453,7 +562,7 @@ class PostProcessorWorker:
|
||||
download_successful_flag = True
|
||||
downloaded_size_bytes = mp_bytes
|
||||
calculated_file_hash = mp_hash
|
||||
downloaded_part_file_path = mp_save_path_for_unique_part_stem_arg + ".part"
|
||||
downloaded_part_file_path = mp_save_path_for_unique_part_stem_arg
|
||||
if mp_file_handle: mp_file_handle.close()
|
||||
break
|
||||
else:
|
||||
@@ -519,12 +628,15 @@ class PostProcessorWorker:
|
||||
if isinstance(e, requests.exceptions.ConnectionError) and ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
|
||||
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
|
||||
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
break
|
||||
if e.response is not None and e.response.status_code == 403:
|
||||
self.logger(f" ⚠️ Download Error (403 Forbidden): {api_original_filename}. This often requires valid cookies.")
|
||||
self.logger(f" Will retry... Check your 'Use Cookie' settings if this persists.")
|
||||
last_exception_for_retry_later = e
|
||||
else:
|
||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
|
||||
last_exception_for_retry_later = e
|
||||
@@ -601,26 +713,22 @@ class PostProcessorWorker:
|
||||
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
|
||||
try:
|
||||
with Image.open(downloaded_part_file_path) as img:
|
||||
# Convert to RGB to avoid issues with paletted images or alpha channels in WebP
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Use an in-memory buffer to save the compressed image
|
||||
output_buffer = BytesIO()
|
||||
img.save(output_buffer, format='WebP', quality=85)
|
||||
|
||||
# This buffer now holds the compressed data
|
||||
data_to_write_io = output_buffer
|
||||
|
||||
# Update the filename to use the .webp extension
|
||||
base, _ = os.path.splitext(filename_to_save_in_main_path)
|
||||
filename_to_save_in_main_path = f"{base}.webp"
|
||||
self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
|
||||
|
||||
except Exception as e_compress:
|
||||
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.")
|
||||
data_to_write_io = None # Ensure we fall back to saving the original
|
||||
|
||||
data_to_write_io = None
|
||||
|
||||
effective_save_folder = target_folder_path
|
||||
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
||||
counter = 1
|
||||
@@ -637,17 +745,14 @@ class PostProcessorWorker:
|
||||
|
||||
try:
|
||||
if data_to_write_io:
|
||||
# Write the compressed data from the in-memory buffer
|
||||
with open(final_save_path, 'wb') as f_out:
|
||||
f_out.write(data_to_write_io.getvalue())
|
||||
# Clean up the original downloaded part file
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try:
|
||||
os.remove(downloaded_part_file_path)
|
||||
except OSError as e_rem:
|
||||
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
||||
else:
|
||||
# No compression was done, just rename the original file
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
time.sleep(0.1)
|
||||
os.rename(downloaded_part_file_path, final_save_path)
|
||||
@@ -694,7 +799,7 @@ class PostProcessorWorker:
|
||||
self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
|
||||
|
||||
permanent_failure_details = {
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': file_download_headers,
|
||||
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post,
|
||||
'forced_filename_override': filename_to_save_in_main_path,
|
||||
@@ -708,7 +813,7 @@ class PostProcessorWorker:
|
||||
details_for_failure = {
|
||||
'file_info': file_info,
|
||||
'target_folder_path': target_folder_path,
|
||||
'headers': headers,
|
||||
'headers': file_download_headers,
|
||||
'original_post_id_for_log': original_post_id_for_log,
|
||||
'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post,
|
||||
@@ -740,8 +845,11 @@ class PostProcessorWorker:
|
||||
history_data_for_this_post = None
|
||||
|
||||
parsed_api_url = urlparse(self.api_url_input)
|
||||
referer_url = f"https://{parsed_api_url.netloc}/"
|
||||
headers = {'User-Agent': 'Mozilla/5.0', 'Referer': referer_url, 'Accept': '*/*'}
|
||||
post_data = self.post
|
||||
post_id = post_data.get('id', 'unknown_id')
|
||||
|
||||
post_page_url = f"https://{parsed_api_url.netloc}/{self.service}/user/{self.user_id}/post/{post_id}"
|
||||
headers = {'User-Agent': 'Mozilla/5.0', 'Referer': post_page_url, 'Accept': '*/*'}
|
||||
link_pattern = re.compile(r"""<a\s+.*?href=["'](https?://[^"']+)["'][^>]*>(.*?)</a>""", re.IGNORECASE | re.DOTALL)
|
||||
post_data = self.post
|
||||
post_title = post_data.get('title', '') or 'untitled_post'
|
||||
@@ -751,6 +859,17 @@ class PostProcessorWorker:
|
||||
|
||||
effective_unwanted_keywords_for_folder_naming = self.unwanted_keywords.copy()
|
||||
is_full_creator_download_no_char_filter = not self.target_post_id_from_initial_url and not current_character_filters
|
||||
|
||||
if (self.show_external_links or self.extract_links_only):
|
||||
embed_data = post_data.get('embed')
|
||||
if isinstance(embed_data, dict) and embed_data.get('url'):
|
||||
embed_url = embed_data['url']
|
||||
embed_subject = embed_data.get('subject', embed_url) # Use subject as link text, fallback to URL
|
||||
platform = get_link_platform(embed_url)
|
||||
|
||||
self.logger(f" 🔗 Found embed link: {embed_url}")
|
||||
self._emit_signal('external_link', post_title, embed_subject, embed_url, platform, "")
|
||||
|
||||
if is_full_creator_download_no_char_filter and self.creator_download_folder_ignore_words:
|
||||
self.logger(f" Applying creator download specific folder ignore words ({len(self.creator_download_folder_ignore_words)} words).")
|
||||
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
|
||||
@@ -789,8 +908,8 @@ class PostProcessorWorker:
|
||||
|
||||
all_files_from_post_api_for_char_check = []
|
||||
api_file_domain_for_char_check = urlparse(self.api_url_input).netloc
|
||||
if not api_file_domain_for_char_check or not any(d in api_file_domain_for_char_check.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
|
||||
api_file_domain_for_char_check = "kemono.su" if "kemono" in self.service.lower() else "coomer.party"
|
||||
if not api_file_domain_for_char_check or not any(d in api_file_domain_for_char_check.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
api_file_domain_for_char_check = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
||||
if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'):
|
||||
original_api_name = post_main_file_info.get('name') or os.path.basename(post_main_file_info['path'].lstrip('/'))
|
||||
if original_api_name:
|
||||
@@ -833,9 +952,9 @@ class PostProcessorWorker:
|
||||
try:
|
||||
parsed_input_url_for_comments = urlparse(self.api_url_input)
|
||||
api_domain_for_comments = parsed_input_url_for_comments.netloc
|
||||
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
|
||||
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
|
||||
api_domain_for_comments = "kemono.su" if "kemono" in self.service.lower() else "coomer.party"
|
||||
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
||||
comments_data = fetch_post_comments(
|
||||
api_domain_for_comments, self.service, self.user_id, post_id,
|
||||
headers, self.logger, self.cancellation_event, self.pause_event,
|
||||
@@ -996,7 +1115,10 @@ class PostProcessorWorker:
|
||||
determined_post_save_path_for_history = os.path.join(determined_post_save_path_for_history, base_folder_names_for_post_content[0])
|
||||
|
||||
if not self.extract_links_only and self.use_post_subfolders:
|
||||
cleaned_post_title_for_sub = clean_folder_name(post_title)
|
||||
cleaned_post_title_for_sub = robust_clean_name(post_title)
|
||||
max_folder_len = 100
|
||||
if len(cleaned_post_title_for_sub) > max_folder_len:
|
||||
cleaned_post_title_for_sub = cleaned_post_title_for_sub[:max_folder_len].strip()
|
||||
post_id_for_fallback = self.post.get('id', 'unknown_id')
|
||||
|
||||
if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder":
|
||||
@@ -1175,11 +1297,18 @@ class PostProcessorWorker:
|
||||
if FPDF:
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# If the application is run as a bundled exe, _MEIPASS is the temp folder
|
||||
base_path = sys._MEIPASS
|
||||
else:
|
||||
# If running as a normal .py script, use the project_root_dir
|
||||
base_path = self.project_root_dir
|
||||
|
||||
font_path = ""
|
||||
bold_font_path = ""
|
||||
if self.project_root_dir:
|
||||
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
if base_path:
|
||||
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
@@ -1312,9 +1441,8 @@ class PostProcessorWorker:
|
||||
|
||||
all_files_from_post_api = []
|
||||
api_file_domain = urlparse(self.api_url_input).netloc
|
||||
if not api_file_domain or not any(d in api_file_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
|
||||
api_file_domain = "kemono.su" if "kemono" in self.service.lower() else "coomer.party"
|
||||
|
||||
if not api_file_domain or not any(d in api_file_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
api_file_domain = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
||||
if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'):
|
||||
file_path = post_main_file_info['path'].lstrip('/')
|
||||
original_api_name = post_main_file_info.get('name') or os.path.basename(file_path)
|
||||
@@ -1572,7 +1700,7 @@ class PostProcessorWorker:
|
||||
self._download_single_file,
|
||||
file_info=file_info_to_dl,
|
||||
target_folder_path=current_path_for_file_instance,
|
||||
headers=headers, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag,
|
||||
post_page_url=post_page_url, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag,
|
||||
post_title=post_title, manga_date_file_counter_ref=manga_date_counter_to_pass,
|
||||
manga_global_file_counter_ref=manga_global_counter_to_pass, folder_context_name_for_history=folder_context_for_file,
|
||||
file_index_in_post=file_idx, num_files_in_this_post=len(files_to_download_info_list)
|
||||
@@ -1666,10 +1794,12 @@ class PostProcessorWorker:
|
||||
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
|
||||
path_to_check_for_emptiness = determined_post_save_path_for_history
|
||||
try:
|
||||
# Check if the path is a directory and if it's empty
|
||||
if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness):
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
|
||||
os.rmdir(path_to_check_for_emptiness)
|
||||
except OSError as e_rmdir:
|
||||
# Log if removal fails for any reason (e.g., permissions)
|
||||
self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
|
||||
result_tuple = (total_downloaded_this_post, total_skipped_this_post,
|
||||
@@ -1678,6 +1808,15 @@ class PostProcessorWorker:
|
||||
None)
|
||||
|
||||
finally:
|
||||
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
|
||||
path_to_check_for_emptiness = determined_post_save_path_for_history
|
||||
try:
|
||||
if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness):
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
|
||||
os.rmdir(path_to_check_for_emptiness)
|
||||
except OSError as e_rmdir:
|
||||
self.logger(f" ⚠️ Could not remove potentially empty subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
|
||||
return result_tuple
|
||||
@@ -1718,6 +1857,8 @@ class DownloadThread(QThread):
|
||||
remove_from_filename_words_list=None,
|
||||
manga_date_prefix='',
|
||||
allow_multipart_download=True,
|
||||
multipart_parts_count=4,
|
||||
multipart_min_size_mb=100,
|
||||
selected_cookie_file=None,
|
||||
override_output_dir=None,
|
||||
app_base_dir=None,
|
||||
@@ -1780,6 +1921,8 @@ class DownloadThread(QThread):
|
||||
self.remove_from_filename_words_list = remove_from_filename_words_list
|
||||
self.manga_date_prefix = manga_date_prefix
|
||||
self.allow_multipart_download = allow_multipart_download
|
||||
self.multipart_parts_count = multipart_parts_count
|
||||
self.multipart_min_size_mb = multipart_min_size_mb
|
||||
self.selected_cookie_file = selected_cookie_file
|
||||
self.app_base_dir = app_base_dir
|
||||
self.cookie_text = cookie_text
|
||||
@@ -1921,6 +2064,8 @@ class DownloadThread(QThread):
|
||||
'text_only_scope': self.text_only_scope,
|
||||
'text_export_format': self.text_export_format,
|
||||
'single_pdf_mode': self.single_pdf_mode,
|
||||
'multipart_parts_count': self.multipart_parts_count,
|
||||
'multipart_min_size_mb': self.multipart_min_size_mb,
|
||||
'project_root_dir': self.project_root_dir,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ import os
|
||||
import re
|
||||
import traceback
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||
|
||||
# --- Third-Party Library Imports ---
|
||||
import requests
|
||||
|
||||
try:
|
||||
from mega import Mega
|
||||
MEGA_AVAILABLE = True
|
||||
from Crypto.Cipher import AES
|
||||
PYCRYPTODOME_AVAILABLE = True
|
||||
except ImportError:
|
||||
MEGA_AVAILABLE = False
|
||||
PYCRYPTODOME_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import gdown
|
||||
@@ -19,17 +21,12 @@ try:
|
||||
except ImportError:
|
||||
GDRIVE_AVAILABLE = False
|
||||
|
||||
# --- Helper Functions ---
|
||||
MEGA_API_URL = "https://g.api.mega.co.nz"
|
||||
|
||||
def _get_filename_from_headers(headers):
|
||||
"""
|
||||
Extracts a filename from the Content-Disposition header.
|
||||
|
||||
Args:
|
||||
headers (dict): A dictionary of HTTP response headers.
|
||||
|
||||
Returns:
|
||||
str or None: The extracted filename, or None if not found.
|
||||
(This is from your original file and is kept for Dropbox downloads)
|
||||
"""
|
||||
cd = headers.get('content-disposition')
|
||||
if not cd:
|
||||
@@ -37,64 +34,180 @@ def _get_filename_from_headers(headers):
|
||||
|
||||
fname_match = re.findall('filename="?([^"]+)"?', cd)
|
||||
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())
|
||||
return sanitized_name
|
||||
|
||||
return None
|
||||
|
||||
# --- Main Service Downloader Functions ---
|
||||
# --- NEW: Helper functions for Mega decryption ---
|
||||
|
||||
def urlb64_to_b64(s):
|
||||
"""Converts a URL-safe base64 string to a standard base64 string."""
|
||||
s = s.replace('-', '+').replace('_', '/')
|
||||
s += '=' * (-len(s) % 4)
|
||||
return s
|
||||
|
||||
def b64_to_bytes(s):
|
||||
"""Decodes a URL-safe base64 string to bytes."""
|
||||
return base64.b64decode(urlb64_to_b64(s))
|
||||
|
||||
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:
|
||||
hex_raw_key = bytes_to_hex(b64_to_bytes(file_key))
|
||||
hex_key = hrk2hk(hex_raw_key)
|
||||
key_bytes = hex_to_bytes(hex_key)
|
||||
|
||||
# Request file attributes
|
||||
payload = [{"a": "g", "p": file_id}]
|
||||
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
|
||||
response.raise_for_status()
|
||||
res_json = response.json()
|
||||
|
||||
if isinstance(res_json, list) and isinstance(res_json[0], int) and res_json[0] < 0:
|
||||
logger_func(f" [Mega] ❌ API Error: {res_json[0]}. The link may be invalid or removed.")
|
||||
return None
|
||||
|
||||
file_size = res_json[0]['s']
|
||||
at_b64 = res_json[0]['at']
|
||||
|
||||
# Decrypt attributes to get the file name
|
||||
at_dec_json_str = decrypt_at(at_b64, key_bytes)
|
||||
at_dec_json = json.loads(at_dec_json_str)
|
||||
file_name = at_dec_json['n']
|
||||
|
||||
# Request the temporary download URL
|
||||
payload = [{"a": "g", "g": 1, "p": file_id}]
|
||||
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']
|
||||
|
||||
return {
|
||||
'file_name': file_name,
|
||||
'file_size': file_size,
|
||||
'dl_url': dl_temp_url,
|
||||
'hex_raw_key': hex_raw_key
|
||||
}
|
||||
except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
logger_func(f" [Mega] ❌ Failed to get file info: {e}")
|
||||
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'')
|
||||
|
||||
try:
|
||||
with requests.get(dl_url, stream=True, timeout=(15, 300)) as r:
|
||||
r.raise_for_status()
|
||||
downloaded_bytes = 0
|
||||
last_log_time = time.time()
|
||||
|
||||
with open(final_path, 'wb') as f:
|
||||
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)
|
||||
|
||||
# Log progress every second
|
||||
current_time = time.time()
|
||||
if current_time - last_log_time > 1:
|
||||
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:
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred during download/decryption: {e}")
|
||||
|
||||
|
||||
# --- 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.
|
||||
Handles both public links and links that include a decryption key.
|
||||
Downloads a file from a Mega.nz URL using direct requests and decryption.
|
||||
This replaces the old mega.py implementation.
|
||||
"""
|
||||
if not MEGA_AVAILABLE:
|
||||
logger_func("❌ Mega download failed: 'mega.py' library is not installed.")
|
||||
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 Mega client...")
|
||||
try:
|
||||
mega = Mega()
|
||||
# Anonymous login is sufficient for public links
|
||||
m = mega.login()
|
||||
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)
|
||||
|
||||
# --- MODIFIED PART: Added error handling for invalid links ---
|
||||
try:
|
||||
file_details = m.find(mega_url)
|
||||
if file_details is None:
|
||||
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
# This block catches the "Expecting value" error
|
||||
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
# Catch other potential errors from the mega.py library
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
|
||||
return
|
||||
# --- END OF MODIFIED PART ---
|
||||
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
|
||||
|
||||
filename = file_details[1]['a']['n']
|
||||
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
|
||||
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)
|
||||
|
||||
# Sanitize filename before saving
|
||||
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
|
||||
final_path = os.path.join(download_path, safe_filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(final_path):
|
||||
logger_func(f" [Mega] ℹ️ File '{safe_filename}' already exists. Skipping download.")
|
||||
return
|
||||
|
||||
# Start the download
|
||||
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
|
||||
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
|
||||
|
||||
except Exception as e:
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
|
||||
# --- 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."""
|
||||
@@ -103,12 +216,9 @@ def download_gdrive_file(url, download_path, logger_func=print):
|
||||
return
|
||||
try:
|
||||
logger_func(f" [G-Drive] Starting download for: {url}")
|
||||
# --- MODIFIED PART: Added a message and set quiet=True ---
|
||||
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
|
||||
|
||||
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
|
||||
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
|
||||
# --- END OF MODIFIED PART ---
|
||||
|
||||
if output_path and os.path.exists(output_path):
|
||||
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
|
||||
@@ -120,15 +230,9 @@ def download_gdrive_file(url, 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.
|
||||
|
||||
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}")
|
||||
|
||||
# Modify the Dropbox URL to force a direct download instead of showing the preview page.
|
||||
parsed_url = urlparse(dropbox_link)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
query_params['dl'] = ['1']
|
||||
@@ -145,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:
|
||||
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"
|
||||
full_save_path = os.path.join(download_path, filename)
|
||||
|
||||
logger_func(f" [Dropbox] Starting download of '{filename}'...")
|
||||
|
||||
# Write file to disk in chunks
|
||||
with open(full_save_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# --- Standard Library Imports ---
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
@@ -10,28 +11,49 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# --- Third-Party Library Imports ---
|
||||
import requests
|
||||
MULTIPART_DOWNLOADER_AVAILABLE = True
|
||||
|
||||
# --- Module Constants ---
|
||||
CHUNK_DOWNLOAD_RETRY_DELAY = 2
|
||||
MAX_CHUNK_DOWNLOAD_RETRIES = 1
|
||||
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(
|
||||
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,
|
||||
skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
|
||||
logger_func, emitter=None, api_original_filename=None
|
||||
):
|
||||
"""
|
||||
Downloads a single segment (chunk) of a larger file. This function is
|
||||
intended to be run in a separate thread by a ThreadPoolExecutor.
|
||||
Downloads a single segment (chunk) of a larger file to its own unique part file.
|
||||
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 ---
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
@@ -49,103 +71,135 @@ def _download_individual_chunk(
|
||||
time.sleep(0.2)
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
|
||||
|
||||
# Prepare headers for the specific byte range of this chunk
|
||||
chunk_headers = headers.copy()
|
||||
if end_byte != -1:
|
||||
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
|
||||
|
||||
bytes_this_chunk = 0
|
||||
last_speed_calc_time = time.time()
|
||||
bytes_at_last_speed_calc = 0
|
||||
# Set this chunk's status to 'active' before starting the download.
|
||||
with progress_data['lock']:
|
||||
progress_data['chunks_status'][part_num]['active'] = True
|
||||
|
||||
# --- Retry Loop ---
|
||||
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
return bytes_this_chunk, False
|
||||
try:
|
||||
# Prepare headers for the specific byte range of this chunk
|
||||
chunk_headers = headers.copy()
|
||||
if end_byte != -1:
|
||||
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
|
||||
|
||||
try:
|
||||
if attempt > 0:
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...")
|
||||
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
|
||||
last_speed_calc_time = time.time()
|
||||
bytes_at_last_speed_calc = bytes_this_chunk
|
||||
bytes_this_chunk = 0
|
||||
last_speed_calc_time = time.time()
|
||||
bytes_at_last_speed_calc = 0
|
||||
|
||||
logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
|
||||
|
||||
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
|
||||
response.raise_for_status()
|
||||
# --- Retry Loop ---
|
||||
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
return bytes_this_chunk, False
|
||||
|
||||
# --- Data Writing Loop ---
|
||||
with open(temp_file_path, 'r+b') as f:
|
||||
f.seek(start_byte)
|
||||
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
return bytes_this_chunk, False
|
||||
if pause_event and pause_event.is_set():
|
||||
# Handle pausing during the download stream
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
|
||||
time.sleep(0.2)
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
|
||||
try:
|
||||
if attempt > 0:
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...")
|
||||
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
|
||||
last_speed_calc_time = time.time()
|
||||
bytes_at_last_speed_calc = bytes_this_chunk
|
||||
|
||||
if data_segment:
|
||||
f.write(data_segment)
|
||||
bytes_this_chunk += len(data_segment)
|
||||
|
||||
# Update shared progress data structure
|
||||
with progress_data['lock']:
|
||||
progress_data['total_downloaded_so_far'] += len(data_segment)
|
||||
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
|
||||
|
||||
# Calculate and update speed for this chunk
|
||||
current_time = time.time()
|
||||
time_delta = current_time - last_speed_calc_time
|
||||
if time_delta > 0.5:
|
||||
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
|
||||
current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
|
||||
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
|
||||
last_speed_calc_time = current_time
|
||||
bytes_at_last_speed_calc = bytes_this_chunk
|
||||
|
||||
# Emit progress signal to the UI via the queue
|
||||
if emitter and (current_time - global_emit_time_ref[0] > 0.25):
|
||||
global_emit_time_ref[0] = current_time
|
||||
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
|
||||
if isinstance(emitter, queue.Queue):
|
||||
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
|
||||
elif hasattr(emitter, 'file_progress_signal'):
|
||||
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
|
||||
|
||||
# If we reach here, the download for this chunk was successful
|
||||
return bytes_this_chunk, True
|
||||
logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
|
||||
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
|
||||
return bytes_this_chunk, False # Break loop on non-retryable errors
|
||||
except Exception as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
|
||||
return bytes_this_chunk, False
|
||||
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
|
||||
response.raise_for_status()
|
||||
|
||||
return bytes_this_chunk, False
|
||||
# --- Data Writing Loop ---
|
||||
# We open the unique chunk file in write-binary ('wb') mode.
|
||||
# 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):
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
return bytes_this_chunk, False
|
||||
if pause_event and pause_event.is_set():
|
||||
# Handle pausing during the download stream
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
|
||||
time.sleep(0.2)
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
|
||||
|
||||
if data_segment:
|
||||
f.write(data_segment)
|
||||
bytes_this_chunk += len(data_segment)
|
||||
|
||||
# Update shared progress data structure
|
||||
with progress_data['lock']:
|
||||
progress_data['total_downloaded_so_far'] += len(data_segment)
|
||||
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
|
||||
|
||||
# Calculate and update speed for this chunk
|
||||
current_time = time.time()
|
||||
time_delta = current_time - last_speed_calc_time
|
||||
if time_delta > 0.5:
|
||||
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
|
||||
current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
|
||||
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
|
||||
last_speed_calc_time = current_time
|
||||
bytes_at_last_speed_calc = bytes_this_chunk
|
||||
|
||||
# Emit progress signal to the UI via the queue
|
||||
if emitter and (current_time - global_emit_time_ref[0] > 0.25):
|
||||
global_emit_time_ref[0] = current_time
|
||||
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
|
||||
if isinstance(emitter, queue.Queue):
|
||||
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
|
||||
elif hasattr(emitter, 'file_progress_signal'):
|
||||
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
|
||||
|
||||
# If we get here, the download for this chunk is successful
|
||||
return bytes_this_chunk, True
|
||||
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
|
||||
return bytes_this_chunk, False # Break loop on non-retryable errors
|
||||
except Exception as e:
|
||||
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
|
||||
return bytes_this_chunk, False
|
||||
|
||||
# If the retry loop finishes without a successful download
|
||||
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,
|
||||
emitter_for_multipart, cookies_for_chunk_session,
|
||||
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:
|
||||
with open(temp_file_path, 'wb') as f_temp:
|
||||
if total_size > 0:
|
||||
f_temp.truncate(total_size)
|
||||
except IOError as e:
|
||||
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}")
|
||||
return False, 0, None, None
|
||||
This function orchestrates the download process by:
|
||||
1. Checking for already completed chunk files to resume a previous download.
|
||||
2. Submitting only the missing chunks to a thread pool for parallel download.
|
||||
3. Assembling the final file from the individual chunks upon successful completion.
|
||||
4. Cleaning up temporary chunk files after assembly.
|
||||
5. Leaving completed chunks on disk if the download fails, allowing for a future resume.
|
||||
|
||||
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
|
||||
chunks_ranges = []
|
||||
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
|
||||
if 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))
|
||||
|
||||
# Calculate the expected size of each chunk
|
||||
chunk_actual_sizes = []
|
||||
for start, end in chunks_ranges:
|
||||
if end == -1 and start == 0:
|
||||
chunk_actual_sizes.append(0)
|
||||
else:
|
||||
chunk_actual_sizes.append(end - start + 1)
|
||||
chunk_actual_sizes.append(end - start + 1 if end != -1 else 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.")
|
||||
if os.path.exists(temp_file_path): os.remove(temp_file_path)
|
||||
logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting.")
|
||||
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 = {
|
||||
'total_file_size': total_size,
|
||||
'total_downloaded_so_far': 0,
|
||||
'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)
|
||||
],
|
||||
'total_downloaded_so_far': total_bytes_resumed,
|
||||
'chunks_status': [],
|
||||
'lock': threading.Lock(),
|
||||
'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 = []
|
||||
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:
|
||||
for i, (start, end) in enumerate(chunks_ranges):
|
||||
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
|
||||
chunk_futures.append(chunk_pool.submit(
|
||||
_download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path,
|
||||
for chunk_info in chunks_to_download:
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
all_chunks_successful = False
|
||||
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,
|
||||
progress_data=progress_data, cancellation_event=cancellation_event, 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,
|
||||
progress_data=progress_data, cancellation_event=cancellation_event,
|
||||
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
|
||||
))
|
||||
)
|
||||
chunk_futures.append(future)
|
||||
|
||||
for future in as_completed(chunk_futures):
|
||||
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
|
||||
bytes_downloaded_this_chunk, success_this_chunk = future.result()
|
||||
total_bytes_from_chunks += bytes_downloaded_this_chunk
|
||||
if not success_this_chunk:
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
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():
|
||||
logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
|
||||
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):
|
||||
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}")
|
||||
# --- Assembly and Cleanup Phase ---
|
||||
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()
|
||||
with open(temp_file_path, 'rb') as f_hash:
|
||||
for buf in iter(lambda: f_hash.read(4096*10), b''):
|
||||
md5_hasher.update(buf)
|
||||
calculated_hash = md5_hasher.hexdigest()
|
||||
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb')
|
||||
try:
|
||||
with open(save_path, 'wb') as final_file:
|
||||
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()
|
||||
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:
|
||||
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 os.path.exists(temp_file_path):
|
||||
try: os.remove(temp_file_path)
|
||||
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
|
||||
# If download failed, we do NOT clean up, allowing for resumption later
|
||||
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.")
|
||||
return False, total_bytes_final, None, None
|
||||
|
||||
@@ -960,15 +960,16 @@ 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 }.")
|
||||
|
||||
# --- 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 :
|
||||
self .parent_app .link_input .blockSignals (True )
|
||||
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 .link_input .blockSignals (False )
|
||||
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 )
|
||||
)
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
self.selected_creators_for_queue.clear()
|
||||
|
||||
@@ -989,9 +990,6 @@ class EmptyPopupDialog (QDialog ):
|
||||
self .add_selected_button .setEnabled (True )
|
||||
self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_domain_for_service (self ,service_name ):
|
||||
"""Determines the base domain for a given service."""
|
||||
service_lower =service_name .lower ()
|
||||
|
||||
@@ -37,13 +37,13 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
self ._init_ui ()
|
||||
self ._fetch_favorite_artists ()
|
||||
|
||||
def _get_domain_for_service (self ,service_name ):
|
||||
service_lower =service_name .lower ()
|
||||
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
|
||||
if service_lower in coomer_primary_services :
|
||||
return "coomer.su"
|
||||
else :
|
||||
return "kemono.su"
|
||||
def _get_domain_for_service(self, service_name):
|
||||
service_lower = service_name.lower()
|
||||
coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans'}
|
||||
if service_lower in coomer_primary_services:
|
||||
return "coomer.st" # Use the new domain
|
||||
else:
|
||||
return "kemono.cr" # Use the new domain
|
||||
|
||||
def _tr (self ,key ,default_text =""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
@@ -128,9 +128,29 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
def _fetch_favorite_artists (self ):
|
||||
|
||||
if self.cookies_config['use_cookie']:
|
||||
# Check if we can load cookies for at least one of the services.
|
||||
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
|
||||
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
|
||||
# --- 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.
|
||||
@@ -139,7 +159,7 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
|
||||
cookie_help_dialog.exec_()
|
||||
self.download_button.setEnabled(False)
|
||||
return # Stop further execution
|
||||
return # Stop further execution
|
||||
|
||||
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
|
||||
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
|
||||
@@ -149,9 +169,12 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
errors_occurred =[]
|
||||
any_cookies_loaded_successfully_for_any_source =False
|
||||
|
||||
api_sources =[
|
||||
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
|
||||
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
|
||||
kemono_cr_fav_url = "https://kemono.cr/api/v1/account/favorites?type=artist"
|
||||
coomer_st_fav_url = "https://coomer.st/api/v1/account/favorites?type=artist"
|
||||
|
||||
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 :
|
||||
@@ -159,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']))
|
||||
QCoreApplication .processEvents ()
|
||||
|
||||
cookies_dict_for_source =None
|
||||
if self .cookies_config ['use_cookie']:
|
||||
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 =source ['domain']
|
||||
cookies_dict_for_source = None
|
||||
if self.cookies_config['use_cookie']:
|
||||
primary_domain = source['domain']
|
||||
fallback_domain = None
|
||||
if primary_domain == "kemono.cr":
|
||||
fallback_domain = "kemono.su"
|
||||
elif primary_domain == "coomer.st":
|
||||
fallback_domain = "coomer.su"
|
||||
|
||||
# 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
|
||||
else :
|
||||
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
|
||||
|
||||
# If no cookies found, try the fallback domain
|
||||
if not cookies_dict_for_source and fallback_domain:
|
||||
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 :
|
||||
headers ={'User-Agent':'Mozilla/5.0'}
|
||||
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
|
||||
@@ -223,7 +267,7 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
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 ._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_ ()
|
||||
self .download_button .setEnabled (False )
|
||||
if not fetched_any_successfully :
|
||||
|
||||
@@ -34,28 +34,30 @@ class FavoritePostsFetcherThread (QThread ):
|
||||
self .target_domain_preference =target_domain_preference
|
||||
self .cancellation_event =threading .Event ()
|
||||
self .error_key_map ={
|
||||
"Kemono.su":"kemono_su",
|
||||
"Coomer.su":"coomer_su"
|
||||
"kemono.cr":"kemono_su",
|
||||
"coomer.st":"coomer_su"
|
||||
}
|
||||
|
||||
def _logger (self ,message ):
|
||||
self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
|
||||
|
||||
def run (self ):
|
||||
kemono_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"
|
||||
def run(self):
|
||||
kemono_su_fav_posts_url = "https://kemono.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 =[]
|
||||
error_messages_for_summary =[]
|
||||
fetched_any_successfully =False
|
||||
any_cookies_loaded_successfully_for_any_source =False
|
||||
all_fetched_posts_temp = []
|
||||
error_messages_for_summary = []
|
||||
fetched_any_successfully = False
|
||||
any_cookies_loaded_successfully_for_any_source = False
|
||||
|
||||
self .status_update .emit ("key_fetching_fav_post_list_init")
|
||||
self .progress_bar_update .emit (0 ,0 )
|
||||
self.status_update.emit("key_fetching_fav_post_list_init")
|
||||
self.progress_bar_update.emit(0, 0)
|
||||
|
||||
api_sources =[
|
||||
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
|
||||
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
|
||||
api_sources = [
|
||||
{"name": "Kemono.cr", "url": kemono_cr_fav_posts_url, "domain": "kemono.cr"},
|
||||
{"name": "Coomer.st", "url": coomer_st_fav_posts_url, "domain": "coomer.st"}
|
||||
]
|
||||
|
||||
api_sources_to_try =[]
|
||||
@@ -76,20 +78,41 @@ class FavoritePostsFetcherThread (QThread ):
|
||||
if self .cancellation_event .is_set ():
|
||||
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
|
||||
return
|
||||
cookies_dict_for_source =None
|
||||
if self .cookies_config ['use_cookie']:
|
||||
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 =source ['domain']
|
||||
cookies_dict_for_source = None
|
||||
if self.cookies_config['use_cookie']:
|
||||
primary_domain = source['domain']
|
||||
fallback_domain = None
|
||||
if primary_domain == "kemono.cr":
|
||||
fallback_domain = "kemono.su"
|
||||
elif primary_domain == "coomer.st":
|
||||
fallback_domain = "coomer.su"
|
||||
|
||||
# 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
|
||||
else :
|
||||
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
|
||||
|
||||
# If no cookies found, try the fallback domain
|
||||
if not cookies_dict_for_source and fallback_domain:
|
||||
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']})")
|
||||
source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
|
||||
@@ -409,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":
|
||||
status_label_text_key ="fav_posts_cookies_required_error"
|
||||
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_ ()
|
||||
elif status_key =="KEY_AUTH_FAILED":
|
||||
status_label_text_key ="fav_posts_auth_failed_title"
|
||||
self ._logger (f"Auth error: {status_key }. Showing help dialog.")
|
||||
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 ))
|
||||
cookie_help_dialog =CookieHelpDialog (self )
|
||||
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
|
||||
cookie_help_dialog .exec_ ()
|
||||
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
|
||||
status_label_text_key ="fav_posts_no_posts_found_status"
|
||||
|
||||
@@ -15,7 +15,8 @@ from ...utils.resolution import get_dark_theme
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...config.constants import (
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
|
||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
||||
COOKIE_TEXT_KEY, USE_COOKIE_KEY
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +90,9 @@ class FutureSettingsDialog(QDialog):
|
||||
# Default Path
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_download_path)
|
||||
# --- START: MODIFIED LOGIC ---
|
||||
self.save_path_button.clicked.connect(self._save_cookie_and_path)
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
@@ -143,11 +146,13 @@ class FutureSettingsDialog(QDialog):
|
||||
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_path_button", "Save Current Download Path"))
|
||||
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
|
||||
self.save_path_button.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"))
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
# Populate dropdowns
|
||||
self._populate_display_combo_boxes()
|
||||
@@ -275,22 +280,43 @@ class FutureSettingsDialog(QDialog):
|
||||
if msg_box.clickedButton() == restart_button:
|
||||
self.parent_app._request_restart_application()
|
||||
|
||||
def _save_download_path(self):
|
||||
def _save_cookie_and_path(self):
|
||||
"""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:
|
||||
current_path = self.parent_app.dir_input.text().strip()
|
||||
if current_path and os.path.isdir(current_path):
|
||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||
self.parent_app.settings.sync()
|
||||
QMessageBox.information(self,
|
||||
self._tr("settings_save_path_success_title", "Path Saved"),
|
||||
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
|
||||
elif not current_path:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_empty_title", "Empty Path"),
|
||||
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_invalid_title", "Invalid Path"),
|
||||
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
|
||||
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()
|
||||
|
||||
# --- User Feedback ---
|
||||
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:
|
||||
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")
|
||||
QMessageBox.warning(self, self._tr("settings_save_nothing_title", "Nothing to Save"),
|
||||
self._tr("settings_save_nothing_message", "The download location is not a valid directory and no cookie was active."))
|
||||
return
|
||||
|
||||
QMessageBox.information(self, self._tr("settings_save_success_title", "Settings Saved"), message)
|
||||
|
||||
@@ -4,7 +4,7 @@ from PyQt5.QtCore import QUrl, QSize, Qt
|
||||
from PyQt5.QtGui import QIcon, QDesktopServices
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QScrollArea, QFrame, QWidget
|
||||
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
|
||||
)
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
@@ -46,13 +46,12 @@ class TourStepWidget(QWidget):
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
|
||||
class HelpGuideDialog (QDialog ):
|
||||
"""A multi-page dialog for displaying the feature guide."""
|
||||
def __init__ (self ,steps_data ,parent_app ,parent =None ):
|
||||
super ().__init__ (parent )
|
||||
self .current_step =0
|
||||
self .steps_data =steps_data
|
||||
self .parent_app =parent_app
|
||||
class HelpGuideDialog(QDialog):
|
||||
"""A multi-page dialog for displaying the feature guide with a navigation list."""
|
||||
def __init__(self, steps_data, parent_app, parent=None):
|
||||
super().__init__(parent)
|
||||
self.steps_data = steps_data
|
||||
self.parent_app = parent_app
|
||||
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
@@ -61,7 +60,7 @@ class HelpGuideDialog (QDialog ):
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
self.setModal(True)
|
||||
self.resize(int(650 * scale), int(600 * scale))
|
||||
self.resize(int(800 * scale), int(650 * scale))
|
||||
|
||||
dialog_font_size = int(11 * scale)
|
||||
|
||||
@@ -69,6 +68,7 @@ class HelpGuideDialog (QDialog ):
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
else:
|
||||
# Basic light theme fallback
|
||||
current_theme_style = f"""
|
||||
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
|
||||
QLabel {{ color: #1E1E1E; }}
|
||||
@@ -86,118 +86,107 @@ class HelpGuideDialog (QDialog ):
|
||||
"""
|
||||
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self ._init_ui ()
|
||||
if self .parent_app :
|
||||
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
|
||||
self._init_ui()
|
||||
if self.parent_app:
|
||||
self.move(self.parent_app.geometry().center() - self.rect().center())
|
||||
|
||||
def _tr (self ,key ,default_text =""):
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable (get_translation )and self .parent_app :
|
||||
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
|
||||
return default_text
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, 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 ):
|
||||
main_layout =QVBoxLayout (self )
|
||||
main_layout .setContentsMargins (0 ,0 ,0 ,0 )
|
||||
main_layout .setSpacing (0 )
|
||||
# Title
|
||||
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1.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 ()
|
||||
main_layout .addWidget (self .stacked_widget ,1 )
|
||||
# Content Layout (Navigation + Stacked Pages)
|
||||
content_layout = QHBoxLayout()
|
||||
main_layout.addLayout(content_layout, 1)
|
||||
|
||||
self .tour_steps_widgets =[]
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
for title, content in self.steps_data:
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.tour_steps_widgets.append(step_widget)
|
||||
self.nav_list = QListWidget()
|
||||
self.nav_list.setFixedWidth(int(220 * scale))
|
||||
self.nav_list.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
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.stacked_widget = QStackedWidget()
|
||||
content_layout.addWidget(self.stacked_widget)
|
||||
|
||||
for title_key, content_key in self.steps_data:
|
||||
title = self._tr(title_key, title_key)
|
||||
content = self._tr(content_key, f"Content for {content_key} not found.")
|
||||
|
||||
self.nav_list.addItem(title)
|
||||
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
|
||||
self.nav_list.currentRowChanged.connect(self.stacked_widget.setCurrentIndex)
|
||||
if self.nav_list.count() > 0:
|
||||
self.nav_list.setCurrentRow(0)
|
||||
|
||||
buttons_layout =QHBoxLayout ()
|
||||
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
|
||||
buttons_layout .setSpacing (10 )
|
||||
# Footer Layout (Social links and Close button)
|
||||
footer_layout = QHBoxLayout()
|
||||
footer_layout.setContentsMargins(0, 10, 0, 0)
|
||||
|
||||
# Social Media Icons
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
assets_base_dir = sys._MEIPASS
|
||||
else:
|
||||
assets_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
|
||||
self .back_button .clicked .connect (self ._previous_step )
|
||||
self .back_button .setEnabled (False )
|
||||
github_icon_path = os.path.join(assets_base_dir, "assets", "github.png")
|
||||
instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png")
|
||||
discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png")
|
||||
|
||||
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
|
||||
assets_base_dir =sys ._MEIPASS
|
||||
else :
|
||||
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
self.github_button = QPushButton(QIcon(github_icon_path), "")
|
||||
self.instagram_button = QPushButton(QIcon(instagram_icon_path), "")
|
||||
self.discord_button = QPushButton(QIcon(discord_icon_path), "")
|
||||
|
||||
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
|
||||
instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
|
||||
discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
|
||||
|
||||
self .github_button =QPushButton (QIcon (github_icon_path ),"")
|
||||
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
|
||||
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
|
||||
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
self .github_button .setIconSize (icon_size )
|
||||
self .instagram_button .setIconSize (icon_size )
|
||||
self .Discord_button .setIconSize (icon_size )
|
||||
|
||||
for button, tooltip_key, url in [
|
||||
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi9587"),
|
||||
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
|
||||
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
|
||||
]:
|
||||
button.setIconSize(icon_size)
|
||||
button.setToolTip(self._tr(tooltip_key))
|
||||
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
|
||||
button.setStyleSheet("background-color: transparent; border: none;")
|
||||
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
|
||||
footer_layout.addWidget(button)
|
||||
|
||||
self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
|
||||
self .next_button .clicked .connect (self ._next_step_action )
|
||||
self .next_button .setDefault (True )
|
||||
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)"))
|
||||
footer_layout.addStretch(1)
|
||||
|
||||
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
|
||||
self.finish_button.clicked.connect(self.accept)
|
||||
footer_layout.addWidget(self.finish_button)
|
||||
|
||||
social_layout =QHBoxLayout ()
|
||||
social_layout .setSpacing (10 )
|
||||
social_layout .addWidget (self .github_button )
|
||||
social_layout .addWidget (self .instagram_button )
|
||||
social_layout .addWidget (self .Discord_button )
|
||||
|
||||
while buttons_layout .count ():
|
||||
item =buttons_layout .takeAt (0 )
|
||||
if item .widget ():
|
||||
item .widget ().setParent (None )
|
||||
elif item .layout ():
|
||||
pass
|
||||
buttons_layout .addLayout (social_layout )
|
||||
buttons_layout .addStretch (1 )
|
||||
buttons_layout .addWidget (self .back_button )
|
||||
buttons_layout .addWidget (self .next_button )
|
||||
main_layout .addLayout (buttons_layout )
|
||||
self ._update_button_states ()
|
||||
|
||||
def _next_step_action (self ):
|
||||
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 ):
|
||||
if self .current_step >0 :
|
||||
self .current_step -=1
|
||||
self .stacked_widget .setCurrentIndex (self .current_step )
|
||||
self ._update_button_states ()
|
||||
|
||||
def _update_button_states (self ):
|
||||
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"))
|
||||
main_layout.addLayout(footer_layout)
|
||||
@@ -24,7 +24,7 @@ class MoreOptionsDialog(QDialog):
|
||||
layout.addWidget(self.description_label)
|
||||
self.radio_button_group = QButtonGroup(self)
|
||||
self.radio_content = QRadioButton("Description/Content")
|
||||
self.radio_comments = QRadioButton("Comments (Not Working)")
|
||||
self.radio_comments = QRadioButton("Comments")
|
||||
self.radio_button_group.addButton(self.radio_content)
|
||||
self.radio_button_group.addButton(self.radio_comments)
|
||||
layout.addWidget(self.radio_content)
|
||||
|
||||
118
src/ui/dialogs/MultipartScopeDialog.py
Normal file
118
src/ui/dialogs/MultipartScopeDialog.py
Normal 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
|
||||
@@ -3,8 +3,27 @@ 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:
|
||||
@@ -12,19 +31,6 @@ def strip_html_tags(text):
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
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')
|
||||
|
||||
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.
|
||||
@@ -68,7 +74,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
@@ -89,7 +95,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
pdf.multi_cell(w=0, h=7, txt=body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
pdf.ln(3)
|
||||
@@ -97,7 +103,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
@@ -105,4 +111,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -58,6 +58,7 @@ from .dialogs.MoreOptionsDialog import MoreOptionsDialog
|
||||
from .dialogs.SinglePDF import create_single_pdf_from_content
|
||||
from .dialogs.SupportDialog import SupportDialog
|
||||
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
|
||||
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
|
||||
|
||||
class DynamicFilterHolder:
|
||||
"""A thread-safe class to hold and update character filters during a download."""
|
||||
@@ -105,6 +106,7 @@ class DownloaderApp (QWidget ):
|
||||
self.active_update_profile = None
|
||||
self.new_posts_for_update = []
|
||||
self.is_finishing = False
|
||||
self.finish_lock = threading.Lock()
|
||||
|
||||
saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
|
||||
if saved_res != "Auto":
|
||||
@@ -221,6 +223,9 @@ class DownloaderApp (QWidget ):
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.allow_multipart_download_setting = False
|
||||
self.multipart_scope = 'both'
|
||||
self.multipart_parts_count = 4
|
||||
self.multipart_min_size_mb = 100
|
||||
self.use_cookie_setting = False
|
||||
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
|
||||
self.cookie_text_setting = ""
|
||||
@@ -235,7 +240,8 @@ class DownloaderApp (QWidget ):
|
||||
self.session_temp_files = []
|
||||
self.single_pdf_mode = False
|
||||
self.save_creator_json_enabled_this_session = True
|
||||
|
||||
self.is_single_post_session = False
|
||||
|
||||
print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}")
|
||||
|
||||
try:
|
||||
@@ -266,7 +272,7 @@ class DownloaderApp (QWidget ):
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v6.2.0")
|
||||
self.setWindowTitle("Kemono Downloader v6.3.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
self.log_signal.emit("ℹ️ Local API server functionality has been removed.")
|
||||
@@ -284,6 +290,7 @@ class DownloaderApp (QWidget ):
|
||||
self._retranslate_main_ui()
|
||||
self._load_persistent_history()
|
||||
self._load_saved_download_location()
|
||||
self._load_saved_cookie_settings()
|
||||
self._update_button_states_and_connections()
|
||||
self._check_for_interrupted_session()
|
||||
|
||||
@@ -1016,23 +1023,42 @@ class DownloaderApp (QWidget ):
|
||||
def save_known_names(self):
|
||||
"""
|
||||
Saves the current list of known names (KNOWN_NAMES) to the config file.
|
||||
This version includes a fix to ensure the destination directory exists
|
||||
before attempting to write the file, preventing crashes in new installations.
|
||||
FIX: This version re-reads the file from disk before saving to preserve
|
||||
any external edits made by the user.
|
||||
"""
|
||||
global KNOWN_NAMES
|
||||
try:
|
||||
config_dir = os.path.dirname(self.config_file)
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
if os.path.exists(self.config_file):
|
||||
self.log_signal.emit("ℹ️ Re-reading Known.txt before saving to check for external edits...")
|
||||
disk_names = set()
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
disk_names.add(line.strip())
|
||||
|
||||
for entry in KNOWN_NAMES:
|
||||
if entry["is_group"]:
|
||||
f.write(f"({', '.join(sorted(entry['aliases'], key=str.lower))})\n")
|
||||
disk_names.add(f"({', '.join(sorted(entry['aliases'], key=str.lower))})")
|
||||
else:
|
||||
f.write(entry["name"] + '\n')
|
||||
disk_names.add(entry["name"])
|
||||
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
for name in sorted(list(disk_names), key=str.lower):
|
||||
if name:
|
||||
f.write(name + '\n')
|
||||
|
||||
else:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
for entry in KNOWN_NAMES:
|
||||
if entry["is_group"]:
|
||||
f.write(f"({', '.join(sorted(entry['aliases'], key=str.lower))})\n")
|
||||
else:
|
||||
f.write(entry["name"] + '\n')
|
||||
|
||||
if hasattr(self, 'log_signal'):
|
||||
self.log_signal.emit(f"💾 Saved {len(KNOWN_NAMES)} known entries to {self.config_file}")
|
||||
self.log_signal.emit(f"💾 Saved known entries to {self.config_file}")
|
||||
|
||||
except Exception as e:
|
||||
log_msg = f"❌ Error saving config '{self.config_file}': {e}"
|
||||
@@ -1570,6 +1596,31 @@ class DownloaderApp (QWidget ):
|
||||
QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }")
|
||||
|
||||
def handle_main_log(self, message):
|
||||
if isinstance(message, str) and message.startswith("MANGA_FETCH_PROGRESS:"):
|
||||
try:
|
||||
parts = message.split(":")
|
||||
fetched_count = int(parts[1])
|
||||
page_num = int(parts[2])
|
||||
self.progress_label.setText(self._tr("progress_fetching_manga_pages", "Progress: Fetching Page {page} ({count} posts found)...").format(page=page_num, count=fetched_count))
|
||||
QCoreApplication.processEvents()
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
fetched_count = int(message.split(":")[1])
|
||||
self.progress_label.setText(self._tr("progress_fetching_manga_posts", "Progress: Fetching Manga Posts ({count})...").format(count=fetched_count))
|
||||
QCoreApplication.processEvents()
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return
|
||||
elif isinstance(message, str) and message.startswith("MANGA_FETCH_COMPLETE:"):
|
||||
try:
|
||||
total_posts = int(message.split(":")[1])
|
||||
self.total_posts_to_process = total_posts
|
||||
self.processed_posts_count = 0
|
||||
self.update_progress_display(self.total_posts_to_process, self.processed_posts_count)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return
|
||||
|
||||
if message.startswith("TEMP_FILE_PATH:"):
|
||||
filepath = message.split(":", 1)[1]
|
||||
if self.single_pdf_setting:
|
||||
@@ -2561,23 +2612,42 @@ class DownloaderApp (QWidget ):
|
||||
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
|
||||
|
||||
def _toggle_manga_filename_style (self ):
|
||||
current_style =self .manga_filename_style
|
||||
new_style =""
|
||||
if current_style ==STYLE_POST_TITLE :
|
||||
new_style =STYLE_ORIGINAL_NAME
|
||||
elif current_style ==STYLE_ORIGINAL_NAME :
|
||||
new_style =STYLE_DATE_POST_TITLE
|
||||
elif current_style ==STYLE_DATE_POST_TITLE :
|
||||
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
|
||||
new_style =STYLE_DATE_BASED
|
||||
elif current_style ==STYLE_DATE_BASED :
|
||||
new_style =STYLE_POST_ID # Change this line
|
||||
elif current_style ==STYLE_POST_ID: # Add this block
|
||||
new_style =STYLE_POST_TITLE
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
|
||||
new_style =STYLE_POST_TITLE
|
||||
url_text = self.link_input.text().strip() if self.link_input else ""
|
||||
_, _, post_id = extract_post_info(url_text)
|
||||
is_single_post = bool(post_id)
|
||||
|
||||
current_style = self.manga_filename_style
|
||||
new_style = ""
|
||||
|
||||
if is_single_post:
|
||||
# Cycle through a limited set of styles suitable for single posts
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
new_style = STYLE_DATE_POST_TITLE
|
||||
elif current_style == STYLE_DATE_POST_TITLE:
|
||||
new_style = STYLE_ORIGINAL_NAME
|
||||
elif current_style == STYLE_ORIGINAL_NAME:
|
||||
new_style = STYLE_POST_ID
|
||||
elif current_style == STYLE_POST_ID:
|
||||
new_style = STYLE_POST_TITLE
|
||||
else: # Fallback for any other style
|
||||
new_style = STYLE_POST_TITLE
|
||||
else:
|
||||
# Original cycling logic for creator feeds
|
||||
if current_style ==STYLE_POST_TITLE :
|
||||
new_style =STYLE_ORIGINAL_NAME
|
||||
elif current_style ==STYLE_ORIGINAL_NAME :
|
||||
new_style =STYLE_DATE_POST_TITLE
|
||||
elif current_style ==STYLE_DATE_POST_TITLE :
|
||||
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
|
||||
new_style =STYLE_DATE_BASED
|
||||
elif current_style ==STYLE_DATE_BASED :
|
||||
new_style =STYLE_POST_ID
|
||||
elif current_style ==STYLE_POST_ID:
|
||||
new_style =STYLE_POST_TITLE
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
|
||||
new_style =STYLE_POST_TITLE
|
||||
|
||||
self .manga_filename_style =new_style
|
||||
self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
|
||||
@@ -2644,15 +2714,26 @@ class DownloaderApp (QWidget ):
|
||||
_ ,_ ,post_id =extract_post_info (url_text )
|
||||
|
||||
is_creator_feed =not post_id if url_text else False
|
||||
is_single_post = bool(post_id)
|
||||
is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
|
||||
|
||||
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue):
|
||||
is_single_post = True
|
||||
|
||||
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on
|
||||
|
||||
if self .manga_mode_checkbox :
|
||||
self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on )
|
||||
if not is_creator_feed and self .manga_mode_checkbox .isChecked ():
|
||||
self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox)
|
||||
if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked ():
|
||||
self .manga_mode_checkbox .setChecked (False )
|
||||
checked =self .manga_mode_checkbox .isChecked ()
|
||||
|
||||
manga_mode_effectively_on =is_creator_feed and checked
|
||||
manga_mode_effectively_on = can_enable_manga_checkbox and checked
|
||||
|
||||
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
if is_single_post and self.manga_filename_style in sequential_styles:
|
||||
self.manga_filename_style = STYLE_POST_TITLE
|
||||
self._update_manga_filename_style_button_text()
|
||||
|
||||
if self .manga_rename_toggle_button :
|
||||
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
|
||||
@@ -2686,11 +2767,9 @@ class DownloaderApp (QWidget ):
|
||||
self .manga_date_prefix_input .setMaximumWidth (16777215 )
|
||||
self .manga_date_prefix_input .setMinimumWidth (0 )
|
||||
|
||||
if hasattr (self ,'multipart_toggle_button'):
|
||||
|
||||
hide_multipart_button_due_mode =is_only_links_mode or is_only_archives_mode or is_only_audio_mode
|
||||
hide_multipart_button_due_manga_mode =manga_mode_effectively_on
|
||||
self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode ))
|
||||
if hasattr(self, 'multipart_toggle_button'):
|
||||
hide_multipart_button_due_mode = is_only_links_mode or is_only_archives_mode or is_only_audio_mode
|
||||
self.multipart_toggle_button.setVisible(not hide_multipart_button_due_mode)
|
||||
|
||||
self ._update_multithreading_for_date_mode ()
|
||||
|
||||
@@ -2762,7 +2841,9 @@ class DownloaderApp (QWidget ):
|
||||
if total_posts >0 or processed_posts >0 :
|
||||
self .file_progress_label .setText ("")
|
||||
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False):
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
|
||||
self.finish_lock = threading.Lock()
|
||||
self.is_finishing = False
|
||||
if self.active_update_profile:
|
||||
if not self.new_posts_for_update:
|
||||
return self._check_for_updates()
|
||||
@@ -2888,46 +2969,67 @@ class DownloaderApp (QWidget ):
|
||||
self.cancellation_message_logged_this_session = False
|
||||
|
||||
service, user_id, post_id_from_url = extract_post_info(api_url)
|
||||
|
||||
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
|
||||
self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}")
|
||||
self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
|
||||
self.download_finished(
|
||||
total_downloaded=0,
|
||||
total_skipped=1,
|
||||
cancelled_by_user=False,
|
||||
kept_original_names_list=[]
|
||||
)
|
||||
return False
|
||||
|
||||
if not service or not user_id:
|
||||
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
|
||||
return False
|
||||
|
||||
# Read the setting at the start of the download
|
||||
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||
self.is_single_post_session = bool(post_id_from_url)
|
||||
|
||||
profile_processed_ids = set() # Default to an empty set
|
||||
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
# --- CREATOR PROFILE LOGIC ---
|
||||
creator_name_for_profile = None
|
||||
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
|
||||
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
|
||||
else:
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name_for_profile = self.creator_name_cache.get(creator_key)
|
||||
if not self.is_single_post_session:
|
||||
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||
|
||||
if not creator_name_for_profile:
|
||||
creator_name_for_profile = f"{service}_{user_id}"
|
||||
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
|
||||
creator_profile_data = {}
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
creator_name_for_profile = None
|
||||
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
|
||||
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
|
||||
else:
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name_for_profile = self.creator_name_cache.get(creator_key)
|
||||
|
||||
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
|
||||
if not creator_name_for_profile:
|
||||
creator_name_for_profile = f"{service}_{user_id}"
|
||||
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
|
||||
|
||||
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
|
||||
|
||||
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
|
||||
creator_profile_data['settings'] = current_settings
|
||||
|
||||
creator_profile_data.setdefault('creator_url', [])
|
||||
if api_url not in creator_profile_data['creator_url']:
|
||||
creator_profile_data['creator_url'].append(api_url)
|
||||
|
||||
if self.active_update_profile:
|
||||
self.log_signal.emit(" Update session active: Loading existing processed post IDs to find new content.")
|
||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||
|
||||
elif not is_restore:
|
||||
self.log_signal.emit(" Fresh download session: Clearing previous post history for this creator to re-download all.")
|
||||
if 'processed_post_ids' in creator_profile_data:
|
||||
creator_profile_data['processed_post_ids'] = []
|
||||
|
||||
creator_profile_data.setdefault('processed_post_ids', [])
|
||||
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
|
||||
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
|
||||
|
||||
# Get all current UI settings and add them to the profile
|
||||
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
|
||||
creator_profile_data['settings'] = current_settings
|
||||
|
||||
creator_profile_data.setdefault('creator_url', [])
|
||||
if api_url not in creator_profile_data['creator_url']:
|
||||
creator_profile_data['creator_url'].append(api_url)
|
||||
profile_processed_ids = set()
|
||||
|
||||
creator_profile_data.setdefault('processed_post_ids', [])
|
||||
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
|
||||
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
|
||||
|
||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||
# --- END OF PROFILE LOGIC ---
|
||||
|
||||
# The rest of this logic runs regardless, but uses the profile data if it was loaded
|
||||
session_processed_ids = set(processed_post_ids_for_restore)
|
||||
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||
processed_post_ids_for_this_run = list(combined_processed_ids)
|
||||
@@ -3055,7 +3157,7 @@ class DownloaderApp (QWidget ):
|
||||
elif backend_filter_mode == 'audio': current_mode_log_text = "Audio Download"
|
||||
|
||||
current_char_filter_scope = self.get_char_filter_scope()
|
||||
manga_mode = manga_mode_is_checked and not post_id_from_url
|
||||
manga_mode = manga_mode_is_checked
|
||||
|
||||
manga_date_prefix_text = ""
|
||||
if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_ORIGINAL_NAME) and hasattr(self, 'manga_date_prefix_input'):
|
||||
@@ -3370,6 +3472,9 @@ class DownloaderApp (QWidget ):
|
||||
'num_file_threads_for_worker': effective_num_file_threads_per_worker,
|
||||
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
|
||||
'allow_multipart_download': allow_multipart,
|
||||
'multipart_scope': self.multipart_scope,
|
||||
'multipart_parts_count': self.multipart_parts_count,
|
||||
'multipart_min_size_mb': self.multipart_min_size_mb,
|
||||
'cookie_text': cookie_text_from_input,
|
||||
'selected_cookie_file': selected_cookie_file_path_for_backend,
|
||||
'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread,
|
||||
@@ -3414,7 +3519,7 @@ class DownloaderApp (QWidget ):
|
||||
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'scan_content_for_images',
|
||||
'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', 'override_output_dir', 'project_root_dir',
|
||||
'text_only_scope', 'text_export_format',
|
||||
'single_pdf_mode',
|
||||
'single_pdf_mode','multipart_parts_count', 'multipart_min_size_mb',
|
||||
'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode',
|
||||
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
|
||||
'processed_post_ids'
|
||||
@@ -3431,7 +3536,6 @@ class DownloaderApp (QWidget ):
|
||||
self.is_paused = False
|
||||
return True
|
||||
|
||||
|
||||
def restore_download(self):
|
||||
"""Initiates the download restoration process."""
|
||||
if self._is_download_active():
|
||||
@@ -3478,6 +3582,7 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr (self .download_thread ,'file_progress_signal'):self .download_thread .file_progress_signal .connect (self .update_file_progress_display )
|
||||
if hasattr (self .download_thread ,'missed_character_post_signal'):
|
||||
self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post )
|
||||
if hasattr(self.download_thread, 'overall_progress_signal'): self.download_thread.overall_progress_signal.connect(self.update_progress_display)
|
||||
if hasattr (self .download_thread ,'retryable_file_failed_signal'):
|
||||
|
||||
if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
|
||||
@@ -3862,7 +3967,12 @@ class DownloaderApp (QWidget ):
|
||||
if not filepath.lower().endswith('.pdf'):
|
||||
filepath += '.pdf'
|
||||
|
||||
font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
base_path = sys._MEIPASS
|
||||
else:
|
||||
base_path = self.app_base_dir
|
||||
|
||||
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
|
||||
self.log_signal.emit(" Sorting collected posts by date (oldest first)...")
|
||||
sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z'))
|
||||
@@ -3871,8 +3981,8 @@ class DownloaderApp (QWidget ):
|
||||
self.log_signal.emit("="*40)
|
||||
|
||||
def _add_to_history_candidates(self, history_data):
|
||||
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
||||
if self.save_creator_json_enabled_this_session and not self.is_single_post_session:
|
||||
post_id = history_data.get('post_id')
|
||||
service = history_data.get('service')
|
||||
user_id = history_data.get('user_id')
|
||||
@@ -3880,7 +3990,6 @@ class DownloaderApp (QWidget ):
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}")
|
||||
|
||||
# Load the profile data before using it to prevent NameError
|
||||
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
|
||||
|
||||
if post_id not in profile_data.get('processed_post_ids', []):
|
||||
@@ -3893,6 +4002,7 @@ class DownloaderApp (QWidget ):
|
||||
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
|
||||
self.download_history_candidates.append(history_data)
|
||||
|
||||
|
||||
def _finalize_download_history (self ):
|
||||
"""Processes candidates and selects the final 3 history entries.
|
||||
Only updates final_download_history_entries if new candidates are available.
|
||||
@@ -4182,9 +4292,12 @@ class DownloaderApp (QWidget ):
|
||||
# Update UI to "Cancelling" state
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
|
||||
if hasattr(self, 'reset_button'):
|
||||
self.reset_button.setEnabled(False)
|
||||
|
||||
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
|
||||
|
||||
# Signal all active components to stop
|
||||
if self.download_thread and self.download_thread.isRunning():
|
||||
self.download_thread.requestInterruption()
|
||||
self.log_signal.emit(" Signaled single download thread to interrupt.")
|
||||
@@ -4196,25 +4309,30 @@ class DownloaderApp (QWidget ):
|
||||
self.log_signal.emit(" Cancelling active External Link download thread...")
|
||||
self.external_link_download_thread.cancel()
|
||||
|
||||
def _get_domain_for_service (self ,service_name :str )->str :
|
||||
def _get_domain_for_service(self, service_name: str) -> str:
|
||||
"""Determines the base domain for a given service."""
|
||||
if not isinstance (service_name ,str ):
|
||||
return "kemono.su"
|
||||
service_lower =service_name .lower ()
|
||||
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'}
|
||||
if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']:
|
||||
return "coomer.su"
|
||||
return "kemono.su"
|
||||
|
||||
if not isinstance(service_name, str):
|
||||
return "kemono.cr" # Default fallback
|
||||
service_lower = service_name.lower()
|
||||
coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans', 'gumroad', 'subscribestar', 'dlsite'}
|
||||
if service_lower in coomer_primary_services:
|
||||
return "coomer.st"
|
||||
return "kemono.cr"
|
||||
|
||||
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None):
|
||||
if self.is_finishing:
|
||||
if not self.finish_lock.acquire(blocking=False):
|
||||
return
|
||||
self.is_finishing = True
|
||||
|
||||
try:
|
||||
if self.is_finishing:
|
||||
return
|
||||
self.is_finishing = True
|
||||
|
||||
if cancelled_by_user:
|
||||
self.log_signal.emit("✅ Cancellation complete. Resetting UI.")
|
||||
self._clear_session_file()
|
||||
self.interrupted_session_data = None
|
||||
self.is_restore_pending = False
|
||||
current_url = self.link_input.text()
|
||||
current_dir = self.dir_input.text()
|
||||
self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir)
|
||||
@@ -4222,13 +4340,14 @@ class DownloaderApp (QWidget ):
|
||||
self.file_progress_label.setText("")
|
||||
if self.pause_event: self.pause_event.clear()
|
||||
self.is_paused = False
|
||||
return # Exit after handling cancellation
|
||||
return
|
||||
|
||||
self.log_signal.emit("🏁 Download of current item complete.")
|
||||
|
||||
if self.is_processing_favorites_queue and self.favorite_download_queue:
|
||||
self.log_signal.emit("✅ Item finished. Processing next in queue...")
|
||||
self.is_finishing = False # Allow the next item in queue to start
|
||||
self.is_finishing = False
|
||||
self.finish_lock.release()
|
||||
self._process_next_favorite_download()
|
||||
return
|
||||
|
||||
@@ -4317,6 +4436,7 @@ class DownloaderApp (QWidget ):
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.is_finishing = False # Allow retry session to start
|
||||
self.finish_lock.release() # Release lock for the retry session
|
||||
self._start_failed_files_retry_session()
|
||||
return # Exit to allow retry session to run
|
||||
else:
|
||||
@@ -4334,7 +4454,7 @@ class DownloaderApp (QWidget ):
|
||||
self.cancellation_message_logged_this_session = False
|
||||
self.active_update_profile = None
|
||||
finally:
|
||||
self.is_finishing = False
|
||||
pass
|
||||
|
||||
def _handle_keep_duplicates_toggled(self, checked):
|
||||
"""Shows the duplicate handling dialog when the checkbox is checked."""
|
||||
@@ -4454,44 +4574,51 @@ class DownloaderApp (QWidget ):
|
||||
self .active_retry_futures_map [future ]=job_details
|
||||
self .active_retry_futures .append (future )
|
||||
|
||||
def _execute_single_file_retry (self ,job_details ,common_args ):
|
||||
"""Executes a single file download retry attempt."""
|
||||
dummy_post_data ={'id':job_details ['original_post_id_for_log'],'title':job_details ['post_title']}
|
||||
def _execute_single_file_retry(self, job_details, common_args):
|
||||
"""
|
||||
Executes a single file download retry attempt. This function is called by the retry thread pool.
|
||||
"""
|
||||
# This worker is temporary and only for this retry task.
|
||||
# It needs dummy post data to initialize.
|
||||
dummy_post_data = {'id': job_details['original_post_id_for_log'], 'title': job_details['post_title']}
|
||||
|
||||
ppw_init_args ={
|
||||
**common_args ,
|
||||
'post_data':dummy_post_data ,
|
||||
'service':job_details .get ('service','unknown_service'),
|
||||
'user_id':job_details .get ('user_id','unknown_user'),
|
||||
'api_url_input':job_details .get ('api_url_input',''),
|
||||
'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ),
|
||||
'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ),
|
||||
'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
|
||||
'use_cookie':common_args .get ('use_cookie',False ),
|
||||
'cookie_text':common_args .get ('cookie_text',""),
|
||||
'selected_cookie_file':common_args .get ('selected_cookie_file',None ),
|
||||
'app_base_dir':common_args .get ('app_base_dir',None ),
|
||||
# Reconstruct the post_page_url, which is needed by the download function
|
||||
service = job_details.get('service', 'unknown_service')
|
||||
user_id = job_details.get('user_id', 'unknown_user')
|
||||
post_id = job_details.get('original_post_id_for_log', 'unknown_id')
|
||||
api_url_input = job_details.get('api_url_input', '')
|
||||
parsed_api_url = urlparse(api_url_input)
|
||||
api_domain = parsed_api_url.netloc if parsed_api_url.netloc else self._get_domain_for_service(service)
|
||||
post_page_url = f"https://{api_domain}/{service}/user/{user_id}/post/{post_id}"
|
||||
|
||||
# Prepare all arguments for the PostProcessorWorker
|
||||
ppw_init_args = {
|
||||
**common_args,
|
||||
'post_data': dummy_post_data,
|
||||
'service': service,
|
||||
'user_id': user_id,
|
||||
'api_url_input': api_url_input
|
||||
}
|
||||
worker =PostProcessorWorker (**ppw_init_args )
|
||||
|
||||
dl_count ,skip_count ,filename_saved ,original_kept ,status ,_ =worker ._download_single_file (
|
||||
file_info =job_details ['file_info'],
|
||||
target_folder_path =job_details ['target_folder_path'],
|
||||
headers =job_details ['headers'],
|
||||
original_post_id_for_log =job_details ['original_post_id_for_log'],
|
||||
skip_event =None ,
|
||||
post_title =job_details ['post_title'],
|
||||
file_index_in_post =job_details ['file_index_in_post'],
|
||||
num_files_in_this_post =job_details ['num_files_in_this_post'],
|
||||
forced_filename_override =job_details .get ('forced_filename_override')
|
||||
worker = PostProcessorWorker(**ppw_init_args)
|
||||
|
||||
# Call the download method with the corrected arguments
|
||||
dl_count, skip_count, filename_saved, original_kept, status, _ = worker._download_single_file(
|
||||
file_info=job_details['file_info'],
|
||||
target_folder_path=job_details['target_folder_path'],
|
||||
post_page_url=post_page_url, # Using the correct argument
|
||||
original_post_id_for_log=job_details['original_post_id_for_log'],
|
||||
skip_event=None,
|
||||
post_title=job_details['post_title'],
|
||||
file_index_in_post=job_details['file_index_in_post'],
|
||||
num_files_in_this_post=job_details['num_files_in_this_post'],
|
||||
forced_filename_override=job_details.get('forced_filename_override')
|
||||
)
|
||||
|
||||
is_successful_download = (status == FILE_DOWNLOAD_STATUS_SUCCESS)
|
||||
is_resolved_as_skipped = (status == FILE_DOWNLOAD_STATUS_SKIPPED)
|
||||
|
||||
|
||||
is_successful_download =(status ==FILE_DOWNLOAD_STATUS_SUCCESS )
|
||||
is_resolved_as_skipped =(status ==FILE_DOWNLOAD_STATUS_SKIPPED )
|
||||
|
||||
return is_successful_download or is_resolved_as_skipped
|
||||
return is_successful_download or is_resolved_as_skipped
|
||||
|
||||
def _handle_retry_future_result (self ,future ):
|
||||
self .processed_retry_count +=1
|
||||
@@ -4588,12 +4715,10 @@ class DownloaderApp (QWidget ):
|
||||
def reset_application_state(self):
|
||||
self.log_signal.emit("🔄 Resetting application state to defaults...")
|
||||
|
||||
# --- MODIFIED PART: Signal all threads to stop first, but do not wait ---
|
||||
if self._is_download_active():
|
||||
self.log_signal.emit(" Cancelling all active background tasks for reset...")
|
||||
self.cancellation_event.set() # Signal all threads to stop
|
||||
self.cancellation_event.set()
|
||||
|
||||
# Initiate non-blocking shutdowns
|
||||
if self.download_thread and self.download_thread.isRunning():
|
||||
self.download_thread.requestInterruption()
|
||||
if self.thread_pool:
|
||||
@@ -4604,9 +4729,8 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
|
||||
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
self.retry_thread_pool = None
|
||||
|
||||
self.cancellation_event.clear() # Reset the event for the next run
|
||||
# --- END OF MODIFIED PART ---
|
||||
|
||||
self.cancellation_event.clear()
|
||||
|
||||
if self.pause_event:
|
||||
self.pause_event.clear()
|
||||
@@ -4678,123 +4802,6 @@ class DownloaderApp (QWidget ):
|
||||
self.interrupted_session_data = None
|
||||
self.is_restore_pending = False
|
||||
self.last_link_input_text_for_queue_sync = ""
|
||||
|
||||
def _reset_ui_to_defaults(self):
|
||||
"""Resets all UI elements and relevant state to their default values."""
|
||||
self.link_input.clear()
|
||||
self.custom_folder_input.clear()
|
||||
self.character_input.clear()
|
||||
self.skip_words_input.clear()
|
||||
self.start_page_input.clear()
|
||||
self.end_page_input.clear()
|
||||
self.new_char_input.clear()
|
||||
if hasattr(self, 'remove_from_filename_input'):
|
||||
self.remove_from_filename_input.clear()
|
||||
self.character_search_input.clear()
|
||||
self.thread_count_input.setText("4")
|
||||
if hasattr(self, 'manga_date_prefix_input'):
|
||||
self.manga_date_prefix_input.clear()
|
||||
self.radio_all.setChecked(True)
|
||||
self.skip_zip_checkbox.setChecked(True)
|
||||
self.download_thumbnails_checkbox.setChecked(False)
|
||||
self.compress_images_checkbox.setChecked(False)
|
||||
self.use_subfolders_checkbox.setChecked(False)
|
||||
self.use_subfolder_per_post_checkbox.setChecked(True)
|
||||
self.use_multithreading_checkbox.setChecked(True)
|
||||
if self.favorite_mode_checkbox:
|
||||
self.favorite_mode_checkbox.setChecked(False)
|
||||
|
||||
if hasattr(self, 'keep_duplicates_checkbox'):
|
||||
self.keep_duplicates_checkbox.setChecked(False)
|
||||
|
||||
self.external_links_checkbox.setChecked(False)
|
||||
if self.manga_mode_checkbox:
|
||||
self.manga_mode_checkbox.setChecked(False)
|
||||
if hasattr(self, 'use_cookie_checkbox'):
|
||||
self.use_cookie_checkbox.setChecked(False)
|
||||
self.selected_cookie_filepath = None
|
||||
if hasattr(self, 'cookie_text_input'):
|
||||
self.cookie_text_input.clear()
|
||||
if self.main_log_output:
|
||||
self.main_log_output.clear()
|
||||
if self.external_log_output:
|
||||
self.external_log_output.clear()
|
||||
if self.missed_character_log_output:
|
||||
self.missed_character_log_output.clear()
|
||||
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
|
||||
self.file_progress_label.setText("")
|
||||
self.missed_title_key_terms_count.clear()
|
||||
self.missed_title_key_terms_examples.clear()
|
||||
self.logged_summary_for_key_term.clear()
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.cancellation_message_logged_this_session = False
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.allow_multipart_download_setting = False
|
||||
self.skip_words_scope = SKIP_SCOPE_POSTS
|
||||
self.char_filter_scope = CHAR_SCOPE_TITLE
|
||||
self.manga_filename_style = STYLE_POST_TITLE
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
|
||||
self._update_skip_scope_button_text()
|
||||
self._update_char_filter_scope_button_text()
|
||||
self._update_manga_filename_style_button_text()
|
||||
self._update_multipart_toggle_button_text()
|
||||
self._update_favorite_scope_button_text()
|
||||
self.current_log_view = 'progress'
|
||||
self.is_paused = False
|
||||
if self.pause_event:
|
||||
self.pause_event.clear()
|
||||
|
||||
self.external_link_queue.clear()
|
||||
self.extracted_links_cache = []
|
||||
self._is_processing_external_link_queue = False
|
||||
self._current_link_post_title = None
|
||||
if self.download_extracted_links_button:
|
||||
self.download_extracted_links_button.setEnabled(False)
|
||||
self.favorite_download_queue.clear()
|
||||
self.is_processing_favorites_queue = False
|
||||
self.current_processing_favorite_item_info = None
|
||||
self.interrupted_session_data = None
|
||||
self.is_restore_pending = False
|
||||
self.last_link_input_text_for_queue_sync = ""
|
||||
self._update_button_states_and_connections()
|
||||
self.total_posts_to_process = 0
|
||||
self.processed_posts_count = 0
|
||||
self.download_counter = 0
|
||||
self.skip_counter = 0
|
||||
self.all_kept_original_filenames = []
|
||||
if self.log_view_stack:
|
||||
self.log_view_stack.setCurrentIndex(0)
|
||||
if self.progress_log_label:
|
||||
self.progress_log_label.setText(self._tr("progress_log_label_text", "📜 Progress Log:"))
|
||||
if self.log_verbosity_toggle_button:
|
||||
self.log_verbosity_toggle_button.setText(self.EYE_ICON)
|
||||
self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.")
|
||||
self.filter_character_list("")
|
||||
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
|
||||
self.update_ui_for_manga_mode(False)
|
||||
self.update_custom_folder_visibility(self.link_input.text())
|
||||
self.update_page_range_enabled_state()
|
||||
self._update_cookie_input_visibility(False)
|
||||
self._update_cookie_input_placeholders_and_tooltips()
|
||||
self.download_btn.setEnabled(True)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
if self.reset_button:
|
||||
self.reset_button.setEnabled(True)
|
||||
self.reset_button.setText(self._tr("reset_button_text", "🔄 Reset"))
|
||||
self.reset_button.setToolTip(self._tr("reset_button_tooltip", "Reset all inputs and logs to default state (only when idle)."))
|
||||
if hasattr(self, 'favorite_mode_checkbox'):
|
||||
self._handle_favorite_mode_toggle(False)
|
||||
if hasattr(self, 'scan_content_images_checkbox'):
|
||||
self.scan_content_images_checkbox.setChecked(False)
|
||||
if hasattr(self, 'download_thumbnails_checkbox'):
|
||||
self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked())
|
||||
|
||||
self.set_ui_enabled(True)
|
||||
self.log_signal.emit("✅ UI reset to defaults. Ready for new operation.")
|
||||
self._update_button_states_and_connections()
|
||||
|
||||
def _show_feature_guide (self ):
|
||||
steps_content_keys =[
|
||||
@@ -4838,14 +4845,15 @@ class DownloaderApp (QWidget ):
|
||||
with QMutexLocker (self .prompt_mutex ):self ._add_character_response =result
|
||||
self .log_signal .emit (f" Main thread received character prompt response: {'Action resulted in addition/confirmation'if result else 'Action resulted in no addition/declined'}")
|
||||
|
||||
def _update_multipart_toggle_button_text (self ):
|
||||
if hasattr (self ,'multipart_toggle_button'):
|
||||
if self .allow_multipart_download_setting :
|
||||
self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON"))
|
||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON"))
|
||||
else :
|
||||
self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
|
||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
|
||||
def _update_multipart_toggle_button_text(self):
|
||||
if hasattr(self, 'multipart_toggle_button'):
|
||||
if self.allow_multipart_download_setting:
|
||||
scope_text = self.multipart_scope.capitalize()
|
||||
self.multipart_toggle_button.setText(self._tr("multipart_on_button_text", f"Multi-part: {scope_text}"))
|
||||
self.multipart_toggle_button.setToolTip(self._tr("multipart_on_button_tooltip", f"Multipart download is ON. Applied to: {scope_text} files. Click to change."))
|
||||
else:
|
||||
self.multipart_toggle_button.setText(self._tr("multipart_off_button_text", "Multi-part: OFF"))
|
||||
self.multipart_toggle_button.setToolTip(self._tr("multipart_off_button_tooltip", "Multipart download is OFF. Click to enable and set options."))
|
||||
|
||||
def _update_error_button_count(self):
|
||||
"""Updates the Error button text to show the count of failed files."""
|
||||
@@ -4860,36 +4868,25 @@ class DownloaderApp (QWidget ):
|
||||
else:
|
||||
self.error_btn.setText(base_text)
|
||||
|
||||
def _toggle_multipart_mode (self ):
|
||||
if not self .allow_multipart_download_setting :
|
||||
msg_box =QMessageBox (self )
|
||||
msg_box .setIcon (QMessageBox .Warning )
|
||||
msg_box .setWindowTitle ("Multi-part Download Advisory")
|
||||
msg_box .setText (
|
||||
"<b>Multi-part download advisory:</b><br><br>"
|
||||
"<ul>"
|
||||
"<li>Best suited for <b>large files</b> (e.g., single post videos).</li>"
|
||||
"<li>When downloading a full creator feed with many small files (like images):"
|
||||
"<ul><li>May not offer significant speed benefits.</li>"
|
||||
"<li>Could potentially make the UI feel <b>choppy</b>.</li>"
|
||||
"<li>May <b>spam the process log</b> with rapid, numerous small download messages.</li></ul></li>"
|
||||
"<li>Consider using the <b>'Videos' filter</b> if downloading a creator feed to primarily target large files for multi-part.</li>"
|
||||
"</ul><br>"
|
||||
"Do you want to enable multi-part download?"
|
||||
)
|
||||
proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
|
||||
cancel_button =msg_box .addButton ("Cancel",QMessageBox .RejectRole )
|
||||
msg_box .setDefaultButton (proceed_button )
|
||||
msg_box .exec_ ()
|
||||
|
||||
if msg_box .clickedButton ()==cancel_button :
|
||||
self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.")
|
||||
return
|
||||
|
||||
self .allow_multipart_download_setting =not self .allow_multipart_download_setting
|
||||
self ._update_multipart_toggle_button_text ()
|
||||
self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting )
|
||||
self .log_signal .emit (f"ℹ️ Multi-part download set to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
|
||||
def _toggle_multipart_mode(self):
|
||||
"""
|
||||
Opens the Multipart Scope Dialog and updates settings based on user choice.
|
||||
"""
|
||||
current_scope = self.multipart_scope if self.allow_multipart_download_setting else 'both'
|
||||
dialog = MultipartScopeDialog(current_scope, self.multipart_parts_count, self.multipart_min_size_mb, self)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.multipart_scope = dialog.get_selected_scope()
|
||||
self.multipart_parts_count = dialog.get_selected_parts()
|
||||
self.multipart_min_size_mb = dialog.get_selected_min_size() # Get the new value
|
||||
self.allow_multipart_download_setting = True
|
||||
self.log_signal.emit(f"ℹ️ Multi-part download enabled: Scope='{self.multipart_scope.capitalize()}', Parts={self.multipart_parts_count}, Min Size={self.multipart_min_size_mb} MB")
|
||||
else:
|
||||
self.allow_multipart_download_setting = False
|
||||
self.log_signal.emit("ℹ️ Multi-part download setting remains OFF.")
|
||||
|
||||
self._update_multipart_toggle_button_text()
|
||||
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
|
||||
|
||||
def _open_known_txt_file (self ):
|
||||
if not os .path .exists (self .config_file ):
|
||||
@@ -5164,6 +5161,31 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr(self, 'link_input'):
|
||||
self.last_link_input_text_for_queue_sync = self.link_input.text()
|
||||
|
||||
# --- START: MODIFIED LOGIC ---
|
||||
# Manually trigger the UI update now that the queue is populated and the dialog is closed.
|
||||
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
def _load_saved_cookie_settings(self):
|
||||
"""Loads and applies saved cookie settings on startup."""
|
||||
try:
|
||||
use_cookie_saved = self.settings.value(USE_COOKIE_KEY, False, type=bool)
|
||||
cookie_content_saved = self.settings.value(COOKIE_TEXT_KEY, "", type=str)
|
||||
|
||||
if use_cookie_saved and cookie_content_saved:
|
||||
self.use_cookie_checkbox.setChecked(True)
|
||||
self.cookie_text_input.setText(cookie_content_saved)
|
||||
|
||||
# Check if the saved content is a file path and update UI accordingly
|
||||
if os.path.exists(cookie_content_saved):
|
||||
self.selected_cookie_filepath = cookie_content_saved
|
||||
self.cookie_text_input.setReadOnly(True)
|
||||
self._update_cookie_input_placeholders_and_tooltips()
|
||||
|
||||
self.log_signal.emit(f"ℹ️ Loaded saved cookie settings.")
|
||||
except Exception as e:
|
||||
self.log_signal.emit(f"⚠️ Could not load saved cookie settings: {e}")
|
||||
|
||||
def _show_favorite_artists_dialog (self ):
|
||||
if self ._is_download_active ()or self .is_processing_favorites_queue :
|
||||
QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
|
||||
@@ -5219,42 +5241,54 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
target_domain_preference_for_fetch =None
|
||||
|
||||
if cookies_config ['use_cookie']:
|
||||
self .log_signal .emit ("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
|
||||
kemono_cookies =prepare_cookies_for_request (
|
||||
cookies_config ['use_cookie'],
|
||||
cookies_config ['cookie_text'],
|
||||
cookies_config ['selected_cookie_file'],
|
||||
cookies_config ['app_base_dir'],
|
||||
lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Kemono] {msg }"),
|
||||
target_domain ="kemono.su"
|
||||
if cookies_config['use_cookie']:
|
||||
self.log_signal.emit("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
|
||||
|
||||
# --- Kemono Check with Fallback ---
|
||||
kemono_cookies = prepare_cookies_for_request(
|
||||
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
|
||||
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
|
||||
target_domain="kemono.cr"
|
||||
)
|
||||
coomer_cookies =prepare_cookies_for_request (
|
||||
cookies_config ['use_cookie'],
|
||||
cookies_config ['cookie_text'],
|
||||
cookies_config ['selected_cookie_file'],
|
||||
cookies_config ['app_base_dir'],
|
||||
lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Coomer] {msg }"),
|
||||
target_domain ="coomer.su"
|
||||
if not kemono_cookies:
|
||||
self.log_signal.emit(" ↳ No cookies for kemono.cr, trying fallback kemono.su...")
|
||||
kemono_cookies = prepare_cookies_for_request(
|
||||
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
|
||||
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
|
||||
target_domain="kemono.su"
|
||||
)
|
||||
|
||||
# --- Coomer Check with Fallback ---
|
||||
coomer_cookies = prepare_cookies_for_request(
|
||||
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
|
||||
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
|
||||
target_domain="coomer.st"
|
||||
)
|
||||
if not coomer_cookies:
|
||||
self.log_signal.emit(" ↳ No cookies for coomer.st, trying fallback coomer.su...")
|
||||
coomer_cookies = prepare_cookies_for_request(
|
||||
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
|
||||
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
|
||||
target_domain="coomer.su"
|
||||
)
|
||||
|
||||
kemono_ok =bool (kemono_cookies )
|
||||
coomer_ok =bool (coomer_cookies )
|
||||
kemono_ok = bool(kemono_cookies)
|
||||
coomer_ok = bool(coomer_cookies)
|
||||
|
||||
if kemono_ok and not coomer_ok :
|
||||
target_domain_preference_for_fetch ="kemono.su"
|
||||
self .log_signal .emit (" ↳ Only Kemono.su cookies loaded. Will fetch favorites from Kemono.su only.")
|
||||
elif coomer_ok and not kemono_ok :
|
||||
target_domain_preference_for_fetch ="coomer.su"
|
||||
self .log_signal .emit (" ↳ Only Coomer.su cookies loaded. Will fetch favorites from Coomer.su only.")
|
||||
elif kemono_ok and coomer_ok :
|
||||
target_domain_preference_for_fetch =None
|
||||
self .log_signal .emit (" ↳ Cookies for both Kemono.su and Coomer.su loaded. Will attempt to fetch from both.")
|
||||
else :
|
||||
self .log_signal .emit (" ↳ No valid cookies loaded for Kemono.su or Coomer.su.")
|
||||
cookie_help_dialog =CookieHelpDialog (self ,self )
|
||||
cookie_help_dialog .exec_ ()
|
||||
return
|
||||
if kemono_ok and not coomer_ok:
|
||||
target_domain_preference_for_fetch = "kemono.cr"
|
||||
self.log_signal.emit(" ↳ Only Kemono cookies loaded. Will fetch favorites from Kemono.cr only.")
|
||||
elif coomer_ok and not kemono_ok:
|
||||
target_domain_preference_for_fetch = "coomer.st"
|
||||
self.log_signal.emit(" ↳ Only Coomer cookies loaded. Will fetch favorites from Coomer.st only.")
|
||||
elif kemono_ok and coomer_ok:
|
||||
target_domain_preference_for_fetch = None
|
||||
self.log_signal.emit(" ↳ Cookies for both Kemono and Coomer loaded. Will attempt to fetch from both.")
|
||||
else:
|
||||
self.log_signal.emit(" ↳ No valid cookies loaded for Kemono.cr or Coomer.st.")
|
||||
cookie_help_dialog = CookieHelpDialog(self, self)
|
||||
cookie_help_dialog.exec_()
|
||||
return
|
||||
else :
|
||||
self .log_signal .emit ("Favorite Posts: 'Use Cookie' is NOT checked. Cookies are required.")
|
||||
cookie_help_dialog =CookieHelpDialog (self ,self )
|
||||
@@ -5285,7 +5319,7 @@ class DownloaderApp (QWidget ):
|
||||
else :
|
||||
self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.")
|
||||
|
||||
def _process_next_favorite_download (self ):
|
||||
def _process_next_favorite_download(self):
|
||||
|
||||
if self.favorite_download_queue and not self.is_processing_favorites_queue:
|
||||
manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
|
||||
@@ -5330,33 +5364,43 @@ class DownloaderApp (QWidget ):
|
||||
next_url =self .current_processing_favorite_item_info ['url']
|
||||
item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item')
|
||||
|
||||
item_type =self .current_processing_favorite_item_info .get ('type','artist')
|
||||
self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })")
|
||||
# --- START: MODIFIED SECTION ---
|
||||
# Get the type of item from the queue to help start_download make smarter decisions.
|
||||
item_type = self.current_processing_favorite_item_info.get('type', 'artist')
|
||||
self.log_signal.emit(f"▶️ Processing next favorite from queue ({item_type}): '{item_display_name}' ({next_url})")
|
||||
|
||||
override_dir =None
|
||||
item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup')
|
||||
if item_scope is None :
|
||||
item_scope =self .favorite_download_scope
|
||||
override_dir = None
|
||||
item_scope = self.current_processing_favorite_item_info.get('scope_from_popup')
|
||||
if item_scope is None:
|
||||
item_scope = self.favorite_download_scope
|
||||
|
||||
main_download_dir =self .dir_input .text ().strip ()
|
||||
main_download_dir = self.dir_input.text().strip()
|
||||
|
||||
should_create_artist_folder =False
|
||||
if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS :
|
||||
should_create_artist_folder =True
|
||||
elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS :
|
||||
should_create_artist_folder =True
|
||||
should_create_artist_folder = False
|
||||
if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
|
||||
should_create_artist_folder = True
|
||||
elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
|
||||
should_create_artist_folder = True
|
||||
|
||||
if should_create_artist_folder and main_download_dir :
|
||||
folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder')
|
||||
item_specific_folder_name =clean_folder_name (folder_name_key )
|
||||
override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name ))
|
||||
self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'")
|
||||
if should_create_artist_folder and main_download_dir:
|
||||
folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
|
||||
item_specific_folder_name = clean_folder_name(folder_name_key)
|
||||
override_dir = os.path.normpath(os.path.join(main_download_dir, item_specific_folder_name))
|
||||
self.log_signal.emit(f" Scope requires artist folder. Target directory: '{override_dir}'")
|
||||
|
||||
success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir, is_continuation=True )
|
||||
# Pass the item_type to the start_download function
|
||||
success_starting_download = self.start_download(
|
||||
direct_api_url=next_url,
|
||||
override_output_dir=override_dir,
|
||||
is_continuation=True,
|
||||
item_type_from_queue=item_type
|
||||
)
|
||||
# --- END: MODIFIED SECTION ---
|
||||
|
||||
if not success_starting_download :
|
||||
self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.")
|
||||
self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[])
|
||||
if not success_starting_download:
|
||||
self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping and moving to the next item in queue.")
|
||||
# Use a QTimer to avoid deep recursion and correctly move to the next item.
|
||||
QTimer.singleShot(100, self._process_next_favorite_download)
|
||||
|
||||
class ExternalLinkDownloadThread (QThread ):
|
||||
"""A QThread to handle downloading multiple external links sequentially."""
|
||||
|
||||
@@ -196,10 +196,9 @@ def get_link_platform(url):
|
||||
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 'pixiv.net' in domain: return 'pixiv'
|
||||
if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono'
|
||||
if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer'
|
||||
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 or 'coomer.st' in domain: return 'coomer'
|
||||
|
||||
# Fallback to a generic name for other domains
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
return parts[-2]
|
||||
|
||||
Reference in New Issue
Block a user