10 Commits

Author SHA1 Message Date
Yuvi9587
a5cb04ea6f Update features.md 2025-08-03 06:46:30 -07:00
Yuvi9587
842f18d70d Update features.md 2025-08-03 06:32:32 -07:00
Yuvi9587
fb3f0e8913 Update features.md 2025-08-03 06:11:05 -07:00
Yuvi9587
0758887154 Update features.md 2025-08-03 06:07:05 -07:00
Yuvi9587
e752d881e7 Update features.md 2025-08-03 06:01:32 -07:00
Yuvi9587
a776d1abe9 Update features.md 2025-08-03 06:01:15 -07:00
Yuvi9587
21d1ce4fa9 Commit 2025-08-03 05:46:51 -07:00
Yuvi9587
d5112a25ee Commit 2025-08-01 09:42:10 -07:00
Yuvi9587
791ce503ff Update main_window.py 2025-08-01 07:57:32 -07:00
Yuvi9587
e5b519d5ce Commit 2025-08-01 06:33:36 -07:00
5 changed files with 805 additions and 339 deletions

View File

@@ -1,147 +1,391 @@
<div> <div>
<h1>Kemono Downloader - Comprehensive Feature Guide</h1> <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> <p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
<hr> <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> <h2><strong>1. URL Input (🔗)</strong></h2>
<h3><strong>Primary Inputs (Top-Left)</strong></h3> <p>This is the primary input field where you specify the content you want to download.</p>
<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> <p><strong>Functionality:</strong></p>
<li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can: <ul>
<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>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>Post URL:</strong> A direct link to a specific post (e.g., .../post/98765). Downloads only the specified post.</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>
</ul>
</li> <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>
<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> <hr>
</ul>
<hr> <h2><strong>2. Creator Selection & Update (🎨)</strong></h2>
<h2><strong>Filtering & Naming (Left Panel)</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>These features give you precise control over what gets downloaded and how it's named and organized.</p>
<ul> <p><strong>Functionality:</strong></p>
<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>
<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>Filter: [Scope] Button</strong>: This button changes how the character filter works: <li><strong>Batch Selection:</strong> Select multiple creators and click "Add Selected" to add them to the batch download session.</li>
<ul> <li><strong>Update Checker:</strong> Use a saved profile (.json) to download only new content based on previously fetched posts.</li>
<li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li> <li><strong>Post Fetching & Filtering:</strong> "Fetch Posts" loads post titles, allowing you to choose specific posts for download.</li>
<li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li> </ul>
<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> <hr>
</ul>
</li> <h2><strong>3. Download Location Input (📁)</strong></h2>
</ul> <p>This input defines the destination directory for downloaded files.</p>
</li>
<li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>). <p><strong>Functionality:</strong></p>
<ul> <ul>
<li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works: <li><strong>Manual Entry:</strong> Enter or paste the folder path.</li>
<ul> <li><strong>Browse Button:</strong> Opens a system dialog to choose a folder.</li>
<li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li> <li><strong>Directory Creation:</strong> If the folder doesn't exist, the app can create it after user confirmation.</li>
<li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li> </ul>
<li><strong>Both</strong>: Applies both levels of skipping.</li>
</ul> <hr>
</li>
</ul> <h2><strong>4. Filter by Character(s) & Scope Button</strong></h2>
</li> <p>Used to download content for specific characters or series and organize them into subfolders.</p>
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
</ul> <p><strong>Input Field (Filter by Character(s)):</strong></p>
<h3><strong>File Type Filter (Radio Buttons)</strong></h3> <ul>
<p>This section lets you choose the kind of content you want:</p> <li>Enter comma-separated names (e.g., <code>Tifa, Aerith</code>).</li>
<ul> <li>Group aliases using parentheses (e.g., <code>(Cloud, Zack)</code>).</li>
<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>Names are matched against titles, filenames, or comments.</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>If "Separate Folders by Known.txt" is enabled, the name becomes the subfolder name.</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>
</ul>
<hr> <p><strong>Scope Button Modes:</strong></p>
<h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2> <ul>
<ul> <li><strong>Filter: Title</strong> (default) Match names in post titles only.</li>
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li> <li><strong>Filter: Files</strong> Match names in filenames only.</li>
<li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li> <li><strong>Filter: Both</strong> Try title match first, then filenames.</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>Filter: Comments</strong> Try filenames first, then post comments if no match.</li>
<li><strong>Compress to WebP</strong>: Saves disk space by automatically converting large images into the efficient WebP format.</li> </ul>
<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> <hr>
<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> <h2><strong>5. Skip with Words & Scope Button</strong></h2>
<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> <p>Prevents downloading content based on keywords.</p>
<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> <p><strong>Input Field (Skip with Words):</strong></p>
</ul> <ul>
<hr> <li>Enter comma-separated keywords (e.g., <code>WIP, sketch, preview</code>).</li>
<h2><strong>Known Names Management (Bottom-Left)</strong></h2> <li>Matching is case-insensitive.</li>
<p>This powerful feature automates the creation of organized, named folders.</p> <li>If a keyword matches, the file or post is skipped.</li>
<ul> </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> <p><strong>Scope Button Modes:</strong></p>
<li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li> <ul>
<li><strong>Add New Name</strong>: <li><strong>Scope: Posts</strong> (default) Skips post if title contains a keyword.</li>
<ul> <li><strong>Scope: Files</strong> Skips individual files with keyword matches.</li>
<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>Scope: Both</strong> Skips entire post if title matches, otherwise filters individual files.</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>
</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>
</div> </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>&lt;img&gt;</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 posts 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 (216).</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 didnt 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>

