19 Commits

Author SHA1 Message Date
Yuvi9587
bf111d109a Update main.py 2025-05-16 11:37:43 +05:30
Yuvi9587
00f8ff63d6 Commit 2025-05-16 11:23:37 +05:30
Yuvi9587
aee0ff999d Commit 2025-05-15 08:45:32 +05:30
Yuvi9587
b5e9080285 Commit 2025-05-14 16:26:18 +05:30
Yuvi9587
25d33f1531 readme.md 2025-05-13 21:38:55 +05:30
Yuvi9587
ff0ccb2631 Commit 2025-05-13 07:31:09 +05:30
Yuvi9587
da507b2b3a Commit 2025-05-12 18:37:11 +05:30
Yuvi9587
9165903e96 Update main.py 2025-05-12 10:54:57 +05:30
Yuvi9587
f85de58fcb Commit 2025-05-12 10:54:31 +05:30
Yuvi9587
ccfb8496a2 Commit 2025-05-11 15:55:21 +05:30
Yuvi9587
e0d3e1b5af commit 2025-05-10 23:59:00 +05:30
Yuvi9587
50ee50cd5c readme.md 2025-05-10 12:16:45 +05:30
Yuvi9587
8982026d79 Commit 2025-05-10 11:11:35 +05:30
Yuvi9587
aec44f1782 Commit 2025-05-10 11:07:27 +05:30
Yuvi9587
866a5a90de Commit 2025-05-09 19:03:01 +05:30
Yuvi9587
929051d46c Commit 2025-05-08 22:13:12 +05:30
Yuvi9587
eada5057b7 Add MIT License 2025-05-08 22:10:35 +05:30
Yuvi9587
fe0b369446 Update readme.md 2025-05-08 20:24:22 +05:30
Yuvi9587
c0c2db709b Commit 2025-05-08 19:49:50 +05:30
6 changed files with 4418 additions and 1388 deletions

View File

@@ -1 +0,0 @@
Hinata

21
LICENSE Normal file
View File

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

File diff suppressed because it is too large Load Diff

3335
main.py

File diff suppressed because it is too large Load Diff

232
multipart_downloader.py Normal file
View File

