mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
8 Commits
v7.0.0
...
a9b210b2ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b210b2ba | ||
|
|
ec94417569 | ||
|
|
0a902895a8 | ||
|
|
7217bfdb39 | ||
|
|
24880b5042 | ||
|
|
510ae5e1d1 | ||
|
|
65b4759bad | ||
|
|
6e993d88de |
500
features.md
500
features.md
@@ -1,391 +1,159 @@
|
|||||||
<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>1. Core Concepts & Supported Sites</h2>
|
||||||
|
<h3>URL Input (🔗)</h3>
|
||||||
<h2><strong>1. URL Input (🔗)</strong></h2>
|
<p>This is the primary input field where you specify the content you want to download.</p>
|
||||||
<p>This is the primary input field where you specify the content you want to download.</p>
|
<p><strong>Supported URL Types:</strong></p>
|
||||||
|
|
||||||
<p><strong>Functionality:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Creator URL:</strong> A link to a creator's main page (e.g., https://kemono.su/patreon/user/12345). Downloads all posts from the creator.</li>
|
|
||||||
<li><strong>Post URL:</strong> A direct link to a specific post (e.g., .../post/98765). Downloads only the specified post.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><strong>Interaction with Other Features:</strong> The content of this field influences "Manga Mode" and "Page Range". "Page Range" is enabled only with a creator URL.</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2><strong>2. Creator Selection & Update (🎨)</strong></h2>
|
|
||||||
<p>The color palette emoji button opens the Creator Selection & Update dialog. This allows managing and downloading from a local creator database.</p>
|
|
||||||
|
|
||||||
<p><strong>Functionality:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Creator Browser:</strong> Loads a list from <code>creators.json</code>. Search by name, service, or paste a URL to find creators.</li>
|
|
||||||
<li><strong>Batch Selection:</strong> Select multiple creators and click "Add Selected" to add them to the batch download session.</li>
|
|
||||||
<li><strong>Update Checker:</strong> Use a saved profile (.json) to download only new content based on previously fetched posts.</li>
|
|
||||||
<li><strong>Post Fetching & Filtering:</strong> "Fetch Posts" loads post titles, allowing you to choose specific posts for download.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2><strong>3. Download Location Input (📁)</strong></h2>
|
|
||||||
<p>This input defines the destination directory for downloaded files.</p>
|
|
||||||
|
|
||||||
<p><strong>Functionality:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Manual Entry:</strong> Enter or paste the folder path.</li>
|
|
||||||
<li><strong>Browse Button:</strong> Opens a system dialog to choose a folder.</li>
|
|
||||||
<li><strong>Directory Creation:</strong> If the folder doesn't exist, the app can create it after user confirmation.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2><strong>4. Filter by Character(s) & Scope Button</strong></h2>
|
|
||||||
<p>Used to download content for specific characters or series and organize them into subfolders.</p>
|
|
||||||
|
|
||||||
<p><strong>Input Field (Filter by Character(s)):</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Enter comma-separated names (e.g., <code>Tifa, Aerith</code>).</li>
|
|
||||||
<li>Group aliases using parentheses (e.g., <code>(Cloud, Zack)</code>).</li>
|
|
||||||
<li>Names are matched against titles, filenames, or comments.</li>
|
|
||||||
<li>If "Separate Folders by Known.txt" is enabled, the name becomes the subfolder name.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><strong>Scope Button Modes:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Filter: Title</strong> (default) – Match names in post titles only.</li>
|
|
||||||
<li><strong>Filter: Files</strong> – Match names in filenames only.</li>
|
|
||||||
<li><strong>Filter: Both</strong> – Try title match first, then filenames.</li>
|
|
||||||
<li><strong>Filter: Comments</strong> – Try filenames first, then post comments if no match.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2><strong>5. Skip with Words & Scope Button</strong></h2>
|
|
||||||
<p>Prevents downloading content based on keywords.</p>
|
|
||||||
|
|
||||||
<p><strong>Input Field (Skip with Words):</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Enter comma-separated keywords (e.g., <code>WIP, sketch, preview</code>).</li>
|
|
||||||
<li>Matching is case-insensitive.</li>
|
|
||||||
<li>If a keyword matches, the file or post is skipped.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><strong>Scope Button Modes:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Scope: Posts</strong> (default) – Skips post if title contains a keyword.</li>
|
|
||||||
<li><strong>Scope: Files</strong> – Skips individual files with keyword matches.</li>
|
|
||||||
<li><strong>Scope: Both</strong> – Skips entire post if title matches, otherwise filters individual files.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2><strong>Filter File Section (Radio Buttons)</strong></h2>
|
|
||||||
<p>This section uses a group of radio buttons to control the primary download mode, dictating which types of files are targeted. Only one of these modes can be active at a time.</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>All:</strong> Default mode. Downloads every file and attachment provided by the API, regardless of type.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Images/GIFs:</strong> Filters for common image formats (<code>.jpg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>), skipping non-image files.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Videos:</strong> Filters for common video formats like <code>.mp4</code>, <code>.webm</code>, and <code>.mov</code>, skipping all others.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Only Archives:</strong> Downloads only archive files (<code>.zip</code>, <code>.rar</code>). Disables "Compress to WebP" and unchecks "Skip Archives".
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Only Audio:</strong> Filters for common audio formats like <code>.mp3</code>, <code>.wav</code>, and <code>.flac</code>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Only Links:</strong> Extracts external hyperlinks from post descriptions (e.g., Mega, Google Drive) and displays them in the log. Disables all download options.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>More:</strong> Opens the "More Options" dialog to download text-based content instead of media files.
|
|
||||||
<ul>
|
|
||||||
<li><strong>Scope:</strong> Choose to extract from post description or comments.</li>
|
|
||||||
<li><strong>Export Format:</strong> Save text as PDF, DOCX, or TXT.</li>
|
|
||||||
<li><strong>Single PDF:</strong> Optionally compile all text into one PDF.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2><strong>Check Box Buttons</strong></h2>
|
|
||||||
<p>These checkboxes provide additional toggles to refine the download behavior and enable special features.</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>⭐ Favorite Mode:</strong> Changes workflow to download from your personal favorites. Disables the URL input.
|
|
||||||
<ul>
|
|
||||||
<li><strong>Favorite Artists:</strong> Opens a dialog to select from your favorited creators.</li>
|
|
||||||
<li><strong>Favorite Posts:</strong> Opens a dialog to select from your favorited posts on Kemono and Coomer.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Skip Archives:</strong> When checked, archive files (<code>.zip</code>, <code>.rar</code>) are ignored. Disabled in "Only Archives" mode.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Download Thumbnail Only:</strong> Saves only thumbnail previews, not full-resolution files. Enables "Scan Content for Images".
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Scan Content for Images:</strong> Parses post HTML for embedded images not listed in the API. Looks for <code><img></code> tags and direct image links.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Compress to WebP:</strong> Converts large images (over 1.5 MB) to WebP format using the Pillow library for space-saving.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Keep Duplicates:</strong> Provides control over duplicate handling via the "Duplicate Handling Options" dialog.
|
|
||||||
<ul>
|
|
||||||
<li><strong>Skip by Hash:</strong> Default – skip identical files.</li>
|
|
||||||
<li><strong>Keep Everything:</strong> Save all files regardless of duplication.</li>
|
|
||||||
<li><strong>Limit:</strong> Set a limit on how many copies of the same file are saved. A limit of <code>0</code> means no limit.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<h2><strong>Folder Organization Checkboxes</strong></h2>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li><strong>Creator URL</strong>: A link to a creator's main page. Downloads all posts from that creator.</li>
|
||||||
<strong>Separate folders by Known.txt:</strong> Automatically organizes downloads into folders based on name matches.
|
<li><strong>Post URL</strong>: A direct link to a specific post. Downloads only that single post.</li>
|
||||||
<ul>
|
<li><strong>Batch Command</strong>: Special keywords to trigger bulk downloading from a text file (see Batch Downloading section).</li>
|
||||||
<li>Uses "Filter by Character(s)" input first, if available.</li>
|
|
||||||
<li>Then checks names in <code>Known.txt</code>.</li>
|
|
||||||
<li>Falls back to extracting from post title.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Subfolder per post:</strong> Creates a unique folder per post, using the post’s title.
|
|
||||||
<ul>
|
|
||||||
<li>Prevents mixing files from multiple posts.</li>
|
|
||||||
<li>Can be combined with Known.txt-based folders.</li>
|
|
||||||
<li>Ensures uniqueness (e.g., <code>My Post Title_1</code>).</li>
|
|
||||||
<li>Automatically removes empty folders.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Date prefix:</strong> Enabled only with "Subfolder per post". Prepends the post date (e.g., <code>2025-08-03 My Post Title</code>) for chronological sorting.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<p><strong>Supported Websites:</strong></p>
|
||||||
<h2><strong>General Functionality Checkboxes</strong></h2>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>Kemono (<code>kemono.su</code>, <code>kemono.party</code>, etc.)</li>
|
||||||
<strong>Use cookie:</strong> Enables login-based access via cookies.
|
<li>Coomer (<code>coomer.su</code>, <code>coomer.party</code>, etc.)</li>
|
||||||
|
<li>Discord (via Kemono/Coomer API)</li>
|
||||||
|
<li>Bunkr</li>
|
||||||
|
<li>Erome</li>
|
||||||
|
<li>Saint2.su</li>
|
||||||
|
<li>nhentai</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2>2. Main Download Controls & Inputs</h2>
|
||||||
|
<h3>Download Location (📁)</h3>
|
||||||
|
<p>This input defines the main folder where your files will be saved.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Browse Button</strong>: Opens a system dialog to choose a folder.</li>
|
||||||
|
<li><strong>Directory Creation</strong>: If the folder doesn't exist, the app will ask for confirmation to create it.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Filter by Character(s) & Scope</h3>
|
||||||
|
<p>Used to download content for specific characters or series and organize them into subfolders.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Input Field</strong>: Enter comma-separated names (e.g., <code>Tifa, Aerith</code>). Group aliases using parentheses for folder naming (e.g., <code>(Cloud, Zack)</code>).</li>
|
||||||
|
<li><strong>Scope Button</strong>: Cycles through where to look for name matches:
|
||||||
<ul>
|
<ul>
|
||||||
<li>Paste cookie string directly, or browse to select a <code>cookies.txt</code> file.</li>
|
<li><strong>Filter: Title</strong>: Matches names in the post title.</li>
|
||||||
<li>Cookies are used in all authenticated API requests.</li>
|
<li><strong>Filter: Files</strong>: Matches names in the filenames.</li>
|
||||||
</ul>
|
<li><strong>Filter: Both</strong>: Checks the title first, then filenames.</li>
|
||||||
</li>
|
<li><strong>Filter: Comments</strong>: Checks filenames first, then post comments.</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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h2><strong>Start Download</strong></h2>
|
<h3>Skip with Words & Scope</h3>
|
||||||
|
<p>Prevents downloading content based on keywords or file size.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li><strong>Input Field</strong>: Enter comma-separated keywords (e.g., <code>WIP, sketch, preview</code>).</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><strong>Skip by Size</strong>: Enter a number in square brackets to skip any file <strong>smaller than</strong> that size in MB. For example, <code>WIP, [200]</code> skips files with "WIP" in the name AND any file smaller than 200 MB.</li>
|
||||||
</li>
|
<li><strong>Scope Button</strong>: Cycles through where to apply keyword filters:
|
||||||
<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>
|
<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>Scope: Posts</strong>: Skips the entire post if the title matches.</li>
|
||||||
<li><strong>"▶️ Resume Download":</strong> Clears the <code>pause_event</code>, allowing threads to resume their work.</li>
|
<li><strong>Scope: Files</strong>: Skips individual files if the filename matches.</li>
|
||||||
</ul>
|
<li><strong>Scope: Both</strong>: Checks the post title first, then individual files.</li>
|
||||||
</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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h2><strong>"Known Area" and its Controls</strong></h2>
|
<h3>Remove Words from Name (✂️)</h3>
|
||||||
<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>
|
<p>Enter comma-separated words to remove from final filenames (e.g., <code>patreon, [HD]</code>). This helps clean up file naming.</p>
|
||||||
|
<hr>
|
||||||
|
<h2>3. Primary Download Modes (Filter File Section)</h2>
|
||||||
|
<p>This section uses radio buttons to set the main download mode. Only one can be active at a time.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li><strong>All</strong>: Default mode. Downloads every file and attachment.</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><strong>Images/GIFs</strong>: Downloads only common image formats.</li>
|
||||||
</li>
|
<li><strong>Videos</strong>: Downloads only common video formats.</li>
|
||||||
<li>
|
<li><strong>Only Archives</strong>: Downloads only <code>.zip</code>, <code>.rar</code>, etc.</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><strong>Only Audio</strong>: Downloads only common audio formats.</li>
|
||||||
</li>
|
<li><strong>Only Links</strong>: Extracts external hyperlinks (e.g., Mega, Google Drive) from post descriptions instead of downloading files. <strong>This mode unlocks special features</strong> (see section 6).</li>
|
||||||
<li>
|
<li><strong>More</strong>: Opens a dialog to download text-based content.
|
||||||
<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>
|
<ul>
|
||||||
<li>Recently downloaded files</li>
|
<li><strong>Scope</strong>: Choose to extract text from the post description or comments.</li>
|
||||||
<li>The first few posts processed in the last session</li>
|
<li><strong>Export Format</strong>: Save as PDF, DOCX, or TXT.</li>
|
||||||
</ul>
|
<li><strong>Single PDF</strong>: Compile all text from the session into one consolidated PDF file.</li>
|
||||||
This allows for a quick review of recent activity.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Settings Button:</strong> Opens the Settings dialog (from <code>FutureSettingsDialog.py</code>), where you can change app-wide settings such as theme (light/dark) and language.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Support Button:</strong> Opens the Support dialog (from <code>SupportDialog.py</code>), which includes developer info, source links, and donation platforms like Ko-fi or Patreon.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<h2><strong>Log Area Controls</strong></h2>
|
|
||||||
<p>These controls are located around the main log panel and offer tools for managing downloads, configuring advanced options, and resetting the application.</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Multi-part: OFF</strong><br>
|
|
||||||
This button acts as both a status indicator and a configuration panel for multi-part downloading (parallel downloading of large files).
|
|
||||||
<ul>
|
|
||||||
<li><strong>Function:</strong> Opens the <code>Multipart Download Options</code> dialog (defined in <code>MultipartScopeDialog.py</code>).</li>
|
|
||||||
<li><strong>Scope Options:</strong> Choose between "Videos Only", "Archives Only", or "Both".</li>
|
|
||||||
<li><strong>Number of parts:</strong> Set how many simultaneous connections to use (2–16).</li>
|
|
||||||
<li><strong>Minimum file size:</strong> Set a threshold (MB) below which files are downloaded normally.</li>
|
|
||||||
<li><strong>Status:</strong> After applying settings, the button's text updates (e.g., <code>Multi-part: Both</code>); otherwise, it resets to <code>Multi-part: OFF</code>.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<strong>👁️ Eye Emoji Button (Log View Toggle)</strong><br>
|
|
||||||
Switches between two views in the log panel:
|
|
||||||
<ul>
|
|
||||||
<li><strong>👁️ Progress Log View:</strong> Shows real-time download progress, status messages, and errors.</li>
|
|
||||||
<li><strong>🚫 Missed Character View:</strong> Displays names detected in posts that didn’t match the current filter — useful for updating <code>Known.txt</code>.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<strong>Reset Button</strong><br>
|
|
||||||
Performs a full "soft reset" of the UI when the application is idle.
|
|
||||||
<ul>
|
|
||||||
<li>Clears all inputs (except saved Download Location)</li>
|
|
||||||
<li>Resets checkboxes, buttons, and logs</li>
|
|
||||||
<li>Clears counters, queues, and restores the UI to its default state</li>
|
|
||||||
<li><strong>Note:</strong> This is different from <em>Cancel & Reset UI</em>, which halts active downloads</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<hr>
|
||||||
<h3><strong>The Progress Log and "Only Links" Mode Controls</strong></h3>
|
<h2>4. Advanced Features & Toggles (Checkboxes)</h2>
|
||||||
|
<h3>Folder Organization</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li><strong>Separate folders by Known.txt</strong>: Automatically organizes downloads into subfolders based on name matches from your <code>Known.txt</code> list or the "Filter by Character(s)" input.</li>
|
||||||
<strong>Standard Mode (Progress Log)</strong><br>
|
<li><strong>Subfolder per post</strong>: Creates a unique folder for each post, named after the post's title. This prevents files from different posts from mixing.</li>
|
||||||
This is the default behavior. The <code>main_log_output</code> field displays:
|
<li><strong>Date prefix</strong>: (Only available with "Subfolder per post") Prepends the post date to the folder name (e.g., <code>2025-08-03 My Post Title</code>) for chronological sorting.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Special Modes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>⭐ Favorite Mode</strong>: Switches the UI to download from your personal favorites list instead of using the URL input.</li>
|
||||||
|
<li><strong>Manga/Comic mode</strong>: Sorts a creator's posts from oldest to newest before downloading, ensuring correct page order. A scope button appears to control the filename style (e.g., using post title, date, or a global number).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>File Handling</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Skip Archives</strong>: Ignores <code>.zip</code> and <code>.rar</code> files during downloads.</li>
|
||||||
|
<li><strong>Download Thumbnail Only</strong>: Saves only the small preview images instead of full-resolution files.</li>
|
||||||
|
<li><strong>Scan Content for Images</strong>: Parses post HTML to find embedded images that may not be listed in the API data.</li>
|
||||||
|
<li><strong>Compress to WebP</strong>: Converts large images (over 1.5 MB) to the space-saving WebP format.</li>
|
||||||
|
<li><strong>Keep Duplicates</strong>: Opens a dialog to control how duplicate files are handled (skip by default, keep all, or keep a specific number of copies).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>General Functionality</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Use cookie</strong>: Enables login-based access. You can paste a cookie string or browse for a <code>cookies.txt</code> file.</li>
|
||||||
|
<li><strong>Use Multithreading</strong>: Enables parallel processing of posts for faster downloads. You can set the number of concurrent worker threads.</li>
|
||||||
|
<li><strong>Show external links in log</strong>: Opens a secondary log panel that displays external links found in post descriptions.</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2>5. Specialized Downloaders & Batch Mode</h2>
|
||||||
|
<h3>Discord Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>When a Discord URL is entered, a <strong>Scope</strong> button appears.
|
||||||
<ul>
|
<ul>
|
||||||
<li>Post processing steps</li>
|
<li><strong>Scope: Files</strong>: Downloads all files from the channel/server.</li>
|
||||||
<li>Download/skipped file notifications</li>
|
<li><strong>Scope: Messages</strong>: Saves the entire message history of the channel/server as a formatted PDF.</li>
|
||||||
<li>Error messages</li>
|
|
||||||
<li>Session summaries</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li>A <strong>"Save as PDF"</strong> button also appears as a shortcut for the message saving feature.</li>
|
||||||
<li>
|
</ul>
|
||||||
<strong>"Only Links" Mode</strong><br>
|
<h3>Batch Downloading (<code>nhentai</code> & <code>saint2.su</code>)</h3>
|
||||||
When enabled, the log panel switches modes and reveals new controls.
|
<p>This feature allows you to download hundreds of galleries or videos from a simple text file.</p>
|
||||||
|
<ol>
|
||||||
|
<li>In the <code>appdata</code> folder, create <code>nhentai.txt</code> or <code>saint2.su.txt</code>.</li>
|
||||||
|
<li>Add one full URL per line to the corresponding file.</li>
|
||||||
|
<li>In the app's URL input, type either <code>nhentai.net</code> or <code>saint2.su</code> and click "Start Download".</li>
|
||||||
|
<li>The app will read the file and process every URL in the queue.</li>
|
||||||
|
</ol>
|
||||||
|
<hr>
|
||||||
|
<h2>6. "Only Links" Mode: Extraction & Direct Download</h2>
|
||||||
|
<p>When you select the <strong>"Only Links"</strong> radio button, the application's behavior changes significantly.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Link Extraction</strong>: Instead of downloading files, the main log panel will fill with all external links found (Mega, Google Drive, Dropbox, etc.).</li>
|
||||||
|
<li><strong>Export Links</strong>: An "Export Links" button appears, allowing you to save the full list of extracted URLs to a <code>.txt</code> file.</li>
|
||||||
|
<li><strong>Direct Cloud Download</strong>: A <strong>"Download"</strong> button appears next to the export button.
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>📜 Extracted Links Log:</strong> Replaces progress info with a list of found external links (e.g., Mega, Dropbox).</li>
|
<li>Clicking this opens a new dialog listing all supported cloud links (Mega, G-Drive, Dropbox).</li>
|
||||||
<li><strong>Export Links Button:</strong> Saves the extracted links to a <code>.txt</code> file.</li>
|
<li>You can select which files you want to download from this list.</li>
|
||||||
<li><strong>Download Button:</strong> Opens the <code>Download Selected External Links</code> dialog (from <code>DownloadExtractedLinksDialog.py</code>), where you can:
|
<li>The application will then download the selected files directly from the cloud service to your chosen download location.</li>
|
||||||
<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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2>7. Session & Process Management</h2>
|
||||||
|
<h3>Main Action Buttons</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Start Download</strong>: Begins the download process. This button's text changes contextually (e.g., "Extract Links", "Check for Updates").</li>
|
||||||
|
<li><strong>Pause / Resume</strong>: Pauses or resumes the ongoing download. When paused, you can safely change some settings.</li>
|
||||||
|
<li><strong>Cancel & Reset UI</strong>: Stops the current download and performs a soft reset of the UI, preserving your URL and download location.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Restore Interrupted Download</h3>
|
||||||
|
<p>If the application is closed unexpectedly during a download, it will save its progress.</p>
|
||||||
|
<ul>
|
||||||
|
<li>On the next launch, the UI will be pre-filled with the settings from the interrupted session.</li>
|
||||||
|
<li>The <strong>Pause</strong> button will change to <strong>"🔄 Restore Download"</strong>. Clicking it will resume the download exactly where it left off, skipping already processed posts.</li>
|
||||||
|
<li>The <strong>Cancel</strong> button will change to <strong>"🗑️ Discard Session"</strong>, allowing you to clear the saved state and start fresh.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Other UI Controls</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Error Button</strong>: Shows a count of failed files. Clicking it opens a dialog where you can view, export, or retry the failed downloads.</li>
|
||||||
|
<li><strong>History Button</strong>: Shows a log of recently downloaded files and processed posts.</li>
|
||||||
|
<li><strong>Settings Button</strong>: Opens the settings dialog where you can change the theme, language, and <strong>check for application updates</strong>.</li>
|
||||||
|
<li><strong>Support Button</strong>: Opens a dialog with links to the project's source code and developer support pages.</li>
|
||||||
|
</ul>
|
||||||
|
|||||||
2
main.py
2
main.py
@@ -107,4 +107,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
202
readme.md
202
readme.md
@@ -1,162 +1,116 @@
|
|||||||
<h1 align="center">Kemono Downloader </h1>
|
<h1 align="center">Kemono Downloader</h1>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<table>
|
||||||
<table>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="Read/Read.png" alt="Default Mode" width="400"><br>
|
<img src="Read/Read.png" alt="Default Mode" width="400"><br>
|
||||||
<strong>Default</strong>
|
<strong>Default Mode</strong>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="Read/Read1.png" alt="Favorite Mode" width="400"><br>
|
<img src="Read/Read1.png" alt="Favorite Mode" width="400"><br>
|
||||||
<strong>Favorite Mode</strong>
|
<strong>Favorite Mode</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="Read/Read2.png" alt="Single Post" width="400"><br>
|
<img src="Read/Read2.png" alt="Single Post" width="400"><br>
|
||||||
<strong>Single Post</strong>
|
<strong>Single Post</strong>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"><br>
|
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"><br>
|
||||||
<strong>Manga/Comic Mode</strong>
|
<strong>Manga/Comic Mode</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
<hr>
|
||||||
|
|
||||||
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** (and its mirrors like kemono.party) and **[Coomer.party](https://coomer.party)** (and its mirrors like coomer.su).
|
<p>A powerful, feature-rich GUI application for downloading content from a wide array of sites, including <strong>Kemono</strong>, <strong>Coomer</strong>, <strong>Bunkr</strong>, <strong>Erome</strong>, <strong>Saint2.su</strong>, and <strong>nhentai</strong>.</p>
|
||||||
|
<p>Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation — all within a modern and user-friendly graphical interface.</p>
|
||||||
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation — all within a modern and user-friendly graphical interface.
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<a href="features.md"><img src="https://img.shields.io/badge/📚%20Full%20Feature%20List-FFD700?style=for-the-badge&logoColor=black&color=FFD700" alt="Full Feature List"></a>
|
||||||
[](features.md)
|
<a href="LICENSE"><img src="https://img.shields.io/badge/📝%20License-90EE90?style=for-the-badge&logoColor=black&color=90EE90" alt="License"></a>
|
||||||
[](LICENSE)
|
<a href="note.md"><img src="https://img.shields.io/badge/⚠️%20Important%20Note-FFCCCB?style=for-the-badge&logoColor=black&color=FFCCCB" alt="Important Note"></a>
|
||||||
[](note.md)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2><strong>Core Capabilities Overview</strong></h2>
|
<h2>Core Capabilities Overview</h2>
|
||||||
|
<h3>High-Performance & Resilient Downloading</h3>
|
||||||
<h3><strong>High-Performance Downloading</strong></h3>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Multi-threading:</strong> Processes multiple posts simultaneously to greatly accelerate downloads from large creator profiles.</li>
|
<li><strong>Multi-threading:</strong> Processes multiple posts simultaneously to greatly accelerate downloads from large creator profiles.</li>
|
||||||
<li><strong>Multi-part Downloading:</strong> Splits large files into chunks and downloads them in parallel to maximize speed.</li>
|
<li><strong>Multi-part Downloading:</strong> Splits large files into chunks and downloads them in parallel to maximize speed.</li>
|
||||||
<li><strong>Resilience:</strong> Supports pausing, resuming, and restoring downloads after crashes or interruptions.</li>
|
<li><strong>Session Management:</strong> Supports pausing, resuming, and <strong>restoring downloads</strong> after crashes or interruptions, so you never lose your progress.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3>Expanded Site Support</h3>
|
||||||
<h3><strong>Advanced Filtering & Content Control</strong></h3>
|
<ul>
|
||||||
|
<li><strong>Direct Downloading:</strong> Full support for Kemono, Coomer, Bunkr, Erome, Saint2.su, and nhentai.</li>
|
||||||
|
<li><strong>Batch Mode:</strong> Download hundreds of URLs at once from <code>nhentai.txt</code> or <code>saint2.su.txt</code> files.</li>
|
||||||
|
<li><strong>Discord Support:</strong> Download files or save entire channel histories as PDFs directly through the API.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Advanced Filtering & Content Control</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Content Type Filtering:</strong> Select whether to download all files or limit to images, videos, audio, or archives only.</li>
|
<li><strong>Content Type Filtering:</strong> Select whether to download all files or limit to images, videos, audio, or archives only.</li>
|
||||||
<li><strong>Keyword Skipping:</strong> Automatically skips posts or files containing certain keywords (e.g., "WIP", "sketch").</li>
|
<li><strong>Keyword Skipping:</strong> Automatically skips posts or files containing certain keywords (e.g., "WIP", "sketch").</li>
|
||||||
<li><strong>Character Filtering:</strong> Restricts downloads to posts that match specific character or series names.</li>
|
<li><strong>Skip by Size:</strong> Avoid small files by setting a minimum size threshold in MB (e.g., <code>[200]</code>).</li>
|
||||||
|
<li><strong>Character Filtering:</strong> Restricts downloads to posts that match specific character or series names, with scope controls for title, filename, or comments.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3>Intelligent File Organization</h3>
|
||||||
<h3><strong>File Organization & Renaming</strong></h3>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Automated Subfolders:</strong> Automatically organizes downloaded files into subdirectories based on character names or per post.</li>
|
<li><strong>Automated Subfolders:</strong> Automatically organizes downloaded files into subdirectories based on character names or per post.</li>
|
||||||
<li><strong>Advanced File Renaming:</strong> Flexible renaming options, especially in Manga Mode, including:
|
<li><strong>Advanced File Renaming:</strong> Flexible renaming options, especially in Manga Mode, including by post title, date, sequential numbering, or post ID.</li>
|
||||||
<ul>
|
<li><strong>Filename Cleaning:</strong> Automatically removes unwanted text from filenames.</li>
|
||||||
<li><strong>Post Title:</strong> Uses the post's title (e.g., <code>Chapter-One.jpg</code>).</li>
|
|
||||||
<li><strong>Date + Original Name:</strong> Prepends the publication date to the original filename.</li>
|
|
||||||
<li><strong>Date + Title:</strong> Combines the date with the post title.</li>
|
|
||||||
<li><strong>Sequential Numbering (Date Based):</strong> Simple sequence numbers (e.g., <code>001.jpg</code>, <code>002.jpg</code>).</li>
|
|
||||||
<li><strong>Title + Global Numbering:</strong> Uses post title with a globally incrementing number across the session.</li>
|
|
||||||
<li><strong>Post ID:</strong> Names files using the post’s unique ID.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3>Specialized Modes</h3>
|
||||||
<h3><strong>Specialized Modes</strong></h3>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Manga/Comic Mode:</strong> Sorts posts chronologically before downloading to ensure pages appear in the correct sequence.</li>
|
<li><strong>Manga/Comic Mode:</strong> Sorts posts chronologically before downloading to ensure pages appear in the correct sequence.</li>
|
||||||
<li><strong>Favorite Mode:</strong> Connects to your account and downloads from your favorites list (artists or posts).</li>
|
<li><strong>Favorite Mode:</strong> Connects to your account and downloads from your favorites list (artists or posts).</li>
|
||||||
<li><strong>Link Extraction Mode:</strong> Extracts external links from posts for export or targeted downloading.</li>
|
<li><strong>Link Extraction Mode:</strong> Extracts external links (Mega, Google Drive) from posts for export or <strong>direct in-app downloading</strong>.</li>
|
||||||
<li><strong>Text Extraction Mode:</strong> Saves post descriptions or comment sections as <code>PDF</code>, <code>DOCX</code>, or <code>TXT</code> files.</li>
|
<li><strong>Text Extraction Mode:</strong> Saves post descriptions or comment sections as <code>PDF</code>, <code>DOCX</code>, or <code>TXT</code> files.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3>Utility & Advanced Features</h3>
|
||||||
<h3><strong>Utility & Advanced Features</strong></h3>
|
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><strong>In-App Updater:</strong> Check for new versions directly from the settings menu.</li>
|
||||||
<li><strong>Cookie Support:</strong> Enables access to subscriber-only content via browser session cookies.</li>
|
<li><strong>Cookie Support:</strong> Enables access to subscriber-only content via browser session cookies.</li>
|
||||||
<li><strong>Duplicate Detection:</strong> Prevents saving duplicate files using content-based comparison, with configurable limits.</li>
|
<li><strong>Duplicate Detection:</strong> Prevents saving duplicate files using content-based comparison, with configurable limits.</li>
|
||||||
<li><strong>Image Compression:</strong> Automatically converts large images to <code>.webp</code> to reduce disk usage.</li>
|
<li><strong>Image Compression:</strong> Automatically converts large images to <code>.webp</code> to reduce disk usage.</li>
|
||||||
<li><strong>Creator Management:</strong> Built-in creator browser and update checker for downloading only new posts from saved profiles.</li>
|
<li><strong>Creator Management:</strong> Built-in creator browser and update checker for downloading only new posts from saved profiles.</li>
|
||||||
<li><strong>Error Handling:</strong> Tracks failed downloads and provides a retry dialog with options to export or redownload missing files.</li>
|
<li><strong>Error Handling:</strong> Tracks failed downloads and provides a retry dialog with options to export or redownload missing files.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h2>💻 Installation</h2>
|
||||||
## 💻 Installation
|
<h3>Requirements</h3>
|
||||||
|
<ul>
|
||||||
### Requirements
|
<li>Python 3.6 or higher</li>
|
||||||
|
<li>pip (Python package installer)</li>
|
||||||
- Python 3.6 or higher
|
</ul>
|
||||||
- pip (Python package installer)
|
<h3>Install Dependencies</h3>
|
||||||
|
<pre><code>pip install PyQt5 requests cloudscraper Pillow fpdf2 python-docx
|
||||||
### Install Dependencies
|
</code></pre>
|
||||||
|
<h3>Running the Application</h3>
|
||||||
```bash
|
<p>Navigate to the application's directory in your terminal and run:</p>
|
||||||
pip install PyQt5 requests Pillow mega.py fpdf2 python-docx
|
<pre><code>python main.py
|
||||||
```
|
</code></pre>
|
||||||
|
<h2>Contribution</h2>
|
||||||
### Running the Application
|
<p>Feel free to fork this repo and submit pull requests for bug fixes, new features, or UI improvements!</p>
|
||||||
Navigate to the application's directory in your terminal and run:
|
<h2>License</h2>
|
||||||
```bash
|
<p>This project is under the MIT Licence</p>
|
||||||
python main.py
|
<h2>Star History</h2>
|
||||||
```
|
|
||||||
|
|
||||||
### Optional Setup
|
|
||||||
- **Main Inputs:**
|
|
||||||
- Place your `cookies.txt` in the root directory (if using cookies).
|
|
||||||
- Prepare your `Known.txt` and `creators.json` in the same directory for advanced filtering and selection features.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### AttributeError: module 'asyncio' has no attribute 'coroutine'
|
|
||||||
|
|
||||||
If you encounter an error message similar to:
|
|
||||||
```
|
|
||||||
AttributeError: module 'asyncio' has no attribute 'coroutine'. Did you mean: 'coroutines'?
|
|
||||||
```
|
|
||||||
This usually means that a dependency, often `tenacity` (used by `mega.py`), is an older version that's incompatible with your Python version (typically Python 3.10+).
|
|
||||||
|
|
||||||
To fix this, activate your virtual environment and run the following commands to upgrade the libraries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install --upgrade tenacity
|
|
||||||
pip install --upgrade mega.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contribution
|
|
||||||
|
|
||||||
Feel free to fork this repo and submit pull requests for bug fixes, new features, or UI improvements!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is under the MIT Licence
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
<table align="center" style="border-collapse: collapse; border: none; margin-left: auto; margin-right: auto;">
|
<table align="center" style="border-collapse: collapse; border: none; margin-left: auto; margin-right: auto;">
|
||||||
<tr>
|
<tbody>
|
||||||
<td align="center" valign="middle" style="padding: 10px; border: none;">
|
<tr>
|
||||||
<a href="https://www.star-history.com/#Yuvi9587/Kemono-Downloader&Date">
|
<td align="center" valign="middle" style="padding: 10px; border: none;">
|
||||||
<img src="https://api.star-history.com/svg?repos=Yuvi9587/Kemono-Downloader&type=Date" alt="Star History Chart" width="650">
|
<a href="https://www.star-history.com/#Yuvi9587/Kemono-Downloader&Date">
|
||||||
</a>
|
<img src="https://api.star-history.com/svg?repos=Yuvi9587/Kemono-Downloader&type=Date" alt="Star History Chart" width="650">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://buymeacoffee.com/yuvi9587">
|
<a href="https://buymeacoffee.com/yuvi9587">
|
||||||
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
|
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# --- Application Metadata ---
|
|
||||||
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
|
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
|
||||||
CONFIG_APP_NAME_MAIN = "ApplicationSettings"
|
CONFIG_APP_NAME_MAIN = "ApplicationSettings"
|
||||||
CONFIG_APP_NAME_TOUR = "ApplicationTour"
|
CONFIG_APP_NAME_TOUR = "ApplicationTour"
|
||||||
@@ -9,7 +8,7 @@ STYLE_ORIGINAL_NAME = "original_name"
|
|||||||
STYLE_DATE_BASED = "date_based"
|
STYLE_DATE_BASED = "date_based"
|
||||||
STYLE_DATE_POST_TITLE = "date_post_title"
|
STYLE_DATE_POST_TITLE = "date_post_title"
|
||||||
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
|
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
|
||||||
STYLE_POST_ID = "post_id" # Add this line
|
STYLE_POST_ID = "post_id"
|
||||||
MANGA_DATE_PREFIX_DEFAULT = ""
|
MANGA_DATE_PREFIX_DEFAULT = ""
|
||||||
|
|
||||||
# --- Download Scopes ---
|
# --- Download Scopes ---
|
||||||
@@ -60,7 +59,10 @@ DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
|
|||||||
RESOLUTION_KEY = "window_resolution"
|
RESOLUTION_KEY = "window_resolution"
|
||||||
UI_SCALE_KEY = "ui_scale_factor"
|
UI_SCALE_KEY = "ui_scale_factor"
|
||||||
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
|
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
|
||||||
FETCH_FIRST_KEY = "fetchAllPostsFirst"
|
FETCH_FIRST_KEY = "fetchAllPostsFirst"
|
||||||
|
DISCORD_TOKEN_KEY = "discord/token"
|
||||||
|
|
||||||
|
POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
|
||||||
|
|
||||||
# --- UI Constants and Identifiers ---
|
# --- UI Constants and Identifiers ---
|
||||||
HTML_PREFIX = "<!HTML!>"
|
HTML_PREFIX = "<!HTML!>"
|
||||||
@@ -120,4 +122,4 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
|
|||||||
|
|
||||||
# --- Duplicate Handling Modes ---
|
# --- Duplicate Handling Modes ---
|
||||||
DUPLICATE_HANDLING_HASH = "hash"
|
DUPLICATE_HANDLING_HASH = "hash"
|
||||||
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
|
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
|
||||||
72
src/core/Hentai2read_client.py
Normal file
72
src/core/Hentai2read_client.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# src/core/Hentai2read_client.py
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import cloudscraper
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
def fetch_hentai2read_data(url, logger, session):
|
||||||
|
"""
|
||||||
|
Scrapes a SINGLE Hentai2Read chapter page using a provided session.
|
||||||
|
"""
|
||||||
|
logger(f"Attempting to fetch chapter data from: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
page_content_text = response.text
|
||||||
|
soup = BeautifulSoup(page_content_text, 'html.parser')
|
||||||
|
|
||||||
|
album_title = ""
|
||||||
|
title_tags = soup.select('span[itemprop="name"]')
|
||||||
|
if title_tags:
|
||||||
|
album_title = title_tags[-1].text.strip()
|
||||||
|
|
||||||
|
if not album_title:
|
||||||
|
title_tag = soup.select_one('h1.title')
|
||||||
|
if title_tag:
|
||||||
|
album_title = title_tag.text.strip()
|
||||||
|
|
||||||
|
if not album_title:
|
||||||
|
logger("❌ Could not find album title on page.")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
image_urls = []
|
||||||
|
try:
|
||||||
|
start_index = page_content_text.index("'images' : ") + len("'images' : ")
|
||||||
|
end_index = page_content_text.index(",\n", start_index)
|
||||||
|
images_json_str = page_content_text[start_index:end_index]
|
||||||
|
image_paths = json.loads(images_json_str)
|
||||||
|
image_urls = ["https://hentaicdn.com/hentai" + part for part in image_paths]
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
logger("❌ Could not find or parse image JSON data for this chapter.")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if not image_urls:
|
||||||
|
logger("❌ No image URLs found for this chapter.")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
logger(f" Found {len(image_urls)} images for album '{album_title}'.")
|
||||||
|
|
||||||
|
files_to_download = []
|
||||||
|
for i, img_url in enumerate(image_urls):
|
||||||
|
page_num = i + 1
|
||||||
|
extension = os.path.splitext(img_url)[1].split('?')[0]
|
||||||
|
if not extension: extension = ".jpg"
|
||||||
|
filename = f"{page_num:03d}{extension}"
|
||||||
|
files_to_download.append({'url': img_url, 'filename': filename})
|
||||||
|
|
||||||
|
return album_title, files_to_download
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
logger(f" Chapter not found (404 Error). This likely marks the end of the series.")
|
||||||
|
else:
|
||||||
|
logger(f"❌ An HTTP error occurred: {e}")
|
||||||
|
return None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"❌ An unexpected error occurred while fetching data: {e}")
|
||||||
|
return None, None
|
||||||
@@ -63,7 +63,6 @@ def parse_datetime(date_string, format="%Y-%m-%dT%H:%M:%S%z", utcoffset=0):
|
|||||||
unquote = urllib.parse.unquote
|
unquote = urllib.parse.unquote
|
||||||
unescape = html.unescape
|
unescape = html.unescape
|
||||||
|
|
||||||
# --- From: util.py ---
|
|
||||||
def decrypt_xor(encrypted, key, base64=True, fromhex=False):
|
def decrypt_xor(encrypted, key, base64=True, fromhex=False):
|
||||||
if base64: encrypted = binascii.a2b_base64(encrypted)
|
if base64: encrypted = binascii.a2b_base64(encrypted)
|
||||||
if fromhex: encrypted = bytes.fromhex(encrypted.decode())
|
if fromhex: encrypted = bytes.fromhex(encrypted.decode())
|
||||||
@@ -76,7 +75,6 @@ def advance(iterable, num):
|
|||||||
def json_loads(s): return json.loads(s)
|
def json_loads(s): return json.loads(s)
|
||||||
def json_dumps(obj): return json.dumps(obj, separators=(",", ":"))
|
def json_dumps(obj): return json.dumps(obj, separators=(",", ":"))
|
||||||
|
|
||||||
# --- From: common.py ---
|
|
||||||
class Extractor:
|
class Extractor:
|
||||||
def __init__(self, match, logger):
|
def __init__(self, match, logger):
|
||||||
self.log = logger
|
self.log = logger
|
||||||
@@ -116,7 +114,6 @@ class Extractor:
|
|||||||
if not kwargs.get("fatal", True): return {}
|
if not kwargs.get("fatal", True): return {}
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# --- From: bunkr.py (Adapted) ---
|
|
||||||
BASE_PATTERN_BUNKR = r"(?:https?://)?(?:[a-zA-Z0-9-]+\.)?(bunkr\.(?:si|la|ws|red|black|media|site|is|to|ac|cr|ci|fi|pk|ps|sk|ph|su)|bunkrr\.ru)"
|
BASE_PATTERN_BUNKR = r"(?:https?://)?(?:[a-zA-Z0-9-]+\.)?(bunkr\.(?:si|la|ws|red|black|media|site|is|to|ac|cr|ci|fi|pk|ps|sk|ph|su)|bunkrr\.ru)"
|
||||||
DOMAINS = ["bunkr.si", "bunkr.ws", "bunkr.la", "bunkr.red", "bunkr.black", "bunkr.media", "bunkr.site"]
|
DOMAINS = ["bunkr.si", "bunkr.ws", "bunkr.la", "bunkr.red", "bunkr.black", "bunkr.media", "bunkr.site"]
|
||||||
CF_DOMAINS = set()
|
CF_DOMAINS = set()
|
||||||
@@ -195,10 +192,6 @@ class BunkrMediaExtractor(BunkrAlbumExtractor):
|
|||||||
self.log.error("%s: %s", exc.__class__.__name__, exc)
|
self.log.error("%s: %s", exc.__class__.__name__, exc)
|
||||||
yield MockMessage.Directory, {"album_name": "error", "count": 0}, {}
|
yield MockMessage.Directory, {"album_name": "error", "count": 0}, {}
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# --- PUBLIC API FOR THE GUI ---
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
def get_bunkr_extractor(url, logger):
|
def get_bunkr_extractor(url, logger):
|
||||||
"""Selects the correct Bunkr extractor based on the URL pattern."""
|
"""Selects the correct Bunkr extractor based on the URL pattern."""
|
||||||
if BunkrAlbumExtractor.pattern.match(url):
|
if BunkrAlbumExtractor.pattern.match(url):
|
||||||
@@ -235,7 +228,6 @@ def fetch_bunkr_data(url, logger):
|
|||||||
album_name = re.sub(r'[<>:"/\\|?*]', '_', raw_album_name).strip() or "untitled"
|
album_name = re.sub(r'[<>:"/\\|?*]', '_', raw_album_name).strip() or "untitled"
|
||||||
logger.info(f"Processing Bunkr album: {album_name}")
|
logger.info(f"Processing Bunkr album: {album_name}")
|
||||||
elif msg_type == MockMessage.Url:
|
elif msg_type == MockMessage.Url:
|
||||||
# data here is the file_data dictionary
|
|
||||||
files_to_download.append(data)
|
files_to_download.append(data)
|
||||||
|
|
||||||
if not files_to_download:
|
if not files_to_download:
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ def fetch_channel_messages(channel_id, logger=print, cancellation_event=None, pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset = 0
|
offset = 0
|
||||||
# --- FIX: Corrected the page size for Discord API pagination ---
|
|
||||||
page_size = 150
|
page_size = 150
|
||||||
# --- END FIX ---
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# src/core/erome_client.py
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -7,10 +6,8 @@ import time
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import cloudscraper
|
||||||
|
|
||||||
# #############################################################################
|
|
||||||
# SECTION: Utility functions adapted from the original script
|
|
||||||
# #############################################################################
|
|
||||||
|
|
||||||
def extr(txt, begin, end, default=""):
|
def extr(txt, begin, end, default=""):
|
||||||
"""Stripped-down version of 'extract()' to find text between two delimiters."""
|
"""Stripped-down version of 'extract()' to find text between two delimiters."""
|
||||||
@@ -49,14 +46,10 @@ def nameext_from_url(url):
|
|||||||
def parse_timestamp(ts, default=None):
|
def parse_timestamp(ts, default=None):
|
||||||
"""Creates a datetime object from a Unix timestamp."""
|
"""Creates a datetime object from a Unix timestamp."""
|
||||||
try:
|
try:
|
||||||
# Use fromtimestamp for simplicity and compatibility
|
|
||||||
return datetime.fromtimestamp(int(ts))
|
return datetime.fromtimestamp(int(ts))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
# #############################################################################
|
|
||||||
# SECTION: Main Erome Fetching Logic
|
|
||||||
# #############################################################################
|
|
||||||
|
|
||||||
def fetch_erome_data(url, logger):
|
def fetch_erome_data(url, logger):
|
||||||
"""
|
"""
|
||||||
@@ -78,15 +71,10 @@ def fetch_erome_data(url, logger):
|
|||||||
album_id = album_id_match.group(1)
|
album_id = album_id_match.group(1)
|
||||||
page_url = f"https://www.erome.com/a/{album_id}"
|
page_url = f"https://www.erome.com/a/{album_id}"
|
||||||
|
|
||||||
session = requests.Session()
|
session = cloudscraper.create_scraper()
|
||||||
session.headers.update({
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
|
||||||
"Referer": "https://www.erome.com/"
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger(f" Fetching Erome album page: {page_url}")
|
logger(f" Fetching Erome album page: {page_url}")
|
||||||
# Add a loop to handle "Please wait" pages
|
|
||||||
for attempt in range(5):
|
for attempt in range(5):
|
||||||
response = session.get(page_url, timeout=30)
|
response = session.get(page_url, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -103,17 +91,14 @@ def fetch_erome_data(url, logger):
|
|||||||
title = html.unescape(extr(page_content, 'property="og:title" content="', '"'))
|
title = html.unescape(extr(page_content, 'property="og:title" content="', '"'))
|
||||||
user = urllib.parse.unquote(extr(page_content, 'href="https://www.erome.com/', '"', default="unknown_user"))
|
user = urllib.parse.unquote(extr(page_content, 'href="https://www.erome.com/', '"', default="unknown_user"))
|
||||||
|
|
||||||
# Sanitize title and user for folder creation
|
|
||||||
sanitized_title = re.sub(r'[<>:"/\\|?*]', '_', title).strip()
|
sanitized_title = re.sub(r'[<>:"/\\|?*]', '_', title).strip()
|
||||||
sanitized_user = re.sub(r'[<>:"/\\|?*]', '_', user).strip()
|
sanitized_user = re.sub(r'[<>:"/\\|?*]', '_', user).strip()
|
||||||
|
|
||||||
album_folder_name = f"Erome - {sanitized_user} - {sanitized_title} [{album_id}]"
|
album_folder_name = f"Erome - {sanitized_user} - {sanitized_title} [{album_id}]"
|
||||||
|
|
||||||
urls = []
|
urls = []
|
||||||
# Split the page content by media groups to find all videos
|
|
||||||
media_groups = page_content.split('<div class="media-group"')
|
media_groups = page_content.split('<div class="media-group"')
|
||||||
for group in media_groups[1:]: # Skip the part before the first media group
|
for group in media_groups[1:]:
|
||||||
# Prioritize <source> tag, fall back to data-src for images
|
|
||||||
video_url = extr(group, '<source src="', '"') or extr(group, 'data-src="', '"')
|
video_url = extr(group, '<source src="', '"') or extr(group, 'data-src="', '"')
|
||||||
if video_url:
|
if video_url:
|
||||||
urls.append(video_url)
|
urls.append(video_url)
|
||||||
@@ -127,7 +112,6 @@ def fetch_erome_data(url, logger):
|
|||||||
file_list = []
|
file_list = []
|
||||||
for i, file_url in enumerate(urls, 1):
|
for i, file_url in enumerate(urls, 1):
|
||||||
filename_info = nameext_from_url(file_url)
|
filename_info = nameext_from_url(file_url)
|
||||||
# Create a clean, descriptive filename
|
|
||||||
filename = f"{album_id}_{sanitized_title}_{i:03d}.{filename_info.get('extension', 'mp4')}"
|
filename = f"{album_id}_{sanitized_title}_{i:03d}.{filename_info.get('extension', 'mp4')}"
|
||||||
|
|
||||||
file_data = {
|
file_data = {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ def fetch_nhentai_gallery(gallery_id, logger=print):
|
|||||||
"""
|
"""
|
||||||
api_url = f"https://nhentai.net/api/gallery/{gallery_id}"
|
api_url = f"https://nhentai.net/api/gallery/{gallery_id}"
|
||||||
|
|
||||||
# Create a cloudscraper instance
|
|
||||||
scraper = cloudscraper.create_scraper()
|
scraper = cloudscraper.create_scraper()
|
||||||
|
|
||||||
logger(f" Fetching nhentai gallery metadata from: {api_url}")
|
logger(f" Fetching nhentai gallery metadata from: {api_url}")
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
# src/core/saint2_client.py
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re as re_module
|
import re as re_module
|
||||||
import html
|
import html
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# ##############################################################################
|
|
||||||
# SECTION: Utility functions adapted from the original script
|
|
||||||
# ##############################################################################
|
|
||||||
|
|
||||||
PATTERN_CACHE = {}
|
PATTERN_CACHE = {}
|
||||||
|
|
||||||
@@ -46,10 +41,6 @@ def nameext_from_url(url):
|
|||||||
data["filename"], data["extension"] = filename, ""
|
data["filename"], data["extension"] = filename, ""
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# ##############################################################################
|
|
||||||
# SECTION: Extractor Logic adapted for the main application
|
|
||||||
# ##############################################################################
|
|
||||||
|
|
||||||
class BaseExtractor:
|
class BaseExtractor:
|
||||||
"""A simplified base class for extractors."""
|
"""A simplified base class for extractors."""
|
||||||
def __init__(self, match, session, logger):
|
def __init__(self, match, session, logger):
|
||||||
@@ -165,7 +156,6 @@ def fetch_saint2_data(url, logger):
|
|||||||
if match:
|
if match:
|
||||||
extractor = extractor_cls(match, session, logger)
|
extractor = extractor_cls(match, session, logger)
|
||||||
album_title, files = extractor.items()
|
album_title, files = extractor.items()
|
||||||
# Sanitize the album title to be a valid folder name
|
|
||||||
sanitized_title = re_module.sub(r'[<>:"/\\|?*]', '_', album_title) if album_title else "saint2_download"
|
sanitized_title = re_module.sub(r'[<>:"/\\|?*]', '_', album_title) if album_title else "saint2_download"
|
||||||
return sanitized_title, files
|
return sanitized_title, files
|
||||||
|
|
||||||
|
|||||||
@@ -848,6 +848,8 @@ class PostProcessorWorker:
|
|||||||
'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,
|
||||||
|
'service': self.service,
|
||||||
|
'user_id': self.user_id
|
||||||
}
|
}
|
||||||
return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
|
return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
|
||||||
finally:
|
finally:
|
||||||
@@ -873,25 +875,22 @@ class PostProcessorWorker:
|
|||||||
def process(self):
|
def process(self):
|
||||||
|
|
||||||
if self.service == 'discord':
|
if self.service == 'discord':
|
||||||
# For Discord, self.post is a MESSAGE object from the API.
|
|
||||||
post_title = self.post.get('content', '') or f"Message {self.post.get('id', 'N/A')}"
|
post_title = self.post.get('content', '') or f"Message {self.post.get('id', 'N/A')}"
|
||||||
post_id = self.post.get('id', 'unknown_id')
|
post_id = self.post.get('id', 'unknown_id')
|
||||||
post_main_file_info = {} # Discord messages don't have a single main file
|
post_main_file_info = {}
|
||||||
post_attachments = self.post.get('attachments', [])
|
post_attachments = self.post.get('attachments', [])
|
||||||
post_content_html = self.post.get('content', '')
|
post_content_html = self.post.get('content', '')
|
||||||
post_data = self.post # Keep a reference to the original message object
|
post_data = self.post
|
||||||
log_prefix = "Message"
|
log_prefix = "Message"
|
||||||
else:
|
else:
|
||||||
# Existing logic for standard creator posts
|
|
||||||
post_title = self.post.get('title', '') or 'untitled_post'
|
post_title = self.post.get('title', '') or 'untitled_post'
|
||||||
post_id = self.post.get('id', 'unknown_id')
|
post_id = self.post.get('id', 'unknown_id')
|
||||||
post_main_file_info = self.post.get('file')
|
post_main_file_info = self.post.get('file')
|
||||||
post_attachments = self.post.get('attachments', [])
|
post_attachments = self.post.get('attachments', [])
|
||||||
post_content_html = self.post.get('content', '')
|
post_content_html = self.post.get('content', '')
|
||||||
post_data = self.post # Reference to the post object
|
post_data = self.post
|
||||||
log_prefix = "Post"
|
log_prefix = "Post"
|
||||||
|
|
||||||
# --- FIX: FETCH FULL POST DATA IF CONTENT IS MISSING BUT NEEDED ---
|
|
||||||
content_is_needed = (
|
content_is_needed = (
|
||||||
self.show_external_links or
|
self.show_external_links or
|
||||||
self.extract_links_only or
|
self.extract_links_only or
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -271,4 +271,4 @@ def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}")
|
logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}")
|
||||||
traceback.print_exc(limit=2)
|
traceback.print_exc(limit=2)
|
||||||
@@ -42,17 +42,12 @@ class ErrorFilesDialog(QDialog):
|
|||||||
if app_icon and not app_icon.isNull():
|
if app_icon and not app_icon.isNull():
|
||||||
self.setWindowIcon(app_icon)
|
self.setWindowIcon(app_icon)
|
||||||
|
|
||||||
# --- START OF FIX ---
|
|
||||||
# Get the user-defined scale factor from the parent application.
|
|
||||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||||
|
|
||||||
# Define base dimensions and apply the correct scale factor.
|
|
||||||
base_width, base_height = 550, 400
|
base_width, base_height = 550, 400
|
||||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||||
# --- END OF FIX ---
|
|
||||||
|
|
||||||
# --- Initialize UI and Apply Theming ---
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
self._retranslate_ui()
|
self._retranslate_ui()
|
||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
@@ -106,7 +101,17 @@ class ErrorFilesDialog(QDialog):
|
|||||||
post_title = error_info.get('post_title', 'Unknown Post')
|
post_title = error_info.get('post_title', 'Unknown Post')
|
||||||
post_id = error_info.get('original_post_id_for_log', 'N/A')
|
post_id = error_info.get('original_post_id_for_log', 'N/A')
|
||||||
|
|
||||||
item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
|
creator_name = "Unknown Creator"
|
||||||
|
service = error_info.get('service')
|
||||||
|
user_id = error_info.get('user_id')
|
||||||
|
|
||||||
|
# Check if we have the necessary info and access to the cache
|
||||||
|
if service and user_id and hasattr(self.parent_app, 'creator_name_cache'):
|
||||||
|
creator_key = (service.lower(), str(user_id))
|
||||||
|
# Look up the name, fall back to the user_id if not found
|
||||||
|
creator_name = self.parent_app.creator_name_cache.get(creator_key, user_id)
|
||||||
|
|
||||||
|
item_text = f"File: {filename}\nCreator: {creator_name} - Post: '{post_title}' (ID: {post_id})"
|
||||||
list_item = QListWidgetItem(item_text)
|
list_item = QListWidgetItem(item_text)
|
||||||
list_item.setData(Qt.UserRole, error_info)
|
list_item.setData(Qt.UserRole, error_info)
|
||||||
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
|
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
|
||||||
|
|||||||
@@ -4,24 +4,109 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# --- PyQt5 Imports ---
|
# --- PyQt5 Imports ---
|
||||||
from PyQt5.QtCore import Qt, QStandardPaths
|
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
|
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Local Application Imports ---
|
# --- Local Application Imports ---
|
||||||
from ...i18n.translator import get_translation
|
from ...i18n.translator import get_translation
|
||||||
from ...utils.resolution import get_dark_theme
|
from ...utils.resolution import get_dark_theme
|
||||||
|
from ..assets import get_app_icon_object
|
||||||
|
|
||||||
from ..main_window import get_app_icon_object
|
from ..main_window import get_app_icon_object
|
||||||
from ...config.constants import (
|
from ...config.constants import (
|
||||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
||||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
||||||
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
|
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
|
||||||
FETCH_FIRST_KEY
|
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
|
||||||
)
|
)
|
||||||
from ...services.updater import UpdateChecker, UpdateDownloader
|
from ...services.updater import UpdateChecker, UpdateDownloader
|
||||||
|
|
||||||
|
class CountdownMessageBox(QDialog):
|
||||||
|
"""
|
||||||
|
A custom message box that includes a countdown timer for the 'Yes' button,
|
||||||
|
which automatically accepts the dialog when the timer reaches zero.
|
||||||
|
"""
|
||||||
|
def __init__(self, title, text, countdown_seconds=10, parent_app=None, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.parent_app = parent_app
|
||||||
|
self.countdown = countdown_seconds
|
||||||
|
|
||||||
|
# --- Basic Window Setup ---
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.setModal(True)
|
||||||
|
app_icon = get_app_icon_object()
|
||||||
|
if app_icon and not app_icon.isNull():
|
||||||
|
self.setWindowIcon(app_icon)
|
||||||
|
|
||||||
|
self._init_ui(text)
|
||||||
|
self._apply_theme()
|
||||||
|
|
||||||
|
# --- Timer Setup ---
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(1000) # Tick every second
|
||||||
|
self.timer.timeout.connect(self._update_countdown)
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
def _init_ui(self, text):
|
||||||
|
"""Initializes the UI components of the dialog."""
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
self.message_label = QLabel(text)
|
||||||
|
self.message_label.setWordWrap(True)
|
||||||
|
self.message_label.setAlignment(Qt.AlignCenter)
|
||||||
|
main_layout.addWidget(self.message_label)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch(1)
|
||||||
|
|
||||||
|
self.yes_button = QPushButton()
|
||||||
|
self.yes_button.clicked.connect(self.accept)
|
||||||
|
self.yes_button.setDefault(True)
|
||||||
|
|
||||||
|
self.no_button = QPushButton()
|
||||||
|
self.no_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
|
buttons_layout.addWidget(self.yes_button)
|
||||||
|
buttons_layout.addWidget(self.no_button)
|
||||||
|
buttons_layout.addStretch(1)
|
||||||
|
|
||||||
|
main_layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self._retranslate_ui()
|
||||||
|
self._update_countdown() # Initial text setup
|
||||||
|
|
||||||
|
def _tr(self, key, default_text=""):
|
||||||
|
"""Helper for translations."""
|
||||||
|
if self.parent_app and hasattr(self.parent_app, 'current_selected_language'):
|
||||||
|
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||||
|
return default_text
|
||||||
|
|
||||||
|
def _retranslate_ui(self):
|
||||||
|
"""Sets translated text for UI elements."""
|
||||||
|
self.no_button.setText(self._tr("no_button_text", "No"))
|
||||||
|
# The 'yes' button text is handled by the countdown
|
||||||
|
|
||||||
|
def _update_countdown(self):
|
||||||
|
"""Updates the countdown and button text each second."""
|
||||||
|
if self.countdown <= 0:
|
||||||
|
self.timer.stop()
|
||||||
|
self.accept() # Automatically accept when countdown finishes
|
||||||
|
return
|
||||||
|
|
||||||
|
yes_text = self._tr("yes_button_text", "Yes")
|
||||||
|
self.yes_button.setText(f"{yes_text} ({self.countdown})")
|
||||||
|
self.countdown -= 1
|
||||||
|
|
||||||
|
def _apply_theme(self):
|
||||||
|
"""Applies the current theme from the parent application."""
|
||||||
|
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||||
|
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||||
|
self.setStyleSheet(get_dark_theme(scale))
|
||||||
|
else:
|
||||||
|
self.setStyleSheet("")
|
||||||
|
|
||||||
class FutureSettingsDialog(QDialog):
|
class FutureSettingsDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
A dialog for managing application-wide settings like theme, language,
|
A dialog for managing application-wide settings like theme, language,
|
||||||
@@ -39,7 +124,7 @@ class FutureSettingsDialog(QDialog):
|
|||||||
|
|
||||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||||
scale_factor = screen_height / 800.0
|
scale_factor = screen_height / 800.0
|
||||||
base_min_w, base_min_h = 420, 480 # Increased height for update section
|
base_min_w, base_min_h = 420, 520 # Increased height for new options
|
||||||
scaled_min_w = int(base_min_w * scale_factor)
|
scaled_min_w = int(base_min_w * scale_factor)
|
||||||
scaled_min_h = int(base_min_h * scale_factor)
|
scaled_min_h = int(base_min_h * scale_factor)
|
||||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||||
@@ -55,7 +140,6 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self.interface_group_box = QGroupBox()
|
self.interface_group_box = QGroupBox()
|
||||||
interface_layout = QGridLayout(self.interface_group_box)
|
interface_layout = QGridLayout(self.interface_group_box)
|
||||||
|
|
||||||
# Theme, UI Scale, Language (unchanged)...
|
|
||||||
self.theme_label = QLabel()
|
self.theme_label = QLabel()
|
||||||
self.theme_toggle_button = QPushButton()
|
self.theme_toggle_button = QPushButton()
|
||||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||||
@@ -87,21 +171,26 @@ class FutureSettingsDialog(QDialog):
|
|||||||
|
|
||||||
self.default_path_label = QLabel()
|
self.default_path_label = QLabel()
|
||||||
self.save_path_button = QPushButton()
|
self.save_path_button = QPushButton()
|
||||||
self.save_path_button.clicked.connect(self._save_cookie_and_path)
|
self.save_path_button.clicked.connect(self._save_settings)
|
||||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||||
|
|
||||||
|
self.post_download_action_label = QLabel()
|
||||||
|
self.post_download_action_combo = QComboBox()
|
||||||
|
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
|
||||||
|
download_window_layout.addWidget(self.post_download_action_label, 2, 0)
|
||||||
|
download_window_layout.addWidget(self.post_download_action_combo, 2, 1)
|
||||||
|
|
||||||
self.save_creator_json_checkbox = QCheckBox()
|
self.save_creator_json_checkbox = QCheckBox()
|
||||||
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
||||||
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
|
download_window_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
|
||||||
|
|
||||||
self.fetch_first_checkbox = QCheckBox()
|
self.fetch_first_checkbox = QCheckBox()
|
||||||
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
||||||
download_window_layout.addWidget(self.fetch_first_checkbox, 3, 0, 1, 2)
|
download_window_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
||||||
|
|
||||||
main_layout.addWidget(self.download_window_group_box)
|
main_layout.addWidget(self.download_window_group_box)
|
||||||
|
|
||||||
# --- NEW: Update Section ---
|
|
||||||
self.update_group_box = QGroupBox()
|
self.update_group_box = QGroupBox()
|
||||||
update_layout = QGridLayout(self.update_group_box)
|
update_layout = QGridLayout(self.update_group_box)
|
||||||
self.version_label = QLabel()
|
self.version_label = QLabel()
|
||||||
@@ -112,7 +201,6 @@ class FutureSettingsDialog(QDialog):
|
|||||||
update_layout.addWidget(self.update_status_label, 0, 1)
|
update_layout.addWidget(self.update_status_label, 0, 1)
|
||||||
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
||||||
main_layout.addWidget(self.update_group_box)
|
main_layout.addWidget(self.update_group_box)
|
||||||
# --- END: New Section ---
|
|
||||||
|
|
||||||
main_layout.addStretch(1)
|
main_layout.addStretch(1)
|
||||||
|
|
||||||
@@ -129,28 +217,27 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||||
|
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||||
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||||
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
|
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
|
||||||
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
|
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
|
||||||
self._update_theme_toggle_button_text()
|
self._update_theme_toggle_button_text()
|
||||||
self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path"))
|
self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
|
||||||
self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions."))
|
self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
|
||||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||||
|
|
||||||
# --- NEW: Translations for Update Section ---
|
|
||||||
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
||||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
||||||
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
|
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
|
||||||
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
|
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
|
||||||
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||||
# --- END: New Translations ---
|
|
||||||
|
|
||||||
self._populate_display_combo_boxes()
|
self._populate_display_combo_boxes()
|
||||||
self._populate_language_combo_box()
|
self._populate_language_combo_box()
|
||||||
|
self._populate_post_download_action_combo()
|
||||||
self._load_checkbox_states()
|
self._load_checkbox_states()
|
||||||
|
|
||||||
def _check_for_updates(self):
|
def _check_for_updates(self):
|
||||||
"""Starts the update check thread."""
|
|
||||||
self.check_update_button.setEnabled(False)
|
self.check_update_button.setEnabled(False)
|
||||||
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
|
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
|
||||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
||||||
@@ -189,7 +276,6 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self.check_update_button.setEnabled(True)
|
self.check_update_button.setEnabled(True)
|
||||||
self.ok_button.setEnabled(True)
|
self.ok_button.setEnabled(True)
|
||||||
|
|
||||||
# --- (The rest of the file remains unchanged from your provided code) ---
|
|
||||||
def _load_checkbox_states(self):
|
def _load_checkbox_states(self):
|
||||||
self.save_creator_json_checkbox.blockSignals(True)
|
self.save_creator_json_checkbox.blockSignals(True)
|
||||||
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||||
@@ -252,15 +338,9 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self.ui_scale_combo_box.blockSignals(True)
|
self.ui_scale_combo_box.blockSignals(True)
|
||||||
self.ui_scale_combo_box.clear()
|
self.ui_scale_combo_box.clear()
|
||||||
scales = [
|
scales = [
|
||||||
(0.5, "50%"),
|
(0.5, "50%"), (0.7, "70%"), (0.9, "90%"), (1.0, "100% (Default)"),
|
||||||
(0.7, "70%"),
|
(1.25, "125%"), (1.50, "150%"), (1.75, "175%"), (2.0, "200%")
|
||||||
(0.9, "90%"),
|
]
|
||||||
(1.0, "100% (Default)"),
|
|
||||||
(1.25, "125%"),
|
|
||||||
(1.50, "150%"),
|
|
||||||
(1.75, "175%"),
|
|
||||||
(2.0, "200%")
|
|
||||||
]
|
|
||||||
current_scale = self.parent_app.settings.value(UI_SCALE_KEY, 1.0)
|
current_scale = self.parent_app.settings.value(UI_SCALE_KEY, 1.0)
|
||||||
for scale_val, scale_name in scales:
|
for scale_val, scale_name in scales:
|
||||||
self.ui_scale_combo_box.addItem(scale_name, scale_val)
|
self.ui_scale_combo_box.addItem(scale_name, scale_val)
|
||||||
@@ -285,7 +365,7 @@ class FutureSettingsDialog(QDialog):
|
|||||||
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
|
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
|
||||||
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
|
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
|
||||||
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
|
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
|
||||||
]
|
]
|
||||||
current_lang = self.parent_app.current_selected_language
|
current_lang = self.parent_app.current_selected_language
|
||||||
for lang_code, lang_name in languages:
|
for lang_code, lang_name in languages:
|
||||||
self.language_combo_box.addItem(lang_name, lang_code)
|
self.language_combo_box.addItem(lang_name, lang_code)
|
||||||
@@ -305,14 +385,44 @@ class FutureSettingsDialog(QDialog):
|
|||||||
QMessageBox.information(self, self._tr("language_change_title", "Language Changed"),
|
QMessageBox.information(self, self._tr("language_change_title", "Language Changed"),
|
||||||
self._tr("language_change_message", "A restart is required..."))
|
self._tr("language_change_message", "A restart is required..."))
|
||||||
|
|
||||||
def _save_cookie_and_path(self):
|
def _populate_post_download_action_combo(self):
|
||||||
|
"""Populates the action dropdown and sets the current selection from settings."""
|
||||||
|
self.post_download_action_combo.blockSignals(True)
|
||||||
|
self.post_download_action_combo.clear()
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
(self._tr("action_off", "Off"), "off"),
|
||||||
|
(self._tr("action_notify", "Notify with Sound"), "notify"),
|
||||||
|
(self._tr("action_sleep", "Sleep"), "sleep"),
|
||||||
|
(self._tr("action_shutdown", "Shutdown"), "shutdown")
|
||||||
|
]
|
||||||
|
|
||||||
|
current_action = self.parent_app.settings.value(POST_DOWNLOAD_ACTION_KEY, "off")
|
||||||
|
|
||||||
|
for text, key in actions:
|
||||||
|
self.post_download_action_combo.addItem(text, key)
|
||||||
|
if current_action == key:
|
||||||
|
self.post_download_action_combo.setCurrentIndex(self.post_download_action_combo.count() - 1)
|
||||||
|
|
||||||
|
self.post_download_action_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _post_download_action_changed(self):
|
||||||
|
"""Saves the selected post-download action to settings."""
|
||||||
|
selected_action = self.post_download_action_combo.currentData()
|
||||||
|
self.parent_app.settings.setValue(POST_DOWNLOAD_ACTION_KEY, selected_action)
|
||||||
|
self.parent_app.settings.sync()
|
||||||
|
|
||||||
|
def _save_settings(self):
|
||||||
path_saved = False
|
path_saved = False
|
||||||
cookie_saved = False
|
cookie_saved = False
|
||||||
|
token_saved = False
|
||||||
|
|
||||||
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
|
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
|
||||||
current_path = self.parent_app.dir_input.text().strip()
|
current_path = self.parent_app.dir_input.text().strip()
|
||||||
if current_path and os.path.isdir(current_path):
|
if current_path and os.path.isdir(current_path):
|
||||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||||
path_saved = True
|
path_saved = True
|
||||||
|
|
||||||
if hasattr(self.parent_app, 'use_cookie_checkbox'):
|
if hasattr(self.parent_app, 'use_cookie_checkbox'):
|
||||||
use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
|
use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
|
||||||
cookie_content = self.parent_app.cookie_text_input.text().strip()
|
cookie_content = self.parent_app.cookie_text_input.text().strip()
|
||||||
@@ -323,8 +433,20 @@ class FutureSettingsDialog(QDialog):
|
|||||||
else:
|
else:
|
||||||
self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
|
self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
|
||||||
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "")
|
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "")
|
||||||
|
|
||||||
|
if (hasattr(self.parent_app, 'remove_from_filename_input') and
|
||||||
|
hasattr(self.parent_app, 'remove_from_filename_label_widget')):
|
||||||
|
|
||||||
|
label_text = self.parent_app.remove_from_filename_label_widget.text()
|
||||||
|
if "Token" in label_text:
|
||||||
|
discord_token = self.parent_app.remove_from_filename_input.text().strip()
|
||||||
|
if discord_token:
|
||||||
|
self.parent_app.settings.setValue(DISCORD_TOKEN_KEY, discord_token)
|
||||||
|
token_saved = True
|
||||||
|
|
||||||
self.parent_app.settings.sync()
|
self.parent_app.settings.sync()
|
||||||
if path_saved or cookie_saved:
|
|
||||||
QMessageBox.information(self, "Settings Saved", "Settings have been saved.")
|
if path_saved or cookie_saved or token_saved:
|
||||||
|
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings to save.")
|
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
try:
|
try:
|
||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
FPDF_AVAILABLE = True
|
FPDF_AVAILABLE = True
|
||||||
@@ -29,7 +30,7 @@ except ImportError:
|
|||||||
FPDF = None
|
FPDF = None
|
||||||
PDF = None
|
PDF = None
|
||||||
|
|
||||||
def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print):
|
def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print, cancellation_event=None, pause_event=None):
|
||||||
"""
|
"""
|
||||||
Creates a single PDF from a list of Discord message objects, formatted as a chat log.
|
Creates a single PDF from a list of Discord message objects, formatted as a chat log.
|
||||||
UPDATED to include clickable links for attachments and embeds.
|
UPDATED to include clickable links for attachments and embeds.
|
||||||
@@ -42,8 +43,20 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
|
|||||||
logger(" No messages were found or fetched to create a PDF.")
|
logger(" No messages were found or fetched to create a PDF.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# --- FIX: This helper function now correctly accepts and checks the event objects ---
|
||||||
|
def check_events(c_event, p_event):
|
||||||
|
"""Helper to safely check for pause and cancel events."""
|
||||||
|
if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled:
|
||||||
|
return True # Stop
|
||||||
|
if p_event and hasattr(p_event, 'is_paused'):
|
||||||
|
while p_event.is_paused:
|
||||||
|
time.sleep(0.5)
|
||||||
|
if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
logger(" Sorting messages by date (oldest first)...")
|
logger(" Sorting messages by date (oldest first)...")
|
||||||
messages_data.sort(key=lambda m: m.get('published', ''))
|
messages_data.sort(key=lambda m: m.get('published', m.get('timestamp', '')))
|
||||||
|
|
||||||
pdf = PDF(server_name, channel_name)
|
pdf = PDF(server_name, channel_name)
|
||||||
default_font_family = 'DejaVu'
|
default_font_family = 'DejaVu'
|
||||||
@@ -78,14 +91,19 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
|
|||||||
logger(f" Starting PDF creation with {len(messages_data)} messages...")
|
logger(f" Starting PDF creation with {len(messages_data)} messages...")
|
||||||
|
|
||||||
for i, message in enumerate(messages_data):
|
for i, message in enumerate(messages_data):
|
||||||
|
# --- FIX: Pass the event objects to the helper function ---
|
||||||
|
if i % 50 == 0:
|
||||||
|
if check_events(cancellation_event, pause_event):
|
||||||
|
logger(" PDF generation cancelled by user.")
|
||||||
|
return False
|
||||||
|
|
||||||
author = message.get('author', {}).get('global_name') or message.get('author', {}).get('username', 'Unknown User')
|
author = message.get('author', {}).get('global_name') or message.get('author', {}).get('username', 'Unknown User')
|
||||||
timestamp_str = message.get('published', '')
|
timestamp_str = message.get('published', message.get('timestamp', ''))
|
||||||
content = message.get('content', '')
|
content = message.get('content', '')
|
||||||
attachments = message.get('attachments', [])
|
attachments = message.get('attachments', [])
|
||||||
embeds = message.get('embeds', [])
|
embeds = message.get('embeds', [])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Handle timezone information correctly
|
|
||||||
if timestamp_str.endswith('Z'):
|
if timestamp_str.endswith('Z'):
|
||||||
timestamp_str = timestamp_str[:-1] + '+00:00'
|
timestamp_str = timestamp_str[:-1] + '+00:00'
|
||||||
dt_obj = datetime.datetime.fromisoformat(timestamp_str)
|
dt_obj = datetime.datetime.fromisoformat(timestamp_str)
|
||||||
@@ -93,14 +111,12 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
formatted_timestamp = timestamp_str
|
formatted_timestamp = timestamp_str
|
||||||
|
|
||||||
# Draw a separator line
|
|
||||||
if i > 0:
|
if i > 0:
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
pdf.set_draw_color(200, 200, 200) # Light grey line
|
pdf.set_draw_color(200, 200, 200)
|
||||||
pdf.cell(0, 0, '', border='T')
|
pdf.cell(0, 0, '', border='T')
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
|
|
||||||
# Message Header
|
|
||||||
pdf.set_font(default_font_family, 'B', 11)
|
pdf.set_font(default_font_family, 'B', 11)
|
||||||
pdf.write(5, f"{author} ")
|
pdf.write(5, f"{author} ")
|
||||||
pdf.set_font(default_font_family, '', 9)
|
pdf.set_font(default_font_family, '', 9)
|
||||||
@@ -109,33 +125,31 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
|
|||||||
pdf.set_text_color(0, 0, 0)
|
pdf.set_text_color(0, 0, 0)
|
||||||
pdf.ln(6)
|
pdf.ln(6)
|
||||||
|
|
||||||
# Message Content
|
|
||||||
if content:
|
if content:
|
||||||
pdf.set_font(default_font_family, '', 10)
|
pdf.set_font(default_font_family, '', 10)
|
||||||
pdf.multi_cell(w=0, h=5, text=content)
|
pdf.multi_cell(w=0, h=5, text=content)
|
||||||
|
|
||||||
# --- START: MODIFIED ATTACHMENT AND EMBED LOGIC ---
|
|
||||||
if attachments or embeds:
|
if attachments or embeds:
|
||||||
pdf.ln(1)
|
pdf.ln(1)
|
||||||
pdf.set_font(default_font_family, '', 9)
|
pdf.set_font(default_font_family, '', 9)
|
||||||
pdf.set_text_color(22, 119, 219) # A nice blue for links
|
pdf.set_text_color(22, 119, 219)
|
||||||
|
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
file_name = att.get('name', 'untitled')
|
file_name = att.get('filename', 'untitled')
|
||||||
file_path = att.get('path', '')
|
full_url = att.get('url', '#')
|
||||||
# Construct the full, clickable URL for the attachment
|
|
||||||
full_url = f"https://kemono.cr/data{file_path}"
|
|
||||||
pdf.write(5, text=f"[Attachment: {file_name}]", link=full_url)
|
pdf.write(5, text=f"[Attachment: {file_name}]", link=full_url)
|
||||||
pdf.ln() # New line after each attachment
|
pdf.ln()
|
||||||
|
|
||||||
for embed in embeds:
|
for embed in embeds:
|
||||||
embed_url = embed.get('url', 'no url')
|
embed_url = embed.get('url', 'no url')
|
||||||
# The embed URL is already a full URL
|
|
||||||
pdf.write(5, text=f"[Embed: {embed_url}]", link=embed_url)
|
pdf.write(5, text=f"[Embed: {embed_url}]", link=embed_url)
|
||||||
pdf.ln() # New line after each embed
|
pdf.ln()
|
||||||
|
|
||||||
pdf.set_text_color(0, 0, 0) # Reset color to black
|
pdf.set_text_color(0, 0, 0)
|
||||||
# --- END: MODIFIED ATTACHMENT AND EMBED LOGIC ---
|
|
||||||
|
if check_events(cancellation_event, pause_event):
|
||||||
|
logger(" PDF generation cancelled by user before final save.")
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdf.output(output_filename)
|
pdf.output(output_filename)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -138,22 +138,10 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# In src/utils/network_utils.py
|
|
||||||
|
|
||||||
def extract_post_info(url_string):
|
def extract_post_info(url_string):
|
||||||
"""
|
"""
|
||||||
Parses a URL string to extract the service, user ID, and post ID.
|
Parses a URL string to extract the service, user ID, and post ID.
|
||||||
UPDATED to support Discord, Bunkr, and nhentai URLs.
|
UPDATED to support Hentai2Read series and chapters.
|
||||||
|
|
||||||
Args:
|
|
||||||
url_string (str): The URL to parse.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: A tuple containing (service, id1, id2).
|
|
||||||
For posts: (service, user_id, post_id).
|
|
||||||
For Discord: ('discord', server_id, channel_id).
|
|
||||||
For Bunkr: ('bunkr', full_url, None).
|
|
||||||
For nhentai: ('nhentai', gallery_id, None).
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(url_string, str) or not url_string.strip():
|
if not isinstance(url_string, str) or not url_string.strip():
|
||||||
return None, None, None
|
return None, None, None
|
||||||
@@ -171,6 +159,18 @@ def extract_post_info(url_string):
|
|||||||
nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url)
|
nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url)
|
||||||
if nhentai_match:
|
if nhentai_match:
|
||||||
return 'nhentai', nhentai_match.group(1), None
|
return 'nhentai', nhentai_match.group(1), None
|
||||||
|
|
||||||
|
# --- Hentai2Read Check (Updated) ---
|
||||||
|
# This regex now captures the manga slug (id1) and optionally the chapter number (id2)
|
||||||
|
hentai2read_match = re.search(r'hentai2read\.com/([^/]+)(?:/(\d+))?/?', stripped_url)
|
||||||
|
if hentai2read_match:
|
||||||
|
manga_slug, chapter_num = hentai2read_match.groups()
|
||||||
|
return 'hentai2read', manga_slug, chapter_num # chapter_num will be None for series URLs
|
||||||
|
|
||||||
|
discord_channel_match = re.search(r'discord\.com/channels/(@me|\d+)/(\d+)', stripped_url)
|
||||||
|
if discord_channel_match:
|
||||||
|
server_id, channel_id = discord_channel_match.groups()
|
||||||
|
return 'discord', server_id, channel_id
|
||||||
|
|
||||||
# --- Kemono/Coomer/Discord Parsing ---
|
# --- Kemono/Coomer/Discord Parsing ---
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ def setup_ui(main_app):
|
|||||||
advanced_row2_layout.addLayout(multithreading_layout)
|
advanced_row2_layout.addLayout(multithreading_layout)
|
||||||
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
|
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
|
||||||
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
|
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
|
||||||
main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
|
main_app.manga_mode_checkbox = QCheckBox("Renaming Mode")
|
||||||
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
|
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
|
||||||
advanced_row2_layout.addStretch(1)
|
advanced_row2_layout.addStretch(1)
|
||||||
checkboxes_group_layout.addLayout(advanced_row2_layout)
|
checkboxes_group_layout.addLayout(advanced_row2_layout)
|
||||||
@@ -391,10 +391,23 @@ def setup_ui(main_app):
|
|||||||
main_app.link_search_button.setVisible(False)
|
main_app.link_search_button.setVisible(False)
|
||||||
main_app.link_search_button.setFixedWidth(int(30 * scale))
|
main_app.link_search_button.setFixedWidth(int(30 * scale))
|
||||||
log_title_layout.addWidget(main_app.link_search_button)
|
log_title_layout.addWidget(main_app.link_search_button)
|
||||||
|
|
||||||
|
discord_controls_layout = QHBoxLayout()
|
||||||
|
|
||||||
main_app.discord_scope_toggle_button = QPushButton("Scope: Files")
|
main_app.discord_scope_toggle_button = QPushButton("Scope: Files")
|
||||||
main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default
|
main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default
|
||||||
main_app.discord_scope_toggle_button.setFixedWidth(int(140 * scale))
|
discord_controls_layout.addWidget(main_app.discord_scope_toggle_button)
|
||||||
log_title_layout.addWidget(main_app.discord_scope_toggle_button)
|
|
||||||
|
main_app.discord_message_limit_input = QLineEdit(main_app)
|
||||||
|
main_app.discord_message_limit_input.setPlaceholderText("Msg Limit")
|
||||||
|
main_app.discord_message_limit_input.setToolTip("Optional: Limit the number of recent messages to process.")
|
||||||
|
main_app.discord_message_limit_input.setValidator(QIntValidator(1, 9999999, main_app))
|
||||||
|
main_app.discord_message_limit_input.setFixedWidth(int(80 * scale))
|
||||||
|
main_app.discord_message_limit_input.setVisible(False) # Hide it by default
|
||||||
|
discord_controls_layout.addWidget(main_app.discord_message_limit_input)
|
||||||
|
|
||||||
|
log_title_layout.addLayout(discord_controls_layout)
|
||||||
|
|
||||||
main_app.manga_rename_toggle_button = QPushButton()
|
main_app.manga_rename_toggle_button = QPushButton()
|
||||||
main_app.manga_rename_toggle_button.setVisible(False)
|
main_app.manga_rename_toggle_button.setVisible(False)
|
||||||
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
|
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
|
||||||
|
|||||||
Reference in New Issue
Block a user