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_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}" unique_part_file_stem_on_disk = f"{temp_file_base_for_unique_part}_{unique_id_for_part_file}"
max_retries = 3 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 retry_delay = 5
downloaded_size_bytes = 0 downloaded_size_bytes = 0
calculated_file_hash = None calculated_file_hash = None
@@ -741,8 +774,11 @@ class PostProcessorWorker:
history_data_for_this_post = None history_data_for_this_post = None
parsed_api_url = urlparse(self.api_url_input) parsed_api_url = urlparse(self.api_url_input)
referer_url = f"https://{parsed_api_url.netloc}/" post_data = self.post
headers = {'User-Agent': 'Mozilla/5.0', 'Referer': referer_url, 'Accept': '*/*'} 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) link_pattern = re.compile(r"""<a\s+.*?href=["'](https?://[^"']+)["'][^>]*>(.*?)</a>""", re.IGNORECASE | re.DOTALL)
post_data = self.post post_data = self.post
post_title = post_data.get('title', '') or 'untitled_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() 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 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: 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).") 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) 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 = [] all_files_from_post_api_for_char_check = []
api_file_domain_for_char_check = urlparse(self.api_url_input).netloc 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']): 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'): 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('/')) original_api_name = post_main_file_info.get('name') or os.path.basename(post_main_file_info['path'].lstrip('/'))
if original_api_name: if original_api_name:
@@ -834,9 +881,9 @@ class PostProcessorWorker:
try: try:
parsed_input_url_for_comments = urlparse(self.api_url_input) parsed_input_url_for_comments = urlparse(self.api_url_input)
api_domain_for_comments = parsed_input_url_for_comments.netloc 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.") 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( comments_data = fetch_post_comments(
api_domain_for_comments, self.service, self.user_id, post_id, api_domain_for_comments, self.service, self.user_id, post_id,
headers, self.logger, self.cancellation_event, self.pause_event, headers, self.logger, self.cancellation_event, self.pause_event,
@@ -1321,7 +1368,7 @@ class PostProcessorWorker:
all_files_from_post_api = [] all_files_from_post_api = []
api_file_domain = urlparse(self.api_url_input).netloc 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']): 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'): 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('/') file_path = post_main_file_info['path'].lstrip('/')
original_api_name = post_main_file_info.get('name') or os.path.basename(file_path) 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 re
import traceback import traceback
import json import json
import base64
import time
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
# --- Third-Party Library Imports --- # --- Third-Party Library Imports ---
# Make sure to install these: pip install requests pycryptodome gdown
import requests import requests
try: try:
from mega import Mega from Crypto.Cipher import AES
MEGA_AVAILABLE = True PYCRYPTODOME_AVAILABLE = True
except ImportError: except ImportError:
MEGA_AVAILABLE = False PYCRYPTODOME_AVAILABLE = False
try: try:
import gdown import gdown
@@ -19,17 +23,15 @@ try:
except ImportError: except ImportError:
GDRIVE_AVAILABLE = False 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): def _get_filename_from_headers(headers):
""" """
Extracts a filename from the Content-Disposition header. Extracts a filename from the Content-Disposition header.
(This is from your original file and is kept for Dropbox downloads)
Args:
headers (dict): A dictionary of HTTP response headers.
Returns:
str or None: The extracted filename, or None if not found.
""" """
cd = headers.get('content-disposition') cd = headers.get('content-disposition')
if not cd: if not cd:
@@ -37,64 +39,180 @@ def _get_filename_from_headers(headers):
fname_match = re.findall('filename="?([^"]+)"?', cd) fname_match = re.findall('filename="?([^"]+)"?', cd)
if fname_match: 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()) sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip())
return sanitized_name return sanitized_name
return None 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): def download_mega_file(mega_url, download_path, logger_func=print):
""" """
Downloads a file from a Mega.nz URL. Downloads a file from a Mega.nz URL using direct requests and decryption.
Handles both public links and links that include a decryption key. This replaces the old mega.py implementation.
""" """
if not MEGA_AVAILABLE: if not PYCRYPTODOME_AVAILABLE:
logger_func("❌ Mega download failed: 'mega.py' library is not installed.") logger_func("❌ Mega download failed: 'pycryptodome' library is not installed. Please run: pip install pycryptodome")
return return
logger_func(f" [Mega] Initializing Mega client...") logger_func(f" [Mega] Initializing download for: {mega_url}")
try:
mega = Mega() # Regex to capture file ID and key from both old and new URL formats
# Anonymous login is sufficient for public links match = re.search(r'mega(?:\.co)?\.nz/(?:file/|#!)?([a-zA-Z0-9]+)(?:#|!)([a-zA-Z0-9_.-]+)', mega_url)
m = mega.login() 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 --- session = requests.Session()
try: session.headers.update({'User-Agent': 'Kemono-Downloader-PyQt/1.0'})
file_details = m.find(mega_url)
if file_details is None: file_info = get_mega_file_info(file_id, file_key, session, logger_func)
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}") if not file_info:
return logger_func(f" [Mega] ❌ Failed to get file info. The link may be invalid or expired. Aborting.")
except (ValueError, json.JSONDecodeError) as e: return
# 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 ---
filename = file_details[1]['a']['n'] logger_func(f" [Mega] File found: '{file_info['file_name']}' (Size: {file_info['file_size'] / 1024 / 1024:.2f} MB)")
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
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 # --- ORIGINAL Functions for Google Drive and Dropbox (Unchanged) ---
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}")
def download_gdrive_file(url, download_path, logger_func=print): def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link.""" """Downloads a file from a Google Drive link."""
@@ -103,12 +221,9 @@ def download_gdrive_file(url, download_path, logger_func=print):
return return
try: try:
logger_func(f" [G-Drive] Starting download for: {url}") 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.") 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) 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): if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{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): 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. 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}") 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) parsed_url = urlparse(dropbox_link)
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)
query_params['dl'] = ['1'] 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: with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r:
r.raise_for_status() 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" 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) full_save_path = os.path.join(download_path, filename)
logger_func(f" [Dropbox] Starting download of '{filename}'...") logger_func(f" [Dropbox] Starting download of '{filename}'...")
# Write file to disk in chunks
with open(full_save_path, 'wb') as f: with open(full_save_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)

View File

@@ -1,4 +1,5 @@
# --- Standard Library Imports --- # --- Standard Library Imports ---
# --- Standard Library Imports ---
import os import os
import time import time
import hashlib import hashlib
@@ -10,27 +11,49 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
# --- Third-Party Library Imports --- # --- Third-Party Library Imports ---
import requests import requests
MULTIPART_DOWNLOADER_AVAILABLE = True
# --- Module Constants --- # --- Module Constants ---
CHUNK_DOWNLOAD_RETRY_DELAY = 2 CHUNK_DOWNLOAD_RETRY_DELAY = 2
MAX_CHUNK_DOWNLOAD_RETRIES = 1 MAX_CHUNK_DOWNLOAD_RETRIES = 1
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk 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( 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, part_num, total_parts, progress_data, cancellation_event,
skip_event, pause_event, global_emit_time_ref, cookies_for_chunk, skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
logger_func, emitter=None, api_original_filename=None logger_func, emitter=None, api_original_filename=None
): ):
""" """
Downloads a single segment (chunk) of a larger file. This function is Downloads a single segment (chunk) of a larger file to its own unique part file.
intended to be run in a separate thread by a ThreadPoolExecutor. 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 --- # --- Pre-download checks for control events ---
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
@@ -48,18 +71,16 @@ def _download_individual_chunk(
time.sleep(0.2) time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.") logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
# --- START: FIX ---
# Set this chunk's status to 'active' before starting the download. # Set this chunk's status to 'active' before starting the download.
with progress_data['lock']: with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = True progress_data['chunks_status'][part_num]['active'] = True
# --- END: FIX ---
try: try:
# Prepare headers for the specific byte range of this chunk # Prepare headers for the specific byte range of this chunk
chunk_headers = headers.copy() chunk_headers = headers.copy()
if end_byte != -1: if end_byte != -1:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}" chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
bytes_this_chunk = 0 bytes_this_chunk = 0
last_speed_calc_time = time.time() last_speed_calc_time = time.time()
bytes_at_last_speed_calc = 0 bytes_at_last_speed_calc = 0
@@ -77,13 +98,14 @@ def _download_individual_chunk(
bytes_at_last_speed_calc = bytes_this_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'}") 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 = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
response.raise_for_status() response.raise_for_status()
# --- Data Writing Loop --- # --- Data Writing Loop ---
with open(temp_file_path, 'r+b') as f: # We open the unique chunk file in write-binary ('wb') mode.
f.seek(start_byte) # 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): for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
return bytes_this_chunk, False return bytes_this_chunk, False
@@ -98,12 +120,12 @@ def _download_individual_chunk(
if data_segment: if data_segment:
f.write(data_segment) f.write(data_segment)
bytes_this_chunk += len(data_segment) bytes_this_chunk += len(data_segment)
# Update shared progress data structure # Update shared progress data structure
with progress_data['lock']: with progress_data['lock']:
progress_data['total_downloaded_so_far'] += len(data_segment) 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]['downloaded'] = bytes_this_chunk
# Calculate and update speed for this chunk # Calculate and update speed for this chunk
current_time = time.time() current_time = time.time()
time_delta = current_time - last_speed_calc_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 progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
last_speed_calc_time = current_time last_speed_calc_time = current_time
bytes_at_last_speed_calc = bytes_this_chunk bytes_at_last_speed_calc = bytes_this_chunk
# Emit progress signal to the UI via the queue # Emit progress signal to the UI via the queue
if emitter and (current_time - global_emit_time_ref[0] > 0.25): if emitter and (current_time - global_emit_time_ref[0] > 0.25):
global_emit_time_ref[0] = current_time 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)}) emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'): elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy) 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 return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: 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)}") logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False return bytes_this_chunk, False
# If the retry loop finishes without a successful download
return bytes_this_chunk, False return bytes_this_chunk, False
finally: finally:
# This block runs whether the download succeeded or failed
with progress_data['lock']: with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0.0 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, def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,
emitter_for_multipart, cookies_for_chunk_session, emitter_for_multipart, cookies_for_chunk_session,
cancellation_event, skip_event, logger_func, pause_event): 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: This function orchestrates the download process by:
with open(temp_file_path, 'wb') as f_temp: 1. Checking for already completed chunk files to resume a previous download.
if total_size > 0: 2. Submitting only the missing chunks to a thread pool for parallel download.
f_temp.truncate(total_size) 3. Assembling the final file from the individual chunks upon successful completion.
except IOError as e: 4. Cleaning up temporary chunk files after assembly.
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}") 5. Leaving completed chunks on disk if the download fails, allowing for a future resume.
return False, 0, None, None
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 chunk_size_calc = total_size // num_parts
chunks_ranges = [] chunks_ranges = []
for i in range(num_parts): 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 end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
if start <= end: if start <= end:
chunks_ranges.append((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)) chunks_ranges.append((0, -1))
# Calculate the expected size of each chunk
chunk_actual_sizes = [] chunk_actual_sizes = []
for start, end in chunks_ranges: for start, end in chunks_ranges:
if end == -1 and start == 0: chunk_actual_sizes.append(end - start + 1 if end != -1 else 0)
chunk_actual_sizes.append(0)
else:
chunk_actual_sizes.append(end - start + 1)
if not chunks_ranges and total_size > 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.") logger_func(f" ⚠️ No valid chunk ranges for multipart download of '{api_original_filename}'. Aborting.")
if os.path.exists(temp_file_path): os.remove(temp_file_path)
return False, 0, None, None 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 = { progress_data = {
'total_file_size': total_size, 'total_file_size': total_size,
'total_downloaded_so_far': 0, 'total_downloaded_so_far': total_bytes_resumed,
'chunks_status': [ '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)
],
'lock': threading.Lock(), 'lock': threading.Lock(),
'last_global_emit_time': [time.time()] '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 = [] chunk_futures = []
all_chunks_successful = True 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: 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): for chunk_info in chunks_to_download:
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break if cancellation_event and cancellation_event.is_set():
chunk_futures.append(chunk_pool.submit( all_chunks_successful = False
_download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path, 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, 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'], progress_data=progress_data, cancellation_event=cancellation_event,
pause_event=pause_event, cookies_for_chunk=cookies_for_chunk_session, logger_func=logger_func, emitter=emitter_for_multipart, 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 api_original_filename=api_original_filename
)) )
chunk_futures.append(future)
for future in as_completed(chunk_futures): for future in as_completed(chunk_futures):
if cancellation_event and cancellation_event.is_set(): all_chunks_successful = False; break if cancellation_event and cancellation_event.is_set():
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 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(): if cancellation_event and cancellation_event.is_set():
logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.") logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.")
all_chunks_successful = False 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): # --- Assembly and Cleanup Phase ---
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}") 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() md5_hasher = hashlib.md5()
with open(temp_file_path, 'rb') as f_hash: try:
for buf in iter(lambda: f_hash.read(4096*10), b''): with open(save_path, 'wb') as final_file:
md5_hasher.update(buf) for i in range(num_parts):
calculated_hash = md5_hasher.hexdigest() chunk_part_path = f"{save_path}.part{i}"
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb') 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: 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 download failed, we do NOT clean up, allowing for resumption later
if os.path.exists(temp_file_path): 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.")
try: os.remove(temp_file_path) return False, total_bytes_final, None, None
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

View File

@@ -37,13 +37,13 @@ class FavoriteArtistsDialog (QDialog ):
self ._init_ui () self ._init_ui ()
self ._fetch_favorite_artists () self ._fetch_favorite_artists ()
def _get_domain_for_service (self ,service_name ): def _get_domain_for_service(self, service_name):
service_lower =service_name .lower () service_lower = service_name.lower()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'} coomer_primary_services = {'onlyfans', 'fansly', 'manyvids', 'candfans'}
if service_lower in coomer_primary_services : if service_lower in coomer_primary_services:
return "coomer.su" return "coomer.st" # Use the new domain
else : else:
return "kemono.su" return "kemono.cr" # Use the new domain
def _tr (self ,key ,default_text =""): def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language.""" """Helper to get translation based on current app language."""
@@ -128,9 +128,29 @@ class FavoriteArtistsDialog (QDialog ):
def _fetch_favorite_artists (self ): def _fetch_favorite_artists (self ):
if self.cookies_config['use_cookie']: if self.cookies_config['use_cookie']:
# Check if we can load cookies for at least one of the services. # --- 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.su") kemono_cookies = prepare_cookies_for_request(
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") 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 not kemono_cookies and not coomer_cookies:
# If cookies are enabled but none could be loaded, show help and stop. # 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 = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog.exec_() cookie_help_dialog.exec_()
self.download_button.setEnabled(False) 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" kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
coomer_fav_url ="https://coomer.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 =[] errors_occurred =[]
any_cookies_loaded_successfully_for_any_source =False any_cookies_loaded_successfully_for_any_source =False
api_sources =[ kemono_cr_fav_url = "https://kemono.cr/api/v1/account/favorites?type=artist"
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"}, coomer_st_fav_url = "https://coomer.st/api/v1/account/favorites?type=artist"
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
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 : 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'])) self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
QCoreApplication .processEvents () QCoreApplication .processEvents ()
cookies_dict_for_source =None cookies_dict_for_source = None
if self .cookies_config ['use_cookie']: if self.cookies_config['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request ( primary_domain = source['domain']
True , fallback_domain = None
self .cookies_config ['cookie_text'], if primary_domain == "kemono.cr":
self .cookies_config ['selected_cookie_file'], fallback_domain = "kemono.su"
self .cookies_config ['app_base_dir'], elif primary_domain == "coomer.st":
self ._logger , fallback_domain = "coomer.su"
target_domain =source ['domain']
# 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 # If no cookies found, try the fallback domain
else : if not cookies_dict_for_source and fallback_domain:
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"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 : try :
headers ={'User-Agent':'Mozilla/5.0'} headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 ) 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 : 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 .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.") 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_ () cookie_help_dialog .exec_ ()
self .download_button .setEnabled (False ) self .download_button .setEnabled (False )
if not fetched_any_successfully : if not fetched_any_successfully :

View File

@@ -34,28 +34,30 @@ class FavoritePostsFetcherThread (QThread ):
self .target_domain_preference =target_domain_preference self .target_domain_preference =target_domain_preference
self .cancellation_event =threading .Event () self .cancellation_event =threading .Event ()
self .error_key_map ={ self .error_key_map ={
"Kemono.su":"kemono_su", "kemono.cr":"kemono_su",
"Coomer.su":"coomer_su" "coomer.st":"coomer_su"
} }
def _logger (self ,message ): def _logger (self ,message ):
self .parent_logger_func (f"[FavPostsFetcherThread] {message }") self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
def run (self ): def run(self):
kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post" kemono_su_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" 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 =[] all_fetched_posts_temp = []
error_messages_for_summary =[] error_messages_for_summary = []
fetched_any_successfully =False fetched_any_successfully = False
any_cookies_loaded_successfully_for_any_source =False any_cookies_loaded_successfully_for_any_source = False
self .status_update .emit ("key_fetching_fav_post_list_init") self.status_update.emit("key_fetching_fav_post_list_init")
self .progress_bar_update .emit (0 ,0 ) self.progress_bar_update.emit(0, 0)
api_sources =[ api_sources = [
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"}, {"name": "Kemono.cr", "url": kemono_cr_fav_posts_url, "domain": "kemono.cr"},
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"} {"name": "Coomer.st", "url": coomer_st_fav_posts_url, "domain": "coomer.st"}
] ]
api_sources_to_try =[] api_sources_to_try =[]
@@ -76,20 +78,41 @@ class FavoritePostsFetcherThread (QThread ):
if self .cancellation_event .is_set (): if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING") self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
return return
cookies_dict_for_source =None cookies_dict_for_source = None
if self .cookies_config ['use_cookie']: if self.cookies_config['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request ( primary_domain = source['domain']
True , fallback_domain = None
self .cookies_config ['cookie_text'], if primary_domain == "kemono.cr":
self .cookies_config ['selected_cookie_file'], fallback_domain = "kemono.su"
self .cookies_config ['app_base_dir'], elif primary_domain == "coomer.st":
self ._logger , fallback_domain = "coomer.su"
target_domain =source ['domain']
# 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 # If no cookies found, try the fallback domain
else : if not cookies_dict_for_source and fallback_domain:
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"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']})") 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 ('.','_')) 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": 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" status_label_text_key ="fav_posts_cookies_required_error"
self ._logger (f"Cookie error: {status_key }. Showing help dialog.") 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_ () cookie_help_dialog .exec_ ()
elif status_key =="KEY_AUTH_FAILED": elif status_key =="KEY_AUTH_FAILED":
status_label_text_key ="fav_posts_auth_failed_title" status_label_text_key ="fav_posts_auth_failed_title"
self ._logger (f"Auth error: {status_key }. Showing help dialog.") self ._logger (f"Auth error: {status_key }. Showing help dialog.")
QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"), 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 )) 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_ () cookie_help_dialog .exec_ ()
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS": elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
status_label_text_key ="fav_posts_no_posts_found_status" 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.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
) )
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
@@ -46,13 +46,12 @@ class TourStepWidget(QWidget):
layout.addWidget(scroll_area, 1) layout.addWidget(scroll_area, 1)
class HelpGuideDialog (QDialog ): class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide.""" """A multi-page dialog for displaying the feature guide with a navigation list."""
def __init__ (self ,steps_data ,parent_app ,parent =None ): def __init__(self, steps_data, parent_app, parent=None):
super ().__init__ (parent ) super().__init__(parent)
self .current_step =0 self.steps_data = steps_data
self .steps_data =steps_data self.parent_app = parent_app
self .parent_app =parent_app
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0 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.setWindowIcon(app_icon)
self.setModal(True) 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) 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": if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale) current_theme_style = get_dark_theme(scale)
else: else:
# Basic light theme fallback
current_theme_style = f""" current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }} QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }} QLabel {{ color: #1E1E1E; }}
@@ -86,118 +86,107 @@ class HelpGuideDialog (QDialog ):
""" """
self.setStyleSheet(current_theme_style) self.setStyleSheet(current_theme_style)
self ._init_ui () self._init_ui()
if self .parent_app : if self.parent_app:
self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) 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.""" """Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app : if callable(get_translation) and self.parent_app:
return get_translation (self .parent_app .current_selected_language ,key ,default_text ) return get_translation(self.parent_app.current_selected_language, key, default_text)
return 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 ): # Title
main_layout =QVBoxLayout (self ) title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
main_layout .setContentsMargins (0 ,0 ,0 ,0 ) scale = getattr(self.parent_app, 'scale_factor', 1.0)
main_layout .setSpacing (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 () # Content Layout (Navigation + Stacked Pages)
main_layout .addWidget (self .stacked_widget ,1 ) content_layout = QHBoxLayout()
main_layout.addLayout(content_layout, 1)
self .tour_steps_widgets =[] self.nav_list = QListWidget()
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0 self.nav_list.setFixedWidth(int(220 * scale))
for title, content in self.steps_data: self.nav_list.setStyleSheet(f"""
step_widget = TourStepWidget(title, content, scale=scale) QListWidget {{
self.tour_steps_widgets.append(step_widget) 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.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 () # Footer Layout (Social links and Close button)
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 ) footer_layout = QHBoxLayout()
buttons_layout .setSpacing (10 ) 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")) github_icon_path = os.path.join(assets_base_dir, "assets", "github.png")
self .back_button .clicked .connect (self ._previous_step ) instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png")
self .back_button .setEnabled (False ) discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png")
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): self.github_button = QPushButton(QIcon(github_icon_path), "")
assets_base_dir =sys ._MEIPASS self.instagram_button = QPushButton(QIcon(instagram_icon_path), "")
else : self.discord_button = QPushButton(QIcon(discord_icon_path), "")
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
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_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim) icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size ) for button, tooltip_key, url in [
self .Discord_button .setIconSize (icon_size ) (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")) footer_layout.addStretch(1)
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)"))
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 () main_layout.addLayout(footer_layout)
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"))

View File

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