@@ -0,0 +1,232 @@
import os
import time
import requests
import hashlib
import http.client
import traceback
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
CHUNK_DOWNLOAD_RETRY_DELAY = 2 # Slightly reduced for faster retries if needed
MAX_CHUNK_DOWNLOAD_RETRIES = 1 # Further reduced for quicker fallback if a chunk is problematic
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256KB for iter_content within a chunk download
def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, headers,
part_num, total_parts, progress_data, cancellation_event, skip_event, logger,
signals=None, api_original_filename=None): # Added signals and api_original_filename
"""Downloads a single chunk of a file and writes it to the temp file."""
if cancellation_event and cancellation_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled before start.")
return 0, False # bytes_downloaded, success
if skip_event and skip_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Skip event triggered before start.")
return 0, False
chunk_headers = headers.copy()
# end_byte can be -1 for 0-byte files, meaning download from start_byte to end of file (which is start_byte itself)
if end_byte != -1 : # For 0-byte files, end_byte might be -1, Range header should not be set or be 0-0
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
elif start_byte == 0 and end_byte == -1: # Specifically for 0-byte files
# Some servers might not like Range: bytes=0--1.
# For a 0-byte file, we might not even need a range header, or Range: bytes=0-0
# Let's try without for 0-byte, or rely on server to handle 0-0 if Content-Length was 0.
# If Content-Length was 0, the main function might handle it directly.
# This chunking logic is primarily for files > 0 bytes.
# For now, if end_byte is -1, it implies a 0-byte file, so we expect 0 bytes.
pass
bytes_this_chunk = 0
last_progress_emit_time_for_chunk = time.time()
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = 0
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
if cancellation_event and cancellation_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during retry loop.")
return bytes_this_chunk, False
if skip_event and skip_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Skip event during retry loop.")
return bytes_this_chunk, False
try:
if attempt > 0:
logger(f" [Chunk {part_num + 1}/{total_parts}] Retrying download (Attempt {attempt}/{MAX_CHUNK_DOWNLOAD_RETRIES})...")
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
# Reset speed calculation on retry
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = bytes_this_chunk # Current progress of this chunk
# Enhanced log message for chunk start
log_msg = f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}"
logger(log_msg)
print(f"DEBUG_MULTIPART: {log_msg}") # Direct console print for debugging
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True)
response.raise_for_status()
# For 0-byte files, if end_byte was -1, we expect 0 content.
if start_byte == 0 and end_byte == -1 and int(response.headers.get('Content-Length', 0)) == 0:
logger(f" [Chunk {part_num + 1}/{total_parts}] Confirmed 0-byte file.")
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0
return 0, True
with open(temp_file_path, 'r+b') as f: # Open in read-write binary
f.seek(start_byte)
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during data iteration.")
return bytes_this_chunk, False
if skip_event and skip_event.is_set():
logger(f" [Chunk {part_num + 1}/{total_parts}] Skip event during data iteration.")
return bytes_this_chunk, False
if data_segment:
f.write(data_segment)
bytes_this_chunk += len(data_segment)
with progress_data['lock']:
# Increment both the chunk's downloaded and the overall downloaded
progress_data['total_downloaded_so_far'] += len(data_segment)
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
progress_data['chunks_status'][part_num]['active'] = True
current_time = time.time()
time_delta_speed = current_time - last_speed_calc_time
if time_delta_speed > 0.5: # Calculate speed every 0.5 seconds
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
current_speed_bps = (bytes_delta * 8) / time_delta_speed if time_delta_speed > 0 else 0
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
last_speed_calc_time = current_time
bytes_at_last_speed_calc = bytes_this_chunk
# Emit progress more frequently from within the chunk download
if current_time - last_progress_emit_time_for_chunk > 0.1: # Emit up to 10 times/sec per chunk
if signals and hasattr(signals, 'file_progress_signal'):
# Ensure we read the latest total downloaded from progress_data
# Send a copy of the chunks_status list
status_list_copy = [dict(s) for s in progress_data['chunks_status']] # Make a deep enough copy
signals.file_progress_signal.emit(api_original_filename, status_list_copy)
last_progress_emit_time_for_chunk = current_time
return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
logger(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
logger(f" ❌ [Chunk {part_num + 1}/{total_parts}] Failed after {MAX_CHUNK_DOWNLOAD_RETRIES} retries.")
return bytes_this_chunk, False
except requests.exceptions.RequestException as e: # Includes 4xx/5xx errors after raise_for_status
logger(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
return bytes_this_chunk, False
except Exception as e:
logger(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
# Ensure final status is marked as inactive if loop finishes due to retries
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0
return bytes_this_chunk, False # Should be unreachable
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
api_original_filename, signals, cancellation_event, skip_event, logger):
"""
Downloads a file in multiple parts concurrently.
Returns: (download_successful_flag, downloaded_bytes, calculated_file_hash, temp_file_handle_or_None)
The temp_file_handle will be an open read-binary file handle to the .part file if successful, otherwise None.
It is the responsibility of the caller to close this handle and rename/delete the .part file.
"""
logger(f"⬇️ Initializing Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
temp_file_path = save_path + ".part"
try:
with open(temp_file_path, 'wb') as f_temp:
if total_size > 0:
f_temp.truncate(total_size) # Pre-allocate space
except IOError as e:
logger(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}")
return False, 0, None, None
chunk_size_calc = total_size // num_parts
chunks_ranges = []
for i in range(num_parts):
start = i * chunk_size_calc
end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
if start <= end: # Valid range
chunks_ranges.append((start, end))
elif total_size == 0 and i == 0: # Special case for 0-byte file
chunks_ranges.append((0, -1)) # Indicates 0-byte file, download 0 bytes from offset 0
chunk_actual_sizes = []
for start, end in chunks_ranges:
if end == -1 and start == 0: # 0-byte file
chunk_actual_sizes.append(0)
else:
chunk_actual_sizes.append(end - start + 1)
if not chunks_ranges and total_size > 0:
logger(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting multipart.")
if os.path.exists(temp_file_path): os.remove(temp_file_path)
return False, 0, None, None
progress_data = {
'total_file_size': total_size, # Overall file size for reference
'total_downloaded_so_far': 0, # New key for overall progress
'chunks_status': [ # Status for each chunk
{'id': i, 'downloaded': 0, 'total': chunk_actual_sizes[i] if i < len(chunk_actual_sizes) else 0, 'active': False, 'speed_bps': 0.0}
for i in range(num_parts)
],
'lock': threading.Lock()
}
chunk_futures = []
all_chunks_successful = True
total_bytes_from_chunks = 0 # Still useful to verify total downloaded against file size
with ThreadPoolExecutor(max_workers=num_parts, thread_name_prefix=f"MPChunk_{api_original_filename[:10]}_") as chunk_pool:
for i, (start, end) in enumerate(chunks_ranges):
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
chunk_futures.append(chunk_pool.submit(
_download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path,
start_byte=start, end_byte=end, headers=headers, part_num=i, total_parts=num_parts,
progress_data=progress_data, cancellation_event=cancellation_event, skip_event=skip_event, logger=logger,
signals=signals, api_original_filename=api_original_filename # Pass them here
))
for future in as_completed(chunk_futures):
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
bytes_downloaded_this_chunk, success_this_chunk = future.result()
total_bytes_from_chunks += bytes_downloaded_this_chunk
if not success_this_chunk:
all_chunks_successful = False
# Progress is emitted from within _download_individual_chunk
if cancellation_event and cancellation_event.is_set():
logger(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
all_chunks_successful = False
# Ensure a final progress update is sent with all chunks marked inactive (unless still active due to error)
if signals and hasattr(signals, 'file_progress_signal'):
with progress_data['lock']:
# Ensure all chunks are marked inactive for the final signal if download didn't fully succeed or was cancelled
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
signals.file_progress_signal.emit(api_original_filename, status_list_copy)
if all_chunks_successful and (total_bytes_from_chunks == total_size or total_size == 0):
logger(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}")
md5_hasher = hashlib.md5()
with open(temp_file_path, 'rb') as f_hash:
for buf in iter(lambda: f_hash.read(4096*10), b''): # Read in larger buffers for hashing
md5_hasher.update(buf)
calculated_hash = md5_hasher.hexdigest()
# Return an open file handle for the caller to manage (e.g., for compression)
# The caller is responsible for closing this handle and renaming/deleting the .part file.
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb')
else:
logger(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_from_chunks}/{total_size}. Cleaning up.")
if os.path.exists(temp_file_path):
try: os.remove(temp_file_path)
except OSError as e: logger(f" Failed to remove temp part file '{temp_file_path}': {e}")
return False, total_bytes_from_chunks, None, None

272
readme.md
View File

@@ -1,108 +1,204 @@
# Kemono Downloader
# Kemono Downloader v3.2.0
A simple, multi-platform GUI application built with PyQt5 to download content from Kemono.su or Coomer.party creator pages or specific posts, with options for filtering and organizing downloads.
A feature-rich GUI application built with PyQt5 to download content from **Kemono.su** or **Coomer.party**.
Offers robust filtering, smart organization, manga-specific handling, and performance tuning.
## Features
This version introduces:
- Multi-part downloads
- Character filtering by comments
- Filename word removal
- Various UI/workflow enhancements
* **GUI Interface:** Easy-to-use graphical interface.
* **URL Support:** Download from a creator's main page (paginated) or a specific post URL from Kemono or Coomer sites.
* **Download Location:** Select your desired output directory.
* **Subfolder Organization:**
* Organize downloads into folders based on character/artist names found in post titles (using your "Known Names" list).
* Option to create a custom folder for single post downloads.
* Automatic folder naming based on post title if no known names are matched.
* **Known Names List:** Manage a persistent list of known names (artists, characters, series) for improved folder organization and filtering.
* **Content Filtering:**
* **Character/Name Filter:** Only download posts where the specified known name is found in the title.
* **File Type Filter:** Download All Files, Images/GIFs Only, or Videos Only.
* **Skip Words Filter:** Specify a list of comma-separated words to skip posts or files if these words appear in their titles or filenames.
* **Archive Skipping:** Options to skip `.zip` and `.rar` files (enabled by default).
* **Image Compression:** Optionally compress large images (larger than 1.5MB) to WebP format to save space (requires Pillow library).
* **Thumbnail Downloading:** Option to download thumbnails. (Note: The previous local API method for enhanced thumbnail fetching has been removed. Thumbnail availability might depend on the source.)
* **Duplicate Prevention:**
* Avoids re-downloading files with the same content hash.
* Checks for existing filenames in the target directory.
* **Multithreading:** Utilizes multithreading for faster downloads from full creator pages (single posts are processed in a single thread).
* **Progress Log:** View detailed download progress, status messages, and errors.
* **Dark Theme:** Built-in dark theme for comfortable use.
* **Download Management:**
* Ability to cancel an ongoing download process.
* Option to skip the specific file currently being downloaded (in single-thread mode).
* **Persistent Configuration:** Saves the "Known Names" list to a local file.
---
## Prerequisites
## 🚀 What's New in v3.2.0
* Python 3.6 or higher
* `pip` package installer
### 🔹 Character Filter by Post Comments (Beta)
## Installation
- New "Comments" scope for the 'Filter by Character(s)' feature.
1. Clone or download this repository/script to your local machine.
2. Navigate to the script's directory in your terminal or command prompt.
3. Install the required Python libraries:
```bash
pip install PyQt5 requests Pillow
```
*(Pillow is required for image compression and potentially for basic image handling.)*
**How it works:**
1. Checks if any **filenames** match your character filter. If yes → downloads the post (skips comment check).
2. If no filename matches → scans the **post's comments**. If matched → downloads the post.
## How to Run
- Prioritizes filename-matched character name for folder naming, otherwise uses comment match.
- Cycle through filter scopes with the `Filter: [Scope]` button next to the character input.
1. Make sure you have followed the installation steps.
2. Open your terminal or command prompt and navigate to the script's directory.
3. Run the script using Python:
```bash
python main.py
```
---
## How to Use
### ✂️ Remove Specific Words from Filenames
1. **URL Input:** Enter the URL of the Kemono/Coomer creator page (e.g., `https://kemono.su/patreon/user/12345`) or a specific post (e.g., `https://kemono.su/patreon/user/12345/post/67890`) into the "Kemono Creator/Post URL" field.
2. **Download Location:** Use the "Browse" button to select the root directory where you want to save the downloaded content.
3. **Custom Folder Name (Single Post Only):** If downloading a single post and "Separate Folders" is enabled, you can specify a custom folder name for that post's content.
4. **Filter by Show/Character Name (Optional):** If "Separate Folders" is enabled, enter a name from your "Known Names" list. Only posts with titles matching this name will be downloaded into a folder named accordingly. If empty, the script will try to match any known name or derive a folder name from the post title.
5. **Skip Posts/Files with Words:** Enter comma-separated words (e.g., `WIP, sketch, preview`). Posts or files containing these words in their title/filename will be skipped.
6. **File Type Filter:**
* **All:** Downloads all files.
* **Images/GIFs:** Downloads common image formats and GIFs.
* **Videos:** Downloads common video formats.
7. **Options (Checkboxes):**
* **Separate Folders by Name/Title:** Enables creation of subfolders based on known names or post titles. Controls visibility of "Filter by Show/Character Name" and "Custom Folder Name". (Default: On)
* **Download Thumbnails Only:** Attempts to download only thumbnails for posts. (Default: Off)
* **Skip .zip / Skip .rar:** Prevents downloading of these archive types. (Default: On)
* **Compress Large Images (to WebP):** Compresses images larger than 1.5MB. (Default: Off)
* **Use Multithreading:** Enables faster downloads for full creator pages. (Default: On)
8. **Known Names List:**
* The list on the left ("Known Shows/Characters") displays names used for folder organization and filtering. This list is saved in `Known.txt`.
* Use the input field below the list and the " Add" button to add new names.
* Select names and click "🗑️ Delete Selected" to remove them.
* A search bar above the list allows you to filter the displayed names.
9. **Start Download:** Click "⬇️ Start Download" to begin.
10. **Cancel / Skip:**
* **❌ Cancel:** Stops the entire download process.
* **⏭️ Skip Current File:** (Only in single-thread mode during file download) Skips the currently downloading file and moves to the next.
11. **Progress Log:** The area on the right shows detailed logs of the download process, including fetched posts, saved files, skips, and errors.
- Input field: `"✂️ Remove Words from name"`
- Enter comma-separated words (e.g., `patreon, kemono, [HD], _final`)
- These are removed from filenames (case-insensitive) to improve organization.
## Building an Executable (Optional)
---
You can create a standalone `.exe` file for Windows using `PyInstaller`.
### 🧩 Multi-part Downloads for Large Files
1. Install PyInstaller: `pip install pyinstaller`
2. Obtain an icon file (`.ico`). Place it in the same directory as `main.py`.
3. Open your terminal in the script's directory and run:
```bash
pyinstaller --name "YourAppName" --onefile --windowed --icon="your_icon.ico" main.py
```
Replace `"YourAppName"` with your desired application name and `"your_icon.ico"` with the actual name of your icon file.
4. The executable will be found in the `./dist` folder.
- Toggle multi-part downloads (OFF by default).
- Improves speed on large files (e.g., >10MB videos, zips).
- Falls back to single-stream on failure.
- Toggle via `Multi-part: ON/OFF` in the log header.
## Configuration
---
The application saves your list of known names (characters, artists, series, etc.) to a file named `Known.txt` in the same directory as the script (`main.py`). Each name is stored on a new line. You can manually edit this file if needed.
### 🧠 UI and Workflow Enhancements
## Dark Theme
- **Updated Welcome Tour**
Shows on first launch, covers all new and core features.
The application uses a built-in dark theme for the user interface.
- **Smarter Cancel/Reset**
Cancels active tasks and resets UI — but retains URL and Download Directory fields.
## Contributing
- **Simplified Interface**
- Removed "Skip Current File" and local API server for a cleaner experience.
Contributions are welcome! If you find a bug or have a feature request, please open an issue on the GitHub repository (if applicable). If you want to contribute code, please fork the repository and create a pull request.
---
### 📁 Refined File & Duplicate Handling
- **Duplicate Filenames**
Adds numeric suffix (`file.jpg`, `file_1.jpg`, etc.).
Removed the "Duplicate" subfolder system.
- **Efficient Hash Check**
Detects and skips duplicate files within the same session (before writing to disk).
- **Better Temp File Cleanup**
Cleans up `.part` files — especially if duplicate or compressed post-download.
---
## 🧩 Core Features
### 🎛 Simple GUI
- Built with **PyQt5**
- Dark theme, responsive layout
### 📥 Supports Post and Creator URLs
- Download a single post or an entire creators feed.
### 🔢 Page Range Support
- Choose page range when downloading creator feeds (except in Manga Mode).
---
### 🗂 Smart Folder System
- Organize by character names, post titles, or custom labels.
- Option to create a separate folder for each post.
- Uses `Known.txt` for fallback names.
---
### 📚 Known Names Manager
- Add/edit/delete known characters/shows
- Saves entries in `Known.txt` for automatic folder naming.
---
### 🔍 Advanced Filtering
- **Filter by Character(s)**
Scope: `Files`, `Post Titles`, `Both`, or `Post Comments (Beta)`
- **Skip with Words**
Skip posts or files based on keywords. Toggle scope.
- **Media Type Filters**
Choose: `All`, `Images/GIFs`, `Videos`, `📦 Only Archives (.zip/.rar)`
- **🔗 Only Links Mode**
Extracts links from post descriptions.
- **Skip Archives**
Ignore `.zip`/`.rar` unless in "Only Archives" mode.
---
### 📖 Manga/Comic Mode (Creator URLs Only)
- Downloads posts oldest-to-newest.
**Filename Style Toggle:**
- `Post Title` (default): Names first file in post after title.
- `Original File`: Uses original file names.
- Uses manga/series title for filtering and folder naming.
---
### 🖼️ Image Compression
- Converts large images to **WebP** if it significantly reduces size.
- Requires `Pillow` library.
---
### 🖼 Download Thumbnails Only
- Option to fetch only small preview images.
---
### ⚙️ Multithreaded Downloads
- Adjustable threads for:
- Multiple post processing (creator feeds)
- File-level concurrency (within a post)
---
### ⏯ Download Controls
- Start and cancel active operations.
---
### 🌙 Dark Mode Interface
- Modern, dark-themed GUI for comfort and clarity.
---
## 🔧 Backend Enhancements
### ♻️ Retry Logic
- Retries failed file and chunk downloads before skipping.
---
### 🧬 Session-wide Deduplication
- Uses **MD5 hashes** to avoid saving identical files during a session.
---
### 🧹 Smart Naming & Cleanup
- Cleans special characters in names.
- Applies numeric suffixes on collision.
- Removes specified unwanted words.
---
### 📋 Efficient Logging
- Toggle verbosity: `Basic` (important) or `Full` (everything).
- Separate panel for extracted external links.
- Real-time feedback with clear statuses.
---
## 📦 Installation
### Requirements
- Python 3.6+
- Pip (Python package manager)
### Install Libraries
```bash
pip install PyQt5 requests Pillow