4 Commits

Author SHA1 Message Date
Yuvi9587
9888ed0862 Update multipart_downloader.py 2025-07-30 22:28:05 -07:00
Yuvi9587
9e996bf682 Commit 2025-07-30 21:31:02 -07:00
Yuvi9587
e7a6a91542 commit 2025-07-30 19:30:50 -07:00
Yuvi9587
d7faccce18 Commit 2025-07-29 06:37:28 -07:00
7 changed files with 651 additions and 341 deletions

View File

@@ -410,6 +410,39 @@ class PostProcessorWorker:
unique_id_for_part_file = uuid.uuid4().hex[:8]
unique_part_file_stem_on_disk = f"{temp_file_base_for_unique_part}_{unique_id_for_part_file}"
max_retries = 3
if not self.keep_in_post_duplicates:
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
if os.path.exists(final_save_path_check):
try:
# Use a HEAD request to get the expected size without downloading the body
with requests.head(file_url, headers=headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
head_response.raise_for_status()
expected_size = int(head_response.headers.get('Content-Length', -1))
actual_size = os.path.getsize(final_save_path_check)
if expected_size != -1 and actual_size == expected_size:
self.logger(f" -> Skip (File Exists & Complete): '{filename_to_save_in_main_path}' is already on disk with the correct size.")
# We still need to add its hash to the session to prevent duplicates in other modes
# This is a quick hash calculation for the already existing file
try:
md5_hasher = hashlib.md5()
with open(final_save_path_check, 'rb') as f_verify:
for chunk in iter(lambda: f_verify.read(8192), b""):
md5_hasher.update(chunk)
with self.downloaded_hash_counts_lock:
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
except Exception as hash_exc:
self.logger(f" ⚠️ Could not hash existing file '{filename_to_save_in_main_path}' for session: {hash_exc}")
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
else:
self.logger(f" ⚠️ File '{filename_to_save_in_main_path}' exists but is incomplete (Expected: {expected_size}, Actual: {actual_size}). Re-downloading.")
except requests.RequestException as e:
self.logger(f" ⚠️ Could not verify size of existing file '{filename_to_save_in_main_path}': {e}. Proceeding with download.")
retry_delay = 5
downloaded_size_bytes = 0
calculated_file_hash = None
@@ -741,8 +774,11 @@ class PostProcessorWorker:
history_data_for_this_post = None
parsed_api_url = urlparse(self.api_url_input)
referer_url = f"https://{parsed_api_url.netloc}/"
headers = {'User-Agent': 'Mozilla/5.0', 'Referer': referer_url, 'Accept': '*/*'}
post_data = self.post
post_id = post_data.get('id', 'unknown_id')
post_page_url = f"https://{parsed_api_url.netloc}/{self.service}/user/{self.user_id}/post/{post_id}"
headers = {'User-Agent': 'Mozilla/5.0', 'Referer': post_page_url, 'Accept': '*/*'}
link_pattern = re.compile(r"""<a\s+.*?href=["'](https?://[^"']+)["'][^>]*>(.*?)</a>""", re.IGNORECASE | re.DOTALL)
post_data = self.post
post_title = post_data.get('title', '') or 'untitled_post'
@@ -752,6 +788,17 @@ class PostProcessorWorker:
effective_unwanted_keywords_for_folder_naming = self.unwanted_keywords.copy()
is_full_creator_download_no_char_filter = not self.target_post_id_from_initial_url and not current_character_filters
if (self.show_external_links or self.extract_links_only):
embed_data = post_data.get('embed')
if isinstance(embed_data, dict) and embed_data.get('url'):
embed_url = embed_data['url']
embed_subject = embed_data.get('subject', embed_url) # Use subject as link text, fallback to URL
platform = get_link_platform(embed_url)
self.logger(f" 🔗 Found embed link: {embed_url}")
self._emit_signal('external_link', post_title, embed_subject, embed_url, platform, "")
if is_full_creator_download_no_char_filter and self.creator_download_folder_ignore_words:
self.logger(f" Applying creator download specific folder ignore words ({len(self.creator_download_folder_ignore_words)} words).")
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
@@ -791,7 +838,7 @@ class PostProcessorWorker:
all_files_from_post_api_for_char_check = []
api_file_domain_for_char_check = urlparse(self.api_url_input).netloc
if not api_file_domain_for_char_check or not any(d in api_file_domain_for_char_check.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
api_file_domain_for_char_check = "kemono.su" if "kemono" in self.service.lower() else "coomer.st"
api_file_domain_for_char_check = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'):
original_api_name = post_main_file_info.get('name') or os.path.basename(post_main_file_info['path'].lstrip('/'))
if original_api_name:
@@ -834,9 +881,9 @@ class PostProcessorWorker:
try:
parsed_input_url_for_comments = urlparse(self.api_url_input)
api_domain_for_comments = parsed_input_url_for_comments.netloc
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
api_domain_for_comments = "kemono.su" if "kemono" in self.service.lower() else "coomer.party"
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
comments_data = fetch_post_comments(
api_domain_for_comments, self.service, self.user_id, post_id,
headers, self.logger, self.cancellation_event, self.pause_event,
@@ -1321,7 +1368,7 @@ class PostProcessorWorker:
all_files_from_post_api = []
api_file_domain = urlparse(self.api_url_input).netloc
if not api_file_domain or not any(d in api_file_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
api_file_domain = "kemono.su" if "kemono" in self.service.lower() else "coomer.st"
api_file_domain = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'):
file_path = post_main_file_info['path'].lstrip('/')
original_api_name = post_main_file_info.get('name') or os.path.basename(file_path)

View File

@@ -3,15 +3,19 @@ import os
import re
import traceback
import json
import base64
import time
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
# --- Third-Party Library Imports ---
# Make sure to install these: pip install requests pycryptodome gdown
import requests
try:
from mega import Mega
MEGA_AVAILABLE = True
from Crypto.Cipher import AES
PYCRYPTODOME_AVAILABLE = True
except ImportError:
MEGA_AVAILABLE = False
PYCRYPTODOME_AVAILABLE = False
try:
import gdown
@@ -19,17 +23,15 @@ try:
except ImportError:
GDRIVE_AVAILABLE = False
# --- Helper Functions ---
# --- Constants ---
MEGA_API_URL = "https://g.api.mega.co.nz"
# --- Helper Functions (Original and New) ---
def _get_filename_from_headers(headers):
"""
Extracts a filename from the Content-Disposition header.
Args:
headers (dict): A dictionary of HTTP response headers.
Returns:
str or None: The extracted filename, or None if not found.
(This is from your original file and is kept for Dropbox downloads)
"""
cd = headers.get('content-disposition')
if not cd:
@@ -37,64 +39,180 @@ def _get_filename_from_headers(headers):
fname_match = re.findall('filename="?([^"]+)"?', cd)
if fname_match:
# Sanitize the filename to prevent directory traversal issues
# and remove invalid characters for most filesystems.
sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip())
return sanitized_name
return None
# --- Main Service Downloader Functions ---
# --- NEW: Helper functions for Mega decryption ---
def urlb64_to_b64(s):
"""Converts a URL-safe base64 string to a standard base64 string."""
s = s.replace('-', '+').replace('_', '/')
s += '=' * (-len(s) % 4)
return s
def b64_to_bytes(s):
"""Decodes a URL-safe base64 string to bytes."""
return base64.b64decode(urlb64_to_b64(s))
def bytes_to_hex(b):
"""Converts bytes to a hex string."""
return b.hex()
def hex_to_bytes(h):
"""Converts a hex string to bytes."""
return bytes.fromhex(h)
def hrk2hk(hex_raw_key):
"""Derives the final AES key from the raw key components for Mega."""
key_part1 = int(hex_raw_key[0:16], 16)
key_part2 = int(hex_raw_key[16:32], 16)
key_part3 = int(hex_raw_key[32:48], 16)
key_part4 = int(hex_raw_key[48:64], 16)
final_key_part1 = key_part1 ^ key_part3
final_key_part2 = key_part2 ^ key_part4
return f'{final_key_part1:016x}{final_key_part2:016x}'
def decrypt_at(at_b64, key_bytes):
"""Decrypts the 'at' attribute to get file metadata."""
at_bytes = b64_to_bytes(at_b64)
iv = b'\0' * 16
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
decrypted_at = cipher.decrypt(at_bytes)
return decrypted_at.decode('utf-8').strip('\0').replace('MEGA', '')
# --- NEW: Core Logic for Mega Downloads ---
def get_mega_file_info(file_id, file_key, session, logger_func):
"""Fetches file metadata and the temporary download URL from the Mega API."""
try:
hex_raw_key = bytes_to_hex(b64_to_bytes(file_key))
hex_key = hrk2hk(hex_raw_key)
key_bytes = hex_to_bytes(hex_key)
# Request file attributes
payload = [{"a": "g", "p": file_id}]
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
response.raise_for_status()
res_json = response.json()
if isinstance(res_json, list) and isinstance(res_json[0], int) and res_json[0] < 0:
logger_func(f" [Mega] ❌ API Error: {res_json[0]}. The link may be invalid or removed.")
return None
file_size = res_json[0]['s']
at_b64 = res_json[0]['at']
# Decrypt attributes to get the file name
at_dec_json_str = decrypt_at(at_b64, key_bytes)
at_dec_json = json.loads(at_dec_json_str)
file_name = at_dec_json['n']
# Request the temporary download URL
payload = [{"a": "g", "g": 1, "p": file_id}]
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
response.raise_for_status()
res_json = response.json()
dl_temp_url = res_json[0]['g']
return {
'file_name': file_name,
'file_size': file_size,
'dl_url': dl_temp_url,
'hex_raw_key': hex_raw_key
}
except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as e:
logger_func(f" [Mega] ❌ Failed to get file info: {e}")
return None
def download_and_decrypt_mega_file(info, download_path, logger_func):
"""Downloads the file and decrypts it chunk by chunk, reporting progress."""
file_name = info['file_name']
file_size = info['file_size']
dl_url = info['dl_url']
hex_raw_key = info['hex_raw_key']
final_path = os.path.join(download_path, file_name)
if os.path.exists(final_path) and os.path.getsize(final_path) == file_size:
logger_func(f" [Mega] File '{file_name}' already exists with the correct size. Skipping.")
return
# Prepare for decryption
key = hex_to_bytes(hrk2hk(hex_raw_key))
iv_hex = hex_raw_key[32:48] + '0000000000000000'
iv_bytes = hex_to_bytes(iv_hex)
cipher = AES.new(key, AES.MODE_CTR, initial_value=iv_bytes, nonce=b'')
try:
with requests.get(dl_url, stream=True, timeout=(15, 300)) as r:
r.raise_for_status()
downloaded_bytes = 0
last_log_time = time.time()
with open(final_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if not chunk:
continue
decrypted_chunk = cipher.decrypt(chunk)
f.write(decrypted_chunk)
downloaded_bytes += len(chunk)
# Log progress every second
current_time = time.time()
if current_time - last_log_time > 1:
progress_percent = (downloaded_bytes / file_size) * 100 if file_size > 0 else 0
logger_func(f" [Mega] Downloading '{file_name}': {downloaded_bytes/1024/1024:.2f}MB / {file_size/1024/1024:.2f}MB ({progress_percent:.1f}%)")
last_log_time = current_time
logger_func(f" [Mega] ✅ Successfully downloaded '{file_name}' to '{download_path}'")
except requests.RequestException as e:
logger_func(f" [Mega] ❌ Download failed for '{file_name}': {e}")
except IOError as e:
logger_func(f" [Mega] ❌ Could not write to file '{final_path}': {e}")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during download/decryption: {e}")
# --- REPLACEMENT Main Service Downloader Function for Mega ---
def download_mega_file(mega_url, download_path, logger_func=print):
"""
Downloads a file from a Mega.nz URL.
Handles both public links and links that include a decryption key.
Downloads a file from a Mega.nz URL using direct requests and decryption.
This replaces the old mega.py implementation.
"""
if not MEGA_AVAILABLE:
logger_func("❌ Mega download failed: 'mega.py' library is not installed.")
if not PYCRYPTODOME_AVAILABLE:
logger_func("❌ Mega download failed: 'pycryptodome' library is not installed. Please run: pip install pycryptodome")
return
logger_func(f" [Mega] Initializing Mega client...")
try:
mega = Mega()
# Anonymous login is sufficient for public links
m = mega.login()
logger_func(f" [Mega] Initializing download for: {mega_url}")
# Regex to capture file ID and key from both old and new URL formats
match = re.search(r'mega(?:\.co)?\.nz/(?:file/|#!)?([a-zA-Z0-9]+)(?:#|!)([a-zA-Z0-9_.-]+)', mega_url)
if not match:
logger_func(f" [Mega] ❌ Error: Invalid Mega URL format.")
return
file_id = match.group(1)
file_key = match.group(2)
# --- MODIFIED PART: Added error handling for invalid links ---
try:
file_details = m.find(mega_url)
if file_details is None:
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
return
except (ValueError, json.JSONDecodeError) as e:
# This block catches the "Expecting value" error
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
return
except Exception as e:
# Catch other potential errors from the mega.py library
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
return
# --- END OF MODIFIED PART ---
session = requests.Session()
session.headers.update({'User-Agent': 'Kemono-Downloader-PyQt/1.0'})
file_info = get_mega_file_info(file_id, file_key, session, logger_func)
if not file_info:
logger_func(f" [Mega] ❌ Failed to get file info. The link may be invalid or expired. Aborting.")
return
filename = file_details[1]['a']['n']
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
logger_func(f" [Mega] File found: '{file_info['file_name']}' (Size: {file_info['file_size'] / 1024 / 1024:.2f} MB)")
download_and_decrypt_mega_file(file_info, download_path, logger_func)
# Sanitize filename before saving
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
final_path = os.path.join(download_path, safe_filename)
# Check if file already exists
if os.path.exists(final_path):
logger_func(f" [Mega] File '{safe_filename}' already exists. Skipping download.")
return
# Start the download
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
# --- ORIGINAL Functions for Google Drive and Dropbox (Unchanged) ---
def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
@@ -103,12 +221,9 @@ def download_gdrive_file(url, download_path, logger_func=print):
return
try:
logger_func(f" [G-Drive] Starting download for: {url}")
# --- MODIFIED PART: Added a message and set quiet=True ---
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
# --- END OF MODIFIED PART ---
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
@@ -120,15 +235,9 @@ def download_gdrive_file(url, download_path, logger_func=print):
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
"""
Downloads a file from a public Dropbox link by modifying the URL for direct download.
Args:
dropbox_link (str): The public Dropbox link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
"""
logger_func(f" [Dropbox] Attempting to download: {dropbox_link}")
# Modify the Dropbox URL to force a direct download instead of showing the preview page.
parsed_url = urlparse(dropbox_link)
query_params = parse_qs(parsed_url.query)
query_params['dl'] = ['1']
@@ -145,13 +254,11 @@ def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r:
r.raise_for_status()
# Determine filename from headers or URL
filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file"
full_save_path = os.path.join(download_path, filename)
logger_func(f" [Dropbox] Starting download of '{filename}'...")
# Write file to disk in chunks
with open(full_save_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)

View File

@@ -1,4 +1,5 @@
# --- Standard Library Imports ---
# --- Standard Library Imports ---
import os
import time
import hashlib
@@ -10,27 +11,49 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
# --- Third-Party Library Imports ---
import requests
MULTIPART_DOWNLOADER_AVAILABLE = True
# --- Module Constants ---
CHUNK_DOWNLOAD_RETRY_DELAY = 2
MAX_CHUNK_DOWNLOAD_RETRIES = 1
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk
# Flag to indicate if this module and its dependencies are available.
MULTIPART_DOWNLOADER_AVAILABLE = True
def _download_individual_chunk(
chunk_url, temp_file_path, start_byte, end_byte, headers,
chunk_url, chunk_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,
logger_func, emitter=None, api_original_filename=None
):
"""
Downloads a single segment (chunk) of a larger file. This function is
intended to be run in a separate thread by a ThreadPoolExecutor.
Downloads a single segment (chunk) of a larger file to its own unique part file.
This function is intended to be run in a separate thread by a ThreadPoolExecutor.
It handles retries, pauses, and cancellations for its specific chunk.
It handles retries, pauses, and cancellations for its specific chunk. If a
download fails, the partial chunk file is removed, allowing a clean retry later.
Args:
chunk_url (str): The URL to download the file from.
chunk_temp_file_path (str): The unique path to save this specific chunk
(e.g., 'my_video.mp4.part0').
start_byte (int): The starting byte for the Range header.
end_byte (int): The ending byte for the Range header.
headers (dict): The HTTP headers to use for the request.
part_num (int): The index of this chunk (e.g., 0 for the first part).
total_parts (int): The total number of chunks for the entire file.
progress_data (dict): A thread-safe dictionary for sharing progress.
cancellation_event (threading.Event): Event to signal cancellation.
skip_event (threading.Event): Event to signal skipping the file.
pause_event (threading.Event): Event to signal pausing the download.
global_emit_time_ref (list): A mutable list with one element (a timestamp)
to rate-limit UI updates.
cookies_for_chunk (dict): Cookies to use for the request.
logger_func (function): A function to log messages.
emitter (queue.Queue or QObject): Emitter for sending progress to the UI.
api_original_filename (str): The original filename for UI display.
Returns:
tuple: A tuple containing (bytes_downloaded, success_flag).
"""
# --- Pre-download checks for control events ---
if cancellation_event and cancellation_event.is_set():
@@ -48,18 +71,16 @@ def _download_individual_chunk(
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
# --- START: FIX ---
# Set this chunk's status to 'active' before starting the download.
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = True
# --- END: FIX ---
try:
# Prepare headers for the specific byte range of this chunk
chunk_headers = headers.copy()
if end_byte != -1:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
bytes_this_chunk = 0
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = 0
@@ -77,13 +98,14 @@ def _download_individual_chunk(
bytes_at_last_speed_calc = bytes_this_chunk
logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
response.raise_for_status()
# --- Data Writing Loop ---
with open(temp_file_path, 'r+b') as f:
f.seek(start_byte)
# We open the unique chunk file in write-binary ('wb') mode.
# No more seeking is required.
with open(chunk_temp_file_path, 'wb') as f:
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
return bytes_this_chunk, False
@@ -98,12 +120,12 @@ def _download_individual_chunk(
if data_segment:
f.write(data_segment)
bytes_this_chunk += len(data_segment)
# Update shared progress data structure
with progress_data['lock']:
progress_data['total_downloaded_so_far'] += len(data_segment)
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
# Calculate and update speed for this chunk
current_time = time.time()
time_delta = current_time - last_speed_calc_time
@@ -113,7 +135,7 @@ def _download_individual_chunk(
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 signal to the UI via the queue
if emitter and (current_time - global_emit_time_ref[0] > 0.25):
global_emit_time_ref[0] = current_time
@@ -122,7 +144,8 @@ def _download_individual_chunk(
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
# If we get here, the download for this chunk is successful
return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
@@ -134,8 +157,10 @@ def _download_individual_chunk(
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
# If the retry loop finishes without a successful download
return bytes_this_chunk, False
finally:
# This block runs whether the download succeeded or failed
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0.0
@@ -144,17 +169,37 @@ def _download_individual_chunk(
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,
emitter_for_multipart, cookies_for_chunk_session,
cancellation_event, skip_event, logger_func, pause_event):
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"
"""
Manages a resilient, multipart file download by saving each chunk to a separate file.
try:
with open(temp_file_path, 'wb') as f_temp:
if total_size > 0:
f_temp.truncate(total_size)
except IOError as e:
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}")
return False, 0, None, None
This function orchestrates the download process by:
1. Checking for already completed chunk files to resume a previous download.
2. Submitting only the missing chunks to a thread pool for parallel download.
3. Assembling the final file from the individual chunks upon successful completion.
4. Cleaning up temporary chunk files after assembly.
5. Leaving completed chunks on disk if the download fails, allowing for a future resume.
Args:
file_url (str): The URL of the file to download.
save_path (str): The final desired path for the downloaded file (e.g., 'my_video.mp4').
total_size (int): The total size of the file in bytes.
num_parts (int): The number of parts to split the download into.
headers (dict): HTTP headers for the download requests.
api_original_filename (str): The original filename for UI progress display.
emitter_for_multipart (queue.Queue or QObject): Emitter for UI signals.
cookies_for_chunk_session (dict): Cookies for the download requests.
cancellation_event (threading.Event): Event to signal cancellation.
skip_event (threading.Event): Event to signal skipping the file.
logger_func (function): A function for logging messages.
pause_event (threading.Event): Event to signal pausing the download.
Returns:
tuple: A tuple containing (success_flag, total_bytes_downloaded, md5_hash, file_handle).
The file_handle will be for the final assembled file if successful, otherwise None.
"""
logger_func(f"⬇️ Initializing Resumable Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
# Calculate the byte range for each chunk
chunk_size_calc = total_size // num_parts
chunks_ranges = []
for i in range(num_parts):
@@ -162,76 +207,119 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
if start <= end:
chunks_ranges.append((start, end))
elif total_size == 0 and i == 0:
elif total_size == 0 and i == 0: # Handle zero-byte files
chunks_ranges.append((0, -1))
# Calculate the expected size of each chunk
chunk_actual_sizes = []
for start, end in chunks_ranges:
if end == -1 and start == 0:
chunk_actual_sizes.append(0)
else:
chunk_actual_sizes.append(end - start + 1)
chunk_actual_sizes.append(end - start + 1 if end != -1 else 0)
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)
logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting.")
return False, 0, None, None
# --- Resumption Logic: Check for existing complete chunks ---
chunks_to_download = []
total_bytes_resumed = 0
for i, (start, end) in enumerate(chunks_ranges):
chunk_part_path = f"{save_path}.part{i}"
expected_chunk_size = chunk_actual_sizes[i]
if os.path.exists(chunk_part_path) and os.path.getsize(chunk_part_path) == expected_chunk_size:
logger_func(f" [Chunk {i + 1}/{num_parts}] Resuming with existing complete chunk file.")
total_bytes_resumed += expected_chunk_size
else:
chunks_to_download.append({'index': i, 'start': start, 'end': end})
# Setup the shared progress data structure
progress_data = {
'total_file_size': total_size,
'total_downloaded_so_far': 0,
'chunks_status': [
{'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)
],
'total_downloaded_so_far': total_bytes_resumed,
'chunks_status': [],
'lock': threading.Lock(),
'last_global_emit_time': [time.time()]
}
for i in range(num_parts):
is_resumed = not any(c['index'] == i for c in chunks_to_download)
progress_data['chunks_status'].append({
'id': i,
'downloaded': chunk_actual_sizes[i] if is_resumed else 0,
'total': chunk_actual_sizes[i],
'active': False,
'speed_bps': 0.0
})
# --- Download Phase ---
chunk_futures = []
all_chunks_successful = True
total_bytes_from_chunks = 0
total_bytes_from_threads = 0
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,
for chunk_info in chunks_to_download:
if cancellation_event and cancellation_event.is_set():
all_chunks_successful = False
break
i, start, end = chunk_info['index'], chunk_info['start'], chunk_info['end']
chunk_part_path = f"{save_path}.part{i}"
future = chunk_pool.submit(
_download_individual_chunk,
chunk_url=file_url,
chunk_temp_file_path=chunk_part_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,
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
))
)
chunk_futures.append(future)
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:
if cancellation_event and cancellation_event.is_set():
all_chunks_successful = False
bytes_downloaded, success = future.result()
total_bytes_from_threads += bytes_downloaded
if not success:
all_chunks_successful = False
total_bytes_final = total_bytes_resumed + total_bytes_from_threads
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'):
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}")
# --- Assembly and Cleanup Phase ---
if all_chunks_successful and (total_bytes_final == total_size or total_size == 0):
logger_func(f" ✅ All {num_parts} chunks complete. Assembling final file...")
md5_hasher = hashlib.md5()
with open(temp_file_path, 'rb') as f_hash:
for buf in iter(lambda: f_hash.read(4096*10), b''):
md5_hasher.update(buf)
calculated_hash = md5_hasher.hexdigest()
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb')
try:
with open(save_path, 'wb') as final_file:
for i in range(num_parts):
chunk_part_path = f"{save_path}.part{i}"
with open(chunk_part_path, 'rb') as chunk_file:
content = chunk_file.read()
final_file.write(content)
md5_hasher.update(content)
calculated_hash = md5_hasher.hexdigest()
logger_func(f" ✅ Assembly successful for '{api_original_filename}'. Total bytes: {total_bytes_final}")
return True, total_bytes_final, calculated_hash, open(save_path, 'rb')
except Exception as e:
logger_func(f" ❌ Critical error during file assembly: {e}. Cleaning up.")
return False, total_bytes_final, None, None
finally:
# Cleanup all individual chunk files after successful assembly
for i in range(num_parts):
chunk_part_path = f"{save_path}.part{i}"
if os.path.exists(chunk_part_path):
try:
os.remove(chunk_part_path)
except OSError as e:
logger_func(f" ⚠️ Failed to remove temp part file '{chunk_part_path}': {e}")
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
# If download failed, we do NOT clean up, allowing for resumption later
logger_func(f" ❌ Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_final}/{total_size}. Partial chunks saved for future resumption.")
return False, total_bytes_final, None, None

View File

@@ -37,13 +37,13 @@ class FavoriteArtistsDialog (QDialog ):
self ._init_ui ()
self ._fetch_favorite_artists ()
def _get_domain_for_service (self ,service_name ):
service_lower =service_name .lower ()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
if service_lower in coomer_primary_services :
return "coomer.su"
else :
return "kemono.su"
def _get_domain_for_service(self, service_name):
service_lower = service_name.lower()
coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans'}
if service_lower in coomer_primary_services:
return "coomer.st" # Use the new domain
else:
return "kemono.cr" # Use the new domain
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
@@ -128,9 +128,29 @@ class FavoriteArtistsDialog (QDialog ):
def _fetch_favorite_artists (self ):
if self.cookies_config['use_cookie']:
# Check if we can load cookies for at least one of the services.
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
# --- Kemono Check with Fallback ---
kemono_cookies = prepare_cookies_for_request(
True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.cr"
)
if not kemono_cookies:
self._logger("No cookies for kemono.cr, trying fallback kemono.su...")
kemono_cookies = prepare_cookies_for_request(
True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su"
)
# --- Coomer Check with Fallback ---
coomer_cookies = prepare_cookies_for_request(
True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.st"
)
if not coomer_cookies:
self._logger("No cookies for coomer.st, trying fallback coomer.su...")
coomer_cookies = prepare_cookies_for_request(
True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su"
)
if not kemono_cookies and not coomer_cookies:
# If cookies are enabled but none could be loaded, show help and stop.
@@ -139,7 +159,7 @@ class FavoriteArtistsDialog (QDialog ):
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog.exec_()
self.download_button.setEnabled(False)
return # Stop further execution
return # Stop further execution
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
@@ -149,9 +169,12 @@ class FavoriteArtistsDialog (QDialog ):
errors_occurred =[]
any_cookies_loaded_successfully_for_any_source =False
api_sources =[
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
kemono_cr_fav_url = "https://kemono.cr/api/v1/account/favorites?type=artist"
coomer_st_fav_url = "https://coomer.st/api/v1/account/favorites?type=artist"
api_sources = [
{"name": "Kemono.cr", "url": kemono_cr_fav_url, "domain": "kemono.cr"},
{"name": "Coomer.st", "url": coomer_st_fav_url, "domain": "coomer.st"}
]
for source in api_sources :
@@ -159,20 +182,41 @@ class FavoriteArtistsDialog (QDialog ):
self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
QCoreApplication .processEvents ()
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
cookies_dict_for_source = None
if self.cookies_config['use_cookie']:
primary_domain = source['domain']
fallback_domain = None
if primary_domain == "kemono.cr":
fallback_domain = "kemono.su"
elif primary_domain == "coomer.st":
fallback_domain = "coomer.su"
# First, try the primary domain
cookies_dict_for_source = prepare_cookies_for_request(
True,
self.cookies_config['cookie_text'],
self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'],
self._logger,
target_domain=primary_domain
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
# If no cookies found, try the fallback domain
if not cookies_dict_for_source and fallback_domain:
self._logger(f"Warning ({source['name']}): No cookies found for '{primary_domain}'. Trying fallback '{fallback_domain}'...")
cookies_dict_for_source = prepare_cookies_for_request(
True,
self.cookies_config['cookie_text'],
self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'],
self._logger,
target_domain=fallback_domain
)
if cookies_dict_for_source:
any_cookies_loaded_successfully_for_any_source = True
else:
self._logger(f"Warning ({source['name']}): Cookies enabled but could not be loaded for this source (including fallbacks). Fetch might fail.")
try :
headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
@@ -223,7 +267,7 @@ class FavoriteArtistsDialog (QDialog ):
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source."))
self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ ()
self .download_button .setEnabled (False )
if not fetched_any_successfully :

View File

@@ -34,28 +34,30 @@ class FavoritePostsFetcherThread (QThread ):
self .target_domain_preference =target_domain_preference
self .cancellation_event =threading .Event ()
self .error_key_map ={
"Kemono.su":"kemono_su",
"Coomer.su":"coomer_su"
"kemono.cr":"kemono_su",
"coomer.st":"coomer_su"
}
def _logger (self ,message ):
self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
def run (self ):
kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post"
coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post"
def run(self):
kemono_su_fav_posts_url = "https://kemono.su/api/v1/account/favorites?type=post"
coomer_su_fav_posts_url = "https://coomer.su/api/v1/account/favorites?type=post"
kemono_cr_fav_posts_url = "https://kemono.cr/api/v1/account/favorites?type=post"
coomer_st_fav_posts_url = "https://coomer.st/api/v1/account/favorites?type=post"
all_fetched_posts_temp =[]
error_messages_for_summary =[]
fetched_any_successfully =False
any_cookies_loaded_successfully_for_any_source =False
all_fetched_posts_temp = []
error_messages_for_summary = []
fetched_any_successfully = False
any_cookies_loaded_successfully_for_any_source = False
self .status_update .emit ("key_fetching_fav_post_list_init")
self .progress_bar_update .emit (0 ,0 )
self.status_update.emit("key_fetching_fav_post_list_init")
self.progress_bar_update.emit(0, 0)
api_sources =[
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
api_sources = [
{"name": "Kemono.cr", "url": kemono_cr_fav_posts_url, "domain": "kemono.cr"},
{"name": "Coomer.st", "url": coomer_st_fav_posts_url, "domain": "coomer.st"}
]
api_sources_to_try =[]
@@ -76,20 +78,41 @@ class FavoritePostsFetcherThread (QThread ):
if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
return
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
cookies_dict_for_source = None
if self.cookies_config['use_cookie']:
primary_domain = source['domain']
fallback_domain = None
if primary_domain == "kemono.cr":
fallback_domain = "kemono.su"
elif primary_domain == "coomer.st":
fallback_domain = "coomer.su"
# First, try the primary domain
cookies_dict_for_source = prepare_cookies_for_request(
True,
self.cookies_config['cookie_text'],
self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'],
self._logger,
target_domain=primary_domain
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
# If no cookies found, try the fallback domain
if not cookies_dict_for_source and fallback_domain:
self._logger(f"Warning ({source['name']}): No cookies found for '{primary_domain}'. Trying fallback '{fallback_domain}'...")
cookies_dict_for_source = prepare_cookies_for_request(
True,
self.cookies_config['cookie_text'],
self.cookies_config['selected_cookie_file'],
self.cookies_config['app_base_dir'],
self._logger,
target_domain=fallback_domain
)
if cookies_dict_for_source:
any_cookies_loaded_successfully_for_any_source = True
else:
self._logger(f"Warning ({source['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
@@ -409,14 +432,14 @@ class FavoritePostsDialog (QDialog ):
if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
status_label_text_key ="fav_posts_cookies_required_error"
self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ ()
elif status_key =="KEY_AUTH_FAILED":
status_label_text_key ="fav_posts_auth_failed_title"
self ._logger (f"Auth error: {status_key }. Showing help dialog.")
QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog .exec_ ()
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
status_label_text_key ="fav_posts_no_posts_found_status"

View File

@@ -4,7 +4,7 @@ from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
@@ -46,13 +46,12 @@ class TourStepWidget(QWidget):
layout.addWidget(scroll_area, 1)
class HelpGuideDialog (QDialog ):
"""A multi-page dialog for displaying the feature guide."""
def __init__ (self ,steps_data ,parent_app ,parent =None ):
super ().__init__ (parent )
self .current_step =0
self .steps_data =steps_data
self .parent_app =parent_app
class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide with a navigation list."""
def __init__(self, steps_data, parent_app, parent=None):
super().__init__(parent)
self.steps_data = steps_data
self.parent_app = parent_app
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
@@ -61,7 +60,7 @@ class HelpGuideDialog (QDialog ):
self.setWindowIcon(app_icon)
self.setModal(True)
self.resize(int(650 * scale), int(600 * scale))
self.resize(int(800 * scale), int(650 * scale))
dialog_font_size = int(11 * scale)
@@ -69,6 +68,7 @@ class HelpGuideDialog (QDialog ):
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale)
else:
# Basic light theme fallback
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
@@ -86,118 +86,107 @@ class HelpGuideDialog (QDialog ):
"""
self.setStyleSheet(current_theme_style)
self ._init_ui ()
if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
self._init_ui()
if self.parent_app:
self.move(self.parent_app.geometry().center() - self.rect().center())
def _tr (self ,key ,default_text =""):
def _tr(self, key, default_text=""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(15, 15, 15, 15)
main_layout.setSpacing(10)
def _init_ui (self ):
main_layout =QVBoxLayout (self )
main_layout .setContentsMargins (0 ,0 ,0 ,0 )
main_layout .setSpacing (0 )
# Title
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
scale = getattr(self.parent_app, 'scale_factor', 1.0)
title_font_size = int(16 * scale)
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
self .stacked_widget =QStackedWidget ()
main_layout .addWidget (self .stacked_widget ,1 )
# Content Layout (Navigation + Stacked Pages)
content_layout = QHBoxLayout()
main_layout.addLayout(content_layout, 1)
self .tour_steps_widgets =[]
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
for title, content in self.steps_data:
step_widget = TourStepWidget(title, content, scale=scale)
self.tour_steps_widgets.append(step_widget)
self.nav_list = QListWidget()
self.nav_list.setFixedWidth(int(220 * scale))
self.nav_list.setStyleSheet(f"""
QListWidget {{
background-color: #2E2E2E;
border: 1px solid #4A4A4A;
border-radius: 4px;
font-size: {int(11 * scale)}pt;
}}
QListWidget::item {{
padding: 10px;
border-bottom: 1px solid #4A4A4A;
}}
QListWidget::item:selected {{
background-color: #87CEEB;
color: #2E2E2E;
font-weight: bold;
}}
""")
content_layout.addWidget(self.nav_list)
self.stacked_widget = QStackedWidget()
content_layout.addWidget(self.stacked_widget)
for title_key, content_key in self.steps_data:
title = self._tr(title_key, title_key)
content = self._tr(content_key, f"Content for {content_key} not found.")
self.nav_list.addItem(title)
step_widget = TourStepWidget(title, content, scale=scale)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
self.nav_list.currentRowChanged.connect(self.stacked_widget.setCurrentIndex)
if self.nav_list.count() > 0:
self.nav_list.setCurrentRow(0)
buttons_layout =QHBoxLayout ()
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
buttons_layout .setSpacing (10 )
# Footer Layout (Social links and Close button)
footer_layout = QHBoxLayout()
footer_layout.setContentsMargins(0, 10, 0, 0)
# Social Media Icons
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
assets_base_dir = sys._MEIPASS
else:
assets_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
self .back_button .clicked .connect (self ._previous_step )
self .back_button .setEnabled (False )
github_icon_path = os.path.join(assets_base_dir, "assets", "github.png")
instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png")
discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png")
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS
else :
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
self.github_button = QPushButton(QIcon(github_icon_path), "")
self.instagram_button = QPushButton(QIcon(instagram_icon_path), "")
self.discord_button = QPushButton(QIcon(discord_icon_path), "")
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
self .github_button =QPushButton (QIcon (github_icon_path ),"")
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size )
for button, tooltip_key, url in [
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi9587"),
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
]:
button.setIconSize(icon_size)
button.setToolTip(self._tr(tooltip_key))
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
button.setStyleSheet("background-color: transparent; border: none;")
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
footer_layout.addWidget(button)
self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
self .next_button .clicked .connect (self ._next_step_action )
self .next_button .setDefault (True )
self .github_button .clicked .connect (self ._open_github_link )
self .instagram_button .clicked .connect (self ._open_instagram_link )
self .Discord_button .clicked .connect (self ._open_Discord_link )
self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
footer_layout.addStretch(1)
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
self.finish_button.clicked.connect(self.accept)
footer_layout.addWidget(self.finish_button)
social_layout =QHBoxLayout ()
social_layout .setSpacing (10 )
social_layout .addWidget (self .github_button )
social_layout .addWidget (self .instagram_button )
social_layout .addWidget (self .Discord_button )
while buttons_layout .count ():
item =buttons_layout .takeAt (0 )
if item .widget ():
item .widget ().setParent (None )
elif item .layout ():
pass
buttons_layout .addLayout (social_layout )
buttons_layout .addStretch (1 )
buttons_layout .addWidget (self .back_button )
buttons_layout .addWidget (self .next_button )
main_layout .addLayout (buttons_layout )
self ._update_button_states ()
def _next_step_action (self ):
if self .current_step <len (self .tour_steps_widgets )-1 :
self .current_step +=1
self .stacked_widget .setCurrentIndex (self .current_step )
else :
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 _update_button_states (self ):
if self .current_step ==len (self .tour_steps_widgets )-1 :
self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
else :
self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
self .back_button .setEnabled (self .current_step >0 )
def _open_github_link (self ):
QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
def _open_instagram_link (self ):
QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
def _open_Discord_link (self ):
QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))
main_layout.addLayout(footer_layout)

View File

@@ -4288,13 +4288,13 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" Cancelling active External Link download thread...")
self.external_link_download_thread.cancel()
def _get_domain_for_service (self ,service_name :str )->str :
def _get_domain_for_service(self, service_name: str) -> str:
"""Determines the base domain for a given service."""
if not isinstance (service_name ,str ):
return "kemono.cr"
service_lower =service_name .lower ()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'}
if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']:
if not isinstance(service_name, str):
return "kemono.cr" # Default fallback
service_lower = service_name.lower()
coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans', 'gumroad', 'subscribestar', 'dlsite'}
if service_lower in coomer_primary_services:
return "coomer.st"
return "kemono.cr"
@@ -5343,42 +5343,54 @@ class DownloaderApp (QWidget ):
target_domain_preference_for_fetch =None
if cookies_config ['use_cookie']:
self .log_signal .emit ("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
kemono_cookies =prepare_cookies_for_request (
cookies_config ['use_cookie'],
cookies_config ['cookie_text'],
cookies_config ['selected_cookie_file'],
cookies_config ['app_base_dir'],
lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Kemono] {msg }"),
target_domain ="kemono.su"
if cookies_config['use_cookie']:
self.log_signal.emit("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
# --- Kemono Check with Fallback ---
kemono_cookies = prepare_cookies_for_request(
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
target_domain="kemono.cr"
)
coomer_cookies =prepare_cookies_for_request (
cookies_config ['use_cookie'],
cookies_config ['cookie_text'],
cookies_config ['selected_cookie_file'],
cookies_config ['app_base_dir'],
lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Coomer] {msg }"),
target_domain ="coomer.su"
if not kemono_cookies:
self.log_signal.emit(" ↳ No cookies for kemono.cr, trying fallback kemono.su...")
kemono_cookies = prepare_cookies_for_request(
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
target_domain="kemono.su"
)
# --- Coomer Check with Fallback ---
coomer_cookies = prepare_cookies_for_request(
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
target_domain="coomer.st"
)
if not coomer_cookies:
self.log_signal.emit(" ↳ No cookies for coomer.st, trying fallback coomer.su...")
coomer_cookies = prepare_cookies_for_request(
cookies_config['use_cookie'], cookies_config['cookie_text'], cookies_config['selected_cookie_file'],
cookies_config['app_base_dir'], lambda msg: self.log_signal.emit(f"[FavPosts Cookie Check] {msg}"),
target_domain="coomer.su"
)
kemono_ok =bool (kemono_cookies )
coomer_ok =bool (coomer_cookies )
kemono_ok = bool(kemono_cookies)
coomer_ok = bool(coomer_cookies)
if kemono_ok and not coomer_ok :
target_domain_preference_for_fetch ="kemono.su"
self .log_signal .emit (" ↳ Only Kemono.su cookies loaded. Will fetch favorites from Kemono.su only.")
elif coomer_ok and not kemono_ok :
target_domain_preference_for_fetch ="coomer.su"
self .log_signal .emit (" ↳ Only Coomer.su cookies loaded. Will fetch favorites from Coomer.su only.")
elif kemono_ok and coomer_ok :
target_domain_preference_for_fetch =None
self .log_signal .emit (" ↳ Cookies for both Kemono.su and Coomer.su loaded. Will attempt to fetch from both.")
else :
self .log_signal .emit (" ↳ No valid cookies loaded for Kemono.su or Coomer.su.")
cookie_help_dialog =CookieHelpDialog (self ,self )
cookie_help_dialog .exec_ ()
return
if kemono_ok and not coomer_ok:
target_domain_preference_for_fetch = "kemono.cr"
self.log_signal.emit(" ↳ Only Kemono cookies loaded. Will fetch favorites from Kemono.cr only.")
elif coomer_ok and not kemono_ok:
target_domain_preference_for_fetch = "coomer.st"
self.log_signal.emit(" ↳ Only Coomer cookies loaded. Will fetch favorites from Coomer.st only.")
elif kemono_ok and coomer_ok:
target_domain_preference_for_fetch = None
self.log_signal.emit(" ↳ Cookies for both Kemono and Coomer loaded. Will attempt to fetch from both.")
else:
self.log_signal.emit(" ↳ No valid cookies loaded for Kemono.cr or Coomer.st.")
cookie_help_dialog = CookieHelpDialog(self, self)
cookie_help_dialog.exec_()
return
else :
self .log_signal .emit ("Favorite Posts: 'Use Cookie' is NOT checked. Cookies are required.")
cookie_help_dialog =CookieHelpDialog (self ,self )