View File

@@ -54,6 +54,24 @@ from ..utils.text_utils import (
) )
from ..config.constants 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 ): class PostProcessorSignals (QObject ):
progress_signal =pyqtSignal (str ) progress_signal =pyqtSignal (str )
file_download_status_signal =pyqtSignal (bool ) file_download_status_signal =pyqtSignal (bool )
@@ -64,7 +82,6 @@ class PostProcessorSignals (QObject ):
worker_finished_signal = pyqtSignal(tuple) worker_finished_signal = pyqtSignal(tuple)
class PostProcessorWorker: class PostProcessorWorker:
def __init__(self, post_data, download_root, known_names, def __init__(self, post_data, download_root, known_names,
filter_character_list, emitter, filter_character_list, emitter,
unwanted_keywords, filter_mode, skip_zip, unwanted_keywords, filter_mode, skip_zip,
@@ -104,7 +121,10 @@ class PostProcessorWorker:
text_export_format='txt', text_export_format='txt',
single_pdf_mode=False, single_pdf_mode=False,
project_root_dir=None, 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.post = post_data
self.download_root = download_root self.download_root = download_root
@@ -166,7 +186,9 @@ class PostProcessorWorker:
self.single_pdf_mode = single_pdf_mode self.single_pdf_mode = single_pdf_mode
self.project_root_dir = project_root_dir self.project_root_dir = project_root_dir
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] 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: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found.") self.logger("⚠️ Image compression disabled: Pillow library not found.")
self.compress_images = False self.compress_images = False
@@ -201,7 +223,37 @@ class PostProcessorWorker:
return self .dynamic_filter_holder .get_filters () return self .dynamic_filter_holder .get_filters ()
return self .filter_character_list_objects_initial 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, post_title="", file_index_in_post=0, num_files_in_this_post=1,
manga_date_file_counter_ref=None, manga_date_file_counter_ref=None,
forced_filename_override=None, forced_filename_override=None,
@@ -215,6 +267,11 @@ class PostProcessorWorker:
if self.check_cancel() or (skip_event and skip_event.is_set()): if self.check_cancel() or (skip_event and skip_event.is_set()):
return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None 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') file_url = file_info.get('url')
cookies_to_use_for_file = None cookies_to_use_for_file = None
if self.use_cookie: if self.use_cookie:
@@ -233,34 +290,28 @@ class PostProcessorWorker:
self.logger(f" -> Skip File (Keyword in Original Name '{skip_word}'): '{api_original_filename}'. Scope: {self.skip_words_scope}") 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 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) 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 not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else ''
if self.manga_mode_active: if self.manga_mode_active:
if self.manga_filename_style == STYLE_ORIGINAL_NAME: if self.manga_filename_style == STYLE_ORIGINAL_NAME:
# Get the post's publication or added date
published_date_str = self.post.get('published') published_date_str = self.post.get('published')
added_date_str = self.post.get('added') 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 date_to_use_str = published_date_str or added_date_str
if date_to_use_str: if date_to_use_str:
try: try:
# Extract just the YYYY-MM-DD part from the timestamp
formatted_date_str = date_to_use_str.split('T')[0] formatted_date_str = date_to_use_str.split('T')[0]
except Exception: except Exception:
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.") self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
else: else:
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.") 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}" filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
was_original_name_kept_flag = True was_original_name_kept_flag = True
elif self.manga_filename_style == STYLE_POST_TITLE: elif self.manga_filename_style == STYLE_POST_TITLE:
if post_title and post_title.strip(): 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 num_files_in_this_post > 1:
if file_index_in_post == 0: if file_index_in_post == 0:
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
@@ -281,7 +332,7 @@ class PostProcessorWorker:
manga_date_file_counter_ref[0] += 1 manga_date_file_counter_ref[0] += 1
base_numbered_name = f"{counter_val_for_filename:03d}" base_numbered_name = f"{counter_val_for_filename:03d}"
if self.manga_date_prefix and self.manga_date_prefix.strip(): 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: if cleaned_prefix:
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}" filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
else: else:
@@ -298,7 +349,7 @@ class PostProcessorWorker:
with counter_lock: with counter_lock:
counter_val_for_filename = manga_global_file_counter_ref[0] counter_val_for_filename = manga_global_file_counter_ref[0]
manga_global_file_counter_ref[0] += 1 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}" filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}"
else: 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}") 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}")
@@ -330,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'.") 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(): if post_title and post_title.strip():
temp_cleaned_title = clean_filename(post_title.strip()) temp_cleaned_title = robust_clean_name(post_title.strip())
if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_file"): 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.") 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" cleaned_post_title_for_filename = "post"
else: else:
@@ -414,8 +465,7 @@ class PostProcessorWorker:
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path) final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
if os.path.exists(final_save_path_check): if os.path.exists(final_save_path_check):
try: try:
# Use a HEAD request to get the expected size without downloading the body with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
with requests.head(file_url, headers=headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
head_response.raise_for_status() head_response.raise_for_status()
expected_size = int(head_response.headers.get('Content-Length', -1)) expected_size = int(head_response.headers.get('Content-Length', -1))
@@ -423,26 +473,21 @@ class PostProcessorWorker:
if expected_size != -1 and actual_size == expected_size: 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.") self.logger(f" -> Skip (File Exists & Complete): '{filename_to_save_in_main_path}' is already on disk with the correct size.")
# We still need to add its hash to the session to prevent duplicates in other modes
# This is a quick hash calculation for the already existing file
try: try:
md5_hasher = hashlib.md5() md5_hasher = hashlib.md5()
with open(final_save_path_check, 'rb') as f_verify: with open(final_save_path_check, 'rb') as f_verify:
for chunk in iter(lambda: f_verify.read(8192), b""): for chunk in iter(lambda: f_verify.read(8192), b""):
md5_hasher.update(chunk) md5_hasher.update(chunk)
with self.downloaded_hash_counts_lock: with self.downloaded_hash_counts_lock:
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1 self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
except Exception as hash_exc: except Exception as hash_exc:
self.logger(f" ⚠️ Could not hash existing file '{filename_to_save_in_main_path}' for session: {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 return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
else: else:
self.logger(f" ⚠️ File '{filename_to_save_in_main_path}' exists but is incomplete (Expected: {expected_size}, Actual: {actual_size}). Re-downloading.") 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: 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.") self.logger(f" ⚠️ Could not verify size of existing file '{filename_to_save_in_main_path}': {e}. Proceeding with download.")
retry_delay = 5 retry_delay = 5
downloaded_size_bytes = 0 downloaded_size_bytes = 0
calculated_file_hash = None calculated_file_hash = None
@@ -462,14 +507,44 @@ class PostProcessorWorker:
if attempt_num_single_stream > 0: 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})...") 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))) time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1)))
self._emit_signal('file_download_status', True) 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() response.raise_for_status()
total_size_bytes = int(response.headers.get('Content-Length', 0)) 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 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()) 'bytes' in response.headers.get('Accept-Ranges', '').lower())
if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break
if attempt_multipart: if attempt_multipart:
@@ -478,7 +553,7 @@ class PostProcessorWorker:
response_for_this_attempt = None 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_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( 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, 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, cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger,
pause_event=self.pause_event pause_event=self.pause_event
@@ -487,7 +562,7 @@ class PostProcessorWorker:
download_successful_flag = True download_successful_flag = True
downloaded_size_bytes = mp_bytes downloaded_size_bytes = mp_bytes
calculated_file_hash = mp_hash 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() if mp_file_handle: mp_file_handle.close()
break break
else: else:
@@ -553,12 +628,15 @@ class PostProcessorWorker:
if isinstance(e, requests.exceptions.ConnectionError) and ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): 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.") self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") if e.response is not None and e.response.status_code == 403:
last_exception_for_retry_later = e self.logger(f" ⚠️ Download Error (403 Forbidden): {api_original_filename}. This often requires valid cookies.")
is_permanent_error = True self.logger(f" Will retry... Check your 'Use Cookie' settings if this persists.")
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): last_exception_for_retry_later = e
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") else:
break 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: except Exception as e:
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}") self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
last_exception_for_retry_later = e last_exception_for_retry_later = e
@@ -635,25 +713,21 @@ class PostProcessorWorker:
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...") self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
try: try:
with Image.open(downloaded_part_file_path) as img: 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'): if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA') img = img.convert('RGBA')
# Use an in-memory buffer to save the compressed image
output_buffer = BytesIO() output_buffer = BytesIO()
img.save(output_buffer, format='WebP', quality=85) img.save(output_buffer, format='WebP', quality=85)
# This buffer now holds the compressed data
data_to_write_io = output_buffer data_to_write_io = output_buffer
# Update the filename to use the .webp extension
base, _ = os.path.splitext(filename_to_save_in_main_path) base, _ = os.path.splitext(filename_to_save_in_main_path)
filename_to_save_in_main_path = f"{base}.webp" 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") self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
except Exception as e_compress: except Exception as e_compress:
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.") 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 effective_save_folder = target_folder_path
base_name, extension = os.path.splitext(filename_to_save_in_main_path) base_name, extension = os.path.splitext(filename_to_save_in_main_path)
@@ -671,17 +745,14 @@ class PostProcessorWorker:
try: try:
if data_to_write_io: if data_to_write_io:
# Write the compressed data from the in-memory buffer
with open(final_save_path, 'wb') as f_out: with open(final_save_path, 'wb') as f_out:
f_out.write(data_to_write_io.getvalue()) 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): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
except OSError as e_rem: except OSError as e_rem:
self.logger(f" -> Failed to remove .part after compression: {e_rem}") self.logger(f" -> Failed to remove .part after compression: {e_rem}")
else: else:
# No compression was done, just rename the original file
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
time.sleep(0.1) time.sleep(0.1)
os.rename(downloaded_part_file_path, final_save_path) os.rename(downloaded_part_file_path, final_save_path)
@@ -728,7 +799,7 @@ class PostProcessorWorker:
self.logger(f" -> Failed to remove partially saved file: {final_save_path}") self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
permanent_failure_details = { 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, '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, '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, 'forced_filename_override': filename_to_save_in_main_path,
@@ -742,7 +813,7 @@ class PostProcessorWorker:
details_for_failure = { details_for_failure = {
'file_info': file_info, 'file_info': file_info,
'target_folder_path': target_folder_path, 'target_folder_path': target_folder_path,
'headers': headers, 'headers': file_download_headers,
'original_post_id_for_log': original_post_id_for_log, 'original_post_id_for_log': original_post_id_for_log,
'post_title': post_title, 'post_title': post_title,
'file_index_in_post': file_index_in_post, 'file_index_in_post': file_index_in_post,
@@ -1044,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]) 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: 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') 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": if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder":
@@ -1626,7 +1700,7 @@ class PostProcessorWorker:
self._download_single_file, self._download_single_file,
file_info=file_info_to_dl, file_info=file_info_to_dl,
target_folder_path=current_path_for_file_instance, 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, 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, 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) file_index_in_post=file_idx, num_files_in_this_post=len(files_to_download_info_list)
@@ -1783,6 +1857,8 @@ class DownloadThread(QThread):
remove_from_filename_words_list=None, remove_from_filename_words_list=None,
manga_date_prefix='', manga_date_prefix='',
allow_multipart_download=True, allow_multipart_download=True,
multipart_parts_count=4,
multipart_min_size_mb=100,
selected_cookie_file=None, selected_cookie_file=None,
override_output_dir=None, override_output_dir=None,
app_base_dir=None, app_base_dir=None,
@@ -1845,6 +1921,8 @@ class DownloadThread(QThread):
self.remove_from_filename_words_list = remove_from_filename_words_list self.remove_from_filename_words_list = remove_from_filename_words_list
self.manga_date_prefix = manga_date_prefix self.manga_date_prefix = manga_date_prefix
self.allow_multipart_download = allow_multipart_download 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.selected_cookie_file = selected_cookie_file
self.app_base_dir = app_base_dir self.app_base_dir = app_base_dir
self.cookie_text = cookie_text self.cookie_text = cookie_text
@@ -1986,6 +2064,8 @@ class DownloadThread(QThread):
'text_only_scope': self.text_only_scope, 'text_only_scope': self.text_only_scope,
'text_export_format': self.text_export_format, 'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_mode, '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, 'project_root_dir': self.project_root_dir,
} }

