26 Commits

Author SHA1 Message Date
Yuvi9587
f4f07f038d readme.md 2025-05-21 08:09:18 +05:30
Yuvi9587
7443615848 readme.md 2025-05-21 08:08:54 +05:30
Yuvi9587
3d8f62e7fd readme.md 2025-05-21 08:08:25 +05:30
Yuvi9587
525098b840 readme.md 2025-05-21 08:06:47 +05:30
Yuvi9587
39e4496b67 Readme.md 2025-05-21 08:06:04 +05:30
Yuvi9587
d5bf27f8cc Add files via upload 2025-05-21 08:04:10 +05:30
Yuvi9587
4e7c1783ea readme.md 2025-05-20 23:34:56 +05:30
Yuvi9587
ad67860eab readme.md 2025-05-20 23:34:35 +05:30
Yuvi9587
33841395ba readme.md 2025-05-20 23:33:45 +05:30
Yuvi9587
651f9d9f8d Update main.py 2025-05-18 16:17:40 +05:30
Yuvi9587
decef6730f Commit 2025-05-18 16:12:19 +05:30
Yuvi9587
32a12e8a09 Commit 2025-05-17 11:41:43 +05:30
Yuvi9587
62007d2d45 Update readme.md 2025-05-16 16:08:48 +05:30
Yuvi9587
f1e592cf99 Update readme.md 2025-05-16 12:50:32 +05:30
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
7 changed files with 3281 additions and 2168 deletions

197
Known.txt
View File

@@ -1,197 +0,0 @@
Ada
Aeris
Alina
Amara
Anya
Aria
Artemis
Ashe
Astrid
Asuka
Athena
Azura
Belladonna
Bianca
C.C.
Calla
Camilla
Cassia
Celeste
Chika
Clara
Delilah
Dia
Diana
Eira
Elara
Eli
Elise
Elma
Ember
Erza
Esme
Evelyn
Evie
Fiora
Freya
Gasai
Greta
Hanayo
Hancock
Haruhi
Hatsume
Hawkeye
Hinata
Holo
Homura
Ichigo
Illya
Inara
Ino
Isla
Isolde
Ivy
Jeanne
Jinx
Jiro
Juniper
Juvia
Kaelin
Kagome
Kagura
Kaida
Kairi
Kali
Kana
Kanao
Kanna
Kiera
Kikyo
Kirari
Korra
Kotori
Kurisu
Kushina
Kyoko
Lan Fan
Leona
Levy
Lilith
Liora
Lira
Lisanna
Lucia
Lucoa
Lucy
Luna
Lust
Lyra
Madoka
Maia
Makima
Makise
Makomo
Mami
Mari
Marin
Mary
Mavis
Mayuri
Medusa
Mei
Merlin
Mikasa
Milly
Mina
Mion
Mira
Mirabel
Misato
Mitsuri
Momo
Morgana
Nadia
Nami
Naomi
Nelliel
Nerissa
Neve
Nezuko
Noelle
Nova
Nozomi
Nunnally
Nyx
Ochaco
Odette
Ophelia
Orihime
Orla
Perona
Phoebe
Raven
Rei
Reyna
Rhea
Rika
Rin
Rin Tohsaka
Rinoa
Ritsuko
Riza
Robin
Rosalie
Rowan
Ruby
Rukia
Rumi
Saber
Sable
Sakura
Sakura Matou
Sango
Sansa
Satoko
Sayaka
Scáthach
Selene
Seline
Serena
Shinobu
Shion
Shirley
Sierra
Skye
Sophie
Soraya
Sylvia
Talia
Tamayo
Tamsin
Tashigi
Tatiana
Temari
Thalia
Tifa
Toga
Tohru
Tsunade
Umi
Valeria
Viola
Violet
Vivi
Wendy
Winry
Wynne
Yara
Yazawa
Yoruichi
Yoshiko
Yuki Nagato
Yumeko
Yuna
Yuno
Zara
Zelda
Zero Two

BIN
Read.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because it is too large Load Diff

