Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46658a7bab | ||
|
|
927c11f2bb | ||
|
|
a54f2b3567 | ||
|
|
7f2312b64f | ||
|
|
7106694bcb | ||
|
|
6b37d73e5a | ||
|
|
d1c5b205ef | ||
|
|
10b567a5fd | ||
|
|
eed0a919aa | ||
|
|
78357df07f | ||
|
|
8137c76eb4 | ||
|
|
be3a522305 | ||
|
|
13d05765b2 | ||
|
|
f52d16d1e4 | ||
|
|
acb91c7e8a | ||
|
|
c765a7a281 | ||
|
|
5abfcc8550 | ||
|
|
7957468077 | ||
|
|
f774773b63 | ||
|
|
8036cb9835 | ||
|
|
13fc33d2c0 | ||
|
|
8663ef54a3 | ||
|
|
0316813792 | ||
|
|
d201a5396c | ||
|
|
86f9396b6c | ||
|
|
0fb4bb3cb0 | ||
|
|
1528d7ce25 | ||
|
|
4e7eeb7989 | ||
|
|
7f2976a4f4 | ||
|
|
8928cb92da | ||
|
|
a181b76124 | ||
|
|
8f085a8f63 | ||
|
|
93a997351b | ||
|
|
b3af6c1c15 | ||
|
|
4a65263f7d | ||
|
|
1091b5b9b4 | ||
|
|
f6b3ff2f5c | ||
|
|
b399bdf5cf | ||
|
|
9ace161bc8 | ||
|
|
66e52cfd78 | ||
|
|
e665fd3cde | ||
|
|
fc94f4c691 | ||
|
|
78e2012f04 | ||
|
|
3fe9dbacc6 | ||
|
|
004dea06e0 | ||
|
|
8994a69c34 | ||
|
|
f4a692673e | ||
|
|
4cb5f14ef6 | ||
|
|
a596c4f350 | ||
|
|
e091c60d29 | ||
|
|
d2ea026a41 | ||
|
|
bb3d5c20f5 | ||
|
|
a13eae8f16 | ||
|
|
7e5dc71720 | ||
|
|
d7960bbb85 | ||
|
|
c4d5ba3040 | ||
|
|
fd84de7bce | ||
|
|
a6383b20a4 | ||
|
|
651f9d9f8d | ||
|
|
decef6730f | ||
|
|
32a12e8a09 | ||
|
|
62007d2d45 | ||
|
|
f1e592cf99 | ||
|
|
bf111d109a | ||
|
|
00f8ff63d6 | ||
|
|
aee0ff999d | ||
|
|
b5e9080285 | ||
|
|
25d33f1531 | ||
|
|
ff0ccb2631 | ||
|
|
da507b2b3a | ||
|
|
9165903e96 | ||
|
|
f85de58fcb | ||
|
|
ccfb8496a2 | ||
|
|
e0d3e1b5af | ||
|
|
50ee50cd5c |
BIN
Kemono.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
197
Known.txt
@@ -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/Read.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
Read/Read1.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
Read/Read2.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
Read/Read3.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/discord.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/github.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/instagram.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
750524
creators.json
Normal file
1939
downloader_utils.py
245
multipart_downloader.py
Normal file
@@ -0,0 +1,245 @@
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import hashlib
|
||||
import http.client
|
||||
import traceback
|
||||
import threading
|
||||
import queue # Import the missing 'queue' module
|
||||
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, pause_event, global_emit_time_ref, cookies_for_chunk, # Added cookies_for_chunk
|
||||
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
|
||||
|
||||
if pause_event and pause_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download paused before start...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled while paused.")
|
||||
return 0, False
|
||||
time.sleep(0.2) # Shorter sleep for responsive resume
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
|
||||
|
||||
chunk_headers = headers.copy()
|
||||
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
|
||||
pass
|
||||
|
||||
|
||||
bytes_this_chunk = 0
|
||||
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
|
||||
if pause_event and pause_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during retry loop...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in retry loop.")
|
||||
return bytes_this_chunk, False
|
||||
time.sleep(0.2)
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from retry loop pause.")
|
||||
|
||||
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)))
|
||||
last_speed_calc_time = time.time()
|
||||
bytes_at_last_speed_calc = bytes_this_chunk # Current progress of this chunk
|
||||
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)
|
||||
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
|
||||
response.raise_for_status()
|
||||
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 pause_event and pause_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during data iteration...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in data iteration.")
|
||||
return bytes_this_chunk, False
|
||||
time.sleep(0.2)
|
||||
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from data iteration pause.")
|
||||
if data_segment:
|
||||
f.write(data_segment)
|
||||
bytes_this_chunk += len(data_segment)
|
||||
|
||||
with progress_data['lock']:
|
||||
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
|
||||
if emitter and (current_time - global_emit_time_ref[0] > 0.25): # Max ~4Hz for the whole file
|
||||
global_emit_time_ref[0] = current_time # Update shared last emit time
|
||||
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)
|
||||
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 isinstance(e, requests.exceptions.ConnectionError) and \
|
||||
("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
|
||||
logger_func(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
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}")
|
||||
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): # More general check
|
||||
logger_func(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
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
|
||||
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, cookies_for_chunk_session, # Added cookies_for_chunk_session
|
||||
cancellation_event, skip_event, logger_func, pause_event):
|
||||
"""
|
||||
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(),
|
||||
'last_global_emit_time': [time.time()] # Shared mutable for global throttling timestamp
|
||||
}
|
||||
|
||||
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, global_emit_time_ref=progress_data['last_global_emit_time'],
|
||||
pause_event=pause_event, cookies_for_chunk=cookies_for_chunk_session, logger_func=logger_func, emitter=emitter_for_multipart,
|
||||
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
|
||||
if emitter_for_multipart:
|
||||
with progress_data['lock']:
|
||||
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 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
|
||||
339
readme.md
@@ -1,105 +1,306 @@
|
||||
# Kemono Downloader v3.1.0
|
||||
<h1 align="center">Kemono Downloader v4.2.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.
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read.png" alt="Post Downloader Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Default</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read1.png" alt="Creator Downloader Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Favorite mode</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read2.png" alt="Settings Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Single Post</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read3.png" alt="Settings Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Manga/Comic Mode</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
---
|
||||
|
||||
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** (and its mirrors like kemono.party) and **[Coomer.party](https://coomer.party)** (and its mirrors like coomer.su).
|
||||
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation, all within a modern and user-friendly graphical interface.
|
||||
|
||||
*This v5.0.0 release marks a significant feature milestone. Future updates are expected to be less frequent, focusing on maintenance and minor refinements.*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What's New in v3.1.0
|
||||
## What's New in v5.0.0?
|
||||
|
||||
* **Session Resuming**
|
||||
* Automatically saves and resumes incomplete downloads.
|
||||
Version 5.0.0 is a major update, introducing comprehensive new features and refining existing ones for a more powerful and streamlined experience:
|
||||
|
||||
* **Retry on Failure**
|
||||
* Failed files auto-retry up to 3 times.
|
||||
* Clear logging for each retry attempt.
|
||||
### ⭐ Favorite Mode (Artists & Posts)
|
||||
- **Direct Downloads from Your Kemono.su Favorites:**
|
||||
- Enable via the "**⭐ Favorite Mode**" checkbox.
|
||||
- The UI adapts: URL input is replaced, and action buttons change to "**🖼️ Favorite Artists**" and "**📄 Favorite Posts**".
|
||||
- "**🍪 Use Cookie**" is automatically enabled and required.
|
||||
- **Favorite Artists Dialog:** Fetches and lists your favorited artists. Select one or more to queue for download.
|
||||
- **Favorite Posts Dialog:** Fetches and lists your favorited posts, grouped by artist. Includes search, selection, and known name highlighting in post titles.
|
||||
- **Flexible Download Scopes for Favorites:**
|
||||
- `Scope: Selected Location`: Downloads all selected favorites into the main "Download Location".
|
||||
- `Scope: Artist Folders`: Creates a subfolder for each artist within the main "Download Location".
|
||||
- Standard filters (character, skip words, file type) apply to content downloaded via Favorite Mode.
|
||||
|
||||
* **Batch URL Import**
|
||||
* Load multiple creator or post URLs from a `.txt` file.
|
||||
### 🎨 Creator Selection Popup
|
||||
- Click the "**🎨**" button next to the URL input to open the "Creator Selection" dialog.
|
||||
- Loads creators from your `creators.json` file (expected in the app's directory).
|
||||
- Search, select multiple creators, and their names are added to the URL input, comma-separated.
|
||||
- Choose download scope (`Characters` or `Creators`) for items added via this popup, influencing folder structure.
|
||||
|
||||
* **Improved Manga Mode**
|
||||
* Better post ordering and handling of missing or untitled posts.
|
||||
* Optional numeric-only sorting for consistent naming.
|
||||
### 🎯 Advanced Character Filtering & `Known.txt` Integration
|
||||
- **Enhanced Filter Syntax:**
|
||||
- `Nami`: Simple character filter.
|
||||
- `(Vivi, Ulti, Uta)`: Groups distinct characters into a shared folder for the session (e.g., "Vivi Ulti Uta"). Adds "Vivi", "Ulti", "Uta" as *separate* entries to `Known.txt` if new.
|
||||
- `(Boa, Hancock)~`: Defines "Boa" and "Hancock" as aliases for the *same character/entity*. Creates a shared folder (e.g., "Boa Hancock"). Adds "Boa Hancock" as a *single group entry* to `Known.txt` if new, with "Boa" and "Hancock" as its aliases.
|
||||
- **"Add to Filter" Button (⤵️):** Opens a dialog to select names from your `Known.txt` (with search) and add them to the "Filter by Character(s)" field. Grouped names from `Known.txt` are added with the `~` syntax.
|
||||
- **New Name Confirmation:** When new, unrecognized names/groups are used in the filter, a dialog prompts to add them to `Known.txt` with appropriate formatting.
|
||||
|
||||
* **UI Enhancements**
|
||||
* Settings persist across sessions.
|
||||
* Improved layout spacing, tooltips, and status indicators.
|
||||
### 📖 Manga/Comic Mode Enhancements
|
||||
- **"Title+G.Num" Filename Style:** (Post Title + Global Numbering) All files across posts get the post title prefix + a global sequential number (e.g., `Chapter 1_001.jpg`, `Chapter 2_003.jpg`).
|
||||
- **Optional Filename Prefix:** For "Original File" and "Date Based" manga styles, an input field appears to add a custom prefix to filenames.
|
||||
|
||||
* **Stability & Speed**
|
||||
* Faster post fetching with lower memory usage.
|
||||
* Minor bug fixes (duplicate folders, empty post crashes).
|
||||
### 🖼️ Enhanced Image & Content Handling
|
||||
- **"Scan Content for Images":** A checkbox to scan post HTML for `<img>` tags and direct image links, resolving relative paths. Crucial for images embedded in descriptions but not in API attachments.
|
||||
- When "Download Thumbnails Only" is active, "Scan Content for Images" is auto-enabled, and *only* content-scanned images are downloaded.
|
||||
- **"🎧 Only Audio" Filter Mode:** Dedicated mode to download only common audio formats (MP3, WAV, FLAC, etc.).
|
||||
- **"📦 Only Archives" Filter Mode:** Exclusively downloads `.zip` and `.rar` files.
|
||||
|
||||
### ⚙️ UI & Workflow Improvements
|
||||
- **Cookie Management:**
|
||||
- Directly paste cookie strings.
|
||||
- Browse and load `cookies.txt` files.
|
||||
- Automatic fallback to `cookies.txt` in the app directory.
|
||||
- **Multi-part Download Toggle:** Button in the log area to easily switch multi-segment downloads ON/OFF for large files.
|
||||
- **Log View Toggle (👁️ / 🙈):** Switch between the detailed "Progress Log" and the "Missed Character Log" (which now shows intelligently extracted key terms from skipped titles).
|
||||
- **Retry Failed Downloads:** Prompts at the end of a session to retry files that failed with recoverable errors (e.g., IncompleteRead).
|
||||
- **Persistent UI Defaults:** Key filter scopes ("Skip with Words" -> Posts, "Filter by Character(s)" -> Title) now reset to defaults on launch for consistency.
|
||||
- **Refined Onboarding Tour & Help Guide:** Updated guides accessible via the "❓" button.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Core Features
|
||||
## Core Features
|
||||
|
||||
* **Simple GUI**
|
||||
Built with PyQt5 for a clean, responsive experience.
|
||||
This section details the primary functionalities of the Kemono Downloader.
|
||||
|
||||
* **Supports Both Post and Creator URLs**
|
||||
Download a single post or an entire feed with one click.
|
||||
### User Interface & Workflow
|
||||
|
||||
* **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.
|
||||
- **Main Inputs:**
|
||||
- **🔗 Kemono Creator/Post URL:** Paste the full URL of a Kemono/Coomer creator's page or a specific post.
|
||||
- *Example (Creator):* `https://kemono.su/patreon/user/12345`
|
||||
- *Example (Post):* `https://kemono.su/patreon/user/12345/post/98765`
|
||||
- **🎨 Creator Selection Button:** (Next to URL input) Opens a dialog to select creators from `creators.json` to populate the URL field.
|
||||
- **Page Range (Start to End):** For creator URLs, specify a range of pages to fetch. Disabled for single posts or Manga Mode.
|
||||
- **📁 Download Location:** Browse to select the main folder for all downloads. Required unless in "🔗 Only Links" mode.
|
||||
- **Action Buttons:**
|
||||
- **⬇️ Start Download / 🔗 Extract Links:** Initiates the primary operation based on current settings.
|
||||
- **⏸️ Pause / ▶️ Resume Download:** Temporarily halt and continue the process. Some UI settings can be changed while paused.
|
||||
- **❌ Cancel & Reset UI:** Stops the current operation and performs a "soft" UI reset (preserves URL and Directory inputs).
|
||||
- **🔄 Reset:** (In log area) Clears all inputs, logs, and resets settings to default when idle.
|
||||
|
||||
* **Known Names Manager**
|
||||
Add, search, and delete tags for smarter organization.
|
||||
Saved to `Known.txt` for reuse.
|
||||
### Filtering & Content Selection
|
||||
|
||||
* **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.
|
||||
- **🎯 Filter by Character(s):**
|
||||
- Enter character names, comma-separated.
|
||||
- **Syntax Examples:**
|
||||
- `Tifa, Aerith`: Matches posts/files with "Tifa" OR "Aerith". If "Separate Folders" is on, creates folders "Tifa" and "Aerith". Adds "Tifa", "Aerith" to `Known.txt` separately if new.
|
||||
- `(Vivi, Ulti, Uta)`: Matches "Vivi" OR "Ulti" OR "Uta". Session folder: "Vivi Ulti Uta". Adds "Vivi", "Ulti", "Uta" to `Known.txt` as separate entries if new.
|
||||
- `(Boa, Hancock)~`: Matches "Boa" OR "Hancock". Session folder: "Boa Hancock". Adds "Boa Hancock" as a single group entry to `Known.txt` if new (aliases: Boa, Hancock).
|
||||
- **Filter: [Type] Button (Scope):** Cycles how this filter applies:
|
||||
- `Filter: Files`: Checks individual filenames. Only matching files from a post are downloaded.
|
||||
- `Filter: Title`: Checks post titles. All files from a matching post are downloaded.
|
||||
- `Filter: Both`: Checks post title first. If no match, then checks filenames.
|
||||
- `Filter: Comments (Beta)`: Checks filenames first. If no file match, then checks post comments. (Uses more API requests).
|
||||
- **🚫 Skip with Words:**
|
||||
- Enter words (comma-separated) to skip content (e.g., `WIP, sketch`).
|
||||
- **Scope: [Type] Button:** Cycles how skipping applies:
|
||||
- `Scope: Files`: Skips individual files by name.
|
||||
- `Scope: Posts`: Skips entire posts by title.
|
||||
- `Scope: Both`: Post title first, then filenames.
|
||||
- **✂️ Remove Words from name:**
|
||||
- Enter words (comma-separated) to remove from downloaded filenames (e.g., `patreon, [HD]`).
|
||||
- **Filter Files (Radio Buttons):**
|
||||
- `All`: All file types.
|
||||
- `Images/GIFs`: Common image formats.
|
||||
- `Videos`: Common video formats.
|
||||
- `📦 Only Archives`: Exclusively `.zip` and `.rar` files. Disables archive skipping and external link log.
|
||||
- `🎧 Only Audio`: Common audio formats (MP3, WAV, FLAC, etc.).
|
||||
- `🔗 Only Links`: Extracts and displays external links from post descriptions. Disables download options.
|
||||
- **Skip .zip / Skip .rar Checkboxes:** Avoid downloading these archive types (disabled if "📦 Only Archives" is active).
|
||||
|
||||
* **Manga Mode**
|
||||
Rename and sort manga posts by title and upload order.
|
||||
Handles one-image-per-post formats cleanly.
|
||||
### Download Customization
|
||||
|
||||
* **Image Compression**
|
||||
Auto-convert large images (>1.5MB) to WebP (requires Pillow).
|
||||
- **Download Thumbnails Only:** Downloads small API preview images.
|
||||
- If "Scan Content for Images" is also active, *only* images found by content scan are downloaded (API thumbnails ignored).
|
||||
- **Scan Content for Images:** Scans post HTML for `<img>` tags and direct image links, resolving relative paths.
|
||||
- **Compress to WebP:** If Pillow is installed, converts images > 1.5MB to WebP if significantly smaller.
|
||||
- **🗄️ Custom Folder Name (Single Post Only):**
|
||||
- Visible if downloading a single post URL AND "Separate Folders by Name/Title" is enabled.
|
||||
- Set a custom folder name for that specific post's downloads.
|
||||
|
||||
* **Multithreaded Downloads**
|
||||
Adjustable worker count with warnings at unsafe levels.
|
||||
Full threading for creators, single-thread fallback for post mode.
|
||||
### 📖 Manga/Comic Mode (Creator Feeds Only)
|
||||
|
||||
* **Download Controls**
|
||||
Cancel, pause, or skip files mid-download.
|
||||
Visual progress tracking with per-post summaries.
|
||||
- **Chronological Processing:** Downloads posts from oldest to newest.
|
||||
- **Page Range Disabled:** All posts are fetched for sorting.
|
||||
- **Filename Style Toggle Button (in log area):**
|
||||
- `Name: Post Title (Default)`: First file named after post title; subsequent files in the same post keep original names.
|
||||
- `Name: Original File`: All files attempt to keep original names. Optional prefix input appears.
|
||||
- `Name: Title+G.Num`: All files across posts get post title prefix + global sequential number (e.g., `Chapter 1_001.jpg`). Disables post-level multithreading.
|
||||
- `Name: Date Based`: Files named sequentially (e.g., `001.jpg`) by post date. Optional prefix input appears. Disables post-level multithreading.
|
||||
|
||||
* **Dark Mode**
|
||||
Clean and modern dark-themed interface.
|
||||
### Folder Organization
|
||||
|
||||
- **Separate Folders by Name/Title:** Creates subfolders based on "Filter by Character(s)" or post titles. Uses `Known.txt` as a fallback.
|
||||
- **Subfolder per Post:** If "Separate Folders" is on, creates an additional subfolder for each post.
|
||||
- **`Known.txt` Management (Bottom Left UI):**
|
||||
- **List:** Displays primary names from `Known.txt`.
|
||||
- **Add New:** Input field to add new names/groups.
|
||||
- Simple: `My Series`
|
||||
- Group (Separate Known.txt): `(Vivi, Ulti, Uta)`
|
||||
- Group (Single Known.txt with `~`): `(Character A, Char A)~`
|
||||
- **➕ Add Button:** Adds the name/group to `Known.txt`.
|
||||
- **⤵️ Add to Filter Button:** Opens a dialog to select names from `Known.txt` to add to the "Filter by Character(s)" field.
|
||||
- **🗑️ Delete Selected Button:** Removes selected names from `Known.txt`.
|
||||
- **Open Known.txt Button:** Opens `Known.txt` in your default text editor for advanced editing.
|
||||
- **❓ Button:** Opens this feature guide.
|
||||
|
||||
### Advanced & Performance
|
||||
|
||||
- **🍪 Cookie Management:**
|
||||
- **Use Cookie Checkbox:** Enables cookie usage.
|
||||
- **Text Field:** Paste cookie string (e.g., `name1=value1; name2=value2`).
|
||||
- **Browse... Button:** Select a `cookies.txt` file (Netscape format).
|
||||
- *Behavior:* Text field takes precedence. If "Use Cookie" is checked and both are empty, tries to load `cookies.txt` from the app directory.
|
||||
- **Use Multithreading Checkbox & Threads Input:**
|
||||
- *Creator Feeds:* Number of posts to process simultaneously.
|
||||
- *Single Post URLs:* Number of files to download concurrently.
|
||||
- **Multi-part Download Toggle Button (in log area):**
|
||||
- `Multi-part: ON`: Enables multi-segment downloads for large files. Can speed up large file downloads but may increase UI choppiness or log spam with many small files.
|
||||
- `Multi-part: OFF (Default)`: Files downloaded in a single stream.
|
||||
- Disabled if "🔗 Only Links" or "📦 Only Archives" mode is active.
|
||||
|
||||
### Logging & Monitoring
|
||||
|
||||
- **📜 Progress Log / Extracted Links Log:** Main text area for detailed messages or extracted links.
|
||||
- **👁️ / 🙈 Log View Toggle Button:** Switches main log between:
|
||||
- `👁️ Progress Log`: All download activity, errors, summaries.
|
||||
- `🙈 Missed Character Log`: Key terms from post titles/content skipped due to character filters.
|
||||
- **Show External Links in Log Checkbox & Panel:** If checked, a secondary log panel displays external links from post descriptions (disabled in "Only Links" / "Only Archives" modes).
|
||||
- **Export Links Button:** (In "Only Links" mode) Saves extracted links to a `.txt` file.
|
||||
- **Progress Labels:** Display overall post progress and individual file download status/speed.
|
||||
|
||||
### ⭐ Favorite Mode (Downloading from Your Kemono.su Favorites)
|
||||
|
||||
- **Enable:** Check the "**⭐ Favorite Mode**" checkbox (next to "🔗 Only Links").
|
||||
- **UI Changes:**
|
||||
- URL input is replaced with a "Favorite Mode active" message.
|
||||
- Action buttons change to "**🖼️ Favorite Artists**" and "**📄 Favorite Posts**".
|
||||
- "**🍪 Use Cookie**" is auto-enabled and locked (required for favorites).
|
||||
- **🖼️ Favorite Artists Dialog:**
|
||||
- Fetches and lists artists you've favorited on Kemono.su.
|
||||
- Includes search, select all/deselect all, and a "Download Selected" button.
|
||||
- Selected artists are added to a download queue.
|
||||
- **📄 Favorite Posts Dialog:**
|
||||
- Fetches and lists posts you've favorited, grouped by artist and sorted by date.
|
||||
- Includes search (title, creator, ID, service), select all/deselect all.
|
||||
- Highlights known names from your `Known.txt` in post titles for easier identification.
|
||||
- Selected posts are added to a download queue.
|
||||
- **Favorite Download Scope Button:** (Next to "Favorite Posts" button)
|
||||
- `Scope: Selected Location`: All selected favorites download into the main "Download Location". Filters apply globally.
|
||||
- `Scope: Artist Folders`: A subfolder (named after the artist) is created in the main "Download Location" for each artist. Content goes into their specific subfolder. Filters apply within each artist's folder.
|
||||
- **Filters:** Standard "Filter by Character(s)", "Skip with Words", and "Filter Files" options apply to content downloaded from favorites.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Backend Enhancements
|
||||
## Key Files
|
||||
|
||||
* **Session File Support**
|
||||
Downloads can be resumed even after a crash or restart.
|
||||
Session progress is saved automatically.
|
||||
|
||||
* **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.
|
||||
- **`Known.txt`:** (Located in the application's directory)
|
||||
- Stores your list of known shows, characters, or series titles for automatic folder organization.
|
||||
- **Format:** Each line is an entry.
|
||||
- Simple: `My Awesome Series`
|
||||
- Grouped (single `Known.txt` entry, shared folder): `(Boa, Hancock)` - creates folder "Boa Hancock", aliases "Boa", "Hancock".
|
||||
- Used as a fallback for folder naming if "Separate Folders" is on and no active filter matches.
|
||||
- **`creators.json`:** (Expected in the application's directory)
|
||||
- Used by the "🎨 Creator Selection Popup".
|
||||
- A JSON file containing a list of creator objects. Expected structure: `[ [ {creator1_data}, {creator2_data}, ... ] ]` or a flat list `[ {creator1_data}, ... ]`.
|
||||
- Each creator object should ideally have `name`, `service`, `id`, and optionally `favorited` (integer count for sorting in popup).
|
||||
- *Example entry in the inner list:* `{"id": "12345", "name": "ArtistName", "service": "patreon", "favorited": 10}`
|
||||
- **`cookies.txt` (Optional):**
|
||||
- If "Use Cookie" is enabled and no direct string/file is provided, the app looks for this in its directory.
|
||||
- Must be in Netscape cookie file format.
|
||||
- **Application Settings:** UI preferences (like manga style, multipart preference) are saved by Qt's `QSettings` (location varies by OS). Cookie details and some filter scopes are session-based.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
- Python 3.6 or higher
|
||||
- pip (Python package installer)
|
||||
|
||||
* Python 3.6+
|
||||
* Pip packages:
|
||||
### Install Dependencies
|
||||
Open your terminal or command prompt and run:
|
||||
|
||||
```bash
|
||||
pip install PyQt5 requests Pillow
|
||||
```
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
|
||||
### 2. Optional Setup
|
||||
|
||||
- Place your `cookies.txt` in the root directory (if using cookies).
|
||||
- Prepare your `Known.txt` and `creators.json` in the same directory for advanced filtering and selection features.
|
||||
|
||||
---
|
||||
|
||||
## Tips & Best Practices
|
||||
|
||||
- For best results, use **Favorite Mode** if you're a logged-in user with bookmarked artists/posts.
|
||||
- Use **Filter by Character(s)** and keep your `Known.txt` updated to reduce clutter and organize downloads.
|
||||
- Use the **multi-part toggle** for large video/audio files but disable it when downloading large batches of small images to reduce overhead.
|
||||
- Adjust **thread count** based on your internet speed and CPU; too many threads can result in API throttling.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Downloads not starting?**
|
||||
- Ensure the download location is set.
|
||||
- Check your filters aren't too strict.
|
||||
- If in Favorite Mode, make sure cookie is set and valid.
|
||||
|
||||
- **Missing characters/folders?**
|
||||
- Review the Missed Character Log.
|
||||
- Use the "Scan Content for Images" option if image links are embedded in descriptions.
|
||||
|
||||
- **App crashes or logs errors?**
|
||||
- Check the console/log area for stack traces.
|
||||
- Run from terminal to capture more error output.
|
||||
- Ensure `Known.txt` and `creators.json` are valid.
|
||||
|
||||
---
|
||||
|
||||
## Contribution
|
||||
|
||||
Feel free to fork this repo and submit pull requests for bug fixes, new features, or UI improvements!
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the MIT License.
|
||||
325
tour.py
@@ -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()
|
||||