mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f07f038d | ||
|
|
7443615848 | ||
|
|
3d8f62e7fd | ||
|
|
525098b840 | ||
|
|
39e4496b67 | ||
|
|
d5bf27f8cc | ||
|
|
4e7c1783ea | ||
|
|
ad67860eab | ||
|
|
33841395ba | ||
|
|
651f9d9f8d | ||
|
|
decef6730f | ||
|
|
32a12e8a09 | ||
|
|
62007d2d45 | ||
|
|
f1e592cf99 | ||
|
|
bf111d109a | ||
|
|
00f8ff63d6 | ||
|
|
aee0ff999d | ||
|
|
b5e9080285 | ||
|
|
25d33f1531 | ||
|
|
ff0ccb2631 | ||
|
|
da507b2b3a | ||
|
|
9165903e96 | ||
|
|
f85de58fcb | ||
|
|
ccfb8496a2 | ||
|
|
e0d3e1b5af | ||
|
|
50ee50cd5c |
197
Known.txt
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
|
|
||||||
|
|||||||
1256
downloader_utils.py
1256
downloader_utils.py
File diff suppressed because it is too large
Load Diff
238
multipart_downloader.py
Normal file
238
multipart_downloader.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import hashlib
|
||||||
|
import http.client
|
||||||
|
import traceback
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
CHUNK_DOWNLOAD_RETRY_DELAY = 2 # Slightly reduced for faster retries if needed
|
||||||
|
MAX_CHUNK_DOWNLOAD_RETRIES = 1 # Further reduced for quicker fallback if a chunk is problematic
|
||||||
|
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256KB for iter_content within a chunk download
|
||||||
|
|
||||||
|
|
||||||
|
def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, headers,
|
||||||
|
part_num, total_parts, progress_data, cancellation_event, skip_event,
|
||||||
|
logger_func, emitter=None, api_original_filename=None): # Renamed logger, signals to emitter
|
||||||
|
"""Downloads a single chunk of a file and writes it to the temp file."""
|
||||||
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled before start.")
|
||||||
|
return 0, False # bytes_downloaded, success
|
||||||
|
if skip_event and skip_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event triggered before start.")
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
chunk_headers = headers.copy()
|
||||||
|
# end_byte can be -1 for 0-byte files, meaning download from start_byte to end of file (which is start_byte itself)
|
||||||
|
if end_byte != -1 : # For 0-byte files, end_byte might be -1, Range header should not be set or be 0-0
|
||||||
|
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
|
||||||
|
elif start_byte == 0 and end_byte == -1: # Specifically for 0-byte files
|
||||||
|
# Some servers might not like Range: bytes=0--1.
|
||||||
|
# For a 0-byte file, we might not even need a range header, or Range: bytes=0-0
|
||||||
|
# Let's try without for 0-byte, or rely on server to handle 0-0 if Content-Length was 0.
|
||||||
|
# If Content-Length was 0, the main function might handle it directly.
|
||||||
|
# This chunking logic is primarily for files > 0 bytes.
|
||||||
|
# For now, if end_byte is -1, it implies a 0-byte file, so we expect 0 bytes.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
bytes_this_chunk = 0
|
||||||
|
last_progress_emit_time_for_chunk = time.time()
|
||||||
|
last_speed_calc_time = time.time()
|
||||||
|
bytes_at_last_speed_calc = 0
|
||||||
|
|
||||||
|
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
|
||||||
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during retry loop.")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
if skip_event and skip_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during retry loop.")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if attempt > 0:
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying download (Attempt {attempt}/{MAX_CHUNK_DOWNLOAD_RETRIES})...")
|
||||||
|
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
|
||||||
|
# Reset speed calculation on retry
|
||||||
|
last_speed_calc_time = time.time()
|
||||||
|
bytes_at_last_speed_calc = bytes_this_chunk # Current progress of this chunk
|
||||||
|
|
||||||
|
# Enhanced log message for chunk start
|
||||||
|
log_msg = f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}"
|
||||||
|
logger_func(log_msg)
|
||||||
|
print(f"DEBUG_MULTIPART: {log_msg}") # Direct console print for debugging
|
||||||
|
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# For 0-byte files, if end_byte was -1, we expect 0 content.
|
||||||
|
if start_byte == 0 and end_byte == -1 and int(response.headers.get('Content-Length', 0)) == 0:
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Confirmed 0-byte file.")
|
||||||
|
with progress_data['lock']:
|
||||||
|
progress_data['chunks_status'][part_num]['active'] = False
|
||||||
|
progress_data['chunks_status'][part_num]['speed_bps'] = 0
|
||||||
|
return 0, True
|
||||||
|
|
||||||
|
with open(temp_file_path, 'r+b') as f: # Open in read-write binary
|
||||||
|
f.seek(start_byte)
|
||||||
|
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
|
||||||
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during data iteration.")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
if skip_event and skip_event.is_set():
|
||||||
|
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during data iteration.")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
if data_segment:
|
||||||
|
f.write(data_segment)
|
||||||
|
bytes_this_chunk += len(data_segment)
|
||||||
|
|
||||||
|
with progress_data['lock']:
|
||||||
|
# Increment both the chunk's downloaded and the overall downloaded
|
||||||
|
progress_data['total_downloaded_so_far'] += len(data_segment)
|
||||||
|
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
|
||||||
|
progress_data['chunks_status'][part_num]['active'] = True
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
time_delta_speed = current_time - last_speed_calc_time
|
||||||
|
if time_delta_speed > 0.5: # Calculate speed every 0.5 seconds
|
||||||
|
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
|
||||||
|
current_speed_bps = (bytes_delta * 8) / time_delta_speed if time_delta_speed > 0 else 0
|
||||||
|
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
|
||||||
|
last_speed_calc_time = current_time
|
||||||
|
bytes_at_last_speed_calc = bytes_this_chunk
|
||||||
|
|
||||||
|
# Emit progress more frequently from within the chunk download
|
||||||
|
if current_time - last_progress_emit_time_for_chunk > 0.1: # Emit up to 10 times/sec per chunk
|
||||||
|
if emitter:
|
||||||
|
# Ensure we read the latest total downloaded from progress_data
|
||||||
|
# Send a copy of the chunks_status list
|
||||||
|
status_list_copy = [dict(s) for s in progress_data['chunks_status']] # Make a deep enough copy
|
||||||
|
if isinstance(emitter, queue.Queue):
|
||||||
|
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
|
||||||
|
elif hasattr(emitter, 'file_progress_signal'): # PostProcessorSignals-like
|
||||||
|
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
|
||||||
|
last_progress_emit_time_for_chunk = current_time
|
||||||
|
return bytes_this_chunk, True
|
||||||
|
|
||||||
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
|
||||||
|
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
|
||||||
|
if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
|
||||||
|
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Failed after {MAX_CHUNK_DOWNLOAD_RETRIES} retries.")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
except requests.exceptions.RequestException as e: # Includes 4xx/5xx errors after raise_for_status
|
||||||
|
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
except Exception as e:
|
||||||
|
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
|
||||||
|
return bytes_this_chunk, False
|
||||||
|
|
||||||
|
# Ensure final status is marked as inactive if loop finishes due to retries
|
||||||
|
with progress_data['lock']:
|
||||||
|
progress_data['chunks_status'][part_num]['active'] = False
|
||||||
|
progress_data['chunks_status'][part_num]['speed_bps'] = 0
|
||||||
|
return bytes_this_chunk, False # Should be unreachable
|
||||||
|
|
||||||
|
|
||||||
|
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,
|
||||||
|
emitter_for_multipart, cancellation_event, skip_event, logger_func): # Renamed signals, logger
|
||||||
|
"""
|
||||||
|
Downloads a file in multiple parts concurrently.
|
||||||
|
Returns: (download_successful_flag, downloaded_bytes, calculated_file_hash, temp_file_handle_or_None)
|
||||||
|
The temp_file_handle will be an open read-binary file handle to the .part file if successful, otherwise None.
|
||||||
|
It is the responsibility of the caller to close this handle and rename/delete the .part file.
|
||||||
|
"""
|
||||||
|
logger_func(f"⬇️ Initializing Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
|
||||||
|
temp_file_path = save_path + ".part"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(temp_file_path, 'wb') as f_temp:
|
||||||
|
if total_size > 0:
|
||||||
|
f_temp.truncate(total_size) # Pre-allocate space
|
||||||
|
except IOError as e:
|
||||||
|
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}")
|
||||||
|
return False, 0, None, None
|
||||||
|
|
||||||
|
chunk_size_calc = total_size // num_parts
|
||||||
|
chunks_ranges = []
|
||||||
|
for i in range(num_parts):
|
||||||
|
start = i * chunk_size_calc
|
||||||
|
end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
|
||||||
|
if start <= end: # Valid range
|
||||||
|
chunks_ranges.append((start, end))
|
||||||
|
elif total_size == 0 and i == 0: # Special case for 0-byte file
|
||||||
|
chunks_ranges.append((0, -1)) # Indicates 0-byte file, download 0 bytes from offset 0
|
||||||
|
|
||||||
|
chunk_actual_sizes = []
|
||||||
|
for start, end in chunks_ranges:
|
||||||
|
if end == -1 and start == 0: # 0-byte file
|
||||||
|
chunk_actual_sizes.append(0)
|
||||||
|
else:
|
||||||
|
chunk_actual_sizes.append(end - start + 1)
|
||||||
|
|
||||||
|
if not chunks_ranges and total_size > 0:
|
||||||
|
logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting multipart.")
|
||||||
|
if os.path.exists(temp_file_path): os.remove(temp_file_path)
|
||||||
|
return False, 0, None, None
|
||||||
|
|
||||||
|
progress_data = {
|
||||||
|
'total_file_size': total_size, # Overall file size for reference
|
||||||
|
'total_downloaded_so_far': 0, # New key for overall progress
|
||||||
|
'chunks_status': [ # Status for each chunk
|
||||||
|
{'id': i, 'downloaded': 0, 'total': chunk_actual_sizes[i] if i < len(chunk_actual_sizes) else 0, 'active': False, 'speed_bps': 0.0}
|
||||||
|
for i in range(num_parts)
|
||||||
|
],
|
||||||
|
'lock': threading.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk_futures = []
|
||||||
|
all_chunks_successful = True
|
||||||
|
total_bytes_from_chunks = 0 # Still useful to verify total downloaded against file size
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=num_parts, thread_name_prefix=f"MPChunk_{api_original_filename[:10]}_") as chunk_pool:
|
||||||
|
for i, (start, end) in enumerate(chunks_ranges):
|
||||||
|
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
|
||||||
|
chunk_futures.append(chunk_pool.submit(
|
||||||
|
_download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path,
|
||||||
|
start_byte=start, end_byte=end, headers=headers, part_num=i, total_parts=num_parts,
|
||||||
|
progress_data=progress_data, cancellation_event=cancellation_event, skip_event=skip_event,
|
||||||
|
logger_func=logger_func, emitter=emitter_for_multipart, # Pass emitter
|
||||||
|
api_original_filename=api_original_filename
|
||||||
|
))
|
||||||
|
|
||||||
|
for future in as_completed(chunk_futures):
|
||||||
|
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break
|
||||||
|
bytes_downloaded_this_chunk, success_this_chunk = future.result()
|
||||||
|
total_bytes_from_chunks += bytes_downloaded_this_chunk
|
||||||
|
if not success_this_chunk:
|
||||||
|
all_chunks_successful = False
|
||||||
|
|
||||||
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
|
||||||
|
all_chunks_successful = False
|
||||||
|
|
||||||
|
# Ensure a final progress update is sent with all chunks marked inactive (unless still active due to error)
|
||||||
|
if emitter_for_multipart:
|
||||||
|
with progress_data['lock']:
|
||||||
|
# Ensure all chunks are marked inactive for the final signal if download didn't fully succeed or was cancelled
|
||||||
|
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
|
||||||
|
if isinstance(emitter_for_multipart, queue.Queue):
|
||||||
|
emitter_for_multipart.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
|
||||||
|
elif hasattr(emitter_for_multipart, 'file_progress_signal'): # PostProcessorSignals-like
|
||||||
|
emitter_for_multipart.file_progress_signal.emit(api_original_filename, status_list_copy)
|
||||||
|
|
||||||
|
if all_chunks_successful and (total_bytes_from_chunks == total_size or total_size == 0):
|
||||||
|
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}")
|
||||||
|
md5_hasher = hashlib.md5()
|
||||||
|
with open(temp_file_path, 'rb') as f_hash:
|
||||||
|
for buf in iter(lambda: f_hash.read(4096*10), b''): # Read in larger buffers for hashing
|
||||||
|
md5_hasher.update(buf)
|
||||||
|
calculated_hash = md5_hasher.hexdigest()
|
||||||
|
# Return an open file handle for the caller to manage (e.g., for compression)
|
||||||
|
# The caller is responsible for closing this handle and renaming/deleting the .part file.
|
||||||
|
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb')
|
||||||
|
else:
|
||||||
|
logger_func(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_from_chunks}/{total_size}. Cleaning up.")
|
||||||
|
if os.path.exists(temp_file_path):
|
||||||
|
try: os.remove(temp_file_path)
|
||||||
|
except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}")
|
||||||
|
return False, total_bytes_from_chunks, None, None
|
||||||
293
readme.md
293
readme.md
@@ -1,105 +1,244 @@
|
|||||||
# Kemono Downloader v3.1.0
|
<h1 align="center">Kemono Downloader v3.4.0</h1>
|
||||||
|
|
||||||
A feature-rich GUI application built with PyQt5 to download content from [Kemono.su](https://kemono.su) or [Coomer.party](https://coomer.party). Offers robust filtering, smart organization, manga-specific handling, and performance tuning. Now with session resuming, better retry logic, and smarter file management.
|
<div align="center">
|
||||||
|
<img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 What's New in v3.1.0
|
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** and **[Coomer.party](https://coomer.party)**.
|
||||||
|
Built with **PyQt5**, this tool is ideal for users who want deep filtering, customizable folder structures, efficient downloads, and intelligent automation — all within a modern, user-friendly graphical interface.
|
||||||
* **Session Resuming**
|
|
||||||
* Automatically saves and resumes incomplete downloads.
|
|
||||||
|
|
||||||
* **Retry on Failure**
|
|
||||||
* Failed files auto-retry up to 3 times.
|
|
||||||
* Clear logging for each retry attempt.
|
|
||||||
|
|
||||||
* **Batch URL Import**
|
|
||||||
* Load multiple creator or post URLs from a `.txt` file.
|
|
||||||
|
|
||||||
* **Improved Manga Mode**
|
|
||||||
* Better post ordering and handling of missing or untitled posts.
|
|
||||||
* Optional numeric-only sorting for consistent naming.
|
|
||||||
|
|
||||||
* **UI Enhancements**
|
|
||||||
* Settings persist across sessions.
|
|
||||||
* Improved layout spacing, tooltips, and status indicators.
|
|
||||||
|
|
||||||
* **Stability & Speed**
|
|
||||||
* Faster post fetching with lower memory usage.
|
|
||||||
* Minor bug fixes (duplicate folders, empty post crashes).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 Core Features
|
## ✨ What's New in v3.4.0?
|
||||||
|
|
||||||
* **Simple GUI**
|
This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience:
|
||||||
Built with PyQt5 for a clean, responsive experience.
|
|
||||||
|
|
||||||
* **Supports Both Post and Creator URLs**
|
### 📖 Enhanced Manga/Comic Mode
|
||||||
Download a single post or an entire feed with one click.
|
|
||||||
|
|
||||||
* **Smart Folder System**
|
- **New "Date Based" Filename Style:**
|
||||||
Organize files using post titles, known character/show names, or a folder per post.
|
|
||||||
Detects and auto-names folders based on custom keywords.
|
|
||||||
|
|
||||||
* **Known Names Manager**
|
- Perfect for truly sequential content! Files are named numerically (e.g., `001.jpg`, `002.jpg`, `003.ext`...) across an *entire creator's feed*, strictly following post publication order.
|
||||||
Add, search, and delete tags for smarter organization.
|
|
||||||
Saved to `Known.txt` for reuse.
|
|
||||||
|
|
||||||
* **Advanced Filters**
|
- **Smart Numbering:** Automatically resumes from the highest existing number found in the series folder (and subfolders, if "Subfolder per Post" is enabled).
|
||||||
* Skip posts or files with specific keywords (e.g. `WIP`, `sketch`).
|
|
||||||
* Filter by media type: images, videos, or GIFs.
|
|
||||||
* Skip `.zip` and `.rar` archives.
|
|
||||||
|
|
||||||
* **Manga Mode**
|
- **Guaranteed Order:** Disables multi-threading for post processing to ensure sequential accuracy.
|
||||||
Rename and sort manga posts by title and upload order.
|
|
||||||
Handles one-image-per-post formats cleanly.
|
|
||||||
|
|
||||||
* **Image Compression**
|
- Works alongside the existing "Post Title" and "Original File Name" styles.
|
||||||
Auto-convert large images (>1.5MB) to WebP (requires Pillow).
|
|
||||||
|
|
||||||
* **Multithreaded Downloads**
|
|
||||||
Adjustable worker count with warnings at unsafe levels.
|
|
||||||
Full threading for creators, single-thread fallback for post mode.
|
|
||||||
|
|
||||||
* **Download Controls**
|
|
||||||
Cancel, pause, or skip files mid-download.
|
|
||||||
Visual progress tracking with per-post summaries.
|
|
||||||
|
|
||||||
* **Dark Mode**
|
|
||||||
Clean and modern dark-themed interface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Backend Enhancements
|
### ✂️ "Remove Words from Filename" Feature
|
||||||
|
|
||||||
* **Session File Support**
|
- Specify comma-separated words or phrases (case-insensitive) that will be automatically removed from filenames.
|
||||||
Downloads can be resumed even after a crash or restart.
|
|
||||||
Session progress is saved automatically.
|
|
||||||
|
|
||||||
* **Retry Logic**
|
- Example: `patreon, [HD], _final` transforms `AwesomeArt_patreon` `Hinata_Hd` into `AwesomeArt.jpg` `Hinata.jpg`.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Installation
|
### 📦 New "Only Archives" File Filter Mode
|
||||||
|
|
||||||
|
- Exclusively downloads `.zip` and `.rar` files.
|
||||||
|
|
||||||
|
- Automatically disables conflicting options like "Skip .zip/.rar" and external link logging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🗣️ Improved Character Filter Scope - "Comments (Beta)"
|
||||||
|
|
||||||
|
- **File-First Check:** Prioritizes matching filenames before checking post comments for character names.
|
||||||
|
|
||||||
|
- **Comment Fallback:** Only checks comments if no filename match is found, reducing unnecessary API calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧐 Refined "Missed Character Log"
|
||||||
|
|
||||||
|
- Displays a capitalized, alphabetized list of key terms from skipped post titles.
|
||||||
|
|
||||||
|
- Makes it easier to spot patterns or characters that might be unintentionally excluded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🚀 Enhanced Multi-part Download Progress
|
||||||
|
|
||||||
|
- Granular visibility into active chunk downloads and combined speed for large files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🗺️ Updated Onboarding Tour
|
||||||
|
|
||||||
|
- Improved guide for new users, covering v3.4.0 features and existing core functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🛡️ Robust Configuration Path
|
||||||
|
|
||||||
|
- Settings and `Known.txt` are now stored in the system-standard application data folder (e.g., `AppData`, `~/.local/share`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Core Features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Interface & Workflow
|
||||||
|
|
||||||
|
- **Clean PyQt5 GUI** — Simple, modern, and dark-themed.
|
||||||
|
|
||||||
|
- **Persistent Settings** — Saves preferences between sessions.
|
||||||
|
|
||||||
|
- **Download Modes:**
|
||||||
|
- Single Post URL
|
||||||
|
- Entire Creator Feed
|
||||||
|
|
||||||
|
- **Flexible Options:**
|
||||||
|
- Specify Page Range (disabled in Manga Mode)
|
||||||
|
- Custom Folder Name for single posts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧠 Smart Filtering
|
||||||
|
|
||||||
|
- **Character Name Filtering:**
|
||||||
|
- Use `Tifa, Aerith` or group `(Boa, Hancock)` → folder `Boa Hancock`
|
||||||
|
|
||||||
|
- **Filter Scopes:**
|
||||||
|
- `Files`
|
||||||
|
- `Title`
|
||||||
|
- `Both (Title then Files)`
|
||||||
|
- `Comments (Beta - Files first)`
|
||||||
|
|
||||||
|
- **Skip with Words:**
|
||||||
|
- Exclude with `WIP, sketch, preview`
|
||||||
|
|
||||||
|
- **Skip Scopes:**
|
||||||
|
- `Files`
|
||||||
|
- `Posts`
|
||||||
|
- `Both (Posts then Files)`
|
||||||
|
|
||||||
|
- **File Type Filters:**
|
||||||
|
- `All`, `Images/GIFs`, `Videos`, `📦 Only Archives`, `🔗 Only Links`
|
||||||
|
|
||||||
|
- **Filename Cleanup:**
|
||||||
|
- Remove illegal and unwanted characters or phrases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📚 Manga/Comic Mode (Creator Feeds Only)
|
||||||
|
|
||||||
|
- **Chronological Processing** — Oldest posts first
|
||||||
|
|
||||||
|
- **Filename Style Options:**
|
||||||
|
- `Name: Post Title (Default)`
|
||||||
|
- `Name: Original File`
|
||||||
|
- `Name: Date Based (New)`
|
||||||
|
|
||||||
|
- **Best With:** Character filters set to manga/series title
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 Folder Structure & Naming
|
||||||
|
|
||||||
|
- **Subfolders:**
|
||||||
|
- Auto-created based on character name, post title, or `Known.txt`
|
||||||
|
|
||||||
|
- "Subfolder per Post" option for further nesting
|
||||||
|
|
||||||
|
- **Smart Naming:** Cleans invalid characters and structures logically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🖼️ Thumbnail & Compression Tools
|
||||||
|
|
||||||
|
- **Download Thumbnails Only**
|
||||||
|
|
||||||
|
- **Compress to WebP** (via Pillow)
|
||||||
|
- Converts large images to smaller WebP versions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⚙️ Performance Features
|
||||||
|
|
||||||
|
- **Multithreading:**
|
||||||
|
- For both post processing and file downloading
|
||||||
|
|
||||||
|
- **Multi-part Downloads:**
|
||||||
|
- Toggleable in GUI
|
||||||
|
- Splits large files into chunks
|
||||||
|
- Granular chunk-level progress display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 Logging & Progress
|
||||||
|
|
||||||
|
- **Real-time Logs:** Activity, errors, skipped posts
|
||||||
|
|
||||||
|
- **Missed Character Log:** Shows skipped keywords in easy-to-read list
|
||||||
|
|
||||||
|
- **External Links Log:** Shows links (unless disabled in some modes)
|
||||||
|
|
||||||
|
- **Export Links:** Save `.txt` of links (Only Links mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🗃️ Config System
|
||||||
|
|
||||||
|
- **Known.txt:**
|
||||||
|
- Stores names for smart folder suggestions
|
||||||
|
- Supports aliases via `(alias1, alias2)`
|
||||||
|
|
||||||
|
- **Stored in Standard App Data Path**
|
||||||
|
|
||||||
|
- **Editable Within GUI**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Installation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
* Python 3.6+
|
- Python 3.6 or higher
|
||||||
* Pip packages:
|
- pip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install PyQt5 requests Pillow
|
pip install PyQt5 requests Pillow
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## **🛠️ Build a Standalone Executable (Optional)**
|
||||||
|
|
||||||
|
1. Install PyInstaller:
|
||||||
|
```bash
|
||||||
|
pip install pyinstaller
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run:
|
||||||
|
```bash
|
||||||
|
pyinstaller --name "Kemono Downloader" --onefile --windowed --icon="Kemono.ico" main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Output will be in the `dist/` folder.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## **🗂 Config Files**
|
||||||
|
|
||||||
|
- `Known.txt` — character/show names used for folder organization
|
||||||
|
- Supports grouped names in format: `(Name1, Name2)`
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## **💬 Feedback & Support**
|
||||||
|
|
||||||
|
Issues? Suggestions?
|
||||||
|
Open an issue on the [GitHub repository](https://github.com/Yuvi9587/kemono-downloader) or join our community.
|
||||||
|
|||||||
325
tour.py
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()
|
|
||||||
Reference in New Issue
Block a user