3100
main.py

File diff suppressed because it is too large Load Diff

238
multipart_downloader.py Normal file
View File

@@ -0,0 +1,238 @@
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_func, emitter=None, api_original_filename=None): # Renamed logger, signals to emitter
"""Downloads a single chunk of a file and writes it to the temp file."""
if cancellation_event and cancellation_event.is_set():
logger_func(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_func(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_func(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_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during retry loop.")
return bytes_this_chunk, False
try:
if attempt > 0:
logger_func(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_func(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_func(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_func(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_func(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 emitter:
# 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
if isinstance(emitter, queue.Queue):
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'): # PostProcessorSignals-like
emitter.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_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
logger_func(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_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
return bytes_this_chunk, False
except Exception as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
# 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,
emitter_for_multipart, cancellation_event, skip_event, logger_func): # Renamed signals, 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_func(f"⬇️ Initializing Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
temp_file_path = save_path + ".part"
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_func(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_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting multipart.")
if os.path.exists(temp_file_path): os.remove(temp_file_path)
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_func=logger_func, emitter=emitter_for_multipart, # Pass emitter
api_original_filename=api_original_filename
))
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
if cancellation_event and cancellation_event.is_set():
logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
all_chunks_successful = False
# Ensure a final progress update is sent with all chunks marked inactive (unless still active due to error)
if emitter_for_multipart:
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']]
if isinstance(emitter_for_multipart, queue.Queue):
emitter_for_multipart.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter_for_multipart, 'file_progress_signal'): # PostProcessorSignals-like
emitter_for_multipart.file_progress_signal.emit(api_original_filename, status_list_copy)
if all_chunks_successful and (total_bytes_from_chunks == total_size or total_size == 0):
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}")
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_func(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_from_chunks}/{total_size}. Cleaning up.")
if os.path.exists(temp_file_path):
try: os.remove(temp_file_path)
except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}")
return False, total_bytes_from_chunks, None, None

293
readme.md
View File

@@ -1,105 +1,244 @@
# Kemono Downloader v3.1.0
<h1 align="center">Kemono Downloader v3.4.0</h1>
A feature-rich GUI application built with PyQt5 to download content from [Kemono.su](https://kemono.su) or [Coomer.party](https://coomer.party). Offers robust filtering, smart organization, manga-specific handling, and performance tuning. Now with session resuming, better retry logic, and smarter file management.
<div align="center">
<img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/>
</div>
---
## 🚀 What's New in v3.1.0
* **Session Resuming**
* Automatically saves and resumes incomplete downloads.
* **Retry on Failure**
* Failed files auto-retry up to 3 times.
* Clear logging for each retry attempt.
* **Batch URL Import**
* Load multiple creator or post URLs from a `.txt` file.
* **Improved Manga Mode**
* Better post ordering and handling of missing or untitled posts.
* Optional numeric-only sorting for consistent naming.
* **UI Enhancements**
* Settings persist across sessions.
* Improved layout spacing, tooltips, and status indicators.
* **Stability & Speed**
* Faster post fetching with lower memory usage.
* Minor bug fixes (duplicate folders, empty post crashes).
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** and **[Coomer.party](https://coomer.party)**.
Built with **PyQt5**, this tool is ideal for users who want deep filtering, customizable folder structures, efficient downloads, and intelligent automation — all within a modern, user-friendly graphical interface.
---
## 🧩 Core Features
## ✨ What's New in v3.4.0?
* **Simple GUI**
Built with PyQt5 for a clean, responsive experience.
This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience:
* **Supports Both Post and Creator URLs**
Download a single post or an entire feed with one click.
### 📖 Enhanced Manga/Comic Mode
* **Smart Folder System**
Organize files using post titles, known character/show names, or a folder per post.
Detects and auto-names folders based on custom keywords.
- **New "Date Based" Filename Style:**
* **Known Names Manager**
Add, search, and delete tags for smarter organization.
Saved to `Known.txt` for reuse.
- Perfect for truly sequential content! Files are named numerically (e.g., `001.jpg`, `002.jpg`, `003.ext`...) across an *entire creator's feed*, strictly following post publication order.
* **Advanced Filters**
* Skip posts or files with specific keywords (e.g. `WIP`, `sketch`).
* Filter by media type: images, videos, or GIFs.
* Skip `.zip` and `.rar` archives.
- **Smart Numbering:** Automatically resumes from the highest existing number found in the series folder (and subfolders, if "Subfolder per Post" is enabled).
* **Manga Mode**
Rename and sort manga posts by title and upload order.
Handles one-image-per-post formats cleanly.
- **Guaranteed Order:** Disables multi-threading for post processing to ensure sequential accuracy.
* **Image Compression**
Auto-convert large images (>1.5MB) to WebP (requires Pillow).
* **Multithreaded Downloads**
Adjustable worker count with warnings at unsafe levels.
Full threading for creators, single-thread fallback for post mode.
* **Download Controls**
Cancel, pause, or skip files mid-download.
Visual progress tracking with per-post summaries.
* **Dark Mode**
Clean and modern dark-themed interface.
- Works alongside the existing "Post Title" and "Original File Name" styles.
---
## 🔧 Backend Enhancements
### ✂️ "Remove Words from Filename" Feature
* **Session File Support**
Downloads can be resumed even after a crash or restart.
Session progress is saved automatically.
- Specify comma-separated words or phrases (case-insensitive) that will be automatically removed from filenames.
* **Retry Logic**
Auto-retries individual failed files before skipping.
Logs all failures with HTTP codes and reasons.
* **Hash-Based Deduplication**
Prevents redownloading of previously saved files.
* **Smart Naming**
Cleans and standardizes inconsistent post titles.
Adds page indices for manga.
* **Efficient Logging**
Toggle between basic and advanced views.
Live feedback with color-coded logs.
- Example: `patreon, [HD], _final` transforms `AwesomeArt_patreon` `Hinata_Hd` into `AwesomeArt.jpg` `Hinata.jpg`.
---
## 📦 Installation
### 📦 New "Only Archives" File Filter Mode
- Exclusively downloads `.zip` and `.rar` files.
- Automatically disables conflicting options like "Skip .zip/.rar" and external link logging.
---
### 🗣️ Improved Character Filter Scope - "Comments (Beta)"
- **File-First Check:** Prioritizes matching filenames before checking post comments for character names.
- **Comment Fallback:** Only checks comments if no filename match is found, reducing unnecessary API calls.
---
### 🧐 Refined "Missed Character Log"
- Displays a capitalized, alphabetized list of key terms from skipped post titles.
- Makes it easier to spot patterns or characters that might be unintentionally excluded.
---
### 🚀 Enhanced Multi-part Download Progress
- Granular visibility into active chunk downloads and combined speed for large files.
---
### 🗺️ Updated Onboarding Tour
- Improved guide for new users, covering v3.4.0 features and existing core functions.
---
### 🛡️ Robust Configuration Path
- Settings and `Known.txt` are now stored in the system-standard application data folder (e.g., `AppData`, `~/.local/share`).
---
## 🖥️ Core Features
---
### User Interface & Workflow
- **Clean PyQt5 GUI** — Simple, modern, and dark-themed.
- **Persistent Settings** — Saves preferences between sessions.
- **Download Modes:**
- Single Post URL
- Entire Creator Feed
- **Flexible Options:**
- Specify Page Range (disabled in Manga Mode)
- Custom Folder Name for single posts
---
### 🧠 Smart Filtering
- **Character Name Filtering:**
- Use `Tifa, Aerith` or group `(Boa, Hancock)` → folder `Boa Hancock`
- **Filter Scopes:**
- `Files`
- `Title`
- `Both (Title then Files)`
- `Comments (Beta - Files first)`
- **Skip with Words:**
- Exclude with `WIP, sketch, preview`
- **Skip Scopes:**
- `Files`
- `Posts`
- `Both (Posts then Files)`
- **File Type Filters:**
- `All`, `Images/GIFs`, `Videos`, `📦 Only Archives`, `🔗 Only Links`
- **Filename Cleanup:**
- Remove illegal and unwanted characters or phrases
---
### 📚 Manga/Comic Mode (Creator Feeds Only)
- **Chronological Processing** — Oldest posts first
- **Filename Style Options:**
- `Name: Post Title (Default)`
- `Name: Original File`
- `Name: Date Based (New)`
- **Best With:** Character filters set to manga/series title
---
### 📁 Folder Structure & Naming
- **Subfolders:**
- Auto-created based on character name, post title, or `Known.txt`
- "Subfolder per Post" option for further nesting
- **Smart Naming:** Cleans invalid characters and structures logically
---
### 🖼️ Thumbnail & Compression Tools
- **Download Thumbnails Only**
- **Compress to WebP** (via Pillow)
- Converts large images to smaller WebP versions
---
### ⚙️ Performance Features
- **Multithreading:**
- For both post processing and file downloading
- **Multi-part Downloads:**
- Toggleable in GUI
- Splits large files into chunks
- Granular chunk-level progress display
---
### 📋 Logging & Progress
- **Real-time Logs:** Activity, errors, skipped posts
- **Missed Character Log:** Shows skipped keywords in easy-to-read list
- **External Links Log:** Shows links (unless disabled in some modes)
- **Export Links:** Save `.txt` of links (Only Links mode)
---
### 🗃️ Config System
- **Known.txt:**
- Stores names for smart folder suggestions
- Supports aliases via `(alias1, alias2)`
- **Stored in Standard App Data Path**
- **Editable Within GUI**
---
## 💻 Installation
---
### Requirements
* Python 3.6+
* Pip packages:
- Python 3.6 or higher
- pip
---
### Install Dependencies
```bash
pip install PyQt5 requests Pillow
```
***
## **🛠️ Build a Standalone Executable (Optional)**
1. Install PyInstaller:
```bash
pip install pyinstaller
```
2. Run:
```bash
pyinstaller --name "Kemono Downloader" --onefile --windowed --icon="Kemono.ico" main.py
```
3. Output will be in the `dist/` folder.
***
## **🗂 Config Files**
- `Known.txt` — character/show names used for folder organization
- Supports grouped names in format: `(Name1, Name2)`
***
## **💬 Feedback & Support**
Issues? Suggestions?
Open an issue on the [GitHub repository](https://github.com/Yuvi9587/kemono-downloader) or join our community.

325
tour.py
View File

@@ -1,325 +0,0 @@
import sys
import traceback # Added for enhanced error reporting
from PyQt5.QtWidgets import (
QApplication, QDialog, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout,
QStackedWidget, QSpacerItem, QSizePolicy, QCheckBox, QDesktopWidget
)
from PyQt5.QtCore import Qt, QSettings, pyqtSignal
class TourStepWidget(QWidget):
"""A single step/page in the tour."""
def __init__(self, title_text, content_text, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) # Adjusted spacing between title and content for bullet points
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
# Increased padding-bottom for more space below title
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
content_label = QLabel(content_text)
content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft)
content_label.setTextFormat(Qt.RichText)
# Adjusted line-height for bullet point readability
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
layout.addWidget(title_label)
layout.addWidget(content_label)
layout.addStretch(1)
class TourDialog(QDialog):
"""
A dialog that shows a multi-page tour to the user.
Includes a "Never show again" checkbox.
Uses QSettings to remember this preference.
"""
tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal()
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV3" # Updated key for new tour content
def __init__(self, parent=None):
super().__init__(parent)
self.settings = QSettings(self.CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR)
self.current_step = 0
self.setWindowTitle("Welcome to Kemono Downloader!")
self.setModal(True)
# Set fixed square size, smaller than main window
self.setFixedSize(600, 620) # Slightly adjusted for potentially more text
self.setStyleSheet("""
QDialog {
background-color: #2E2E2E;
border: 1px solid #5A5A5A;
}
QLabel {
color: #E0E0E0;
}
QCheckBox {
color: #C0C0C0;
font-size: 10pt;
spacing: 5px;
}
QCheckBox::indicator {
width: 13px;
height: 13px;
}
QPushButton {
background-color: #555;
color: #F0F0F0;
border: 1px solid #6A6A6A;
padding: 8px 15px;
border-radius: 4px;
min-height: 25px;
font-size: 11pt;
}
QPushButton:hover {
background-color: #656565;
}
QPushButton:pressed {
background-color: #4A4A4A;
}
""")
self._init_ui()
self._center_on_screen()
def _center_on_screen(self):
"""Centers the dialog on the screen."""
try:
screen_geometry = QDesktopWidget().screenGeometry()
dialog_geometry = self.frameGeometry()
center_point = screen_geometry.center()
dialog_geometry.moveCenter(center_point)
self.move(dialog_geometry.topLeft())
except Exception as e:
print(f"[Tour] Error centering dialog: {e}")
def _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1)
# --- Define Tour Steps with Updated Content ---
step1_content = (
"Hello! This quick tour will walk you through the main features of the Kemono Downloader."
"<ul>"
"<li>Our goal is to help you easily download content from Kemono and Coomer.</li>"
"<li>Use the <b>Next</b> and <b>Back</b> buttons to navigate.</li>"
"<li>Click <b>Skip Tour</b> to close this guide at any time.</li>"
"<li>Check <b>'Never show this tour again'</b> if you don't want to see this on future startups.</li>"
"</ul>"
)
self.step1 = TourStepWidget("👋 Welcome!", step1_content)
step2_content = (
"Let's start with the basics for downloading:"
"<ul>"
"<li><b>🔗 Kemono Creator/Post URL:</b><br>"
" Paste the full web address (URL) of a creator's page (e.g., <i>https://kemono.su/patreon/user/12345</i>) "
"or a specific post (e.g., <i>.../post/98765</i>).</li><br>"
"<li><b>📁 Download Location:</b><br>"
" Click 'Browse...' to choose a folder on your computer where all downloaded files will be saved. "
"This is required unless you are using 'Only Links' mode.</li><br>"
"<li><b>📄 Page Range (Creator URLs only):</b><br>"
" If downloading from a creator's page, you can specify a range of pages (e.g., pages 2 to 5). "
"Leave blank for all pages. This is disabled for single post URLs or when <b>Manga/Comic Mode</b> is active.</li>"
"</ul>"
)
self.step2 = TourStepWidget("① Getting Started", step2_content)
step3_content = (
"Refine what you download with these filters:"
"<ul>"
"<li><b>🎯 Filter by Character(s):</b><br>"
" Enter character names, comma-separated (e.g., <i>Tifa, Aerith</i>). "
" <ul><li>In <b>Normal Mode</b>, this filters individual files by matching their filenames.</li>"
" <li>In <b>Manga/Comic Mode</b>, this filters entire posts by matching the post title. Useful for targeting specific series.</li>"
" <li>Also helps in folder naming if 'Separate Folders' is enabled.</li></ul></li><br>"
"<li><b>🚫 Skip with Words:</b><br>"
" Enter words, comma-separated (e.g., <i>WIP, sketch, preview</i>). "
" The <b>Scope</b> button (next to this input) cycles how this filter applies:"
" <ul><li><i>Scope: Files:</i> Skips files if their names contain any of these words.</li>"
" <li><i>Scope: Posts:</i> Skips entire posts if their titles contain any of these words.</li>"
" <li><i>Scope: Both:</i> Applies both file and post title skipping.</li></ul></li><br>"
"<li><b>Filter Files (Radio Buttons):</b> Choose what to download:"
" <ul>"
" <li><i>All:</i> Downloads all file types found.</li>"
" <li><i>Images/GIFs:</i> Only common image formats and GIFs.</li>"
" <li><i>Videos:</i> Only common video formats.</li>"
" <li><b><i>📦 Only Archives:</i></b> Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked.</li>"
" <li><i>🔗 Only Links:</i> Extracts and displays external links from post descriptions instead of downloading files.</li>"
" </ul></li>"
"</ul>"
)
self.step3 = TourStepWidget("② Filtering Downloads", step3_content)
step4_content = (
"More options to customize your downloads:"
"<ul>"
"<li><b>Skip .zip / Skip .rar:</b> Check these to avoid downloading these archive file types. "
" <i>(Note: These are disabled and ignored if '📦 Only Archives' mode is selected).</i></li><br>"
"<li><b>Download Thumbnails Only:</b> Downloads small preview images instead of full-sized files (if available).</li><br>"
"<li><b>Compress Large Images:</b> If the 'Pillow' library is installed, images larger than 1.5MB will be converted to WebP format if the WebP version is significantly smaller.</li><br>"
"<li><b>🗄️ Custom Folder Name (Single Post Only):</b><br>"
" If you are downloading a single specific post URL AND 'Separate Folders by Name/Title' is enabled, "
"you can enter a custom name here for that post's download folder.</li>"
"</ul>"
)
self.step4 = TourStepWidget("③ Fine-Tuning Downloads", step4_content)
step5_content = (
"Organize your downloads and manage performance:"
"<ul>"
"<li><b>⚙️ Separate Folders by Name/Title:</b> Creates subfolders based on the 'Filter by Character(s)' input or post titles (can use the 'Known Shows/Characters' list as a fallback for folder names).</li><br>"
"<li><b>Subfolder per Post:</b> If 'Separate Folders' is on, this creates an additional subfolder for <i>each individual post</i> inside the main character/title folder.</li><br>"
"<li><b>🚀 Use Multithreading (Threads):</b> Enables faster downloads for creator pages by processing multiple posts or files concurrently. The number of threads can be adjusted. Single post URLs are processed using a single thread for post data but can use multiple threads for file downloads within that post.</li><br>"
"<li><b>📖 Manga/Comic Mode (Creator URLs only):</b> Tailored for sequential content."
" <ul>"
" <li>Downloads posts from <b>oldest to newest</b>.</li>"
" <li>The 'Page Range' input is disabled as all posts are fetched.</li>"
" <li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title' or 'Name: Original File') appears in the top-right of the log area when this mode is active for a creator feed. Click it to change naming:"
" <ul>"
" <li><b><i>Name: Post Title (Default):</i></b> The first file in a post is named after the post's title (e.g., <i>MyMangaChapter1.jpg</i>). Subsequent files in the <i>same post</i> (if any) will retain their original filenames.</li>"
" <li><b><i>Name: Original File:</i></b> All files will attempt to keep their original filenames as provided by the site (e.g., <i>001.jpg, page_02.png</i>). You'll see a recommendation to use 'Post Title' style if you choose this.</li>"
" </ul>"
" </li>"
" <li>For best results with 'Name: Post Title' style, use the 'Filter by Character(s)' field with the manga/series title.</li>"
" </ul></li><br>"
"<li><b>🎭 Known Shows/Characters:</b> Add names here (e.g., <i>Game Title, Series Name, Character Full Name</i>). These are used for automatic folder creation when 'Separate Folders' is on and no specific 'Filter by Character(s)' is provided for a post.</li>"
"</ul>"
)
self.step5 = TourStepWidget("④ Organization & Performance", step5_content)
step6_content = (
"Monitoring and Controls:"
"<ul>"
"<li><b>📜 Progress Log / Extracted Links Log:</b> Shows detailed download messages. If '🔗 Only Links' mode is active, this area displays the extracted links.</li><br>"
"<li><b>Show External Links in Log:</b> If checked, a secondary log panel appears below the main log to display any external links found in post descriptions. <i>(This is disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).</i></li><br>"
"<li><b>Log Verbosity (Show Basic/Full Log):</b> Toggles the main log between showing all messages (Full) or only key summaries, errors, and warnings (Basic).</li><br>"
"<li><b>🔄 Reset:</b> Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.</li><br>"
"<li><b>⬇️ Start Download / ❌ Cancel:</b> These buttons initiate or stop the current download/extraction process.</li>"
"</ul>"
"<br>You're all set! Click <b>'Finish'</b> to close the tour and start using the downloader."
)
self.step6 = TourStepWidget("⑤ Logs & Final Controls", step6_content)
self.tour_steps = [self.step1, self.step2, self.step3, self.step4, self.step5, self.step6]
for step_widget in self.tour_steps:
self.stacked_widget.addWidget(step_widget)
bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) # Adjusted margins
bottom_controls_layout.setSpacing(10)
self.never_show_again_checkbox = QCheckBox("Never show this tour again")
bottom_controls_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft)
buttons_layout = QHBoxLayout()
buttons_layout.setSpacing(10)
self.skip_button = QPushButton("Skip Tour")
self.skip_button.clicked.connect(self._skip_tour_action)
self.back_button = QPushButton("Back")
self.back_button.clicked.connect(self._previous_step)
self.back_button.setEnabled(False)
self.next_button = QPushButton("Next")
self.next_button.clicked.connect(self._next_step_action)
self.next_button.setDefault(True)
buttons_layout.addWidget(self.skip_button)
buttons_layout.addStretch(1)
buttons_layout.addWidget(self.back_button)
buttons_layout.addWidget(self.next_button)
bottom_controls_layout.addLayout(buttons_layout)
main_layout.addLayout(bottom_controls_layout)
self._update_button_states()
def _handle_exit_actions(self):
if self.never_show_again_checkbox.isChecked():
self.settings.setValue(self.TOUR_SHOWN_KEY, True)
self.settings.sync()
# else:
# print(f"[Tour] '{self.TOUR_SHOWN_KEY}' setting not set to True (checkbox was unchecked on exit).")
def _next_step_action(self):
if self.current_step < len(self.tour_steps) - 1:
self.current_step += 1
self.stacked_widget.setCurrentIndex(self.current_step)
else:
self._handle_exit_actions()
self.tour_finished_normally.emit()
self.accept()
self._update_button_states()
def _previous_step(self):
if self.current_step > 0:
self.current_step -= 1
self.stacked_widget.setCurrentIndex(self.current_step)
self._update_button_states()
def _skip_tour_action(self):
self._handle_exit_actions()
self.tour_skipped.emit()
self.reject()
def _update_button_states(self):
if self.current_step == len(self.tour_steps) - 1:
self.next_button.setText("Finish")
else:
self.next_button.setText("Next")
self.back_button.setEnabled(self.current_step > 0)
@staticmethod
def run_tour_if_needed(parent_app_window):
try:
settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
never_show_again = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)
if never_show_again:
return QDialog.Rejected
tour_dialog = TourDialog(parent_app_window)
result = tour_dialog.exec_()
return result
except Exception as e:
print(f"[Tour] CRITICAL ERROR in run_tour_if_needed: {e}")
traceback.print_exc()
return QDialog.Rejected
if __name__ == '__main__':
app = QApplication(sys.argv)
# --- For testing: force the tour to show by resetting the flag ---
# print("[Tour Test] Resetting 'Never show again' flag for testing purposes.")
# test_settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
# test_settings.setValue(TourDialog.TOUR_SHOWN_KEY, False) # Set to False to force tour
# test_settings.sync()
# --- End testing block ---
print("[Tour Test] Running tour standalone...")
result = TourDialog.run_tour_if_needed(None)
if result == QDialog.Accepted:
print("[Tour Test] Tour dialog was accepted (Finished).")
elif result == QDialog.Rejected:
print("[Tour Test] Tour dialog was rejected (Skipped or previously set to 'Never show again').")
final_settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
print(f"[Tour Test] Final state of '{TourDialog.TOUR_SHOWN_KEY}' in settings: {final_settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)}")
sys.exit()