Initial commit

This commit is contained in:
Yuvi9587
2025-05-05 19:35:24 +05:30
commit 7742311f5e
4 changed files with 978 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

888
main.py Normal file
View File

@@ -0,0 +1,888 @@
import sys
import os
import time
import requests
import re
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget,
QRadioButton, QButtonGroup, QCheckBox
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker
from urllib.parse import urlparse
KNOWN_NAMES = []
def clean_folder_name(name):
return "".join(c for c in name if c.isalnum() or c in (' ', '_', '-')).strip().replace(' ', '_')
def clean_filename(name):
return "".join(c for c in name if c.isalnum() or c in (' ', '_', '-', '.')).strip().replace(' ', '_')
def extract_folder_name_from_title(title, unwanted_keywords):
title_lower = title.lower()
tokens = title_lower.split()
for token in tokens:
clean_token = clean_folder_name(token)
if clean_token and clean_token not in unwanted_keywords:
return clean_token
return 'Uncategorized'
def match_folders_from_title(title, known_names, unwanted_keywords):
title_lower = title.lower()
folders = []
for name in known_names:
cleaned_name = clean_folder_name(name.lower())
if not cleaned_name:
continue
pattern = re.compile(r'\b' + re.escape(cleaned_name) + r'\b')
if pattern.search(title_lower):
folders.append(cleaned_name)
folders = [f for f in folders if f not in unwanted_keywords]
return folders
def is_image(filename):
return filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')) and not filename.lower().endswith('.gif')
def is_video(filename):
return filename.lower().endswith(('.mp4', '.mov', '.mkv', '.webm', '.gif'))
def is_zip(filename):
return filename.lower().endswith('.zip')
def is_rar(filename):
return filename.lower().endswith('.rar')
def is_post_url(url):
return '/post/' in url and url.startswith("https://kemono.su/api/v1/")
def extract_post_info(api_url):
parts = api_url.rstrip('/').split('/')
try:
post_index = parts.index('post')
user_index = parts[post_index::-1].index('user')
user_index_absolute = post_index - user_index
if user_index_absolute > 0:
if post_index - user_index_absolute >= 1:
service = parts[user_index_absolute - 1]
user_id = parts[user_index_absolute + 1]
post_id = parts[post_index + 1]
if service and user_id and post_id:
return service, user_id, post_id
except ValueError:
pass
try:
user_index = parts.index('user')
if user_index > 0 and user_index + 1 < len(parts):
service = parts[user_index - 1]
user_id = parts[user_index + 1]
return service, user_id, None
except ValueError:
pass
return None, None, None
def fetch_single_post(service, user_id, post_id, logger):
api_url = f"https://kemono.su/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f"🔄 Fetching single post: {post_id}...")
headers = {'User-Agent': 'Mozilla/5.0'}
try:
response = requests.get(api_url, headers=headers, timeout=15)
response.raise_for_status()
return [response.json()]
except requests.exceptions.RequestException as e:
logger(f"❌ Error fetching specific post {post_id}: {e}")
return []
except Exception as e:
logger(f"❌ Unexpected error fetching post {post_id}: {e}")
return []
def fetch_posts_paginated(api_url, headers, offset, logger):
paginated_url = f'{api_url}?o={offset}'
try:
response = requests.get(paginated_url, headers=headers, timeout=15)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Error fetching page at offset {offset}: {e}")
except Exception as e:
raise RuntimeError(f"Unexpected error fetching page at offset {offset}: {e}")
def download_from_api(api_url, logger=print):
headers = {'User-Agent': 'Mozilla/5.0'}
service, user_id, post_id = extract_post_info(api_url)
if service and user_id and post_id:
posts = fetch_single_post(service, user_id, post_id, logger)
if posts:
logger(f"📦 Found 1 post (specific URL).")
yield posts
else:
logger(f"❌ Could not fetch specific post {post_id}.")
return
elif service and user_id:
api_base_url = f"https://kemono.su/api/v1/{service}/user/{user_id}"
offset = 0
page = 1
while True:
logger(f"\n🔄 Fetching page {page} from {api_base_url} (offset={offset})...")
try:
posts_batch = fetch_posts_paginated(api_base_url, headers, offset, logger)
except RuntimeError as e:
logger(f"{e}")
break
if not posts_batch:
logger("✅ No more posts to fetch.")
break
logger(f"📦 Found {len(posts_batch)} posts on this page.")
yield posts_batch
offset += 50
page += 1
else:
logger(f"❌ Invalid URL format: {api_url}. Please provide a user page or specific post URL.")
def process_posts(posts, download_root, known_names, filter_character,
unwanted_keywords, logger, filter_mode, skip_zip, skip_rar, use_subfolders, thread):
total_downloaded_batch = 0
total_skipped_batch = 0
headers = {'User-Agent': 'Mozilla.5.0'}
url_pattern = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+')
for post in posts:
if thread.isInterruptionRequested():
logger("⚠️ Cancellation requested.")
return total_downloaded_batch, total_skipped_batch, True
title = post.get('title', 'untitled_post')
post_id = post.get('id', 'unknown_id')
post_file = post.get('file')
attachments = post.get('attachments', [])
post_content = post.get('content', '')
if not isinstance(attachments, list):
logger(f"⚠️ Unexpected attachment format for post {post_id}: {type(attachments)}. Skipping attachments list.")
attachments = []
valid_folder_paths = []
if use_subfolders:
folder_names_for_post = []
if filter_character:
clean_char = clean_folder_name(filter_character.lower())
matched_folders = match_folders_from_title(title, known_names, unwanted_keywords)
if clean_char in matched_folders:
folder_names_for_post = [clean_char]
logger(f"✨ Filter match for post '{title}': Using folder '{clean_char}'.")
else:
continue
else:
matched_folders = match_folders_from_title(title, known_names, unwanted_keywords)
if matched_folders:
logger(f"🎭 Found known character(s) in title '{title}': Using folder(s) {matched_folders}.")
folder_names_for_post = matched_folders
else:
folder_name = extract_folder_name_from_title(title, unwanted_keywords)
logger(f"📝 No known characters found in title '{title}'. Using folder name derived from title: '{folder_name}'.")
folder_names_for_post = [folder_name]
for folder in folder_names_for_post:
try:
folder_path_full = os.path.join(download_root, folder)
os.makedirs(folder_path_full, exist_ok=True)
valid_folder_paths.append(folder_path_full)
except OSError as e:
logger(f"❌ Could not create directory {folder_path_full}: {e}")
else:
valid_folder_paths = [download_root]
try:
os.makedirs(download_root, exist_ok=True)
except OSError as e:
logger(f"❌ Could not access download directory {download_root}: {e}")
continue
if not valid_folder_paths:
logger(f"⚠️ No valid folders/root directory available for post {post_id}. Skipping file processing and link extraction.")
continue
if post_content:
found_links = url_pattern.findall(post_content)
if found_links:
logger(f"🔗 Links found in Post: {title} (ID: {post_id})")
for link in found_links:
logger(f" - {link}")
all_files_to_process = []
if post_file and isinstance(post_file, dict) and post_file.get('path') and (post_file.get('name') or os.path.basename(urlparse(post_file.get('path')).path)):
all_files_to_process.append(post_file)
if attachments:
all_files_to_process.extend(attachments)
if not all_files_to_process:
continue
for file_info in all_files_to_process:
if thread.isInterruptionRequested():
logger("⚠️ Cancellation requested.")
return total_downloaded_batch, total_skipped_batch, True
if hasattr(thread, 'skip_current_file') and thread.skip_current_file:
logger(f"⏭️ Skipping file: {file_info.get('name', 'unknown_file')}")
total_skipped_batch += 1
thread.skip_current_file = False
continue
if not isinstance(file_info, dict):
logger(f"⚠️ Skipping invalid file entry in post {post_id}: {file_info}")
continue
file_url_path = file_info.get('path')
filename = file_info.get('name')
if not filename and file_url_path:
try:
filename = os.path.basename(urlparse(file_url_path).path)
except Exception:
filename = None
if not file_url_path or not filename:
logger(f"⚠️ Missing path or name for a file in post '{title}'. Skipping.")
continue
is_img = is_image(filename)
is_vid = is_video(filename)
is_zip_file = is_zip(filename)
is_rar_file = is_rar(filename)
if filter_mode == 'image' and not is_img:
total_skipped_batch += 1
continue
elif filter_mode == 'video' and not is_vid:
total_skipped_batch += 1
continue
elif skip_zip and is_zip_file:
logger(f"⏭️ Skipping zip file based on user preference: {filename}")
total_skipped_batch += 1
continue
elif skip_rar and is_rar_file:
logger(f"⏭️ Skipping rar file based on user preference: {filename}")
total_skipped_batch += 1
continue
full_url = f"https://kemono.su/data/{file_url_path.lstrip('/')}"
for folder_path in valid_folder_paths:
if thread.isInterruptionRequested():
logger("⚠️ Cancellation requested.")
return total_downloaded_batch, total_skipped_batch, True
if hasattr(thread, 'skip_current_file') and thread.skip_current_file:
logger(f"⏭️ Skipping file download to {os.path.basename(folder_path)}: {filename}")
total_skipped_batch += 1
thread.skip_current_file = False
break
save_filename = f"{clean_filename(title)}_{clean_filename(filename)}"
if len(save_filename) > 200:
save_filename = f"{post_id}_{clean_filename(filename)}"
save_path = os.path.join(folder_path, save_filename)
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
total_skipped_batch += 1
continue
else:
try:
logger(f"⬇️ Downloading {save_filename} to {os.path.basename(folder_path)}...")
thread.current_download_path = save_path
thread.is_downloading_file = True
with requests.get(full_url, headers=headers, timeout=60, stream=True) as r:
r.raise_for_status()
with open(save_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if thread.isInterruptionRequested() or (hasattr(thread, 'skip_current_file') and thread.skip_current_file):
logger("⚠️ Download interrupted or skipped.")
if os.path.exists(save_path):
try: os.remove(save_path)
except OSError: pass
thread.current_download_path = None
thread.is_downloading_file = False
thread.skip_current_file = False
if thread.isInterruptionRequested():
return total_downloaded_batch, total_skipped_batch + 1, True
else:
total_skipped_batch += 1
break
if chunk:
f.write(chunk)
if not (hasattr(thread, 'skip_current_file') and thread.skip_current_file):
total_downloaded_batch += 1
logger(f"✅ Saved in {os.path.basename(folder_path)}: {save_filename}")
time.sleep(0.5)
thread.current_download_path = None
thread.is_downloading_file = False
thread.skip_current_file = False
if not (hasattr(thread, 'skip_current_file') and thread.skip_current_file):
break
except requests.exceptions.RequestException as e:
logger(f"❌ Failed download {save_filename} to {os.path.basename(folder_path)}: {e}")
if os.path.exists(save_path):
try: os.remove(save_path)
except OSError: pass
thread.current_download_path = None
thread.is_downloading_file = False
thread.skip_current_file = False
except IOError as e:
logger(f"❌ Failed save {save_filename} to {os.path.basename(folder_path)}: {e}")
thread.current_download_path = None
thread.is_downloading_file = False
self.skip_current_file = False
except Exception as e:
logger(f"❌ Unexpected error for {save_filename} in {os.path.basename(folder_path)}: {e}")
thread.current_download_path = None
thread.is_downloading_file = False
thread.skip_current_file = False
return total_downloaded_batch, total_skipped_batch, False
class DownloadThread(QThread):
progress_signal = pyqtSignal(str)
add_character_prompt_signal = pyqtSignal(str)
add_character_result_signal = pyqtSignal(bool)
file_download_status_signal = pyqtSignal(bool)
def __init__(self, api_url, output_dir, known_names_copy,
filter_character=None, filter_mode='all', skip_zip=True, skip_rar=True, use_subfolders=True):
super().__init__()
self.api_url = api_url
self.output_dir = output_dir
self.known_names = list(known_names_copy)
self.filter_character = filter_character
self.filter_mode = filter_mode
self.skip_zip = skip_zip
self.skip_rar = skip_rar
self.use_subfolders = use_subfolders
self.mutex = QMutex()
self._add_character_response = None
self.skip_current_file = False
self.current_download_path = None
self.is_downloading_file = False
def run(self):
unwanted_keywords = {'spicy', 'hd', 'nsfw'}
grand_total_downloaded = 0
grand_total_skipped = 0
cancelled_during_processing = False
if self.filter_character and self.use_subfolders:
clean_char = clean_folder_name(self.filter_character.lower())
if clean_char not in (n.lower() for n in self.known_names):
with QMutexLocker(self.mutex):
self._add_character_response = None
self.add_character_prompt_signal.emit(clean_char)
while self._add_character_response is None:
if self.isInterruptionRequested():
self.progress_signal.emit("⚠️ Download cancelled while waiting for user input.")
return
self.msleep(100)
if self._add_character_response:
self.known_names.append(clean_char)
else:
self.progress_signal.emit(f"❌ Character '{clean_char}' not added by user. Aborting task.")
return
elif self.filter_character and not self.use_subfolders:
clean_char = clean_folder_name(self.filter_character.lower())
if clean_char not in (n.lower() for n in self.known_names):
self.progress_signal.emit(f" Character filter '{clean_char}' will be applied, but files will go to the single output folder as 'Download to Separate Folders' is unchecked.")
try:
post_generator = download_from_api(self.api_url, logger=self.update_progress)
for posts_batch in post_generator:
if self.isInterruptionRequested():
self.progress_signal.emit("⚠️ Download cancelled.")
cancelled_during_processing = True
break
self.file_download_status_signal.emit(True)
downloaded, skipped, cancelled_in_batch = process_posts(
posts=posts_batch,
download_root=self.output_dir,
known_names=self.known_names,
filter_character=self.filter_character,
unwanted_keywords=unwanted_keywords,
logger=self.update_progress,
filter_mode=self.filter_mode,
skip_zip=self.skip_zip,
skip_rar=self.skip_rar,
use_subfolders=self.use_subfolders,
thread=self
)
grand_total_downloaded += downloaded
grand_total_skipped += skipped
self.file_download_status_signal.emit(False)
if cancelled_in_batch:
cancelled_during_processing = True
break
if not cancelled_during_processing:
self.progress_signal.emit(f"\n🎉 Finished! Total downloaded: {grand_total_downloaded}, Skipped: {grand_total_skipped}")
else:
self.progress_signal.emit(f"\n⚠️ Download cancelled. Total downloaded: {grand_total_downloaded}, Skipped: {grand_total_skipped}")
except Exception as e:
self.progress_signal.emit(f"\n❌ An unexpected error occurred in download thread: {e}")
self.file_download_status_signal.emit(False)
def update_progress(self, message):
self.progress_signal.emit(message)
def receive_add_character_result(self, result):
with QMutexLocker(self.mutex):
self._add_character_response = result
def cancel(self):
self.requestInterruption()
def skip_file(self):
if self.isRunning() and self.is_downloading_file:
self.skip_current_file = True
self.progress_signal.emit("⏭️ Skip requested for the current file.")
class DownloaderApp(QWidget):
def __init__(self):
super().__init__()
self.config_file = "kemono_downloader_config.txt"
self.load_known_names()
self.setWindowTitle("Kemono Downloader")
self.setGeometry(200, 200, 900, 580)
self.setStyleSheet(self.get_dark_theme())
self.init_ui()
self.download_thread = None
def load_known_names(self):
global KNOWN_NAMES
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
KNOWN_NAMES = [line.strip() for line in f if line.strip()]
except Exception as e:
print(f"Error loading config '{self.config_file}': {e}")
QMessageBox.warning(self, "Config Load Error", f"Could not load character list from {self.config_file}:\n{e}")
KNOWN_NAMES = []
else:
print(f"Config file '{self.config_file}' not found. Starting with empty character list.")
KNOWN_NAMES = []
def save_known_names(self):
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
for name in sorted(KNOWN_NAMES):
f.write(name + '\n')
except Exception as e:
QMessageBox.warning(self, "Config Save Error", f"Could not save character list to {self.config_file}:\n{e}")
def closeEvent(self, event):
self.save_known_names()
if self.download_thread and self.download_thread.isRunning():
reply = QMessageBox.question(self, "Confirm Exit",
"A download is in progress. Are you sure you want to exit? This will cancel the download.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
self.download_thread.cancel()
self.download_thread.wait(2000)
event.accept()
else:
event.ignore()
else:
event.accept()
def init_ui(self):
main_layout = QHBoxLayout()
left_layout = QVBoxLayout()
self.link_label = QLabel("🔗 Kemono Creator Page or Post URL:")
self.link_input = QLineEdit()
self.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/67890")
left_layout.addWidget(self.link_label)
left_layout.addWidget(self.link_input)
self.dir_label = QLabel("📁 Download Location:")
self.dir_input = QLineEdit()
self.dir_button = QPushButton("Browse")
self.dir_button.clicked.connect(self.browse_directory)
dir_layout = QHBoxLayout()
dir_layout.addWidget(self.dir_input)
dir_layout.addWidget(self.dir_button)
left_layout.addWidget(self.dir_label)
left_layout.addLayout(dir_layout)
self.character_label = QLabel("🎯 Filter by Character (optional):")
self.character_input = QLineEdit()
self.character_input.setPlaceholderText("Enter character name exactly as in list (case insensitive match)")
left_layout.addWidget(self.character_label)
left_layout.addWidget(self.character_input)
self.radio_group = QButtonGroup(self)
self.radio_all = QRadioButton("All Files")
self.radio_images = QRadioButton("Images Only (no GIFs)")
self.radio_videos = QRadioButton("Videos Only (includes GIFs)")
self.radio_all.setChecked(True)
self.radio_group.addButton(self.radio_all)
self.radio_group.addButton(self.radio_images)
self.radio_group.addButton(self.radio_videos)
radio_layout = QHBoxLayout()
radio_layout.addWidget(self.radio_all)
radio_layout.addWidget(self.radio_images)
radio_layout.addWidget(self.radio_videos)
left_layout.addLayout(radio_layout)
# Create a new horizontal layout for the checkboxes
checkbox_layout = QHBoxLayout()
self.skip_zip_checkbox = QCheckBox("Skip Zip Files")
self.skip_zip_checkbox.setChecked(True)
checkbox_layout.addWidget(self.skip_zip_checkbox)
self.skip_rar_checkbox = QCheckBox("Skip RAR Files")
self.skip_rar_checkbox.setChecked(True)
checkbox_layout.addWidget(self.skip_rar_checkbox)
self.use_subfolders_checkbox = QCheckBox("Download to Separate Folders")
self.use_subfolders_checkbox.setChecked(True)
checkbox_layout.addWidget(self.use_subfolders_checkbox)
# Add the horizontal checkbox layout to the main left layout
left_layout.addLayout(checkbox_layout)
btn_layout = QHBoxLayout()
self.download_btn = QPushButton("⬇️ Start Download")
self.download_btn.clicked.connect(self.start_download)
self.cancel_btn = QPushButton("❌ Cancel Download")
self.cancel_btn.clicked.connect(self.cancel_download)
self.cancel_btn.setEnabled(False)
self.skip_file_btn = QPushButton("⏭️ Skip Current File")
self.skip_file_btn.clicked.connect(self.skip_current_file)
self.skip_file_btn.setEnabled(False)
btn_layout.addWidget(self.download_btn)
btn_layout.addWidget(self.cancel_btn)
btn_layout.addWidget(self.skip_file_btn)
left_layout.addLayout(btn_layout)
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
left_layout.addWidget(QLabel("📜 Progress Log:"))
left_layout.addWidget(self.log_output)
right_layout = QVBoxLayout()
right_layout.addWidget(QLabel("🎭 Known Characters:"))
self.character_list = QListWidget()
self.character_list.addItems(sorted(KNOWN_NAMES))
right_layout.addWidget(self.character_list)
self.new_char_input = QLineEdit()
self.new_char_input.setPlaceholderText("Add new character name")
self.add_char_button = QPushButton(" Add")
self.delete_char_button = QPushButton("🗑️ Delete Selected")
self.add_char_button.clicked.connect(self.add_new_character)
self.new_char_input.returnPressed.connect(self.add_char_button.click)
self.delete_char_button.clicked.connect(self.delete_selected_character)
char_button_layout = QHBoxLayout()
char_button_layout.addWidget(self.new_char_input, 2)
char_button_layout.addWidget(self.add_char_button, 1)
char_button_layout.addWidget(self.delete_char_button, 1)
right_layout.addLayout(char_button_layout)
main_layout.addLayout(left_layout, 3)
main_layout.addLayout(right_layout, 2)
self.setLayout(main_layout)
def get_dark_theme(self):
return """
QWidget {
background-color: #2b2b2b;
color: #f0f0f0;
font-family: Segoe UI, Arial, sans-serif;
font-size: 10pt;
}
QLineEdit, QTextEdit, QListWidget {
background-color: #3c3f41;
border: 1px solid #555;
padding: 5px;
color: #f0f0f0;
border-radius: 3px;
}
QPushButton {
background-color: #555;
color: #f0f0f0;
border: 1px solid #666;
padding: 6px 12px;
border-radius: 3px;
min-height: 20px;
}
QPushButton:hover {
background-color: #666;
border: 1px solid #777;
}
QPushButton:pressed {
background-color: #444;
}
QPushButton:disabled {
background-color: #444;
color: #888;
border-color: #555;
}
QLabel {
font-weight: bold;
padding-top: 4px;
}
QRadioButton {
spacing: 5px;
color: #f0f0f0;
}
QRadioButton::indicator {
width: 13px;
height: 13px;
}
QListWidget {
alternate-background-color: #333;
border: 1px solid #555;
}
QListWidget::item:selected {
background-color: #0078d7;
color: #ffffff;
}
QCheckBox {
color: #f0f0f0;
spacing: 5px;
}
QCheckBox::indicator {
width: 13px;
height: 13px;
}
"""
def browse_directory(self):
folder = QFileDialog.getExistingDirectory(self, "Select Download Folder")
if folder:
self.dir_input.setText(folder)
def log(self, message):
self.log_output.append(message)
self.log_output.verticalScrollBar().setValue(self.log_output.verticalScrollBar().maximum())
def get_filter_mode(self):
if self.radio_images.isChecked():
return 'image'
elif self.radio_videos.isChecked():
return 'video'
return 'all'
def add_new_character(self):
global KNOWN_NAMES
name = self.new_char_input.text().strip()
if name:
if name.lower() not in (n.lower() for n in KNOWN_NAMES):
KNOWN_NAMES.append(name)
self.character_list.clear()
self.character_list.addItems(sorted(KNOWN_NAMES))
self.log(f"✅ Added '{name}' to known characters.")
self.new_char_input.clear()
self.save_known_names()
else:
QMessageBox.warning(self, "Duplicate", f"'{name}' is already in the list.")
else:
QMessageBox.warning(self, "Input Error", "Character name cannot be empty.")
def delete_selected_character(self):
global KNOWN_NAMES
selected_items = self.character_list.selectedItems()
if not selected_items:
QMessageBox.warning(self, "Selection Error", "Please select a character to delete.")
return
confirm = QMessageBox.question(self, "Confirm Deletion",
f"Are you sure you want to delete {len(selected_items)} selected character(s)?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if confirm == QMessageBox.Yes:
names_to_remove = [item.text() for item in selected_items]
original_count = len(KNOWN_NAMES)
KNOWN_NAMES = [n for n in KNOWN_NAMES if n.lower() not in (rem.lower() for rem in names_to_remove)]
removed_count = original_count - len(KNOWN_NAMES)
if removed_count > 0:
self.log(f"🗑️ Removed {removed_count} character(s).")
self.character_list.clear()
self.character_list.addItems(sorted(KNOWN_NAMES))
self.save_known_names()
else:
self.log("🤷 No matching characters found to remove.")
def start_download(self):
if self.download_thread and self.download_thread.isRunning():
self.log("⚠️ Download already in progress.")
return
api_url = self.link_input.text().strip()
output_dir = self.dir_input.text().strip()
filter_character = self.character_input.text().strip()
filter_mode = self.get_filter_mode()
skip_zip = self.skip_zip_checkbox.isChecked()
skip_rar = self.skip_rar_checkbox.isChecked()
use_subfolders = self.use_subfolders_checkbox.isChecked()
if not api_url:
QMessageBox.warning(self, "Input Error", "Please enter a Kemono creator page or post URL.")
return
if not output_dir:
QMessageBox.warning(self, "Input Error", "Please select a download location.")
return
if filter_character and use_subfolders and clean_folder_name(filter_character.lower()) not in (n.lower() for n in KNOWN_NAMES):
self.log(f" Character '{filter_character}' not found in known list. Will prompt to add (only if using separate folders).")
elif filter_character and not use_subfolders:
self.log(f" Character filter '{filter_character}' will be applied, but files will go to the single output folder as 'Download to Separate Folders' is unchecked.")
self.log_output.clear()
self.log(f"🚀 Starting download from {api_url}...")
self.log(f"📁 Saving to: {output_dir}")
if filter_character:
self.log(f"🎯 Filtering by Character: {filter_character}")
self.log(f"📄 File Type Filter: {filter_mode}")
self.log(f"🤐 Skip Zip Files: {'Yes' if skip_zip else 'No'}")
self.log(f"📦 Skip RAR Files: {'Yes' if skip_rar else 'No'}")
self.log(f"📂 Download Location Mode: {'Separate Folders' if use_subfolders else 'Single Folder'}")
self.download_thread = DownloadThread(
api_url=api_url,
output_dir=output_dir,
known_names_copy=list(KNOWN_NAMES),
filter_character=filter_character if filter_character else None,
filter_mode=filter_mode,
skip_zip=skip_zip,
skip_rar=skip_rar,
use_subfolders=use_subfolders
)
self.download_thread.progress_signal.connect(self.log)
self.download_thread.add_character_prompt_signal.connect(self.prompt_add_character)
self.download_thread.add_character_result_signal.connect(self.download_thread.receive_add_character_result)
self.download_thread.finished.connect(self.download_finished)
self.download_thread.file_download_status_signal.connect(self.update_skip_button_state)
self.download_btn.setEnabled(False)
self.cancel_btn.setEnabled(True)
self.link_input.setEnabled(False)
self.dir_input.setEnabled(False)
self.dir_button.setEnabled(False)
self.character_input.setEnabled(False)
self.radio_all.setEnabled(False)
self.radio_images.setEnabled(False)
self.radio_videos.setEnabled(False)
self.skip_zip_checkbox.setEnabled(False)
self.skip_rar_checkbox.setEnabled(False)
self.use_subfolders_checkbox.setEnabled(False)
self.character_list.setEnabled(False)
self.new_char_input.setEnabled(False)
self.add_char_button.setEnabled(False)
self.delete_char_button.setEnabled(False)
self.download_thread.start()
def cancel_download(self):
if self.download_thread and self.download_thread.isRunning():
self.log("⚠️ Requesting cancellation...")
self.download_thread.cancel()
def skip_current_file(self):
if self.download_thread and self.download_thread.isRunning():
self.download_thread.skip_file()
def update_skip_button_state(self, is_downloading):
self.skip_file_btn.setEnabled(is_downloading)
def download_finished(self):
self.log("Download thread finished.")
self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.skip_file_btn.setEnabled(False)
self.link_input.setEnabled(True)
self.dir_input.setEnabled(True)
self.dir_button.setEnabled(True)
self.character_input.setEnabled(True)
self.radio_all.setEnabled(True)
self.radio_images.setEnabled(True)
self.radio_videos.setEnabled(True)
self.skip_zip_checkbox.setEnabled(True)
self.skip_rar_checkbox.setEnabled(True)
self.use_subfolders_checkbox.setEnabled(True)
self.character_list.setEnabled(True)
self.new_char_input.setEnabled(True)
self.add_char_button.setEnabled(True)
self.delete_char_button.setEnabled(True)
self.download_thread = None
def prompt_add_character(self, character_name):
if self.download_thread and self.download_thread.use_subfolders:
reply = QMessageBox.question(self, "Add Character?",
f"Character '{character_name}' was found in a post title but is not in your known list.\n\nAdd '{character_name}' to your known characters list and download to its folder?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
result = (reply == QMessageBox.Yes)
self.download_thread.add_character_result_signal.emit(result)
if result:
global KNOWN_NAMES
if character_name.lower() not in (n.lower() for n in KNOWN_NAMES):
KNOWN_NAMES.append(character_name)
self.character_list.clear()
self.character_list.addItems(sorted(KNOWN_NAMES))
self.log(f"✅ Added '{character_name}' to known characters (via prompt).")
self.save_known_names()
else:
self.download_thread.add_character_result_signal.emit(False)
if __name__ == '__main__':
app = QApplication(sys.argv)
downloader = DownloaderApp()
downloader.show()
sys.exit(app.exec_())

88
readme.md Normal file
View File

@@ -0,0 +1,88 @@
# Kemono Downloader
A simple, multi-platform GUI application built with PyQt5 to download content from Kemono.su creator pages or specific posts, with options for filtering and organizing downloads.
## Features
* **GUI Interface:** Easy-to-use graphical interface.
* **URL Support:** Download from a creator's main page (paginated) or a specific post URL.
* **Download Location:** Select your desired output directory.
* **Character Filtering:** Optionally filter posts and organize downloads into folders based on character names detected in post titles.
* **Known Characters List:** Manage a persistent list of known characters for better folder organization.
* **File Type Filtering:** Download All Files, Images Only (PNG, JPG, JPEG, WEBP, excluding GIFs), or Videos Only (MP4, MOV, MKV, WEBM, including GIFs).
* **Archive Skipping:** Options to skip `.zip` and `.rar` files.
* **Folder Organization:** Choose to download files into separate folders (based on character/title) or all into a single selected folder.
* **Progress Log:** View download progress and status messages.
* **Dark Theme:** Built-in dark theme for comfortable use.
* **Download Cancellation:** Ability to cancel an ongoing download.
* **Skip Current File:** Option to skip the specific file currently being downloaded within a larger batch.
## Prerequisites
* Python 3.6 or higher
* `pip` package installer
## Installation
1. Clone or download this repository 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
```
## How to Run
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
1. **URL Input:** Enter the URL of the Kemono creator page (e.g., `https://kemono.su/patreon/user/12345`) or a specific post (e.g., `https://kemono.su/api/v1/patreon/user/12345/post/67890`) into the "Kemono Creator Page or Post URL" field. Note that while the API URL format is shown, the GUI can usually handle the standard web page URL format as well (`https://kemono.su/patreon/user/12345` or `https://kemono.su/patreon/user/12345/post/67890`).
2. **Download Location:** Use the "Browse" button to select the root directory where you want to save the downloaded content.
3. **Filter by Character (optional):** Enter a character name from your "Known Characters" list to only download posts tagged with that character. If left empty, it will try to find any known character in the title or default to a name derived from the title.
4. **File Type Filter:** Select the type of files you want to download (All Files, Images Only, or Videos Only).
5. **Skip Archives:** Check "Skip Zip Files" and/or "Skip RAR Files" to prevent downloading these archive formats. These are checked by default.
6. **Folder Organization:** Check "Download to Separate Folders" to create subfolders within your download location based on character names or derived titles. Uncheck it to download all files directly into the selected Download Location folder. This is checked by default.
7. **Known Characters:** The list on the right shows characters the application knows about (saved in `kemono_downloader_config.txt`). You can manually add or delete characters here. If a new character is detected in a post title while "Download to Separate Folders" is enabled and is not in your list, the application may prompt you to add it.
8. **Start Download:** Click the "Start Download" button to begin fetching and processing content.
9. **Cancel Download:** Click "Cancel Download" to stop the process. Note that the current file download might finish before the cancellation takes effect.
10. **Skip Current File:** Click "Skip Current File" to immediately stop downloading the file currently in progress and move to the next one. This button is only enabled when a file is actively being downloaded.
11. **Progress Log:** Monitor the download status, file saves, and any errors in the "Progress Log" area.
## Building an Executable (Optional)
You can create a standalone `.exe` file for Windows using `PyInstaller`.
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 --onefile --windowed --icon="your_icon_name.ico" --name="Kemono Downloader" main.py
```
Replace `"your_icon_name.ico"` with the actual name of your icon file.
4. The executable will be found in the `./dist` folder.
## Configuration
The application saves your list of known characters to a file named `kemono_downloader_config.txt` in the same directory as the script (`main.py`). Each character name is stored on a new line. You can manually edit this file if needed, but be mindful of the format (one name per line).
## Dark Theme
The application comes with a simple dark theme applied via stylesheets.
## Contributing
Contributions are welcome! If you find a bug or have a feature request, please open an issue on the GitHub repository. If you want to contribute code, please fork the repository and create a pull request.
## License
[Specify your license here. E.g., MIT License]
---
**Disclaimer:** This tool interacts with kemono.su. Use it responsibly and in accordance with the website's terms of service. The author is not responsible for any misuse.