View File

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

View File

@@ -3,8 +3,27 @@ import re
try: try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True 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: except ImportError:
FPDF_AVAILABLE = False 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): def strip_html_tags(text):
if not text: if not text:
@@ -12,19 +31,6 @@ def strip_html_tags(text):
clean = re.compile('<.*?>') clean = re.compile('<.*?>')
return re.sub(clean, '', text) 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): 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. 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.ln(10)
pdf.set_font(default_font_family, 'B', 16) 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) pdf.ln(5)
if 'comments' in post and post['comments']: 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.ln(10)
pdf.set_font(default_font_family, '', 11) 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: if comment_index < len(comments_list) - 1:
pdf.ln(3) pdf.ln(3)
@@ -97,7 +103,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
pdf.ln(3) pdf.ln(3)
elif 'content' in post: elif 'content' in post:
pdf.set_font(default_font_family, '', 12) 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: try:
pdf.output(output_filename) pdf.output(output_filename)

View File

@@ -58,6 +58,7 @@ from .dialogs.MoreOptionsDialog import MoreOptionsDialog
from .dialogs.SinglePDF import create_single_pdf_from_content from .dialogs.SinglePDF import create_single_pdf_from_content
from .dialogs.SupportDialog import SupportDialog from .dialogs.SupportDialog import SupportDialog
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
class DynamicFilterHolder: class DynamicFilterHolder:
"""A thread-safe class to hold and update character filters during a download.""" """A thread-safe class to hold and update character filters during a download."""
@@ -222,6 +223,9 @@ class DownloaderApp (QWidget ):
self.only_links_log_display_mode = LOG_DISPLAY_LINKS self.only_links_log_display_mode = LOG_DISPLAY_LINKS
self.mega_download_log_preserved_once = False self.mega_download_log_preserved_once = False
self.allow_multipart_download_setting = 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.use_cookie_setting = False
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool) self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
self.cookie_text_setting = "" self.cookie_text_setting = ""
@@ -236,6 +240,7 @@ class DownloaderApp (QWidget ):
self.session_temp_files = [] self.session_temp_files = []
self.single_pdf_mode = False self.single_pdf_mode = False
self.save_creator_json_enabled_this_session = True 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}") print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -267,7 +272,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None self.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.2.1") self.setWindowTitle("Kemono Downloader v6.3.0")
setup_ui(self) setup_ui(self)
self._connect_signals() self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.") self.log_signal.emit(" Local API server functionality has been removed.")
@@ -1018,23 +1023,42 @@ class DownloaderApp (QWidget ):
def save_known_names(self): def save_known_names(self):
""" """
Saves the current list of known names (KNOWN_NAMES) to the config file. Saves the current list of known names (KNOWN_NAMES) to the config file.
This version includes a fix to ensure the destination directory exists FIX: This version re-reads the file from disk before saving to preserve
before attempting to write the file, preventing crashes in new installations. any external edits made by the user.
""" """
global KNOWN_NAMES global KNOWN_NAMES
try: try:
config_dir = os.path.dirname(self.config_file) config_dir = os.path.dirname(self.config_file)
os.makedirs(config_dir, exist_ok=True) 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: for entry in KNOWN_NAMES:
if entry["is_group"]: 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: 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'): 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: except Exception as e:
log_msg = f"❌ Error saving config '{self.config_file}': {e}" log_msg = f"❌ Error saving config '{self.config_file}': {e}"
@@ -2689,16 +2713,13 @@ class DownloaderApp (QWidget ):
url_text =self .link_input .text ().strip ()if self .link_input else "" url_text =self .link_input .text ().strip ()if self .link_input else ""
_ ,_ ,post_id =extract_post_info (url_text ) _ ,_ ,post_id =extract_post_info (url_text )
# --- START: MODIFIED LOGIC ---
is_creator_feed =not post_id if url_text else False is_creator_feed =not post_id if url_text else False
is_single_post = bool(post_id) is_single_post = bool(post_id)
is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
# If the download queue contains items selected from the popup, treat it as a single-post context for UI purposes.
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue): 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 is_single_post = True
# Allow Manga Mode checkbox for any valid URL (creator or single post) or if single posts are queued.
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on
if self .manga_mode_checkbox : if self .manga_mode_checkbox :
@@ -2709,12 +2730,10 @@ class DownloaderApp (QWidget ):
manga_mode_effectively_on = can_enable_manga_checkbox and checked manga_mode_effectively_on = can_enable_manga_checkbox and checked
# If it's a single post context, prevent sequential styles from being selected as they don't apply.
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
if is_single_post and self.manga_filename_style in sequential_styles: if is_single_post and self.manga_filename_style in sequential_styles:
self.manga_filename_style = STYLE_POST_TITLE # Default to a safe, non-sequential style self.manga_filename_style = STYLE_POST_TITLE
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
# --- END: MODIFIED LOGIC ---
if self .manga_rename_toggle_button : 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 )) 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 ))
@@ -2748,11 +2767,9 @@ class DownloaderApp (QWidget ):
self .manga_date_prefix_input .setMaximumWidth (16777215 ) self .manga_date_prefix_input .setMaximumWidth (16777215 )
self .manga_date_prefix_input .setMinimumWidth (0 ) self .manga_date_prefix_input .setMinimumWidth (0 )
if hasattr (self ,'multipart_toggle_button'): 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_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)
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 ))
self ._update_multithreading_for_date_mode () self ._update_multithreading_for_date_mode ()
@@ -2953,9 +2970,6 @@ class DownloaderApp (QWidget ):
service, user_id, post_id_from_url = extract_post_info(api_url) service, user_id, post_id_from_url = extract_post_info(api_url)
# --- START: MODIFIED SECTION ---
# This check is now smarter. It only triggers the error if the item from the queue
# was supposed to be a post ('single_post_from_popup', etc.) but couldn't be parsed.
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue: 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(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.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
@@ -2966,50 +2980,55 @@ class DownloaderApp (QWidget ):
kept_original_names_list=[] kept_original_names_list=[]
) )
return False return False
# --- END: MODIFIED SECTION ---
if not service or not user_id: if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False return False
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) 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)
creator_profile_data = {} if not self.is_single_post_session:
if self.save_creator_json_enabled_this_session: self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
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 creator_name_for_profile: creator_profile_data = {}
creator_name_for_profile = f"{service}_{user_id}" if self.save_creator_json_enabled_this_session:
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.") 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.")
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 = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
creator_profile_data['settings'] = current_settings
creator_profile_data.setdefault('creator_url', []) current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
if api_url not in creator_profile_data['creator_url']: creator_profile_data['settings'] = current_settings
creator_profile_data['creator_url'].append(api_url)
creator_profile_data.setdefault('processed_post_ids', []) creator_profile_data.setdefault('creator_url', [])
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path) if api_url not in creator_profile_data['creator_url']:
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.") 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.")
profile_processed_ids = set() profile_processed_ids = set()
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'] = []
session_processed_ids = set(processed_post_ids_for_restore) session_processed_ids = set(processed_post_ids_for_restore)
combined_processed_ids = session_processed_ids.union(profile_processed_ids) combined_processed_ids = session_processed_ids.union(profile_processed_ids)
@@ -3453,6 +3472,9 @@ class DownloaderApp (QWidget ):
'num_file_threads_for_worker': effective_num_file_threads_per_worker, 'num_file_threads_for_worker': effective_num_file_threads_per_worker,
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread, 'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
'allow_multipart_download': allow_multipart, '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, 'cookie_text': cookie_text_from_input,
'selected_cookie_file': selected_cookie_file_path_for_backend, 'selected_cookie_file': selected_cookie_file_path_for_backend,
'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread, 'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread,
@@ -3497,7 +3519,7 @@ class DownloaderApp (QWidget ):
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'scan_content_for_images', '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', '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', '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', 'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode',
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock', 'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
'processed_post_ids' 'processed_post_ids'
@@ -3514,7 +3536,6 @@ class DownloaderApp (QWidget ):
self.is_paused = False self.is_paused = False
return True return True
def restore_download(self): def restore_download(self):
"""Initiates the download restoration process.""" """Initiates the download restoration process."""
if self._is_download_active(): if self._is_download_active():
@@ -3961,7 +3982,7 @@ class DownloaderApp (QWidget ):
def _add_to_history_candidates(self, history_data): def _add_to_history_candidates(self, history_data):
"""Adds processed post data to the history candidates list and updates the creator profile.""" """Adds processed post data to the history candidates list and updates the creator profile."""
if self.save_creator_json_enabled_this_session: if self.save_creator_json_enabled_this_session and not self.is_single_post_session:
post_id = history_data.get('post_id') post_id = history_data.get('post_id')
service = history_data.get('service') service = history_data.get('service')
user_id = history_data.get('user_id') user_id = history_data.get('user_id')
@@ -3969,7 +3990,6 @@ class DownloaderApp (QWidget ):
creator_key = (service.lower(), str(user_id)) creator_key = (service.lower(), str(user_id))
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{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) profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
if post_id not in profile_data.get('processed_post_ids', []): if post_id not in profile_data.get('processed_post_ids', []):
@@ -3982,6 +4002,7 @@ class DownloaderApp (QWidget ):
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown')) history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
self.download_history_candidates.append(history_data) self.download_history_candidates.append(history_data)
def _finalize_download_history (self ): def _finalize_download_history (self ):
"""Processes candidates and selects the final 3 history entries. """Processes candidates and selects the final 3 history entries.
Only updates final_download_history_entries if new candidates are available. Only updates final_download_history_entries if new candidates are available.
@@ -4553,42 +4574,49 @@ class DownloaderApp (QWidget ):
self .active_retry_futures_map [future ]=job_details self .active_retry_futures_map [future ]=job_details
self .active_retry_futures .append (future ) self .active_retry_futures .append (future )
def _execute_single_file_retry (self ,job_details ,common_args ): 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']} 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 ={ # Reconstruct the post_page_url, which is needed by the download function
**common_args , service = job_details.get('service', 'unknown_service')
'post_data':dummy_post_data , user_id = job_details.get('user_id', 'unknown_user')
'service':job_details .get ('service','unknown_service'), post_id = job_details.get('original_post_id_for_log', 'unknown_id')
'user_id':job_details .get ('user_id','unknown_user'), api_url_input = job_details.get('api_url_input', '')
'api_url_input':job_details .get ('api_url_input',''), parsed_api_url = urlparse(api_url_input)
'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ), api_domain = parsed_api_url.netloc if parsed_api_url.netloc else self._get_domain_for_service(service)
'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ), post_page_url = f"https://{api_domain}/{service}/user/{user_id}/post/{post_id}"
'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
'use_cookie':common_args .get ('use_cookie',False ), # Prepare all arguments for the PostProcessorWorker
'cookie_text':common_args .get ('cookie_text',""), ppw_init_args = {
'selected_cookie_file':common_args .get ('selected_cookie_file',None ), **common_args,
'app_base_dir':common_args .get ('app_base_dir',None ), '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 ( worker = PostProcessorWorker(**ppw_init_args)
file_info =job_details ['file_info'],
target_folder_path =job_details ['target_folder_path'], # Call the download method with the corrected arguments
headers =job_details ['headers'], dl_count, skip_count, filename_saved, original_kept, status, _ = worker._download_single_file(
original_post_id_for_log =job_details ['original_post_id_for_log'], file_info=job_details['file_info'],
skip_event =None , target_folder_path=job_details['target_folder_path'],
post_title =job_details ['post_title'], post_page_url=post_page_url, # Using the correct argument
file_index_in_post =job_details ['file_index_in_post'], original_post_id_for_log=job_details['original_post_id_for_log'],
num_files_in_this_post =job_details ['num_files_in_this_post'], skip_event=None,
forced_filename_override =job_details .get ('forced_filename_override') 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
@@ -4937,14 +4965,15 @@ class DownloaderApp (QWidget ):
with QMutexLocker (self .prompt_mutex ):self ._add_character_response =result 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'}") 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 ): def _update_multipart_toggle_button_text(self):
if hasattr (self ,'multipart_toggle_button'): if hasattr(self, 'multipart_toggle_button'):
if self .allow_multipart_download_setting : if self.allow_multipart_download_setting:
self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON")) scope_text = self.multipart_scope.capitalize()
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON")) self.multipart_toggle_button.setText(self._tr("multipart_on_button_text", f"Multi-part: {scope_text}"))
else : self.multipart_toggle_button.setToolTip(self._tr("multipart_on_button_tooltip", f"Multipart download is ON. Applied to: {scope_text} files. Click to change."))
self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF")) else:
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF")) 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): def _update_error_button_count(self):
"""Updates the Error button text to show the count of failed files.""" """Updates the Error button text to show the count of failed files."""
@@ -4959,36 +4988,25 @@ class DownloaderApp (QWidget ):
else: else:
self.error_btn.setText(base_text) self.error_btn.setText(base_text)
def _toggle_multipart_mode (self ): def _toggle_multipart_mode(self):
if not self .allow_multipart_download_setting : """
msg_box =QMessageBox (self ) Opens the Multipart Scope Dialog and updates settings based on user choice.
msg_box .setIcon (QMessageBox .Warning ) """
msg_box .setWindowTitle ("Multi-part Download Advisory") current_scope = self.multipart_scope if self.allow_multipart_download_setting else 'both'
msg_box .setText ( dialog = MultipartScopeDialog(current_scope, self.multipart_parts_count, self.multipart_min_size_mb, self)
"<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 : if dialog.exec_() == QDialog.Accepted:
self .log_signal .emit (" Multi-part download enabling cancelled by user.") self.multipart_scope = dialog.get_selected_scope()
return 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 .allow_multipart_download_setting =not self .allow_multipart_download_setting self._update_multipart_toggle_button_text()
self ._update_multipart_toggle_button_text () self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
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 _open_known_txt_file (self ): def _open_known_txt_file (self ):
if not os .path .exists (self .config_file ): if not os .path .exists (self .config_file ):