This commit is contained in:
Yuvi63771
2025-10-08 17:02:46 +05:30
parent df8a305e81
commit 8239fdb8f3
36 changed files with 5380 additions and 1468 deletions

0
LinkMaker/hentai2read.py Normal file
View File

BIN
assets/Ko-fi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
assets/buymeacoffee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
assets/patreon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

View File

@@ -47,6 +47,8 @@ MAX_PARTS_FOR_MULTIPART_DOWNLOAD = 15
# --- UI and Settings Keys (for QSettings) --- # --- UI and Settings Keys (for QSettings) ---
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV19"
MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1" MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
MANGA_CUSTOM_FORMAT_KEY = "mangaCustomFormatV1"
MANGA_CUSTOM_DATE_FORMAT_KEY = "mangaCustomDateFormatV1"
SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1" SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1"
ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1" ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
USE_COOKIE_KEY = "useCookieV1" USE_COOKIE_KEY = "useCookieV1"
@@ -59,6 +61,8 @@ DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution" RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor" UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile" SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
DATE_PREFIX_FORMAT_KEY = "datePrefixFormatV1"
AUTO_RETRY_ON_FINISH_KEY = "auto_retry_on_finish"
FETCH_FIRST_KEY = "fetchAllPostsFirst" FETCH_FIRST_KEY = "fetchAllPostsFirst"
DISCORD_TOKEN_KEY = "discord/token" DISCORD_TOKEN_KEY = "discord/token"
@@ -84,7 +88,7 @@ VIDEO_EXTENSIONS = {
'.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob' '.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
} }
ARCHIVE_EXTENSIONS = { ARCHIVE_EXTENSIONS = {
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2' '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.bin'
} }
AUDIO_EXTENSIONS = { AUDIO_EXTENSIONS = {
'.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus', '.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
@@ -122,4 +126,5 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
# --- Duplicate Handling Modes --- # --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash" DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all" DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
STYLE_CUSTOM = "custom"

View File

@@ -2,71 +2,206 @@
import re import re
import os import os
import json import time
import requests
import cloudscraper import cloudscraper
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urljoin
from concurrent.futures import ThreadPoolExecutor
import queue
def fetch_hentai2read_data(url, logger, session): def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func):
""" """
Scrapes a SINGLE Hentai2Read chapter page using a provided session. Orchestrates the download process using a producer-consumer model.
The main thread scrapes image URLs and puts them in a queue.
A pool of worker threads consumes from the queue to download images concurrently.
""" """
logger(f"Attempting to fetch chapter data from: {url}") scraper = cloudscraper.create_scraper()
try: try:
response = session.get(url, timeout=30) progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
response.raise_for_status() top_level_folder_name, chapters_to_process = _get_series_metadata(start_url, progress_callback, scraper)
if not chapters_to_process:
progress_callback("❌ No chapters found to download. Aborting.")
return 0, 0
page_content_text = response.text total_chapters = len(chapters_to_process)
soup = BeautifulSoup(page_content_text, 'html.parser') overall_progress_callback(total_chapters, 0)
album_title = ""
title_tags = soup.select('span[itemprop="name"]')
if title_tags:
album_title = title_tags[-1].text.strip()
if not album_title: total_downloaded_count = 0
title_tag = soup.select_one('h1.title') total_skipped_count = 0
if title_tag:
album_title = title_tag.text.strip()
if not album_title: for idx, chapter in enumerate(chapters_to_process):
logger("❌ Could not find album title on page.") if check_pause_func(): break
return None, None
progress_callback(f"\n-- Processing and Downloading Chapter {idx + 1}/{total_chapters}: '{chapter['title']}' --")
series_folder = re.sub(r'[\\/*?:"<>|]', "", top_level_folder_name).strip()
chapter_folder = re.sub(r'[\\/*?:"<>|]', "", chapter['title']).strip()
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
os.makedirs(final_save_path, exist_ok=True)
# This function now scrapes and downloads simultaneously
dl_count, skip_count = _process_and_download_chapter(
chapter_url=chapter['url'],
save_path=final_save_path,
scraper=scraper,
progress_callback=progress_callback,
check_pause_func=check_pause_func
)
total_downloaded_count += dl_count
total_skipped_count += skip_count
overall_progress_callback(total_chapters, idx + 1)
if check_pause_func(): break
image_urls = [] return total_downloaded_count, total_skipped_count
try:
start_index = page_content_text.index("'images' : ") + len("'images' : ")
end_index = page_content_text.index(",\n", start_index)
images_json_str = page_content_text[start_index:end_index]
image_paths = json.loads(images_json_str)
image_urls = ["https://hentaicdn.com/hentai" + part for part in image_paths]
except (ValueError, json.JSONDecodeError):
logger("❌ Could not find or parse image JSON data for this chapter.")
return None, None
if not image_urls:
logger("❌ No image URLs found for this chapter.")
return None, None
logger(f" Found {len(image_urls)} images for album '{album_title}'.")
files_to_download = []
for i, img_url in enumerate(image_urls):
page_num = i + 1
extension = os.path.splitext(img_url)[1].split('?')[0]
if not extension: extension = ".jpg"
filename = f"{page_num:03d}{extension}"
files_to_download.append({'url': img_url, 'filename': filename})
return album_title, files_to_download
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
logger(f" Chapter not found (404 Error). This likely marks the end of the series.")
else:
logger(f"❌ An HTTP error occurred: {e}")
return None, None
except Exception as e: except Exception as e:
logger(f"❌ An unexpected error occurred while fetching data: {e}") progress_callback(f"❌ A critical error occurred in the Hentai2Read client: {e}")
return None, None return 0, 0
def _get_series_metadata(start_url, progress_callback, scraper):
"""
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
"""
try:
response = scraper.get(start_url, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
series_title = "Unknown Series"
artist_name = None
metadata_list = soup.select_one("ul.list.list-simple-mini")
if metadata_list:
first_li = metadata_list.find('li', recursive=False)
if first_li and not first_li.find('a'):
series_title = first_li.get_text(strip=True)
for b_tag in metadata_list.find_all('b'):
label = b_tag.get_text(strip=True)
if label in ("Artist", "Author"):
a_tag = b_tag.find_next_sibling('a')
if a_tag:
artist_name = a_tag.get_text(strip=True)
if label == "Artist":
break
top_level_folder_name = artist_name if artist_name else series_title
chapter_links = soup.select("div.media a.pull-left.font-w600")
if not chapter_links:
chapters_to_process = [{'url': start_url, 'title': series_title}]
else:
chapters_to_process = [
{'url': urljoin(start_url, link['href']), 'title': " ".join(link.stripped_strings)}
for link in chapter_links
]
chapters_to_process.reverse()
progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'")
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
return top_level_folder_name, chapters_to_process
except Exception as e:
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata: {e}")
return "Unknown Series", []
### NEW: This function contains the pipeline logic ###
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
"""
Uses a producer-consumer pattern to download a chapter.
The main thread (producer) scrapes URLs one by one.
Worker threads (consumers) download the URLs as they are found.
"""
task_queue = queue.Queue()
num_download_threads = 8
# These will be updated by the worker threads
download_stats = {'downloaded': 0, 'skipped': 0}
def downloader_worker():
"""The function that each download thread will run."""
# Create a unique session for each thread to avoid conflicts
worker_scraper = cloudscraper.create_scraper()
while True:
try:
# Get a task from the queue
task = task_queue.get()
# The sentinel value to signal the end
if task is None:
break
filepath, img_url = task
if os.path.exists(filepath):
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
download_stats['skipped'] += 1
else:
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
response.raise_for_status()
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
download_stats['downloaded'] += 1
except Exception as e:
progress_callback(f" ❌ Download failed for task. Error: {e}")
download_stats['skipped'] += 1
finally:
task_queue.task_done()
# --- Start the downloader threads ---
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
for _ in range(num_download_threads):
executor.submit(downloader_worker)
# --- Main thread acts as the scraper (producer) ---
page_number = 1
while True:
if check_pause_func(): break
if page_number > 300: # Safety break
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
break
page_url_to_check = f"{chapter_url}{page_number}/"
try:
response = scraper.get(page_url_to_check, timeout=30)
if response.history or response.status_code != 200:
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
break
soup = BeautifulSoup(response.text, 'html.parser')
img_tag = soup.select_one("img#arf-reader")
img_src = img_tag.get("src") if img_tag else None
if not img_tag or img_src == "https://static.hentai.direct/hentai":
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
break
normalized_img_src = urljoin(response.url, img_src)
ext = os.path.splitext(normalized_img_src.split('/')[-1])[-1] or ".jpg"
filename = f"{page_number:03d}{ext}"
filepath = os.path.join(save_path, filename)
# Put the download task into the queue for a worker to pick up
task_queue.put((filepath, normalized_img_src))
page_number += 1
time.sleep(0.1) # Small delay between scraping pages
except Exception as e:
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
break
# --- Shutdown sequence ---
# Tell all worker threads to exit by sending the sentinel value
for _ in range(num_download_threads):
task_queue.put(None)
# Wait for all download tasks to be completed
executor.shutdown(wait=True)
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
return download_stats['downloaded'], download_stats['skipped']

116
src/core/allcomic_client.py Normal file
View File

@@ -0,0 +1,116 @@
import requests
import re
from bs4 import BeautifulSoup
import cloudscraper
import time
from urllib.parse import urlparse
def get_chapter_list(series_url, logger_func):
"""
Checks if a URL is a series page and returns a list of all chapter URLs if it is.
Includes a retry mechanism for robust connection.
"""
logger_func(f" [AllComic] Checking for chapter list at: {series_url}")
scraper = cloudscraper.create_scraper()
response = None
max_retries = 8
for attempt in range(max_retries):
try:
response = scraper.get(series_url, timeout=30)
response.raise_for_status()
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
break # Success, exit the loop
except requests.RequestException as e:
logger_func(f" [AllComic] ⚠️ Series page check attempt {attempt + 1}/{max_retries} failed: {e}")
if attempt < max_retries - 1:
wait_time = 2 * (attempt + 1)
logger_func(f" Retrying in {wait_time} seconds...")
time.sleep(wait_time)
else:
logger_func(f" [AllComic] ❌ All attempts to check series page failed.")
return [] # Return empty on final failure
if not response:
return []
try:
soup = BeautifulSoup(response.text, 'html.parser')
chapter_links = soup.select('li.wp-manga-chapter a')
if not chapter_links:
logger_func(" [AllComic] No chapter list found. Assuming this is a single chapter page.")
return []
chapter_urls = [link['href'] for link in chapter_links]
chapter_urls.reverse() # Reverse for oldest-to-newest reading order
logger_func(f" [AllComic] ✅ Found {len(chapter_urls)} chapters.")
return chapter_urls
except Exception as e:
logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
return []
def fetch_chapter_data(chapter_url, logger_func):
"""
Fetches the comic title, chapter title, and image URLs for a single chapter page.
"""
logger_func(f" [AllComic] Fetching page: {chapter_url}")
scraper = cloudscraper.create_scraper(
browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
)
headers = {'Referer': 'https://allporncomic.com/'}
response = None
max_retries = 8
for attempt in range(max_retries):
try:
response = scraper.get(chapter_url, headers=headers, timeout=30)
response.raise_for_status()
break
except requests.RequestException as e:
if attempt < max_retries - 1:
time.sleep(2 * (attempt + 1))
else:
logger_func(f" [AllComic] ❌ All connection attempts failed for chapter: {chapter_url}")
return None, None, None
try:
soup = BeautifulSoup(response.text, 'html.parser')
title_element = soup.find('h1', class_='post-title')
comic_title = None
if title_element:
comic_title = title_element.text.strip()
else:
try:
path_parts = urlparse(chapter_url).path.strip('/').split('/')
if len(path_parts) >= 3 and path_parts[-3] == 'porncomic':
comic_slug = path_parts[-2]
comic_title = comic_slug.replace('-', ' ').title()
except Exception:
comic_title = "Unknown Comic"
chapter_slug = chapter_url.strip('/').split('/')[-1]
chapter_title = chapter_slug.replace('-', ' ').title()
reading_container = soup.find('div', class_='reading-content')
list_of_image_urls = []
if reading_container:
image_elements = reading_container.find_all('img', class_='wp-manga-chapter-img')
for img in image_elements:
img_url = (img.get('data-src') or img.get('src', '')).strip()
if img_url:
list_of_image_urls.append(img_url)
if not comic_title or comic_title == "Unknown Comic" or not list_of_image_urls:
logger_func(f" [AllComic] ❌ Could not find a valid title or images on the page. Title found: '{comic_title}'")
return None, None, None
return comic_title, chapter_title, list_of_image_urls
except Exception as e:
logger_func(f" [AllComic] ❌ An unexpected error occurred while parsing the page: {e}")
return None, None, None

View File

@@ -33,7 +33,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Fetch operation cancelled by user during retry loop.") raise RuntimeError("Fetch operation cancelled by user during retry loop.")
log_message = f" Fetching post list: {paginated_url} (Page approx. {offset // 50 + 1})" log_message = f" Fetching post list: {api_url_base} (Page approx. {offset // 50 + 1})"
if attempt > 0: if attempt > 0:
log_message += f" (Attempt {attempt + 1}/{max_retries})" log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message) logger(log_message)
@@ -247,7 +247,7 @@ def download_from_api(
break break
all_posts_for_manga_mode.extend(posts_batch_manga) all_posts_for_manga_mode.extend(posts_batch_manga)
logger(f"MANGA_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}") logger(f"RENAMING_MODE_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}")
current_offset_manga += page_size current_offset_manga += page_size
time.sleep(0.6) time.sleep(0.6)
@@ -265,7 +265,7 @@ def download_from_api(
if cancellation_event and cancellation_event.is_set(): return if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode: if all_posts_for_manga_mode:
logger(f"MANGA_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}") logger(f"RENAMING_MODE_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}")
if all_posts_for_manga_mode: if all_posts_for_manga_mode:
if processed_post_ids: if processed_post_ids:

375
src/core/booru_client.py Normal file
View File

@@ -0,0 +1,375 @@
# src/core/booru_client.py
import os
import re
import time
import datetime
import urllib.parse
import requests
import logging
import cloudscraper
# --- Start of Combined Code from 1.py ---
# Part 1: Essential Utilities & Exceptions
class BooruClientException(Exception):
"""Base class for exceptions in this client."""
pass
class HttpError(BooruClientException):
"""HTTP request during data extraction failed."""
def __init__(self, message="", response=None):
self.response = response
self.status = response.status_code if response else 0
if response and not message:
message = f"'{response.status_code} {response.reason}' for '{response.url}'"
super().__init__(message)
class NotFoundError(BooruClientException):
pass
def unquote(s):
return urllib.parse.unquote(s)
def parse_datetime(date_string, fmt):
try:
# Assumes date_string is in a format that strptime can handle with timezone
return datetime.datetime.strptime(date_string, fmt)
except (ValueError, TypeError):
return None
def nameext_from_url(url, data=None):
if data is None: data = {}
try:
path = urllib.parse.urlparse(url).path
filename = unquote(os.path.basename(path))
if '.' in filename:
name, ext = filename.rsplit('.', 1)
data["filename"], data["extension"] = name, ext.lower()
else:
data["filename"], data["extension"] = filename, ""
except Exception:
data["filename"], data["extension"] = "", ""
return data
USERAGENT_FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"
# Part 2: Core Extractor Logic
class Extractor:
category = ""
subcategory = ""
directory_fmt = ("{category}", "{id}")
filename_fmt = "{filename}.{extension}"
_retries = 3
_timeout = 30
def __init__(self, match, logger_func=print):
self.url = match.string
self.match = match
self.groups = match.groups()
self.session = cloudscraper.create_scraper()
self.session.headers["User-Agent"] = USERAGENT_FIREFOX
self.log = logger_func
self.api_key = None
self.user_id = None
def set_auth(self, api_key, user_id):
self.api_key = api_key
self.user_id = user_id
self._init_auth()
def _init_auth(self):
"""Placeholder for extractor-specific auth setup."""
pass
def request(self, url, method="GET", fatal=True, **kwargs):
for attempt in range(self._retries + 1):
try:
response = self.session.request(method, url, timeout=self._timeout, **kwargs)
if response.status_code < 400:
return response
if response.status_code == 404 and fatal:
raise NotFoundError(f"Resource not found at {url}")
self.log(f"Request for {url} failed with status {response.status_code}. Retrying...")
except requests.exceptions.RequestException as e:
self.log(f"Request for {url} failed: {e}. Retrying...")
if attempt < self._retries:
time.sleep(2 ** attempt)
if fatal:
raise HttpError(f"Failed to retrieve {url} after {self._retries} retries.")
return None
def request_json(self, url, **kwargs):
response = self.request(url, **kwargs)
try:
return response.json()
except (ValueError, TypeError) as exc:
self.log(f"Failed to decode JSON from {url}: {exc}")
raise BooruClientException("Invalid JSON response")
def items(self):
data = self.metadata()
for item in self.posts():
# Check for our special page update message
if isinstance(item, tuple) and item[0] == 'PAGE_UPDATE':
yield item
continue
# Otherwise, process it as a post
post = item
url = post.get("file_url")
if not url: continue
nameext_from_url(url, post)
post["date"] = parse_datetime(post.get("created_at"), "%Y-%m-%dT%H:%M:%S.%f%z")
if url.startswith("/"):
url = self.root + url
post['file_url'] = url # Ensure full URL
post.update(data)
yield post
class BaseExtractor(Extractor):
instances = ()
def __init__(self, match, logger_func=print):
super().__init__(match, logger_func)
self._init_category()
def _init_category(self):
parsed_url = urllib.parse.urlparse(self.url)
self.root = f"{parsed_url.scheme}://{parsed_url.netloc}"
for i, group in enumerate(self.groups):
if group is not None:
try:
self.category = self.instances[i][0]
return
except IndexError:
continue
@classmethod
def update(cls, instances):
pattern_list = []
instance_list = cls.instances = []
for category, info in instances.items():
root = info["root"].rstrip("/") if info["root"] else ""
instance_list.append((category, root, info))
pattern = info.get("pattern", re.escape(root.partition("://")[2]))
pattern_list.append(f"({pattern})")
return r"(?:https?://)?(?:" + "|".join(pattern_list) + r")"
# Part 3: Danbooru Extractor
class DanbooruExtractor(BaseExtractor):
filename_fmt = "{category}_{id}_{filename}.{extension}"
per_page = 200
def __init__(self, match, logger_func=print):
super().__init__(match, logger_func)
self._auth_logged = False
def _init_auth(self):
if self.user_id and self.api_key:
if not self._auth_logged:
self.log("Danbooru auth set.")
self._auth_logged = True
self.session.auth = (self.user_id, self.api_key)
def items(self):
data = self.metadata()
for item in self.posts():
# Check for our special page update message
if isinstance(item, tuple) and item[0] == 'PAGE_UPDATE':
yield item
continue
# Otherwise, process it as a post
post = item
url = post.get("file_url")
if not url: continue
nameext_from_url(url, post)
post["date"] = parse_datetime(post.get("created_at"), "%Y-%m-%dT%H:%M:%S.%f%z")
if url.startswith("/"):
url = self.root + url
post['file_url'] = url # Ensure full URL
post.update(data)
yield post
def metadata(self):
return {}
def posts(self):
return []
def _pagination(self, endpoint, params, prefix="b"):
url = self.root + endpoint
params["limit"] = self.per_page
params["page"] = 1
threshold = self.per_page - 20
while True:
posts = self.request_json(url, params=params)
if not posts: break
yield ('PAGE_UPDATE', len(posts))
yield from posts
if len(posts) < threshold: return
if prefix:
params["page"] = f"{prefix}{posts[-1]['id']}"
else:
params["page"] += 1
BASE_PATTERN = DanbooruExtractor.update({
"danbooru": {"root": None, "pattern": r"(?:danbooru|safebooru)\.donmai\.us"},
})
class DanbooruTagExtractor(DanbooruExtractor):
subcategory = "tag"
directory_fmt = ("{category}", "{search_tags}")
pattern = BASE_PATTERN + r"(/posts\?(?:[^&#]*&)*tags=([^&#]*))"
def metadata(self):
self.tags = unquote(self.groups[-1].replace("+", " ")).strip()
sanitized_tags = re.sub(r'[\\/*?:"<>|]', "_", self.tags)
return {"search_tags": sanitized_tags}
def posts(self):
return self._pagination("/posts.json", {"tags": self.tags})
class DanbooruPostExtractor(DanbooruExtractor):
subcategory = "post"
pattern = BASE_PATTERN + r"(/post(?:s|/show)/(\d+))"
def posts(self):
post_id = self.groups[-1]
url = f"{self.root}/posts/{post_id}.json"
post = self.request_json(url)
return (post,) if post else ()
class GelbooruBase(Extractor):
category = "gelbooru"
root = "https://gelbooru.com"
def __init__(self, match, logger_func=print):
super().__init__(match, logger_func)
self._auth_logged = False
def _api_request(self, params, key="post"):
# Auth is now added dynamically
if self.api_key and self.user_id:
if not self._auth_logged:
self.log("Gelbooru auth set.")
self._auth_logged = True
params.update({"api_key": self.api_key, "user_id": self.user_id})
url = self.root + "/index.php?page=dapi&q=index&json=1"
data = self.request_json(url, params=params)
if not key: return data
posts = data.get(key, [])
return posts if isinstance(posts, list) else [posts] if posts else []
def items(self):
base_data = self.metadata()
base_data['category'] = self.category
for item in self.posts():
# Check for our special page update message
if isinstance(item, tuple) and item[0] == 'PAGE_UPDATE':
yield item
continue
# Otherwise, process it as a post
post = item
url = post.get("file_url")
if not url: continue
data = base_data.copy()
data.update(post)
nameext_from_url(url, data)
yield data
def metadata(self): return {}
def posts(self): return []
GELBOORU_PATTERN = r"(?:https?://)?(?:www\.)?gelbooru\.com"
class GelbooruTagExtractor(GelbooruBase):
subcategory = "tag"
directory_fmt = ("{category}", "{search_tags}")
filename_fmt = "{category}_{id}_{md5}.{extension}"
pattern = GELBOORU_PATTERN + r"(/index\.php\?page=post&s=list&tags=([^&#]*))"
def metadata(self):
self.tags = unquote(self.groups[-1].replace("+", " ")).strip()
sanitized_tags = re.sub(r'[\\/*?:"<>|]', "_", self.tags)
return {"search_tags": sanitized_tags}
def posts(self):
"""Scrapes HTML search pages as API can be restrictive for tags."""
pid = 0
posts_per_page = 42
search_url = self.root + "/index.php"
params = {"page": "post", "s": "list", "tags": self.tags}
while True:
params['pid'] = pid
self.log(f"Scraping search results page (offset: {pid})...")
response = self.request(search_url, params=params)
html_content = response.text
post_ids = re.findall(r'id="p(\d+)"', html_content)
if not post_ids:
self.log("No more posts found on page. Ending scrape.")
break
yield ('PAGE_UPDATE', len(post_ids))
for post_id in post_ids:
post_data = self._api_request({"s": "post", "id": post_id})
yield from post_data
pid += posts_per_page
class GelbooruPostExtractor(GelbooruBase):
subcategory = "post"
filename_fmt = "{category}_{id}_{md5}.{extension}"
pattern = GELBOORU_PATTERN + r"(/index\.php\?page=post&s=view&id=(\d+))"
def posts(self):
post_id = self.groups[-1]
return self._api_request({"s": "post", "id": post_id})
# --- Main Entry Point ---
EXTRACTORS = [
DanbooruTagExtractor,
DanbooruPostExtractor,
GelbooruTagExtractor,
GelbooruPostExtractor,
]
def find_extractor(url, logger_func):
for extractor_cls in EXTRACTORS:
match = re.search(extractor_cls.pattern, url)
if match:
return extractor_cls(match, logger_func)
return None
def fetch_booru_data(url, api_key, user_id, logger_func):
"""
Main function to find an extractor and yield image data.
"""
extractor = find_extractor(url, logger_func)
if not extractor:
logger_func(f"No suitable Booru extractor found for URL: {url}")
return
logger_func(f"Using extractor: {extractor.__class__.__name__}")
extractor.set_auth(api_key, user_id)
# The 'items' method will now yield the data dictionaries directly
yield from extractor.items()

View File

@@ -207,7 +207,7 @@ def get_bunkr_extractor(url, logger):
def fetch_bunkr_data(url, logger): def fetch_bunkr_data(url, logger):
""" """
Main function to be called from the GUI. Main function to be called from the GUI.
It extracts all file information from a Bunkr URL. It extracts all file information from a Bunkr URL, now handling both albums and direct file links.
Returns: Returns:
A tuple of (album_name, list_of_files) A tuple of (album_name, list_of_files)
@@ -215,6 +215,30 @@ def fetch_bunkr_data(url, logger):
- list_of_files (list): A list of dicts, each containing 'url', 'name', and '_http_headers'. - list_of_files (list): A list of dicts, each containing 'url', 'name', and '_http_headers'.
Returns (None, None) on failure. Returns (None, None) on failure.
""" """
# --- START: New logic to handle direct CDN file URLs ---
try:
parsed_url = urllib.parse.urlparse(url)
# Check if the hostname contains 'cdn' and the path has a common file extension
is_direct_cdn_file = (parsed_url.hostname and 'cdn' in parsed_url.hostname and 'bunkr' in parsed_url.hostname and
any(parsed_url.path.lower().endswith(ext) for ext in ['.mp4', '.mkv', '.webm', '.jpg', '.jpeg', '.png', '.gif', '.zip', '.rar']))
if is_direct_cdn_file:
logger.info("Bunkr direct file URL detected.")
filename = os.path.basename(parsed_url.path)
# Use the filename (without extension) as a sensible album name
album_name = os.path.splitext(filename)[0]
files_to_download = [{
'url': url,
'name': filename,
'_http_headers': {'Referer': 'https://bunkr.ru/'} # Use a generic Referer
}]
return album_name, files_to_download
except Exception as e:
logger.warning(f"Could not parse Bunkr URL for direct file check: {e}")
# --- END: New logic ---
# This is the original logic for album and media pages
extractor = get_bunkr_extractor(url, logger) extractor = get_bunkr_extractor(url, logger)
if not extractor: if not extractor:
return None, None return None, None
@@ -238,4 +262,4 @@ def fetch_bunkr_data(url, logger):
except Exception as e: except Exception as e:
logger.error(f"An error occurred while extracting Bunkr info: {e}", exc_info=True) logger.error(f"An error occurred while extracting Bunkr info: {e}", exc_info=True)
return None, None return None, None

View File

@@ -0,0 +1,125 @@
import re
import os
import cloudscraper
from urllib.parse import urlparse, urljoin
from ..utils.file_utils import clean_folder_name
def fetch_fap_nation_data(album_url, logger_func):
"""
Scrapes a fap-nation page by prioritizing HLS streams first, then falling
back to direct download links. Selects the highest quality available.
"""
logger_func(f" [Fap-Nation] Fetching album data from: {album_url}")
scraper = cloudscraper.create_scraper()
try:
response = scraper.get(album_url, timeout=45)
response.raise_for_status()
html_content = response.text
title_match = re.search(r'<h1[^>]*itemprop="name"[^>]*>(.*?)</h1>', html_content, re.IGNORECASE)
album_slug = clean_folder_name(os.path.basename(urlparse(album_url).path.strip('/')))
album_title = clean_folder_name(title_match.group(1).strip()) if title_match else album_slug
files_to_download = []
final_url = None
link_type = None
filename_from_video_tag = None
video_tag_title_match = re.search(r'data-plyr-config=.*?&quot;title&quot;:.*?&quot;([^&]+?\.mp4)&quot;', html_content, re.IGNORECASE)
if video_tag_title_match:
filename_from_video_tag = clean_folder_name(video_tag_title_match.group(1))
logger_func(f" [Fap-Nation] Found high-quality filename in video tag: {filename_from_video_tag}")
# --- REVISED LOGIC: HLS FIRST ---
# 1. Prioritize finding an HLS stream.
logger_func(" [Fap-Nation] Priority 1: Searching for HLS stream...")
iframe_match = re.search(r'<iframe[^>]+src="([^"]+mediadelivery\.net[^"]+)"', html_content, re.IGNORECASE)
if iframe_match:
iframe_url = iframe_match.group(1)
logger_func(f" [Fap-Nation] Found video iframe. Visiting: {iframe_url}")
try:
iframe_response = scraper.get(iframe_url, timeout=30)
iframe_response.raise_for_status()
iframe_html = iframe_response.text
playlist_match = re.search(r'<source[^>]+src="([^"]+\.m3u8)"', iframe_html, re.IGNORECASE)
if playlist_match:
final_url = playlist_match.group(1)
link_type = 'hls'
logger_func(f" [Fap-Nation] Found embedded HLS stream in iframe: {final_url}")
except Exception as e:
logger_func(f" [Fap-Nation] ⚠️ Error fetching or parsing iframe content: {e}")
if not final_url:
logger_func(" [Fap-Nation] No stream found in iframe. Checking main page content as a last resort...")
js_var_match = re.search(r'"(https?://[^"]+\.m3u8)"', html_content, re.IGNORECASE)
if js_var_match:
final_url = js_var_match.group(1)
link_type = 'hls'
logger_func(f" [Fap-Nation] Found HLS stream on main page: {final_url}")
# 2. Fallback: If no HLS stream was found, search for direct links.
if not final_url:
logger_func(" [Fap-Nation] No HLS stream found. Priority 2 (Fallback): Searching for direct download links...")
direct_link_pattern = r'<a\s+[^>]*href="([^"]+\.(?:mp4|webm|mkv|mov))"[^>]*>'
direct_links_found = re.findall(direct_link_pattern, html_content, re.IGNORECASE)
if direct_links_found:
logger_func(f" [Fap-Nation] Found {len(direct_links_found)} direct media link(s). Selecting the best quality...")
best_link = direct_links_found[0]
for link in direct_links_found:
if '1080p' in link.lower():
best_link = link
break
final_url = best_link
link_type = 'direct'
logger_func(f" [Fap-Nation] Identified direct media link: {final_url}")
# If after all checks, we still have no URL, then fail.
if not final_url:
logger_func(" [Fap-Nation] ❌ Stage 1 Failed: Could not find any HLS stream or direct link.")
return None, []
# --- HLS Quality Selection Logic ---
if link_type == 'hls' and final_url:
logger_func(" [Fap-Nation] HLS stream found. Checking for higher quality variants...")
try:
master_playlist_response = scraper.get(final_url, timeout=20)
master_playlist_response.raise_for_status()
playlist_content = master_playlist_response.text
streams = re.findall(r'#EXT-X-STREAM-INF:.*?RESOLUTION=(\d+)x(\d+).*?\n(.*?)\s', playlist_content)
if streams:
best_stream = max(streams, key=lambda s: int(s[0]) * int(s[1]))
height = best_stream[1]
relative_path = best_stream[2]
new_final_url = urljoin(final_url, relative_path)
logger_func(f" [Fap-Nation] ✅ Best quality found: {height}p. Updating URL to: {new_final_url}")
final_url = new_final_url
else:
logger_func(" [Fap-Nation] No alternate quality streams found in playlist. Using original.")
except Exception as e:
logger_func(f" [Fap-Nation] ⚠️ Could not parse HLS master playlist for quality selection: {e}. Using original URL.")
if final_url and link_type:
if filename_from_video_tag:
base_name, _ = os.path.splitext(filename_from_video_tag)
new_filename = f"{base_name}.mp4"
else:
new_filename = f"{album_slug}.mp4"
files_to_download.append({'url': final_url, 'filename': new_filename, 'type': link_type})
logger_func(f" [Fap-Nation] ✅ Ready to download '{new_filename}' ({link_type} method).")
return album_title, files_to_download
logger_func(f" [Fap-Nation] ❌ Could not determine a valid download link.")
return None, []
except Exception as e:
logger_func(f" [Fap-Nation] ❌ Error fetching Fap-Nation data: {e}")
return None, []

189
src/core/mangadex_client.py Normal file
View File

@@ -0,0 +1,189 @@
# src/core/mangadex_client.py
import os
import re
import time
import cloudscraper
from collections import defaultdict
from ..utils.file_utils import clean_folder_name
def fetch_mangadex_data(start_url, output_dir, logger_func, file_progress_callback, overall_progress_callback, pause_event, cancellation_event):
"""
Fetches and downloads all content from a MangaDex series or chapter URL.
Returns a tuple of (downloaded_count, skipped_count).
"""
grand_total_dl = 0
grand_total_skip = 0
api = _MangadexAPI(logger_func)
def _check_pause():
if cancellation_event and cancellation_event.is_set(): return True
if pause_event and pause_event.is_set():
logger_func(" Download paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): return True
time.sleep(0.5)
logger_func(" Download resumed.")
return cancellation_event.is_set()
series_match = re.search(r"mangadex\.org/(?:title|manga)/([0-9a-f-]+)", start_url)
chapter_match = re.search(r"mangadex\.org/chapter/([0-9a-f-]+)", start_url)
chapters_to_process = []
if series_match:
series_id = series_match.group(1)
logger_func(f" Series detected. Fetching chapter list for ID: {series_id}")
chapters_to_process = api.get_manga_chapters(series_id, cancellation_event, pause_event)
elif chapter_match:
chapter_id = chapter_match.group(1)
logger_func(f" Single chapter detected. Fetching info for ID: {chapter_id}")
chapter_info = api.get_chapter_info(chapter_id)
if chapter_info:
chapters_to_process = [chapter_info]
if not chapters_to_process:
logger_func("❌ No chapters found or failed to fetch chapter info.")
return 0, 0
logger_func(f"✅ Found {len(chapters_to_process)} chapter(s) to download.")
if overall_progress_callback:
overall_progress_callback.emit(len(chapters_to_process), 0)
for chap_idx, chapter_json in enumerate(chapters_to_process):
if _check_pause(): break
try:
metadata = api.transform_chapter_data(chapter_json)
logger_func("-" * 40)
logger_func(f"Processing Chapter {chap_idx + 1}/{len(chapters_to_process)}: Vol. {metadata['volume']} Ch. {metadata['chapter']}{metadata['chapter_minor']} - {metadata['title']}")
server_info = api.get_at_home_server(chapter_json["id"])
if not server_info:
logger_func(" ❌ Could not get image server for this chapter. Skipping.")
continue
base_url = f"{server_info['baseUrl']}/data/{server_info['chapter']['hash']}/"
image_files = server_info['chapter']['data']
series_folder = clean_folder_name(metadata['manga'])
chapter_folder_title = metadata['title'] or ''
chapter_folder = clean_folder_name(f"Vol {metadata['volume']:02d} Chap {metadata['chapter']:03d}{metadata['chapter_minor']} - {chapter_folder_title}".strip().strip('-').strip())
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
os.makedirs(final_save_path, exist_ok=True)
for img_idx, filename in enumerate(image_files):
if _check_pause(): break
full_img_url = base_url + filename
img_path = os.path.join(final_save_path, f"{img_idx + 1:03d}{os.path.splitext(filename)[1]}")
if os.path.exists(img_path):
logger_func(f" -> Skip ({img_idx+1}/{len(image_files)}): '{os.path.basename(img_path)}' already exists.")
grand_total_skip += 1
continue
logger_func(f" Downloading ({img_idx+1}/{len(image_files)}): '{os.path.basename(img_path)}'...")
try:
response = api.session.get(full_img_url, stream=True, timeout=60, headers={'Referer': 'https://mangadex.org/'})
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
if file_progress_callback:
file_progress_callback.emit(os.path.basename(img_path), (0, total_size))
with open(img_path, 'wb') as f:
downloaded_bytes = 0
for chunk in response.iter_content(chunk_size=8192):
if _check_pause(): break
f.write(chunk)
downloaded_bytes += len(chunk)
if file_progress_callback:
file_progress_callback.emit(os.path.basename(img_path), (downloaded_bytes, total_size))
if _check_pause():
if os.path.exists(img_path): os.remove(img_path)
break
grand_total_dl += 1
except Exception as e:
logger_func(f" ❌ Failed to download page {img_idx+1}: {e}")
grand_total_skip += 1
if overall_progress_callback:
overall_progress_callback.emit(len(chapters_to_process), chap_idx + 1)
time.sleep(1)
except Exception as e:
logger_func(f" ❌ An unexpected error occurred while processing chapter {chapter_json.get('id')}: {e}")
return grand_total_dl, grand_total_skip
class _MangadexAPI:
def __init__(self, logger_func):
self.logger_func = logger_func
self.session = cloudscraper.create_scraper()
self.root = "https://api.mangadex.org"
def _call(self, endpoint, params=None, cancellation_event=None):
if cancellation_event and cancellation_event.is_set(): return None
try:
response = self.session.get(f"{self.root}{endpoint}", params=params, timeout=30)
if response.status_code == 429:
retry_after = int(response.headers.get("X-RateLimit-Retry-After", 5))
self.logger_func(f" ⚠️ Rate limited. Waiting for {retry_after} seconds...")
time.sleep(retry_after)
return self._call(endpoint, params, cancellation_event)
response.raise_for_status()
return response.json()
except Exception as e:
self.logger_func(f" ❌ API call to '{endpoint}' failed: {e}")
return None
def get_manga_chapters(self, series_id, cancellation_event, pause_event):
all_chapters = []
offset = 0
limit = 500
base_params = {
"limit": limit, "order[volume]": "asc", "order[chapter]": "asc",
"translatedLanguage[]": ["en"], "includes[]": ["scanlation_group", "user", "manga"]
}
while True:
if cancellation_event.is_set(): break
while pause_event.is_set(): time.sleep(0.5)
params = {**base_params, "offset": offset}
data = self._call(f"/manga/{series_id}/feed", params, cancellation_event)
if not data or data.get("result") != "ok": break
results = data.get("data", [])
all_chapters.extend(results)
if (offset + limit) >= data.get("total", 0): break
offset += limit
return all_chapters
def get_chapter_info(self, chapter_id):
params = {"includes[]": ["scanlation_group", "user", "manga"]}
data = self._call(f"/chapter/{chapter_id}", params)
return data.get("data") if data and data.get("result") == "ok" else None
def get_at_home_server(self, chapter_id):
return self._call(f"/at-home/server/{chapter_id}")
def transform_chapter_data(self, chapter):
relationships = {item["type"]: item for item in chapter.get("relationships", [])}
manga = relationships.get("manga", {})
c_attrs = chapter.get("attributes", {})
m_attrs = manga.get("attributes", {})
chapter_num_str = c_attrs.get("chapter", "0") or "0"
chnum, sep, minor = chapter_num_str.partition(".")
return {
"manga": (m_attrs.get("title", {}).get("en") or next(iter(m_attrs.get("title", {}).values()), "Unknown Series")),
"title": c_attrs.get("title", ""),
"volume": int(float(c_attrs.get("volume", 0) or 0)),
"chapter": int(float(chnum or 0)),
"chapter_minor": sep + minor if minor else ""
}

View File

@@ -0,0 +1,93 @@
import os
import re
import cloudscraper
from ..utils.file_utils import clean_folder_name
# --- ADDED IMPORTS ---
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def fetch_pixeldrain_data(url: str, logger):
"""
Scrapes a given Pixeldrain URL to extract album or file information.
Handles single files (/u/), albums/lists (/l/), and folders (/d/).
"""
logger(f"Fetching data for Pixeldrain URL: {url}")
scraper = cloudscraper.create_scraper()
root = "https://pixeldrain.com"
# --- START OF FIX: Add a robust retry strategy ---
try:
retry_strategy = Retry(
total=5, # Total number of retries
backoff_factor=1, # Wait 1s, 2s, 4s, 8s between retries
status_forcelist=[429, 500, 502, 503, 504], # Retry on these server errors
allowed_methods=["HEAD", "GET"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
scraper.mount("https://", adapter)
scraper.mount("http://", adapter)
logger(" [Pixeldrain] Configured retry strategy for network requests.")
except Exception as e:
logger(f" [Pixeldrain] ⚠️ Could not configure retry strategy: {e}")
# --- END OF FIX ---
file_match = re.search(r"/u/(\w+)", url)
album_match = re.search(r"/l/(\w+)", url)
folder_match = re.search(r"/d/([^?]+)", url)
try:
if file_match:
file_id = file_match.group(1)
logger(f" Detected Pixeldrain File ID: {file_id}")
api_url = f"{root}/api/file/{file_id}/info"
data = scraper.get(api_url).json()
title = data.get("name", file_id)
files = [{
'url': f"{root}/api/file/{file_id}?download",
'filename': data.get("name", f"{file_id}.tmp")
}]
return title, files
elif album_match:
album_id = album_match.group(1)
logger(f" Detected Pixeldrain Album ID: {album_id}")
api_url = f"{root}/api/list/{album_id}"
data = scraper.get(api_url).json()
title = data.get("title", album_id)
files = []
for file_info in data.get("files", []):
files.append({
'url': f"{root}/api/file/{file_info['id']}?download",
'filename': file_info.get("name", f"{file_info['id']}.tmp")
})
return title, files
elif folder_match:
path_id = folder_match.group(1)
logger(f" Detected Pixeldrain Folder Path: {path_id}")
api_url = f"{root}/api/filesystem/{path_id}?stat"
data = scraper.get(api_url).json()
path_info = data["path"][data["base_index"]]
title = path_info.get("name", path_id)
files = []
for child in data.get("children", []):
if child.get("type") == "file":
files.append({
'url': f"{root}/api/filesystem{child['path']}?attach",
'filename': child.get("name")
})
return title, files
else:
logger(" ❌ Could not identify Pixeldrain URL type (file, album, or folder).")
return None, []
except Exception as e:
logger(f"❌ An error occurred while fetching Pixeldrain data: {e}")
return None, []

100
src/core/simpcity_client.py Normal file
View File

@@ -0,0 +1,100 @@
# src/core/simpcity_client.py
import cloudscraper
from bs4 import BeautifulSoup
from urllib.parse import urlparse, unquote
import os
import re
from ..utils.file_utils import clean_folder_name
import urllib.parse
def fetch_single_simpcity_page(url, logger_func, cookies=None, post_id=None):
"""
Scrapes a single SimpCity page for images, external links, video tags, and iframes.
"""
scraper = cloudscraper.create_scraper()
headers = {'Referer': 'https://simpcity.cr/'}
try:
response = scraper.get(url, timeout=30, headers=headers, cookies=cookies)
if response.status_code == 404:
return None, []
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
album_title = None
title_element = soup.find('h1', class_='p-title-value')
if title_element:
album_title = title_element.text.strip()
search_scope = soup
if post_id:
post_content_container = soup.find('div', attrs={'data-lb-id': f'post-{post_id}'})
if post_content_container:
logger_func(f" [SimpCity] ✅ Isolating search to post content container for ID {post_id}.")
search_scope = post_content_container
else:
logger_func(f" [SimpCity] ⚠️ Could not find content container for post ID {post_id}.")
jobs_on_page = []
# Find native SimpCity images
image_tags = search_scope.find_all('img', class_='bbImage')
for img_tag in image_tags:
thumbnail_url = img_tag.get('src')
if not thumbnail_url or not isinstance(thumbnail_url, str) or 'saint2.su' in thumbnail_url: continue
full_url = thumbnail_url.replace('.md.', '.')
filename = img_tag.get('alt', '').replace('.md.', '.') or os.path.basename(unquote(urlparse(full_url).path))
jobs_on_page.append({'type': 'image', 'filename': filename, 'url': full_url})
# Find links in <a> tags, now with redirect handling
link_tags = search_scope.find_all('a', href=True)
for link in link_tags:
href = link.get('href', '')
actual_url = href
if '/misc/goto?url=' in href:
try:
# Extract and decode the real URL from the 'url' parameter
parsed_href = urlparse(href)
query_params = dict(urllib.parse.parse_qsl(parsed_href.query))
if 'url' in query_params:
actual_url = unquote(query_params['url'])
except Exception:
actual_url = href # Fallback if parsing fails
# Perform all checks on the 'actual_url' which is now the real destination
if re.search(r'pixeldrain\.com/[lud]/', actual_url): jobs_on_page.append({'type': 'pixeldrain', 'url': actual_url})
elif re.search(r'saint2\.(su|pk|cr|to)/embed/', actual_url): jobs_on_page.append({'type': 'saint2', 'url': actual_url})
elif re.search(r'bunkr\.(?:cr|si|la|ws|is|ru|su|red|black|media|site|to|ac|ci|fi|pk|ps|sk|ph)|bunkrr\.ru', actual_url): jobs_on_page.append({'type': 'bunkr', 'url': actual_url})
elif re.search(r'mega\.(nz|io)', actual_url): jobs_on_page.append({'type': 'mega', 'url': actual_url})
elif re.search(r'gofile\.io', actual_url): jobs_on_page.append({'type': 'gofile', 'url': actual_url})
# Find direct Saint2 video embeds in <video> tags
video_tags = search_scope.find_all('video')
for video in video_tags:
source_tag = video.find('source')
if source_tag and source_tag.get('src'):
src_url = source_tag['src']
if re.search(r'saint2\.(su|pk|cr|to)', src_url):
jobs_on_page.append({'type': 'saint2_direct', 'url': src_url})
# Find embeds in <iframe> tags (as a fallback)
iframe_tags = search_scope.find_all('iframe')
for iframe in iframe_tags:
src_url = iframe.get('src')
if src_url and isinstance(src_url, str):
if re.search(r'saint2\.(su|pk|cr|to)/embed/', src_url):
jobs_on_page.append({'type': 'saint2', 'url': src_url})
if jobs_on_page:
# We use a set to remove duplicate URLs that might be found in multiple ways
unique_jobs = list({job['url']: job for job in jobs_on_page}.values())
logger_func(f" [SimpCity] Scraper found jobs: {[job['type'] for job in unique_jobs]}")
return album_title, unique_jobs
return album_title, []
except Exception as e:
logger_func(f" [SimpCity] ❌ Error fetching page {url}: {e}")
raise e

View File

@@ -0,0 +1,73 @@
import cloudscraper
from bs4 import BeautifulSoup
import time
def get_chapter_list(series_url, logger_func):
logger_func(f" [Toonily] Scraping series page for chapter list: {series_url}")
scraper = cloudscraper.create_scraper()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Referer': 'https://toonily.com/'
}
try:
response = scraper.get(series_url, timeout=30, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
chapter_links = soup.select('li.wp-manga-chapter > a')
if not chapter_links:
logger_func(" [Toonily] ❌ Could not find any chapter links on the page.")
return []
urls = [link['href'] for link in chapter_links]
urls.reverse()
logger_func(f" [Toonily] Found {len(urls)} chapters.")
return urls
except Exception as e:
logger_func(f" [Toonily] ❌ Error getting chapter list: {e}")
return []
def fetch_chapter_data(chapter_url, logger_func, scraper_session):
"""
Scrapes a single Toonily.com chapter page for its title and image URLs.
"""
main_series_url = chapter_url.rsplit('/', 2)[0] + '/'
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': main_series_url
}
try:
response = scraper_session.get(chapter_url, timeout=30, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
title_element = soup.select_one('h1#chapter-heading')
image_container = soup.select_one('div.reading-content')
if not title_element or not image_container:
logger_func(" [Toonily] ❌ Page structure invalid. Could not find title or image container.")
return None, None, []
full_chapter_title = title_element.text.strip()
if " - Chapter" in full_chapter_title:
series_title = full_chapter_title.split(" - Chapter")[0].strip()
else:
series_title = full_chapter_title.strip()
chapter_title = full_chapter_title # The full string is best for the chapter folder name
image_elements = image_container.select('img')
image_urls = [img.get('data-src', img.get('src')).strip() for img in image_elements if img.get('data-src') or img.get('src')]
return series_title, chapter_title, image_urls
except Exception as e:
logger_func(f" [Toonily] ❌ An error occurred scraping chapter '{chapter_url}': {e}")
return None, None, []

File diff suppressed because it is too large Load Diff

View File

@@ -2869,7 +2869,7 @@ translations ["en"]={
"cookie_help_instruction_intro": "<p>To use cookies, you typically need a <b>cookies.txt</b> file from your browser.</p>", "cookie_help_instruction_intro": "<p>To use cookies, you typically need a <b>cookies.txt</b> file from your browser.</p>",
"cookie_help_how_to_get_title": "<p><b>How to get cookies.txt:</b></p>", "cookie_help_how_to_get_title": "<p><b>How to get cookies.txt:</b></p>",
"cookie_help_step1_extension_intro": "<li>Install the 'Get cookies.txt LOCALLY' extension for your Chrome-based browser:<br><a href=\"https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc\" style=\"color: #87CEEB;\">Get cookies.txt LOCALLY on Chrome Web Store</a></li>", "cookie_help_step1_extension_intro": "<li>Install the 'Get cookies.txt LOCALLY' extension for your Chrome-based browser:<br><a href=\"https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc\" style=\"color: #87CEEB;\">Get cookies.txt LOCALLY on Chrome Web Store</a></li>",
"cookie_help_step2_login": "<li>Go to the website (e.g., kemono.su or coomer.su) and log in if necessary.</li>", "cookie_help_step2_login": "<li>Go to the website (e.g., kemono.su, coomer.su or Simpcity.cr) and log in if necessary.</li>",
"cookie_help_step3_click_icon": "<li>Click the extension icon in your browser's toolbar.</li>", "cookie_help_step3_click_icon": "<li>Click the extension icon in your browser's toolbar.</li>",
"cookie_help_step4_export": "<li>Click an 'Export' button (e.g., \"Export As\", \"Export cookies.txt\" - exact wording may vary by extension version).</li>", "cookie_help_step4_export": "<li>Click an 'Export' button (e.g., \"Export As\", \"Export cookies.txt\" - exact wording may vary by extension version).</li>",
"cookie_help_step5_save_file": "<li>Save the downloaded <code>cookies.txt</code> file to your computer.</li>", "cookie_help_step5_save_file": "<li>Save the downloaded <code>cookies.txt</code> file to your computer.</li>",

View File

@@ -6,11 +6,21 @@ import json
import base64 import base64
import time import time
import zipfile import zipfile
import struct
import sys
import io
import hashlib
from contextlib import redirect_stdout
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
# --- Third-party Library Imports --- # --- Third-party Library Imports ---
import requests import requests
import cloudscraper import cloudscraper
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from ..utils.file_utils import clean_folder_name
try: try:
from Crypto.Cipher import AES from Crypto.Cipher import AES
@@ -25,250 +35,669 @@ except ImportError:
GDRIVE_AVAILABLE = False GDRIVE_AVAILABLE = False
MEGA_API_URL = "https://g.api.mega.co.nz" MEGA_API_URL = "https://g.api.mega.co.nz"
MIN_SIZE_FOR_MULTIPART_MEGA = 20 * 1024 * 1024 # 20 MB
NUM_PARTS_FOR_MEGA = 5
def _get_filename_from_headers(headers): def _get_filename_from_headers(headers):
"""
Extracts a filename from the Content-Disposition header.
"""
cd = headers.get('content-disposition') cd = headers.get('content-disposition')
if not cd: if not cd:
return None return None
# Handles both filename="file.zip" and filename*=UTF-8''file%20name.zip
fname_match = re.findall('filename="?([^"]+)"?', cd) fname_match = re.findall('filename="?([^"]+)"?', cd)
if fname_match: if fname_match:
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
# --- Helper functions for Mega decryption ---
def urlb64_to_b64(s): def urlb64_to_b64(s):
s = s.replace('-', '+').replace('_', '/')
s += '=' * (-len(s) % 4) s += '=' * (-len(s) % 4)
return s return s.replace('-', '+').replace('_', '/')
def b64_to_bytes(s): def b64_to_bytes(s):
return base64.b64decode(urlb64_to_b64(s)) return base64.b64decode(urlb64_to_b64(s))
def bytes_to_hex(b): def bytes_to_b64(b):
return b.hex() return base64.b64encode(b).decode('utf-8')
def hex_to_bytes(h): def _decrypt_mega_attribute(encrypted_attr_b64, key_bytes):
return bytes.fromhex(h)
def hrk2hk(hex_raw_key):
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):
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', '')
# --- Core Logic for Mega Downloads ---
def get_mega_file_info(file_id, file_key, session, logger_func):
try: try:
hex_raw_key = bytes_to_hex(b64_to_bytes(file_key)) attr_bytes = b64_to_bytes(encrypted_attr_b64)
hex_key = hrk2hk(hex_raw_key) padded_len = (len(attr_bytes) + 15) & ~15
key_bytes = hex_to_bytes(hex_key) padded_attr_bytes = attr_bytes.ljust(padded_len, b'\0')
iv = b'\0' * 16
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
decrypted_attr = cipher.decrypt(padded_attr_bytes)
json_str = decrypted_attr.strip(b'\0').decode('utf-8')
if json_str.startswith('MEGA'):
return json.loads(json_str[4:])
return json.loads(json_str)
except Exception:
return {}
payload = [{"a": "g", "p": file_id}] def _decrypt_mega_key(encrypted_key_b64, master_key_bytes):
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20) key_bytes = b64_to_bytes(encrypted_key_b64)
response.raise_for_status() iv = b'\0' * 16
res_json = response.json() cipher = AES.new(master_key_bytes, AES.MODE_ECB)
return cipher.decrypt(key_bytes)
if isinstance(res_json, list) and isinstance(res_json[0], int) and res_json[0] < 0: def _parse_mega_key(key_b64):
logger_func(f" [Mega] ❌ API Error: {res_json[0]}. The link may be invalid or removed.") key_bytes = b64_to_bytes(key_b64)
return None key_parts = struct.unpack('>' + 'I' * (len(key_bytes) // 4), key_bytes)
if len(key_parts) == 8:
final_key = (key_parts[0] ^ key_parts[4], key_parts[1] ^ key_parts[5], key_parts[2] ^ key_parts[6], key_parts[3] ^ key_parts[7])
iv = (key_parts[4], key_parts[5], 0, 0)
key_bytes = struct.pack('>' + 'I' * 4, *final_key)
iv_bytes = struct.pack('>' + 'I' * 4, *iv)
return key_bytes, iv_bytes, None
elif len(key_parts) == 4:
return key_bytes, None, None
raise ValueError("Invalid Mega key length")
file_size = res_json[0]['s'] def _process_file_key(file_key_bytes):
at_b64 = res_json[0]['at'] key_parts = struct.unpack('>' + 'I' * 8, file_key_bytes)
at_dec_json_str = decrypt_at(at_b64, key_bytes) final_key_parts = (key_parts[0] ^ key_parts[4], key_parts[1] ^ key_parts[5], key_parts[2] ^ key_parts[6], key_parts[3] ^ key_parts[7])
at_dec_json = json.loads(at_dec_json_str) return struct.pack('>' + 'I' * 4, *final_key_parts)
file_name = at_dec_json['n']
payload = [{"a": "g", "g": 1, "p": file_id}] def _download_and_decrypt_chunk(args):
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20) url, temp_path, start_byte, end_byte, key, nonce, part_num, progress_data, progress_callback_func, file_name, cancellation_event, pause_event = args
response.raise_for_status() try:
res_json = response.json() headers = {'Range': f'bytes={start_byte}-{end_byte}'}
dl_temp_url = res_json[0]['g'] initial_counter = start_byte // 16
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=initial_counter)
with requests.get(url, headers=headers, stream=True, timeout=(15, 300)) as r:
r.raise_for_status()
with open(temp_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if cancellation_event and cancellation_event.is_set():
return False
while pause_event and pause_event.is_set():
time.sleep(0.5)
if cancellation_event and cancellation_event.is_set():
return False
return { decrypted_chunk = cipher.decrypt(chunk)
'file_name': file_name, f.write(decrypted_chunk)
'file_size': file_size, with progress_data['lock']:
'dl_url': dl_temp_url, progress_data['downloaded'] += len(chunk)
'hex_raw_key': hex_raw_key if progress_callback_func and (time.time() - progress_data['last_update'] > 1):
} progress_callback_func(file_name, (progress_data['downloaded'], progress_data['total_size']))
except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as e: progress_data['last_update'] = time.time()
logger_func(f" [Mega] ❌ Failed to get file info: {e}") return True
return None except Exception as e:
return False
def download_and_decrypt_mega_file(info, download_path, logger_func): def download_and_decrypt_mega_file(info, download_path, logger_func, progress_callback_func=None, cancellation_event=None, pause_event=None):
file_name = info['file_name'] file_name = info['file_name']
file_size = info['file_size'] file_size = info['file_size']
dl_url = info['dl_url'] dl_url = info['dl_url']
hex_raw_key = info['hex_raw_key']
final_path = os.path.join(download_path, file_name) final_path = os.path.join(download_path, file_name)
if os.path.exists(final_path) and os.path.getsize(final_path) == file_size: 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.") logger_func(f" [Mega] File '{file_name}' already exists with the correct size. Skipping.")
return return
key = hex_to_bytes(hrk2hk(hex_raw_key)) os.makedirs(download_path, exist_ok=True)
iv_hex = hex_raw_key[32:48] + '0000000000000000' key, iv, _ = _parse_mega_key(urlb64_to_b64(info['file_key']))
iv_bytes = hex_to_bytes(iv_hex) nonce = iv[:8]
cipher = AES.new(key, AES.MODE_CTR, initial_value=iv_bytes, nonce=b'')
# Check for cancellation before starting
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Mega] Download for '{file_name}' cancelled before starting.")
return
try: if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:
with requests.get(dl_url, stream=True, timeout=(15, 300)) as r: logger_func(f" [Mega] Downloading '{file_name}' (Single Stream)...")
r.raise_for_status() try:
downloaded_bytes = 0 cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)
last_log_time = time.time() with requests.get(dl_url, stream=True, timeout=(15, 300)) as r:
r.raise_for_status()
downloaded_bytes = 0
last_update_time = time.time()
with open(final_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if cancellation_event and cancellation_event.is_set():
break
while pause_event and pause_event.is_set():
time.sleep(0.5)
if cancellation_event and cancellation_event.is_set():
break
if cancellation_event and cancellation_event.is_set():
break
decrypted_chunk = cipher.decrypt(chunk)
f.write(decrypted_chunk)
downloaded_bytes += len(chunk)
current_time = time.time()
if current_time - last_update_time > 1:
if progress_callback_func:
progress_callback_func(file_name, (downloaded_bytes, file_size))
last_update_time = time.time()
with open(final_path, 'wb') as f: if cancellation_event and cancellation_event.is_set():
for chunk in r.iter_content(chunk_size=8192): logger_func(f" [Mega] ❌ Download cancelled for '{file_name}'. Deleting partial file.")
if not chunk: continue if os.path.exists(final_path): os.remove(final_path)
decrypted_chunk = cipher.decrypt(chunk) else:
f.write(decrypted_chunk) logger_func(f" [Mega] ✅ Successfully downloaded '{file_name}'")
downloaded_bytes += len(chunk)
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 Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during download/decryption: {e}")
def download_mega_file(mega_url, download_path, logger_func=print): except Exception as e:
logger_func(f" [Mega] ❌ Download failed for '{file_name}': {e}")
if os.path.exists(final_path): os.remove(final_path)
else:
logger_func(f" [Mega] Downloading '{file_name}' ({NUM_PARTS_FOR_MEGA} Parts)...")
chunk_size = file_size // NUM_PARTS_FOR_MEGA
chunks = []
for i in range(NUM_PARTS_FOR_MEGA):
start = i * chunk_size
end = start + chunk_size - 1 if i < NUM_PARTS_FOR_MEGA - 1 else file_size - 1
chunks.append((start, end))
progress_data = {'downloaded': 0, 'total_size': file_size, 'lock': Lock(), 'last_update': time.time()}
tasks = []
for i, (start, end) in enumerate(chunks):
temp_path = f"{final_path}.part{i}"
tasks.append((dl_url, temp_path, start, end, key, nonce, i, progress_data, progress_callback_func, file_name, cancellation_event, pause_event))
all_parts_successful = True
with ThreadPoolExecutor(max_workers=NUM_PARTS_FOR_MEGA) as executor:
if cancellation_event and cancellation_event.is_set():
executor.shutdown(wait=False, cancel_futures=True)
all_parts_successful = False
else:
results = executor.map(_download_and_decrypt_chunk, tasks)
for result in results:
if not result:
all_parts_successful = False
# Check for cancellation after threads finish/are cancelled
if cancellation_event and cancellation_event.is_set():
all_parts_successful = False
logger_func(f" [Mega] ❌ Multipart download cancelled for '{file_name}'.")
if all_parts_successful:
logger_func(f" [Mega] All parts for '{file_name}' downloaded. Assembling file...")
try:
with open(final_path, 'wb') as f_out:
for i in range(NUM_PARTS_FOR_MEGA):
part_path = f"{final_path}.part{i}"
with open(part_path, 'rb') as f_in:
f_out.write(f_in.read())
os.remove(part_path)
logger_func(f" [Mega] ✅ Successfully downloaded and assembled '{file_name}'")
except Exception as e:
logger_func(f" [Mega] ❌ File assembly failed for '{file_name}': {e}")
else:
logger_func(f" [Mega] ❌ Multipart download failed or was cancelled for '{file_name}'. Cleaning up partial files.")
for i in range(NUM_PARTS_FOR_MEGA):
part_path = f"{final_path}.part{i}"
if os.path.exists(part_path):
os.remove(part_path)
def _process_mega_folder(folder_id, folder_key, session, logger_func):
try:
master_key_bytes, _, _ = _parse_mega_key(folder_key)
payload = [{"a": "f", "c": 1, "r": 1}]
params = {'n': folder_id}
response = session.post(f"{MEGA_API_URL}/cs", params=params, json=payload, timeout=30)
response.raise_for_status()
res_json = response.json()
if isinstance(res_json, int) or (isinstance(res_json, list) and res_json and isinstance(res_json[0], int)):
error_code = res_json if isinstance(res_json, int) else res_json[0]
logger_func(f" [Mega Folder] ❌ API returned error code: {error_code}. The folder may be invalid or removed.")
return None, None
if not isinstance(res_json, list) or not res_json or not isinstance(res_json[0], dict) or 'f' not in res_json[0]:
logger_func(f" [Mega Folder] ❌ Invalid folder data received: {str(res_json)[:200]}")
return None, None
nodes = res_json[0]['f']
decrypted_nodes = {}
for node in nodes:
try:
encrypted_key_b64 = node['k'].split(':')[-1]
decrypted_key_raw = _decrypt_mega_key(encrypted_key_b64, master_key_bytes)
attr_key = _process_file_key(decrypted_key_raw) if node.get('t') == 0 else decrypted_key_raw
attributes = _decrypt_mega_attribute(node['a'], attr_key)
name = re.sub(r'[<>:"/\\|?*]', '_', attributes.get('n', f"unknown_{node['h']}"))
decrypted_nodes[node['h']] = {"name": name, "parent": node.get('p'), "type": node.get('t'), "size": node.get('s'), "raw_key_b64": urlb64_to_b64(bytes_to_b64(decrypted_key_raw))}
except Exception as e:
logger_func(f" [Mega Folder] ⚠️ Could not process node {node.get('h')}: {e}")
root_name = decrypted_nodes.get(folder_id, {}).get("name", "Mega_Folder")
files_to_download = []
for handle, node_info in decrypted_nodes.items():
if node_info.get("type") == 0:
path_parts = [node_info['name']]
current_parent_id = node_info.get('parent')
while current_parent_id in decrypted_nodes:
parent_node = decrypted_nodes[current_parent_id]
path_parts.insert(0, parent_node['name'])
current_parent_id = parent_node.get('parent')
if current_parent_id == folder_id:
break
files_to_download.append({'h': handle, 's': node_info['size'], 'key': node_info['raw_key_b64'], 'relative_path': os.path.join(*path_parts)})
return root_name, files_to_download
except Exception as e:
logger_func(f" [Mega Folder] ❌ Failed to get folder info: {e}")
return None, None
def download_mega_file(mega_url, download_path, logger_func=print, progress_callback_func=None, overall_progress_callback=None, cancellation_event=None, pause_event=None):
if not PYCRYPTODOME_AVAILABLE: if not PYCRYPTODOME_AVAILABLE:
logger_func("❌ Mega download failed: 'pycryptodome' library is not installed. Please run: pip install pycryptodome") logger_func("❌ Mega download failed: 'pycryptodome' library is not installed.")
if overall_progress_callback: overall_progress_callback(1, 1)
return return
logger_func(f" [Mega] Initializing download for: {mega_url}") logger_func(f" [Mega] Initializing download for: {mega_url}")
folder_match = re.search(r'mega(?:\.co)?\.nz/folder/([a-zA-Z0-9]+)#([a-zA-Z0-9_.-]+)', mega_url)
match = re.search(r'mega(?:\.co)?\.nz/(?:file/|#!)?([a-zA-Z0-9]+)(?:#|!)([a-zA-Z0-9_.-]+)', mega_url) file_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)
session = requests.Session() session = requests.Session()
session.headers.update({'User-Agent': 'Kemono-Downloader-PyQt/1.0'}) 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. Aborting.")
return
logger_func(f" [Mega] File found: '{file_info['file_name']}' (Size: {file_info['file_size'] / 1024 / 1024:.2f} MB)") if folder_match:
folder_id, folder_key = folder_match.groups()
download_and_decrypt_mega_file(file_info, download_path, logger_func) logger_func(f" [Mega] Folder link detected. Starting crawl...")
root_folder_name, files = _process_mega_folder(folder_id, folder_key, session, logger_func)
if root_folder_name is None or files is None:
logger_func(" [Mega Folder] ❌ Crawling failed. Aborting.")
if overall_progress_callback: overall_progress_callback(1, 1)
return
if not files:
logger_func(" [Mega Folder] Folder is empty. Nothing to download.")
if overall_progress_callback: overall_progress_callback(0, 0)
return
def download_gdrive_file(url, download_path, logger_func=print): logger_func(" [Mega Folder] Prioritizing largest files first...")
files.sort(key=lambda f: f.get('s', 0), reverse=True)
total_files = len(files)
logger_func(f" [Mega Folder] ✅ Crawl complete. Found {total_files} file(s) in folder '{root_folder_name}'.")
if overall_progress_callback: overall_progress_callback(total_files, 0)
folder_download_path = os.path.join(download_path, root_folder_name)
os.makedirs(folder_download_path, exist_ok=True)
progress_lock = Lock()
processed_count = 0
MAX_WORKERS = 3
logger_func(f" [Mega Folder] Starting concurrent download with up to {MAX_WORKERS} workers...")
def _download_worker(file_data):
nonlocal processed_count
try:
if cancellation_event and cancellation_event.is_set():
return
params = {'n': folder_id}
payload = [{"a": "g", "g": 1, "n": file_data['h']}]
response = session.post(f"{MEGA_API_URL}/cs", params=params, json=payload, timeout=20)
response.raise_for_status()
res_json = response.json()
if isinstance(res_json, int) or (isinstance(res_json, list) and res_json and isinstance(res_json[0], int)):
error_code = res_json if isinstance(res_json, int) else res_json[0]
logger_func(f" [Mega Worker] ❌ API Error {error_code} for '{file_data['relative_path']}'. Skipping.")
return
dl_temp_url = res_json[0]['g']
file_info = {'file_name': os.path.basename(file_data['relative_path']), 'file_size': file_data['s'], 'dl_url': dl_temp_url, 'file_key': file_data['key']}
file_specific_path = os.path.dirname(file_data['relative_path'])
final_download_dir = os.path.join(folder_download_path, file_specific_path)
download_and_decrypt_mega_file(file_info, final_download_dir, logger_func, progress_callback_func, cancellation_event, pause_event)
except Exception as e:
# Don't log error if it was a cancellation
if not (cancellation_event and cancellation_event.is_set()):
logger_func(f" [Mega Worker] ❌ Failed to process '{file_data['relative_path']}': {e}")
finally:
with progress_lock:
processed_count += 1
if overall_progress_callback:
overall_progress_callback(total_files, processed_count)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = [executor.submit(_download_worker, file_data) for file_data in files]
for future in as_completed(futures):
if cancellation_event and cancellation_event.is_set():
# Attempt to cancel remaining futures
for f in futures:
if not f.done():
f.cancel()
break
try:
future.result()
except Exception as e:
if not (cancellation_event and cancellation_event.is_set()):
logger_func(f" [Mega Folder] A download worker failed with an error: {e}")
logger_func(" [Mega Folder] ✅ All concurrent downloads complete or cancelled.")
elif file_match:
if overall_progress_callback: overall_progress_callback(1, 0)
file_id, file_key = file_match.groups()
try:
payload = [{"a": "g", "p": file_id}]
response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20)
res_json = response.json()
if isinstance(res_json, list) and res_json and isinstance(res_json[0], int):
logger_func(f" [Mega] ❌ API Error {res_json[0]}. Link may be invalid or removed.")
if overall_progress_callback: overall_progress_callback(1, 1)
return
file_size = res_json[0]['s']
at_b64 = res_json[0]['at']
raw_file_key_bytes = b64_to_bytes(file_key)
attr_key_bytes = _process_file_key(raw_file_key_bytes)
attrs = _decrypt_mega_attribute(at_b64, attr_key_bytes)
file_name = attrs.get('n', f"unknown_file_{file_id}")
payload_dl = [{"a": "g", "g": 1, "p": file_id}]
response_dl = session.post(f"{MEGA_API_URL}/cs", json=payload_dl, timeout=20)
dl_temp_url = response_dl.json()[0]['g']
file_info_obj = {'file_name': file_name, 'file_size': file_size, 'dl_url': dl_temp_url, 'file_key': file_key}
download_and_decrypt_mega_file(file_info_obj, download_path, logger_func, progress_callback_func, cancellation_event, pause_event)
if overall_progress_callback: overall_progress_callback(1, 1)
except Exception as e:
if not (cancellation_event and cancellation_event.is_set()):
logger_func(f" [Mega] ❌ Failed to process single file: {e}")
if overall_progress_callback: overall_progress_callback(1, 1)
else:
logger_func(f" [Mega] ❌ Error: Invalid or unsupported Mega URL format.")
if '/folder/' in mega_url and '/file/' in mega_url:
logger_func(" [Mega] This looks like a link to a file inside a folder. Please use a direct, shareable link to the individual file.")
if overall_progress_callback: overall_progress_callback(1, 1)
def download_gdrive_file(url, download_path, logger_func=print, progress_callback_func=None, overall_progress_callback=None, use_post_subfolder=False, post_title=None):
if not GDRIVE_AVAILABLE: if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.") logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return return
# --- Subfolder Logic ---
final_download_path = download_path
if use_post_subfolder and post_title:
subfolder_name = clean_folder_name(post_title)
final_download_path = os.path.join(download_path, subfolder_name)
logger_func(f" [G-Drive] Using post subfolder: '{subfolder_name}'")
os.makedirs(final_download_path, exist_ok=True)
# --- End Subfolder Logic ---
original_stdout = sys.stdout
original_stderr = sys.stderr
captured_output_buffer = io.StringIO()
paths = None
try: try:
logger_func(f" [G-Drive] Starting download for: {url}") logger_func(f" [G-Drive] Starting folder download for: {url}")
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True) sys.stdout = captured_output_buffer
sys.stderr = captured_output_buffer
paths = gdown.download_folder(url, output=final_download_path, quiet=False, use_cookies=False, remaining_ok=True)
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e: except Exception as e:
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}") logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
logger_func(" [G-Drive] This can happen if the folder is private, deleted, or you have been rate-limited by Google.")
finally:
sys.stdout = original_stdout
sys.stderr = original_stderr
# --- MODIFIED DROPBOX DOWNLOADER --- captured_output = captured_output_buffer.getvalue()
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): if captured_output:
""" processed_files_count = 0
Downloads a file or a folder (as a zip) from a public Dropbox link. current_filename = None
Uses cloudscraper to handle potential browser checks and auto-extracts zip files.
""" if overall_progress_callback:
overall_progress_callback(-1, 0)
lines = captured_output.splitlines()
for i, line in enumerate(lines):
cleaned_line = line.strip('\r').strip()
if not cleaned_line:
continue
if cleaned_line.startswith("To: "):
try:
if current_filename:
logger_func(f" [G-Drive] ✅ Saved '{current_filename}'")
filepath = cleaned_line[4:]
current_filename = os.path.basename(filepath)
processed_files_count += 1
logger_func(f" [G-Drive] ({processed_files_count}/?) Downloading '{current_filename}'...")
if progress_callback_func:
progress_callback_func(current_filename, "In Progress...")
if overall_progress_callback:
overall_progress_callback(-1, processed_files_count -1)
except Exception:
logger_func(f" [gdown] {cleaned_line}")
if current_filename:
logger_func(f" [G-Drive] ✅ Saved '{current_filename}'")
if overall_progress_callback:
overall_progress_callback(-1, processed_files_count)
if paths and all(os.path.exists(p) for p in paths):
final_folder_path = os.path.dirname(paths[0]) if paths else final_download_path
logger_func(f" [G-Drive] ✅ Finished. Downloaded {len(paths)} file(s) to folder '{final_folder_path}'")
else:
logger_func(f" [G-Drive] ❌ Download failed or folder was empty. Check the log above for details from gdown.")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print, progress_callback_func=None, use_post_subfolder=False, post_title=None):
logger_func(f" [Dropbox] Attempting to download: {dropbox_link}") logger_func(f" [Dropbox] Attempting to download: {dropbox_link}")
# Modify URL to force download (works for both files and folders) final_download_path = download_path
if use_post_subfolder and post_title:
subfolder_name = clean_folder_name(post_title)
final_download_path = os.path.join(download_path, subfolder_name)
logger_func(f" [Dropbox] Using post subfolder: '{subfolder_name}'")
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']
new_query = urlencode(query_params, doseq=True) new_query = urlencode(query_params, doseq=True)
direct_download_url = urlunparse(parsed_url._replace(query=new_query)) direct_download_url = urlunparse(parsed_url._replace(query=new_query))
logger_func(f" [Dropbox] Using direct download URL: {direct_download_url}") logger_func(f" [Dropbox] Using direct download URL: {direct_download_url}")
scraper = cloudscraper.create_scraper() scraper = cloudscraper.create_scraper()
try: try:
if not os.path.exists(download_path): os.makedirs(final_download_path, exist_ok=True)
os.makedirs(download_path, exist_ok=True)
logger_func(f" [Dropbox] Created download directory: {download_path}")
with scraper.get(direct_download_url, stream=True, allow_redirects=True, timeout=(20, 600)) as r: with scraper.get(direct_download_url, stream=True, allow_redirects=True, timeout=(20, 600)) as r:
r.raise_for_status() r.raise_for_status()
filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_download" filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_download"
# If it's a folder, Dropbox will name it FolderName.zip
if not os.path.splitext(filename)[1]: if not os.path.splitext(filename)[1]:
filename += ".zip" filename += ".zip"
full_save_path = os.path.join(final_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}'...")
total_size = int(r.headers.get('content-length', 0)) total_size = int(r.headers.get('content-length', 0))
downloaded_bytes = 0 downloaded_bytes = 0
last_log_time = time.time() last_log_time = time.time()
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)
downloaded_bytes += len(chunk) downloaded_bytes += len(chunk)
current_time = time.time() current_time = time.time()
if total_size > 0 and current_time - last_log_time > 1: if current_time - last_log_time > 1:
progress = (downloaded_bytes / total_size) * 100 if progress_callback_func:
logger_func(f" -> Downloading '{filename}'... {downloaded_bytes/1024/1024:.2f}MB / {total_size/1024/1024:.2f}MB ({progress:.1f}%)") progress_callback_func(filename, (downloaded_bytes, total_size))
last_log_time = current_time last_log_time = current_time
logger_func(f" [Dropbox] ✅ Download complete: {full_save_path}") logger_func(f" [Dropbox] ✅ Download complete: {full_save_path}")
# --- NEW: Auto-extraction logic ---
if zipfile.is_zipfile(full_save_path): if zipfile.is_zipfile(full_save_path):
logger_func(f" [Dropbox] ዚ Detected zip file. Attempting to extract...") logger_func(f" [Dropbox] ዚ Detected zip file. Attempting to extract...")
extract_folder_name = os.path.splitext(filename)[0] extract_folder_name = os.path.splitext(filename)[0]
extract_path = os.path.join(download_path, extract_folder_name) extract_path = os.path.join(final_download_path, extract_folder_name)
os.makedirs(extract_path, exist_ok=True) os.makedirs(extract_path, exist_ok=True)
with zipfile.ZipFile(full_save_path, 'r') as zip_ref: with zipfile.ZipFile(full_save_path, 'r') as zip_ref:
zip_ref.extractall(extract_path) zip_ref.extractall(extract_path)
logger_func(f" [Dropbox] ✅ Successfully extracted to folder: '{extract_path}'") logger_func(f" [Dropbox] ✅ Successfully extracted to folder: '{extract_path}'")
# Optional: remove the zip file after extraction
try: try:
os.remove(full_save_path) os.remove(full_save_path)
logger_func(f" [Dropbox] 🗑️ Removed original zip file.") logger_func(f" [Dropbox] 🗑️ Removed original zip file.")
except OSError as e: except OSError as e:
logger_func(f" [Dropbox] ⚠️ Could not remove original zip file: {e}") logger_func(f" [Dropbox] ⚠️ Could not remove original zip file: {e}")
except Exception as e: except Exception as e:
logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}") logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}")
traceback.print_exc(limit=2)
def _get_gofile_api_token(session, logger_func):
"""Creates a temporary guest account to get an API token."""
try:
logger_func(" [Gofile] Creating temporary guest account for API token...")
response = session.post("https://api.gofile.io/accounts", timeout=20)
response.raise_for_status()
data = response.json()
if data.get("status") == "ok":
token = data["data"]["token"]
logger_func(" [Gofile] ✅ Successfully obtained API token.")
return token
else:
logger_func(f" [Gofile] ❌ Failed to get API token, status: {data.get('status')}")
return None
except Exception as e:
logger_func(f" [Gofile] ❌ Error creating guest account: {e}")
return None
def _get_gofile_website_token(session, logger_func):
"""Fetches the 'wt' (website token) from Gofile's global JS file."""
try:
logger_func(" [Gofile] Fetching website token (wt)...")
response = session.get("https://gofile.io/dist/js/global.js", timeout=20)
response.raise_for_status()
match = re.search(r'\.wt = "([^"]+)"', response.text)
if match:
wt = match.group(1)
logger_func(" [Gofile] ✅ Successfully fetched website token.")
return wt
logger_func(" [Gofile] ❌ Could not find website token in JS file.")
return None
except Exception as e:
logger_func(f" [Gofile] ❌ Error fetching website token: {e}")
return None
def download_gofile_folder(gofile_url, download_path, logger_func=print, progress_callback_func=None, overall_progress_callback=None):
"""Downloads all files from a Gofile folder URL."""
logger_func(f" [Gofile] Initializing download for: {gofile_url}")
match = re.search(r"gofile\.io/d/([^/?#]+)", gofile_url)
if not match:
logger_func(" [Gofile] ❌ Invalid Gofile folder URL format.")
if overall_progress_callback: overall_progress_callback(1, 1)
return
content_id = match.group(1)
scraper = cloudscraper.create_scraper()
try:
retry_strategy = Retry(
total=5,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "POST"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
scraper.mount("http://", adapter)
scraper.mount("https://", adapter)
logger_func(" [Gofile] 🔧 Configured robust retry strategy for network requests.")
except Exception as e:
logger_func(f" [Gofile] ⚠️ Could not configure retry strategy: {e}")
api_token = _get_gofile_api_token(scraper, logger_func)
if not api_token:
if overall_progress_callback: overall_progress_callback(1, 1)
return
website_token = _get_gofile_website_token(scraper, logger_func)
if not website_token:
if overall_progress_callback: overall_progress_callback(1, 1)
return
try:
scraper.cookies.set("accountToken", api_token, domain=".gofile.io")
scraper.headers.update({"Authorization": f"Bearer {api_token}"})
api_url = f"https://api.gofile.io/contents/{content_id}?wt={website_token}"
logger_func(f" [Gofile] Fetching folder contents for ID: {content_id}")
response = scraper.get(api_url, timeout=30)
response.raise_for_status()
data = response.json()
if data.get("status") != "ok":
if data.get("status") == "error-passwordRequired":
logger_func(" [Gofile] ❌ This folder is password protected. Downloading password-protected folders is not supported.")
else:
logger_func(f" [Gofile] ❌ API Error: {data.get('status')}. The folder may be expired or invalid.")
if overall_progress_callback: overall_progress_callback(1, 1)
return
folder_info = data.get("data", {})
folder_name = clean_folder_name(folder_info.get("name", content_id))
files_to_download = [item for item in folder_info.get("children", {}).values() if item.get("type") == "file"]
if not files_to_download:
logger_func(" [Gofile] No files found in this Gofile folder.")
if overall_progress_callback: overall_progress_callback(0, 0)
return
final_download_path = os.path.join(download_path, folder_name)
os.makedirs(final_download_path, exist_ok=True)
logger_func(f" [Gofile] Found {len(files_to_download)} file(s). Saving to folder: '{folder_name}'")
if overall_progress_callback: overall_progress_callback(len(files_to_download), 0)
download_session = requests.Session()
adapter = HTTPAdapter(max_retries=Retry(
total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]
))
download_session.mount("http://", adapter)
download_session.mount("https://", adapter)
for i, file_info in enumerate(files_to_download):
filename = file_info.get("name")
file_url = file_info.get("link")
file_size = file_info.get("size", 0)
filepath = os.path.join(final_download_path, filename)
if os.path.exists(filepath) and os.path.getsize(filepath) == file_size:
logger_func(f" [Gofile] ({i+1}/{len(files_to_download)}) ⏩ Skipping existing file: '{filename}'")
if overall_progress_callback: overall_progress_callback(len(files_to_download), i + 1)
continue
logger_func(f" [Gofile] ({i+1}/{len(files_to_download)}) 🔽 Downloading: '{filename}'")
with download_session.get(file_url, stream=True, timeout=(60, 600)) as r:
r.raise_for_status()
if progress_callback_func:
progress_callback_func(filename, (0, file_size))
downloaded_bytes = 0
last_log_time = time.time()
with open(filepath, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
downloaded_bytes += len(chunk)
current_time = time.time()
if current_time - last_log_time > 0.5: # Update slightly faster
if progress_callback_func:
progress_callback_func(filename, (downloaded_bytes, file_size))
last_log_time = current_time
if progress_callback_func:
progress_callback_func(filename, (file_size, file_size))
logger_func(f" [Gofile] ✅ Finished '{filename}'")
if overall_progress_callback: overall_progress_callback(len(files_to_download), i + 1)
time.sleep(1)
except Exception as e:
logger_func(f" [Gofile] ❌ An error occurred during Gofile download: {e}")
if not isinstance(e, requests.exceptions.RequestException):
traceback.print_exc()

View File

@@ -15,7 +15,7 @@ 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 = 5
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk

View File

@@ -6,7 +6,7 @@ from packaging.version import parse as parse_version
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtCore import QThread, pyqtSignal
# Constants for the updater # Constants for the updater
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi9587/Kemono-Downloader/releases/latest" GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi63771/Kemono-Downloader/releases/latest"
EXE_NAME = "Kemono.Downloader.exe" EXE_NAME = "Kemono.Downloader.exe"
class UpdateChecker(QThread): class UpdateChecker(QThread):
@@ -65,22 +65,17 @@ class UpdateDownloader(QThread):
old_path = os.path.join(app_dir, f"{EXE_NAME}.old") old_path = os.path.join(app_dir, f"{EXE_NAME}.old")
updater_script_path = os.path.join(app_dir, "updater.bat") updater_script_path = os.path.join(app_dir, "updater.bat")
# --- NEW: Path for the PID file ---
pid_file_path = os.path.join(app_dir, "updater.pid") pid_file_path = os.path.join(app_dir, "updater.pid")
# Download the new executable
with requests.get(self.download_url, stream=True, timeout=300) as r: with requests.get(self.download_url, stream=True, timeout=300) as r:
r.raise_for_status() r.raise_for_status()
with open(temp_path, 'wb') as f: with open(temp_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)
# --- NEW: Write the current Process ID to the pid file ---
with open(pid_file_path, "w") as f: with open(pid_file_path, "w") as f:
f.write(str(os.getpid())) f.write(str(os.getpid()))
# --- NEW BATCH SCRIPT ---
# This script now reads the PID from the "updater.pid" file.
script_content = f""" script_content = f"""
@echo off @echo off
SETLOCAL SETLOCAL

View File

@@ -16,7 +16,6 @@ class CookieHelpDialog(QDialog):
It can be displayed as a simple informational popup or as a modal choice It can be displayed as a simple informational popup or as a modal choice
when cookies are required but not found. when cookies are required but not found.
""" """
# Constants to define the user's choice from the dialog
CHOICE_PROCEED_WITHOUT_COOKIES = 1 CHOICE_PROCEED_WITHOUT_COOKIES = 1
CHOICE_CANCEL_DOWNLOAD = 2 CHOICE_CANCEL_DOWNLOAD = 2
CHOICE_OK_INFO_ONLY = 3 CHOICE_OK_INFO_ONLY = 3
@@ -64,7 +63,6 @@ class CookieHelpDialog(QDialog):
button_layout.addStretch(1) button_layout.addStretch(1)
if self.offer_download_without_option: if self.offer_download_without_option:
# Add buttons for making a choice
self.download_without_button = QPushButton() self.download_without_button = QPushButton()
self.download_without_button.clicked.connect(self._proceed_without_cookies) self.download_without_button.clicked.connect(self._proceed_without_cookies)
button_layout.addWidget(self.download_without_button) button_layout.addWidget(self.download_without_button)
@@ -73,7 +71,6 @@ class CookieHelpDialog(QDialog):
self.cancel_button.clicked.connect(self._cancel_download) self.cancel_button.clicked.connect(self._cancel_download)
button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.cancel_button)
else: else:
# Add a simple OK button for informational display
self.ok_button = QPushButton() self.ok_button = QPushButton()
self.ok_button.clicked.connect(self._ok_info_only) self.ok_button.clicked.connect(self._ok_info_only)
button_layout.addWidget(self.ok_button) button_layout.addWidget(self.ok_button)

View File

@@ -0,0 +1,89 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QDialogButtonBox, QTextEdit
)
from PyQt5.QtCore import Qt
class CustomFilenameDialog(QDialog):
"""A dialog for creating a custom filename format string."""
# --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY ---
DISPLAY_KEY_MAP = {
"PostID": "id",
"CreatorName": "creator_name",
"service": "service",
"title": "title",
"added": "added",
"published": "published",
"edited": "edited",
"name": "name"
}
def __init__(self, current_format, current_date_format, parent=None):
super().__init__(parent)
self.setWindowTitle("Custom Filename Format")
self.setMinimumWidth(500)
self.current_format = current_format
self.current_date_format = current_date_format
# --- Main Layout ---
layout = QVBoxLayout(self)
# --- Description ---
description_label = QLabel(
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format."
)
description_label.setWordWrap(True)
layout.addWidget(description_label)
# --- Format Input ---
format_label = QLabel("Filename Format:")
layout.addWidget(format_label)
self.format_input = QLineEdit(self)
self.format_input.setText(self.current_format)
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
layout.addWidget(self.format_input)
# --- Date Format Input ---
date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):")
layout.addWidget(date_format_label)
self.date_format_input = QLineEdit(self)
self.date_format_input.setText(self.current_date_format)
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY")
layout.addWidget(self.date_format_input)
# --- Available Keys Display ---
keys_label = QLabel("Click to add a placeholder:")
layout.addWidget(keys_label)
keys_layout = QHBoxLayout()
keys_layout.setSpacing(5)
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
key_button = QPushButton(f"{{{display_key}}}")
# Use a lambda to pass the correct internal key when the button is clicked
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
keys_layout.addWidget(key_button)
keys_layout.addStretch()
layout.addLayout(keys_layout)
# --- OK/Cancel Buttons ---
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def add_key_to_input(self, key_to_insert):
"""Adds the corresponding internal key placeholder to the input field."""
self.format_input.insert(f" {{{key_to_insert}}} ")
self.format_input.setFocus()
def get_format_string(self):
"""Returns the final format string from the input field."""
return self.format_input.text().strip()
def get_date_format_string(self):
"""Returns the date format string from its input field."""
return self.date_format_input.text().strip()

View File

@@ -2,49 +2,34 @@
from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QFileDialog QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QFileDialog, QCheckBox
) )
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog from .ExportOptionsDialog import ExportOptionsDialog
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
from ...config.constants import AUTO_RETRY_ON_FINISH_KEY
class ErrorFilesDialog(QDialog): class ErrorFilesDialog(QDialog):
""" """
Dialog to display files that were skipped due to errors and Dialog to display files that were skipped due to errors and
allows the user to retry downloading them or export the list of URLs. allows the user to retry downloading them or export the list of URLs.
""" """
# Signal emitted with a list of file info dictionaries to retry
retry_selected_signal = pyqtSignal(list) retry_selected_signal = pyqtSignal(list)
def __init__(self, error_files_info_list, parent_app, parent=None): def __init__(self, error_files_info_list, parent_app, parent=None):
"""
Initializes the dialog.
Args:
error_files_info_list (list): A list of dictionaries, each containing
info about a failed file.
parent_app (DownloaderApp): A reference to the main application window
for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent) super().__init__(parent)
self.parent_app = parent_app self.parent_app = parent_app
self.setModal(True) self.setModal(True)
self.error_files = error_files_info_list self.error_files = error_files_info_list
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0) scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_width, base_height = 600, 450
base_width, base_height = 550, 400
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
@@ -53,21 +38,19 @@ class ErrorFilesDialog(QDialog):
self._apply_theme() self._apply_theme()
def _init_ui(self): def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
self.info_label = QLabel() self.info_label = QLabel()
self.info_label.setWordWrap(True) self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label) main_layout.addWidget(self.info_label)
if self.error_files: self.files_list_widget = QListWidget()
self.files_list_widget = QListWidget() self.files_list_widget.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.files_list_widget.setSelectionMode(QAbstractItemView.NoSelection) main_layout.addWidget(self.files_list_widget)
self._populate_list() self._populate_list()
main_layout.addWidget(self.files_list_widget)
# --- Control Buttons --- # --- Control Buttons ---
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items) self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button) buttons_layout.addWidget(self.select_all_button)
@@ -76,104 +59,170 @@ class ErrorFilesDialog(QDialog):
self.retry_button.clicked.connect(self._handle_retry_selected) self.retry_button.clicked.connect(self._handle_retry_selected)
buttons_layout.addWidget(self.retry_button) buttons_layout.addWidget(self.retry_button)
self.load_button = QPushButton()
self.load_button.clicked.connect(self._handle_load_errors_from_txt)
buttons_layout.addWidget(self.load_button)
self.export_button = QPushButton() self.export_button = QPushButton()
self.export_button.clicked.connect(self._handle_export_errors_to_txt) self.export_button.clicked.connect(self._handle_export_errors_to_txt)
buttons_layout.addWidget(self.export_button) buttons_layout.addWidget(self.export_button)
# The stretch will push everything added after this point to the right
buttons_layout.addStretch(1) buttons_layout.addStretch(1)
# --- MOVED: Auto Retry Checkbox ---
self.auto_retry_checkbox = QCheckBox()
auto_retry_enabled = self.parent_app.settings.value(AUTO_RETRY_ON_FINISH_KEY, False, type=bool)
self.auto_retry_checkbox.setChecked(auto_retry_enabled)
self.auto_retry_checkbox.toggled.connect(self._save_auto_retry_setting)
buttons_layout.addWidget(self.auto_retry_checkbox)
# --- END ---
self.ok_button = QPushButton() self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept) self.ok_button.clicked.connect(self.accept)
self.ok_button.setDefault(True) self.ok_button.setDefault(True)
buttons_layout.addWidget(self.ok_button) buttons_layout.addWidget(self.ok_button)
main_layout.addLayout(buttons_layout) main_layout.addLayout(buttons_layout)
# Enable/disable buttons based on whether there are errors
has_errors = bool(self.error_files) has_errors = bool(self.error_files)
self.select_all_button.setEnabled(has_errors) self.select_all_button.setEnabled(has_errors)
self.retry_button.setEnabled(has_errors) self.retry_button.setEnabled(has_errors)
self.export_button.setEnabled(has_errors) self.export_button.setEnabled(has_errors)
def _populate_list(self): def _populate_list(self):
"""Populates the list widget with details of the failed files.""" self.files_list_widget.clear()
for error_info in self.error_files: for error_info in self.error_files:
filename = error_info.get('forced_filename_override', self._add_item_to_list(error_info)
error_info.get('file_info', {}).get('name', 'Unknown Filename'))
post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A')
creator_name = "Unknown Creator" def _handle_load_errors_from_txt(self):
service = error_info.get('service') """Opens a file dialog to load URLs from a .txt file."""
user_id = error_info.get('user_id') import re
filepath, _ = QFileDialog.getOpenFileName(
self,
self._tr("error_files_load_dialog_title", "Load Error File URLs"),
"",
"Text Files (*.txt);;All Files (*)"
)
if not filepath:
return
try:
detailed_pattern = re.compile(r"^(https?://[^\s]+)\s*\[Post: '(.*?)' \(ID: (.*?)\), File: '(.*?)'\]$")
simple_pattern = re.compile(r'^(https?://[^\s]+)')
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line: continue
url, post_title, post_id, filename = None, 'Loaded from .txt', 'N/A', None
detailed_match = detailed_pattern.match(line)
if detailed_match:
url, post_title, post_id, filename = detailed_match.groups()
else:
simple_match = simple_pattern.match(line)
if simple_match:
url = simple_match.group(1)
filename = url.split('/')[-1]
if url:
simple_error_info = {
'is_loaded_from_txt': True, 'file_info': {'url': url, 'name': filename},
'post_title': post_title, 'original_post_id_for_log': post_id,
'target_folder_path': self.parent_app.dir_input.text().strip(),
'forced_filename_override': filename, 'file_index_in_post': 0,
'num_files_in_this_post': 1, 'service': None, 'user_id': None, 'api_url_input': ''
}
self.error_files.append(simple_error_info)
self._add_item_to_list(simple_error_info)
# Check if we have the necessary info and access to the cache self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files)))
if service and user_id and hasattr(self.parent_app, 'creator_name_cache'):
creator_key = (service.lower(), str(user_id)) has_errors = bool(self.error_files)
# Look up the name, fall back to the user_id if not found self.select_all_button.setEnabled(has_errors)
creator_name = self.parent_app.creator_name_cache.get(creator_key, user_id) self.retry_button.setEnabled(has_errors)
self.export_button.setEnabled(has_errors)
item_text = f"File: {filename}\nCreator: {creator_name} - Post: '{post_title}' (ID: {post_id})"
list_item = QListWidgetItem(item_text) except Exception as e:
list_item.setData(Qt.UserRole, error_info) QMessageBox.critical(self, self._tr("error_files_load_error_title", "Load Error"),
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable) self._tr("error_files_load_error_message", "Could not load or parse the file: {error}").format(error=str(e)))
list_item.setCheckState(Qt.Unchecked)
self.files_list_widget.addItem(list_item)
def _tr(self, key, default_text=""): def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current 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 _retranslate_ui(self): def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("error_files_dialog_title", "Files Skipped Due to Errors")) self.setWindowTitle(self._tr("error_files_dialog_title", "Files Skipped Due to Errors"))
if not self.error_files: if not self.error_files:
self.info_label.setText(self._tr("error_files_no_errors_label", "No files were recorded as skipped...")) self.info_label.setText(self._tr("error_files_no_errors_label", "No files were recorded as skipped..."))
else: else:
self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files))) self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files)))
self.select_all_button.setText(self._tr("error_files_select_all_button", "Select All")) self.auto_retry_checkbox.setText(self._tr("error_files_auto_retry_checkbox", "Auto Retry at End"))
self.select_all_button.setText(self._tr("error_files_select_all_button", "Select/Deselect All"))
self.retry_button.setText(self._tr("error_files_retry_selected_button", "Retry Selected")) self.retry_button.setText(self._tr("error_files_retry_selected_button", "Retry Selected"))
self.load_button.setText(self._tr("error_files_load_urls_button", "Load URLs from .txt"))
self.export_button.setText(self._tr("error_files_export_urls_button", "Export URLs to .txt")) self.export_button.setText(self._tr("error_files_export_urls_button", "Export URLs to .txt"))
self.ok_button.setText(self._tr("ok_button", "OK")) self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1) scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale)) self.setStyleSheet(get_dark_theme(scale))
else: else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("") self.setStyleSheet("")
def _save_auto_retry_setting(self, checked):
"""Saves the state of the auto-retry checkbox to QSettings."""
self.parent_app.settings.setValue(AUTO_RETRY_ON_FINISH_KEY, checked)
def _add_item_to_list(self, error_info):
"""Creates and adds a single QListWidgetItem based on error_info content."""
if error_info.get('is_loaded_from_txt'):
filename = error_info.get('file_info', {}).get('name', 'Unknown Filename')
post_title = error_info.get('post_title', 'N/A')
post_id = error_info.get('original_post_id_for_log', 'N/A')
item_text = f"File: {filename}\nPost: '{post_title}' (ID: {post_id}) [Loaded from .txt]"
else:
filename = error_info.get('forced_filename_override', error_info.get('file_info', {}).get('name', 'Unknown Filename'))
post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A')
creator_name = "Unknown Creator"
service, user_id = error_info.get('service'), error_info.get('user_id')
if service and user_id and hasattr(self.parent_app, 'creator_name_cache'):
creator_name = self.parent_app.creator_name_cache.get((service.lower(), str(user_id)), user_id)
item_text = f"File: {filename}\nCreator: {creator_name} - Post: '{post_title}' (ID: {post_id})"
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, error_info)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Unchecked) # Start as unchecked
self.files_list_widget.addItem(list_item)
def _select_all_items(self): def _select_all_items(self):
"""Checks all items in the list.""" """Toggles checking all items in the list."""
if hasattr(self, 'files_list_widget'): # Determine if we should check or uncheck all based on the first item's state
for i in range(self.files_list_widget.count()): is_currently_checked = self.files_list_widget.item(0).checkState() == Qt.Checked if self.files_list_widget.count() > 0 else False
self.files_list_widget.item(i).setCheckState(Qt.Checked) new_state = Qt.Unchecked if is_currently_checked else Qt.Checked
for i in range(self.files_list_widget.count()):
self.files_list_widget.item(i).setCheckState(new_state)
def _handle_retry_selected(self): def _handle_retry_selected(self):
"""Gathers selected files and emits the retry signal."""
if not hasattr(self, 'files_list_widget'):
return
selected_files_for_retry = [ selected_files_for_retry = [
self.files_list_widget.item(i).data(Qt.UserRole) self.files_list_widget.item(i).data(Qt.UserRole)
for i in range(self.files_list_widget.count()) for i in range(self.files_list_widget.count())
if self.files_list_widget.item(i).checkState() == Qt.Checked if self.files_list_widget.item(i).checkState() == Qt.Checked
] ]
if selected_files_for_retry: if selected_files_for_retry:
self.retry_selected_signal.emit(selected_files_for_retry) self.retry_selected_signal.emit(selected_files_for_retry)
self.accept() self.accept()
else: else:
QMessageBox.information( QMessageBox.information(self, self._tr("fav_artists_no_selection_title", "No Selection"),
self, self._tr("error_files_no_selection_retry_message", "Please check the box next to at least one file to retry."))
self._tr("fav_artists_no_selection_title", "No Selection"),
self._tr("error_files_no_selection_retry_message", "Please select at least one file to retry.")
)
def _handle_export_errors_to_txt(self): def _handle_export_errors_to_txt(self):
"""Exports the URLs of failed files to a text file.""" """Exports the URLs of failed files to a text file."""
@@ -198,10 +247,13 @@ class ErrorFilesDialog(QDialog):
if url: if url:
if export_option == ExportOptionsDialog.EXPORT_MODE_WITH_DETAILS: if export_option == ExportOptionsDialog.EXPORT_MODE_WITH_DETAILS:
original_filename = file_info.get('name', 'Unknown Filename')
post_title = error_item.get('post_title', 'Unknown Post') post_title = error_item.get('post_title', 'Unknown Post')
post_id = error_item.get('original_post_id_for_log', 'N/A') post_id = error_item.get('original_post_id_for_log', 'N/A')
details_string = f" [Post: '{post_title}' (ID: {post_id}), File: '{original_filename}']"
# Prioritize the final renamed filename, but fall back to the original from the API
filename_to_display = error_item.get('forced_filename_override') or file_info.get('name', 'Unknown Filename')
details_string = f" [Post: '{post_title}' (ID: {post_id}), File: '{filename_to_display}']"
lines_to_export.append(f"{url}{details_string}") lines_to_export.append(f"{url}{details_string}")
else: else:
lines_to_export.append(url) lines_to_export.append(url)

View File

@@ -0,0 +1,226 @@
import os
import json
import re
from collections import defaultdict
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton,
QButtonGroup, QCheckBox, QSplitter, QGroupBox, QDialog, QStackedWidget,
QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame,
QMainWindow, QAction, QGridLayout,
)
from PyQt5.QtCore import Qt
class ExportLinksDialog(QDialog):
"""
A dialog for exporting extracted links with various format options, including custom templates.
"""
def __init__(self, links_data, parent=None):
super().__init__(parent)
self.links_data = links_data
self.setWindowTitle("Export Extracted Links")
self.setMinimumWidth(550)
self._setup_ui()
self._update_options_visibility()
def _setup_ui(self):
"""Initializes the UI components of the dialog."""
main_layout = QVBoxLayout(self)
# Format Selection (Top Level)
format_group = QGroupBox("Export Format")
format_layout = QHBoxLayout()
self.radio_txt = QRadioButton("Plain Text (.txt)")
self.radio_json = QRadioButton("JSON (.json)")
self.radio_txt.setChecked(True)
format_layout.addWidget(self.radio_txt)
format_layout.addWidget(self.radio_json)
format_group.setLayout(format_layout)
main_layout.addWidget(format_group)
# TXT Options Group
self.txt_options_group = QGroupBox("TXT Options")
txt_options_layout = QVBoxLayout()
self.txt_mode_group = QButtonGroup(self)
self.radio_simple = QRadioButton("Simple (URL only, one per line)")
self.radio_detailed = QRadioButton("Detailed (with checkboxes)")
self.radio_custom = QRadioButton("Custom Format Template")
self.txt_mode_group.addButton(self.radio_simple)
self.txt_mode_group.addButton(self.radio_detailed)
self.txt_mode_group.addButton(self.radio_custom)
txt_options_layout.addWidget(self.radio_simple)
txt_options_layout.addWidget(self.radio_detailed)
self.detailed_options_widget = QWidget()
detailed_layout = QVBoxLayout(self.detailed_options_widget)
detailed_layout.setContentsMargins(20, 5, 0, 5)
self.check_include_titles = QCheckBox("Include post titles as separators")
self.check_include_link_text = QCheckBox("Include link text/description")
self.check_include_platform = QCheckBox("Include platform (e.g., Mega, GDrive)")
detailed_layout.addWidget(self.check_include_titles)
detailed_layout.addWidget(self.check_include_link_text)
detailed_layout.addWidget(self.check_include_platform)
txt_options_layout.addWidget(self.detailed_options_widget)
txt_options_layout.addWidget(self.radio_custom)
self.custom_format_widget = QWidget()
custom_layout = QVBoxLayout(self.custom_format_widget)
custom_layout.setContentsMargins(20, 5, 0, 5)
placeholders_label = QLabel("Available placeholders: <b>{url} {post_title} {link_text} {platform} {key}</b>")
self.custom_format_input = QTextEdit()
self.custom_format_input.setAcceptRichText(False)
self.custom_format_input.setPlaceholderText("Enter your format, e.g., ({url}) or Title: {post_title}\\nLink: {url}")
self.custom_format_input.setText("{url}")
self.custom_format_input.setFixedHeight(80)
custom_layout.addWidget(placeholders_label)
custom_layout.addWidget(self.custom_format_input)
txt_options_layout.addWidget(self.custom_format_widget)
separator = QLabel("-" * 70)
txt_options_layout.addWidget(separator)
self.check_separate_files = QCheckBox("Save each platform to a separate file (e.g., export_mega.txt)")
txt_options_layout.addWidget(self.check_separate_files)
self.txt_options_group.setLayout(txt_options_layout)
main_layout.addWidget(self.txt_options_group)
# File Path Selection
path_layout = QHBoxLayout()
self.path_input = QLineEdit()
self.browse_button = QPushButton("Browse...")
path_layout.addWidget(self.path_input)
path_layout.addWidget(self.browse_button)
main_layout.addLayout(path_layout)
# Action Buttons
button_layout = QHBoxLayout()
button_layout.addStretch(1)
self.export_button = QPushButton("Export")
self.cancel_button = QPushButton("Cancel")
button_layout.addWidget(self.export_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
# Connections
self.radio_txt.toggled.connect(self._update_options_visibility)
self.radio_simple.toggled.connect(self._update_options_visibility)
self.radio_detailed.toggled.connect(self._update_options_visibility)
self.radio_custom.toggled.connect(self._update_options_visibility)
self.browse_button.clicked.connect(self._browse)
self.export_button.clicked.connect(self._accept_and_export)
self.cancel_button.clicked.connect(self.reject)
self.radio_simple.setChecked(True)
def _update_options_visibility(self):
is_txt = self.radio_txt.isChecked()
self.txt_options_group.setVisible(is_txt)
self.detailed_options_widget.setVisible(is_txt and self.radio_detailed.isChecked())
self.custom_format_widget.setVisible(is_txt and self.radio_custom.isChecked())
def _browse(self, base_filepath):
is_separate_files_mode = self.radio_txt.isChecked() and self.check_separate_files.isChecked()
if is_separate_files_mode:
dir_path = QFileDialog.getExistingDirectory(self, "Select Folder to Save Files")
if dir_path:
self.path_input.setText(os.path.join(dir_path, "exported_links"))
else:
default_filename = "exported_links"
file_filter = "Text Files (*.txt)"
if self.radio_json.isChecked():
default_filename += ".json"
file_filter = "JSON Files (*.json)"
else:
default_filename += ".txt"
filepath, _ = QFileDialog.getSaveFileName(self, "Save Links", default_filename, file_filter)
if filepath:
self.path_input.setText(filepath)
def _accept_and_export(self):
filepath = self.path_input.text().strip()
if not filepath:
QMessageBox.warning(self, "Input Error", "Please select a file path or folder.")
return
try:
if self.radio_txt.isChecked():
self._write_txt_file(filepath)
else:
self._write_json_file(filepath)
QMessageBox.information(self, "Export Successful", "Links successfully exported!")
self.accept()
except OSError as e:
QMessageBox.critical(self, "Export Error", f"Could not write to file:\n{e}")
def _write_txt_file(self, base_filepath):
if self.check_separate_files.isChecked():
links_by_platform = defaultdict(list)
for _, _, link_url, platform, _ in self.links_data:
sanitized_platform = re.sub(r'[<>:"/\\|?*]', '_', platform.lower().replace(' ', '_'))
links_by_platform[sanitized_platform].append(link_url)
base, ext = os.path.splitext(base_filepath)
if not ext: ext = ".txt"
for platform_key, links in links_by_platform.items():
platform_filepath = f"{base}_{platform_key}{ext}"
with open(platform_filepath, 'w', encoding='utf-8') as f:
for url in links:
f.write(url + "\n")
return
with open(base_filepath, 'w', encoding='utf-8') as f:
if self.radio_simple.isChecked():
for _, _, link_url, _, _ in self.links_data:
f.write(link_url + "\n")
elif self.radio_detailed.isChecked():
include_titles = self.check_include_titles.isChecked()
include_text = self.check_include_link_text.isChecked()
include_platform = self.check_include_platform.isChecked()
current_title = None
for post_title, link_text, link_url, platform, _ in self.links_data:
if include_titles and post_title != current_title:
if current_title is not None: f.write("\n" + "="*60 + "\n\n")
f.write(f"# Post: {post_title}\n")
current_title = post_title
line_parts = [link_url]
if include_platform: line_parts.append(f"Platform: {platform}")
if include_text and link_text: line_parts.append(f"Description: {link_text}")
f.write(" | ".join(line_parts) + "\n")
elif self.radio_custom.isChecked():
template = self.custom_format_input.toPlainText().replace("\\n", "\n")
for post_title, link_text, link_url, platform, decryption_key in self.links_data:
formatted_line = template.format(
url=link_url,
post_title=post_title,
link_text=link_text,
platform=platform,
key=decryption_key or ""
)
f.write(formatted_line)
if not template.endswith('\n'):
f.write('\n')
def _write_json_file(self, filepath):
output_data = []
for post_title, link_text, link_url, platform, decryption_key in self.links_data:
output_data.append({
"post_title": post_title,
"url": link_url,
"link_text": link_text,
"platform": platform,
"key": decryption_key or None
})
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(output_data, f, indent=2)

View File

@@ -7,7 +7,7 @@ import sys
from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit
) )
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
@@ -18,6 +18,7 @@ from ..main_window import get_app_icon_object
from ...config.constants import ( from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY, RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
DATE_PREFIX_FORMAT_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_KEY, COOKIE_TEXT_KEY, USE_COOKIE_KEY,
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
) )
@@ -175,19 +176,25 @@ class FutureSettingsDialog(QDialog):
download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1) download_window_layout.addWidget(self.save_path_button, 1, 1)
self.date_prefix_format_label = QLabel()
self.date_prefix_format_input = QLineEdit()
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
download_window_layout.addWidget(self.date_prefix_format_label, 2, 0)
download_window_layout.addWidget(self.date_prefix_format_input, 2, 1)
self.post_download_action_label = QLabel() self.post_download_action_label = QLabel()
self.post_download_action_combo = QComboBox() self.post_download_action_combo = QComboBox()
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed) self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
download_window_layout.addWidget(self.post_download_action_label, 2, 0) download_window_layout.addWidget(self.post_download_action_label, 3, 0)
download_window_layout.addWidget(self.post_download_action_combo, 2, 1) download_window_layout.addWidget(self.post_download_action_combo, 3, 1)
self.save_creator_json_checkbox = QCheckBox() self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed) self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2) download_window_layout.addWidget(self.save_creator_json_checkbox, 4, 0, 1, 2)
self.fetch_first_checkbox = QCheckBox() self.fetch_first_checkbox = QCheckBox()
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed) self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
download_window_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2) download_window_layout.addWidget(self.fetch_first_checkbox, 5, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box) main_layout.addWidget(self.download_window_group_box)
@@ -215,8 +222,26 @@ class FutureSettingsDialog(QDialog):
self.theme_label.setText(self._tr("theme_label", "Theme:")) self.theme_label.setText(self._tr("theme_label", "Theme:"))
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:")) self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
self.language_label.setText(self._tr("language_label", "Language:")) self.language_label.setText(self._tr("language_label", "Language:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
# Update placeholder to include {post}
self.date_prefix_format_input.setPlaceholderText(self._tr("date_prefix_format_placeholder", "e.g., YYYY-MM-DD {post} {postid}"))
# Add the tooltip to explain usage
self.date_prefix_format_input.setToolTip(self._tr(
"date_prefix_format_tooltip",
"Create a custom folder name using placeholders:\n"
"• YYYY, MM, DD: for the date\n"
"{post}: for the post title\n"
"{postid}: for the post's unique ID\n\n"
"Example: {post} [{postid}] [YYYY-MM-DD]"
))
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:")) self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file")) self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)")) self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
@@ -235,6 +260,7 @@ class FutureSettingsDialog(QDialog):
self._populate_display_combo_boxes() self._populate_display_combo_boxes()
self._populate_language_combo_box() self._populate_language_combo_box()
self._populate_post_download_action_combo() self._populate_post_download_action_combo()
self._load_date_prefix_format()
self._load_checkbox_states() self._load_checkbox_states()
def _check_for_updates(self): def _check_for_updates(self):
@@ -412,6 +438,21 @@ class FutureSettingsDialog(QDialog):
self.parent_app.settings.setValue(POST_DOWNLOAD_ACTION_KEY, selected_action) self.parent_app.settings.setValue(POST_DOWNLOAD_ACTION_KEY, selected_action)
self.parent_app.settings.sync() self.parent_app.settings.sync()
def _load_date_prefix_format(self):
"""Loads the saved date prefix format and sets it in the input field."""
self.date_prefix_format_input.blockSignals(True)
current_format = self.parent_app.settings.value(DATE_PREFIX_FORMAT_KEY, "YYYY-MM-DD {post}", type=str)
self.date_prefix_format_input.setText(current_format)
self.date_prefix_format_input.blockSignals(False)
def _date_prefix_format_changed(self, text):
"""Saves the date prefix format whenever it's changed."""
self.parent_app.settings.setValue(DATE_PREFIX_FORMAT_KEY, text)
self.parent_app.settings.sync()
# Also update the live value in the parent app
if hasattr(self.parent_app, 'date_prefix_format'):
self.parent_app.date_prefix_format = text
def _save_settings(self): def _save_settings(self):
path_saved = False path_saved = False
cookie_saved = False cookie_saved = False
@@ -449,4 +490,4 @@ class FutureSettingsDialog(QDialog):
if path_saved or cookie_saved or token_saved: if path_saved or cookie_saved or token_saved:
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.") QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
else: else:
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.") QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")

View File

@@ -172,7 +172,7 @@ class HelpGuideDialog(QDialog):
icon_size = QSize(icon_dim, icon_dim) icon_size = QSize(icon_dim, icon_dim)
for button, tooltip_key, url in [ for button, tooltip_key, url in [
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi9587"), (self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi63771/Kemono-Downloader"),
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"), (self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN") (self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
]: ]:

View File

@@ -66,12 +66,8 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
for i, post in enumerate(posts_data): for i, post in enumerate(posts_data):
if i > 0: if i > 0:
if 'content' in post: # This ensures every post after the first gets its own page.
pdf.add_page() pdf.add_page()
elif 'comments' in post:
pdf.ln(10)
pdf.cell(0, 0, '', border='T')
pdf.ln(10)
pdf.set_font(default_font_family, 'B', 16) pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L') pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')

View File

@@ -1,71 +1,144 @@
# src/ui/dialogs/SupportDialog.py # src/app/dialogs/SupportDialog.py
# --- Standard Library Imports ---
import sys import sys
import os import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout QDialog, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
QPushButton, QSizePolicy
) )
from PyQt5.QtCore import Qt, QSize from PyQt5.QtCore import Qt, QSize, QUrl
from PyQt5.QtGui import QFont, QPixmap from PyQt5.QtGui import QPixmap, QDesktopServices
# --- Local Application Imports ---
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
# --- Helper function for robust asset loading ---
def get_asset_path(filename):
"""
Gets the absolute path to a file in the assets folder,
handling both development and frozen (PyInstaller) environments.
"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running in a normal Python environment from src/ui/dialogs/
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base_path, 'assets', filename)
class SupportDialog(QDialog): class SupportDialog(QDialog):
""" """
A dialog to show support and donation options. A polished dialog showcasing support and community options in a
clean, modern card-based layout.
""" """
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.parent_app = parent self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(450) self.setWindowTitle("❤️ Support & Community")
self.setMinimumWidth(560)
self._init_ui() self._init_ui()
self._apply_theme() self._apply_theme()
def _init_ui(self): def _create_card_button(
"""Initializes all UI components and layouts for the dialog.""" self, icon_path, title, subtitle, url,
# Main layout hover_color="#2E2E2E", min_height=110, icon_size=44
main_layout = QVBoxLayout(self) ):
main_layout.setSpacing(15) """Reusable clickable card widget with icon, title, and subtitle."""
button = QPushButton()
button.setCursor(Qt.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
button.setMinimumHeight(min_height)
# Title Label # Consistent style
title_label = QLabel("Thank You for Your Support!") button.setStyleSheet(f"""
font = title_label.font() QPushButton {{
font.setPointSize(14) background-color: #3A3A3A;
border: 1px solid #555;
border-radius: 10px;
text-align: center;
padding: 12px;
}}
QPushButton:hover {{
background-color: {hover_color};
border: 1px solid #777;
}}
""")
layout = QVBoxLayout(button)
layout.setSpacing(6)
# Icon
icon_label = QLabel()
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
scale = getattr(self.parent_app, 'scale_factor', 1.0)
scaled_size = int(icon_size * scale)
icon_label.setPixmap(
pixmap.scaled(QSize(scaled_size, scaled_size), Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(icon_label)
# Title
title_label = QLabel(title)
font = self.font()
font.setPointSize(11)
font.setBold(True) font.setBold(True)
title_label.setFont(font) title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label) title_label.setStyleSheet("background-color: transparent; border: none;")
layout.addWidget(title_label)
# Informational Text # Subtitle
info_label = QLabel( if subtitle:
"If you find this application useful, please consider supporting its development. " subtitle_label = QLabel(subtitle)
"Your contribution helps cover costs and encourages future updates and features." subtitle_label.setStyleSheet("color: #A8A8A8; background-color: transparent; border: none;")
subtitle_label.setAlignment(Qt.AlignCenter)
layout.addWidget(subtitle_label)
button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(url)))
return button
def _create_section_title(self, text):
"""Stylized section heading."""
label = QLabel(text)
font = label.font()
font.setPointSize(13)
font.setBold(True)
label.setFont(font)
label.setAlignment(Qt.AlignCenter)
label.setStyleSheet("margin-top: 10px; margin-bottom: 5px;")
return label
def _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setSpacing(18)
main_layout.setContentsMargins(20, 20, 20, 20)
# Header
header_label = QLabel("Support the Project")
font = header_label.font()
font.setPointSize(17)
font.setBold(True)
header_label.setFont(font)
header_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(header_label)
subtext = QLabel(
"If you enjoy this application, consider supporting its development. "
"Your help keeps the project alive and growing!"
) )
info_label.setWordWrap(True) subtext.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter) subtext.setAlignment(Qt.AlignCenter)
main_layout.addWidget(info_label) main_layout.addWidget(subtext)
# Financial Support
main_layout.addWidget(self._create_section_title("Contribute Financially"))
donation_layout = QHBoxLayout()
donation_layout.setSpacing(15)
donation_layout.addWidget(self._create_card_button(
get_asset_path("ko-fi.png"), "Ko-fi", "One-time ",
"https://ko-fi.com/yuvi427183", "#2B2F36"
))
donation_layout.addWidget(self._create_card_button(
get_asset_path("patreon.png"), "Patreon", "Soon ",
"https://www.patreon.com/Yuvi102", "#3A2E2B"
))
donation_layout.addWidget(self._create_card_button(
get_asset_path("buymeacoffee.png"), "Buy Me a Coffee", "One-time",
"https://buymeacoffee.com/yuvi9587", "#403520"
))
main_layout.addLayout(donation_layout)
# Separator # Separator
line = QFrame() line = QFrame()
@@ -73,83 +146,62 @@ class SupportDialog(QDialog):
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(line) main_layout.addWidget(line)
# --- Donation Options Layout (using a grid for icons and text) --- # Community Section
options_layout = QGridLayout() main_layout.addWidget(self._create_section_title("Get Help & Connect"))
options_layout.setSpacing(18) community_layout = QHBoxLayout()
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally community_layout.setSpacing(15)
options_layout.setColumnStretch(3, 1)
link_font = self.font() community_layout.addWidget(self._create_card_button(
link_font.setPointSize(12) get_asset_path("github.png"), "GitHub", "Report issues",
link_font.setBold(True) "https://github.com/Yuvi63771/Kemono-Downloader", "#2E2E2E",
min_height=100, icon_size=36
scale = getattr(self.parent_app, 'scale_factor', 1.0) ))
icon_size = int(32 * scale) community_layout.addWidget(self._create_card_button(
get_asset_path("discord.png"), "Discord", "Join the server",
# --- Ko-fi --- "https://discord.gg/BqP64XTdJN", "#2C2F33",
kofi_icon_label = QLabel() min_height=100, icon_size=36
kofi_pixmap = QPixmap(get_asset_path("kofi.png")) ))
if not kofi_pixmap.isNull(): community_layout.addWidget(self._create_card_button(
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation)) get_asset_path("instagram.png"), "Instagram", "Follow me",
"https://www.instagram.com/uvi.arts/", "#3B2E40",
kofi_text_label = QLabel( min_height=100, icon_size=36
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">' ))
'☕ Buy me a Ko-fi' main_layout.addLayout(community_layout)
'</a>'
)
kofi_text_label.setOpenExternalLinks(True)
kofi_text_label.setFont(link_font)
options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- GitHub Sponsors ---
github_icon_label = QLabel()
github_pixmap = QPixmap(get_asset_path("github_sponsors.png"))
if not github_pixmap.isNull():
github_icon_label.setPixmap(github_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
github_text_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #EA4AAA; text-decoration: none;">'
'💜 Sponsor on GitHub'
'</a>'
)
github_text_label.setOpenExternalLinks(True)
github_text_label.setFont(link_font)
options_layout.addWidget(github_icon_label, 1, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(github_text_label, 1, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- Buy Me a Coffee (New) ---
bmac_icon_label = QLabel()
bmac_pixmap = QPixmap(get_asset_path("bmac.png"))
if not bmac_pixmap.isNull():
bmac_icon_label.setPixmap(bmac_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
bmac_text_label = QLabel(
'<a href="https://buymeacoffee.com/yuvi9587" style="color: #FFDD00; text-decoration: none;">'
'🍺 Buy Me a Coffee'
'</a>'
)
bmac_text_label.setOpenExternalLinks(True)
bmac_text_label.setFont(link_font)
options_layout.addWidget(bmac_icon_label, 2, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(bmac_text_label, 2, 2, Qt.AlignLeft | Qt.AlignVCenter)
main_layout.addLayout(options_layout)
# Close Button # Close Button
self.button_box = QDialogButtonBox(QDialogButtonBox.Close) close_button = QPushButton("Close")
self.button_box.rejected.connect(self.reject) close_button.setMinimumWidth(100)
main_layout.addWidget(self.button_box) close_button.clicked.connect(self.accept)
close_button.setStyleSheet("""
QPushButton {
padding: 6px 14px;
border-radius: 6px;
background-color: #444;
color: white;
}
QPushButton:hover {
background-color: #555;
}
""")
self.setLayout(main_layout) button_layout = QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(close_button)
button_layout.addStretch()
main_layout.addLayout(button_layout)
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale)) self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("") self.setStyleSheet("")
def get_asset_path(filename):
"""Return the path to an asset, works in both dev and packaged environments."""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
else:
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
return os.path.join(base_path, 'assets', filename)

View File

@@ -1,6 +1,6 @@
import os import os
import sys import sys
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtCore import pyqtSignal, Qt, QSettings
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
@@ -8,89 +8,88 @@ from PyQt5.QtWidgets import (
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
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
from ...config.constants import ( from ...config.constants import CONFIG_ORGANIZATION_NAME
CONFIG_ORGANIZATION_NAME
)
class TourStepWidget(QWidget): class TourStepWidget(QWidget):
""" """
A custom widget representing a single step or page in the feature tour. A custom widget for a single tour page, with improved styling for titles and content.
It neatly formats a title and its corresponding content.
""" """
def __init__(self, title_text, content_text, parent=None): def __init__(self, title_text, content_text, parent=None):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(25, 20, 25, 20)
layout.setSpacing(10) layout.setSpacing(15)
layout.setAlignment(Qt.AlignHCenter)
title_label = QLabel(title_text) title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") title_label.setWordWrap(True)
title_label.setStyleSheet("font-size: 18pt; font-weight: bold; color: #E0E0E0; padding-bottom: 10px;")
layout.addWidget(title_label) layout.addWidget(title_label)
# Frame for the content area to give it a nice border
content_frame = QFrame()
content_frame.setObjectName("contentFrame")
content_layout = QVBoxLayout(content_frame)
scroll_area = QScrollArea() scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True) scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("background-color: transparent;")
content_label = QLabel(content_text) content_label = QLabel(content_text)
content_label.setWordWrap(True) content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText) content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True) content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") # Indent the content slightly for better readability
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; padding-left: 5px; padding-right: 5px;")
scroll_area.setWidget(content_label) scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1) content_layout.addWidget(scroll_area)
layout.addWidget(content_frame, 1)
class TourDialog(QDialog): class TourDialog(QDialog):
""" """
A dialog that shows a multi-page tour to the user on first launch. A redesigned, multi-page tour dialog with a visual progress indicator.
Includes a "Never show again" checkbox and uses QSettings to remember this preference.
""" """
tour_finished_normally = pyqtSignal() tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal() tour_skipped = pyqtSignal()
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV20" # Version bumped to ensure new tour shows once
CONFIG_ORGANIZATION_NAME = CONFIG_ORGANIZATION_NAME
def __init__(self, parent_app, parent=None): def __init__(self, parent_app, parent=None):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent) super().__init__(parent)
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR) self.settings = QSettings(self.CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR)
self.current_step = 0 self.current_step = 0
self.parent_app = parent_app self.parent_app = parent_app
self.progress_dots = []
self.setWindowIcon(get_app_icon_object()) self.setWindowIcon(get_app_icon_object())
self.setModal(True) self.setModal(True)
self.setFixedSize(600, 620) self.setFixedSize(680, 650)
self._init_ui() self._init_ui()
self._apply_theme() self._apply_theme()
self._center_on_screen() self._center_on_screen()
def _tr(self, key, default_text=""): def _tr(self, key, default_text=""):
"""Helper for translation."""
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): def _init_ui(self):
"""Initializes all UI components and layouts."""
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0) main_layout.setSpacing(0)
self.stacked_widget = QStackedWidget() self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1) main_layout.addWidget(self.stacked_widget, 1)
# All 8 steps from your translator.py file
steps_content = [ steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"), ("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"), ("tour_dialog_step2_title", "tour_dialog_step2_content"),
@@ -102,52 +101,105 @@ class TourDialog(QDialog):
("tour_dialog_step8_title", "tour_dialog_step8_content"), ("tour_dialog_step8_title", "tour_dialog_step8_content"),
] ]
self.tour_steps_widgets = []
for title_key, content_key in steps_content: for title_key, content_key in steps_content:
title = self._tr(title_key, title_key) title = self._tr(title_key, title_key)
content = self._tr(content_key, "Content not found.") content = self._tr(content_key, "Content not found.")
step_widget = TourStepWidget(title, content) step_widget = TourStepWidget(title, content)
self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget) self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!")) self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) # --- Bottom Controls Area ---
bottom_controls_layout.setSpacing(12) bottom_frame = QFrame()
bottom_frame.setObjectName("bottomFrame")
main_layout.addWidget(bottom_frame)
self.never_show_again_checkbox = QCheckBox(self._tr("tour_dialog_never_show_checkbox", "Never show this tour again")) bottom_controls_layout = QVBoxLayout(bottom_frame)
bottom_controls_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft) bottom_controls_layout.setContentsMargins(20, 15, 20, 20)
bottom_controls_layout.setSpacing(15)
buttons_layout = QHBoxLayout() # --- Progress Indicator ---
buttons_layout.setSpacing(10) progress_layout = QHBoxLayout()
self.skip_button = QPushButton(self._tr("tour_dialog_skip_button", "Skip Tour")) progress_layout.addStretch()
for i in range(len(steps_content)):
dot = QLabel()
dot.setObjectName("progressDot")
dot.setFixedSize(12, 12)
self.progress_dots.append(dot)
progress_layout.addWidget(dot)
progress_layout.addStretch()
bottom_controls_layout.addLayout(progress_layout)
# --- Buttons and Checkbox ---
buttons_and_check_layout = QHBoxLayout()
self.never_show_again_checkbox = QCheckBox(self._tr("tour_dialog_never_show_checkbox", "Never show this again"))
buttons_and_check_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft)
buttons_and_check_layout.addStretch()
self.skip_button = QPushButton(self._tr("tour_dialog_skip_button", "Skip"))
self.skip_button.clicked.connect(self._skip_tour_action) self.skip_button.clicked.connect(self._skip_tour_action)
self.back_button = QPushButton(self._tr("tour_dialog_back_button", "Back")) self.back_button = QPushButton(self._tr("tour_dialog_back_button", "Back"))
self.back_button.clicked.connect(self._previous_step) self.back_button.clicked.connect(self._previous_step)
self.next_button = QPushButton(self._tr("tour_dialog_next_button", "Next")) self.next_button = QPushButton(self._tr("tour_dialog_next_button", "Next"))
self.next_button.clicked.connect(self._next_step_action) self.next_button.clicked.connect(self._next_step_action)
self.next_button.setDefault(True) self.next_button.setDefault(True)
self.next_button.setObjectName("nextButton") # For special styling
buttons_layout.addWidget(self.skip_button) buttons_and_check_layout.addWidget(self.skip_button)
buttons_layout.addStretch(1) buttons_and_check_layout.addWidget(self.back_button)
buttons_layout.addWidget(self.back_button) buttons_and_check_layout.addWidget(self.next_button)
buttons_layout.addWidget(self.next_button) bottom_controls_layout.addLayout(buttons_and_check_layout)
bottom_controls_layout.addLayout(buttons_layout) self._update_ui_states()
main_layout.addLayout(bottom_controls_layout)
self._update_button_states()
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale)) dark_theme_base = get_dark_theme(scale)
tour_styles = """
QDialog {
background-color: #2D2D30;
}
#bottomFrame {
background-color: #252526;
border-top: 1px solid #3E3E42;
}
#contentFrame {
border: 1px solid #3E3E42;
border-radius: 5px;
}
QScrollArea {
background-color: transparent;
border: none;
}
#progressDot {
background-color: #555;
border-radius: 6px;
border: 1px solid #4F4F4F;
}
#progressDot[active="true"] {
background-color: #007ACC;
border: 1px solid #005A9E;
}
#nextButton {
background-color: #007ACC;
border: 1px solid #005A9E;
padding: 8px 18px;
font-weight: bold;
}
#nextButton:hover {
background-color: #1E90FF;
}
#nextButton:disabled {
background-color: #444;
border-color: #555;
}
"""
self.setStyleSheet(dark_theme_base + tour_styles)
else: else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }") self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _center_on_screen(self): def _center_on_screen(self):
"""Centers the dialog on the screen."""
try: try:
screen_geo = QApplication.primaryScreen().availableGeometry() screen_geo = QApplication.primaryScreen().availableGeometry()
self.move(screen_geo.center() - self.rect().center()) self.move(screen_geo.center() - self.rect().center())
@@ -155,54 +207,49 @@ class TourDialog(QDialog):
print(f"[TourDialog] Error centering dialog: {e}") print(f"[TourDialog] Error centering dialog: {e}")
def _next_step_action(self): def _next_step_action(self):
"""Moves to the next step or finishes the tour.""" if self.current_step < self.stacked_widget.count() - 1:
if self.current_step < len(self.tour_steps_widgets) - 1:
self.current_step += 1 self.current_step += 1
self.stacked_widget.setCurrentIndex(self.current_step) self.stacked_widget.setCurrentIndex(self.current_step)
else: else:
self._finish_tour_action() self._finish_tour_action()
self._update_button_states() self._update_ui_states()
def _previous_step(self): def _previous_step(self):
"""Moves to the previous step."""
if self.current_step > 0: if self.current_step > 0:
self.current_step -= 1 self.current_step -= 1
self.stacked_widget.setCurrentIndex(self.current_step) self.stacked_widget.setCurrentIndex(self.current_step)
self._update_button_states() self._update_ui_states()
def _update_button_states(self): def _update_ui_states(self):
"""Updates the state and text of navigation buttons.""" is_last_step = self.current_step == self.stacked_widget.count() - 1
is_last_step = self.current_step == len(self.tour_steps_widgets) - 1
self.next_button.setText(self._tr("tour_dialog_finish_button", "Finish") if is_last_step else self._tr("tour_dialog_next_button", "Next")) self.next_button.setText(self._tr("tour_dialog_finish_button", "Finish") if is_last_step else self._tr("tour_dialog_next_button", "Next"))
self.back_button.setEnabled(self.current_step > 0) self.back_button.setEnabled(self.current_step > 0)
self.skip_button.setVisible(not is_last_step)
for i, dot in enumerate(self.progress_dots):
dot.setProperty("active", i == self.current_step)
dot.style().polish(dot)
def _skip_tour_action(self): def _skip_tour_action(self):
"""Handles the action when the tour is skipped."""
self._save_settings_if_checked() self._save_settings_if_checked()
self.tour_skipped.emit() self.tour_skipped.emit()
self.reject() self.reject()
def _finish_tour_action(self): def _finish_tour_action(self):
"""Handles the action when the tour is finished normally."""
self._save_settings_if_checked() self._save_settings_if_checked()
self.tour_finished_normally.emit() self.tour_finished_normally.emit()
self.accept() self.accept()
def _save_settings_if_checked(self): def _save_settings_if_checked(self):
"""Saves the 'never show again' preference to QSettings."""
self.settings.setValue(self.TOUR_SHOWN_KEY, self.never_show_again_checkbox.isChecked()) self.settings.setValue(self.TOUR_SHOWN_KEY, self.never_show_again_checkbox.isChecked())
self.settings.sync() self.settings.sync()
@staticmethod @staticmethod
def should_show_tour(): def should_show_tour():
"""Checks QSettings to see if the tour should be shown on startup."""
settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR) settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
never_show = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool) never_show = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)
return not never_show return not never_show
CONFIG_ORGANIZATION_NAME = CONFIG_ORGANIZATION_NAME
def closeEvent(self, event): def closeEvent(self, event):
"""Ensures settings are saved if the dialog is closed via the 'X' button."""
self._skip_tour_action() self._skip_tour_action()
super().closeEvent(event) super().closeEvent(event)

File diff suppressed because it is too large Load Diff

49
src/utils/command.py Normal file
View File

@@ -0,0 +1,49 @@
import re
# Command constants
CMD_ARCHIVE_ONLY = 'ao'
CMD_DOMAIN_OVERRIDE_PREFIX = '.'
CMD_SFP_PREFIX = 'sfp-'
CMD_UNKNOWN = 'unknown' # New command constant
def parse_commands_from_text(raw_text: str):
"""
Parses special commands from a text string and returns the cleaned text
and a dictionary of found commands.
Commands are in the format [command].
Example: "Tifa, (Cloud, Zack) [.st] [sfp-10] [unknown]"
Returns:
tuple[str, dict]: A tuple containing:
- The text string with commands removed.
- A dictionary of commands and their values.
"""
command_pattern = re.compile(r'\[(.*?)\]')
commands = {}
def command_replacer(match):
command_str = match.group(1).strip().lower()
if command_str.startswith(CMD_DOMAIN_OVERRIDE_PREFIX):
tld = command_str[len(CMD_DOMAIN_OVERRIDE_PREFIX):]
if 'domain_override' not in commands:
commands['domain_override'] = tld
elif command_str == CMD_ARCHIVE_ONLY:
commands['archive_only'] = True
elif command_str.startswith(CMD_SFP_PREFIX):
try:
threshold_str = command_str[len(CMD_SFP_PREFIX):]
threshold = int(threshold_str)
if 'sfp_threshold' not in commands:
commands['sfp_threshold'] = threshold
except (ValueError, IndexError):
pass
elif command_str == CMD_UNKNOWN: # Logic to handle the new command
commands['handle_unknown'] = True
return ''
text_without_commands = command_pattern.sub(command_replacer, raw_text).strip()
return text_without_commands, commands

View File

@@ -20,7 +20,7 @@ VIDEO_EXTENSIONS = {
'.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob' '.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
} }
ARCHIVE_EXTENSIONS = { ARCHIVE_EXTENSIONS = {
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2' '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.bin'
} }
AUDIO_EXTENSIONS = { AUDIO_EXTENSIONS = {
'.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus', '.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
@@ -140,3 +140,5 @@ def is_audio(filename):
if not filename: return False if not filename: return False
_, ext = os.path.splitext(filename) _, ext = os.path.splitext(filename)
return ext.lower() in AUDIO_EXTENSIONS return ext.lower() in AUDIO_EXTENSIONS

View File

@@ -1,14 +1,7 @@
# --- Standard Library Imports ---
import os import os
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
# --- Third-Party Library Imports ---
# This module might not require third-party libraries directly,
# but 'requests' is a common dependency for network operations.
# import requests
def parse_cookie_string(cookie_string): def parse_cookie_string(cookie_string):
""" """
Parses a 'name=value; name2=value2' cookie string into a dictionary. Parses a 'name=value; name2=value2' cookie string into a dictionary.
@@ -106,13 +99,11 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
if not use_cookie_flag: if not use_cookie_flag:
return None return None
# Priority 1: Use the specifically browsed file first
if selected_cookie_file_path and os.path.exists(selected_cookie_file_path): if selected_cookie_file_path and os.path.exists(selected_cookie_file_path):
cookies = load_cookies_from_netscape_file(selected_cookie_file_path, logger_func, target_domain) cookies = load_cookies_from_netscape_file(selected_cookie_file_path, logger_func, target_domain)
if cookies: if cookies:
return cookies return cookies
# Priority 2: Look for a domain-specific cookie file
if app_base_dir and target_domain: if app_base_dir and target_domain:
domain_specific_path = os.path.join(app_base_dir, "data", f"{target_domain}_cookies.txt") domain_specific_path = os.path.join(app_base_dir, "data", f"{target_domain}_cookies.txt")
if os.path.exists(domain_specific_path): if os.path.exists(domain_specific_path):
@@ -120,7 +111,6 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
if cookies: if cookies:
return cookies return cookies
# Priority 3: Look for a generic cookies.txt
if app_base_dir: if app_base_dir:
default_path = os.path.join(app_base_dir, "appdata", "cookies.txt") default_path = os.path.join(app_base_dir, "appdata", "cookies.txt")
if os.path.exists(default_path): if os.path.exists(default_path):
@@ -128,7 +118,6 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
if cookies: if cookies:
return cookies return cookies
# Priority 4: Fall back to manually entered text
if cookie_text_input: if cookie_text_input:
cookies = parse_cookie_string(cookie_text_input) cookies = parse_cookie_string(cookie_text_input)
if cookies: if cookies:
@@ -148,6 +137,16 @@ def extract_post_info(url_string):
stripped_url = url_string.strip() stripped_url = url_string.strip()
# --- Danbooru Check ---
danbooru_match = re.search(r'danbooru\.donmai\.us|safebooru\.donmai\.us', stripped_url)
if danbooru_match:
return 'danbooru', None, None
# --- Gelbooru Check ---
gelbooru_match = re.search(r'gelbooru\.com', stripped_url)
if gelbooru_match:
return 'gelbooru', None, None
# --- Bunkr Check --- # --- Bunkr Check ---
bunkr_pattern = re.compile( bunkr_pattern = re.compile(
r"(?:https?://)?(?:[a-zA-Z0-9-]+\.)?bunkr\.(?:si|la|ws|red|black|media|site|is|to|ac|cr|ci|fi|pk|ps|sk|ph|su|ru)|bunkrr\.ru" r"(?:https?://)?(?:[a-zA-Z0-9-]+\.)?bunkr\.(?:si|la|ws|red|black|media|site|is|to|ac|cr|ci|fi|pk|ps|sk|ph|su|ru)|bunkrr\.ru"
@@ -155,17 +154,28 @@ def extract_post_info(url_string):
if bunkr_pattern.search(stripped_url): if bunkr_pattern.search(stripped_url):
return 'bunkr', stripped_url, None return 'bunkr', stripped_url, None
# --- SimpCity Check (Corrected version) ---
simpcity_match = re.search(r'simpcity\.cr/threads/([^/]+)(?:/post-(\d+))?', stripped_url)
if simpcity_match:
thread_info = simpcity_match.group(1)
post_id = simpcity_match.group(2)
return 'simpcity', thread_info, post_id
# --- nhentai Check --- # --- nhentai Check ---
nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url) nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url)
if nhentai_match: if nhentai_match:
return 'nhentai', nhentai_match.group(1), None return 'nhentai', nhentai_match.group(1), None
# --- Hentai2Read Check (Updated) --- # --- Hentai2Read Check (Corrected to match series, chapter, and image URLs) ---
# This regex now captures the manga slug (id1) and optionally the chapter number (id2)
hentai2read_match = re.search(r'hentai2read\.com/([^/]+)(?:/(\d+))?/?', stripped_url) hentai2read_match = re.search(r'hentai2read\.com/([^/]+)(?:/(\d+))?/?', stripped_url)
if hentai2read_match: if hentai2read_match:
manga_slug, chapter_num = hentai2read_match.groups() manga_slug, chapter_num = hentai2read_match.groups()
return 'hentai2read', manga_slug, chapter_num # chapter_num will be None for series URLs return 'hentai2read', manga_slug, chapter_num
# --- Pixeldrain Check ---
pixeldrain_match = re.search(r'pixeldrain\.com/[lud]/([^/?#]+)', stripped_url)
if pixeldrain_match:
return 'pixeldrain', stripped_url, None
discord_channel_match = re.search(r'discord\.com/channels/(@me|\d+)/(\d+)', stripped_url) discord_channel_match = re.search(r'discord\.com/channels/(@me|\d+)/(\d+)', stripped_url)
if discord_channel_match: if discord_channel_match:
@@ -196,7 +206,7 @@ def extract_post_info(url_string):
print(f"Debug: Exception during URL parsing for '{url_string}': {e}") print(f"Debug: Exception during URL parsing for '{url_string}': {e}")
return None, None, None return None, None, None
def get_link_platform(url): def get_link_platform(url):
""" """
Identifies the platform of a given URL based on its domain. Identifies the platform of a given URL based on its domain.

View File

@@ -28,19 +28,12 @@ def setup_ui(main_app):
main_app.scale_factor = scale main_app.scale_factor = scale
default_font = QApplication.font() default_font = QApplication.font()
base_font_size = 9 # Use a standard base size base_font_size = 9
default_font.setPointSize(int(base_font_size * scale)) default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font) main_app.setFont(default_font)
default_font = QApplication.font()
base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font)
# --- END: Improved Scaling Logic ---
main_app.main_splitter = QSplitter(Qt.Horizontal) main_app.main_splitter = QSplitter(Qt.Horizontal)
# --- Use a scroll area for the left panel for consistency ---
left_scroll_area = QScrollArea() left_scroll_area = QScrollArea()
left_scroll_area.setWidgetResizable(True) left_scroll_area.setWidgetResizable(True)
left_scroll_area.setFrameShape(QFrame.NoFrame) left_scroll_area.setFrameShape(QFrame.NoFrame)
@@ -75,7 +68,7 @@ def setup_ui(main_app):
main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup) main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup)
url_input_layout.addWidget(main_app.empty_popup_button) url_input_layout.addWidget(main_app.empty_popup_button)
main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:")) main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:"))
main_app.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") main_app.page_range_label .setStyleSheet("font-weight: bold; padding-left: 10px;")
url_input_layout.addWidget(main_app.page_range_label) url_input_layout.addWidget(main_app.page_range_label)
main_app.start_page_input = QLineEdit() main_app.start_page_input = QLineEdit()
main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start")) main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start"))
@@ -134,8 +127,6 @@ def setup_ui(main_app):
main_app._update_char_filter_scope_button_text() main_app._update_char_filter_scope_button_text()
char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1) char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1)
character_filter_v_layout.addLayout(char_input_and_button_layout) character_filter_v_layout.addLayout(char_input_and_button_layout)
# --- Custom Folder Widget Definition ---
main_app.custom_folder_widget = QWidget() main_app.custom_folder_widget = QWidget()
custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget) custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget)
custom_folder_v_layout.setContentsMargins(0, 0, 0, 0) custom_folder_v_layout.setContentsMargins(0, 0, 0, 0)
@@ -146,7 +137,6 @@ def setup_ui(main_app):
custom_folder_v_layout.addWidget(main_app.custom_folder_label) custom_folder_v_layout.addWidget(main_app.custom_folder_label)
custom_folder_v_layout.addWidget(main_app.custom_folder_input) custom_folder_v_layout.addWidget(main_app.custom_folder_input)
main_app.custom_folder_widget.setVisible(False) main_app.custom_folder_widget.setVisible(False)
filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1) filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1)
filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1) filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1)
left_layout.addWidget(main_app.filters_and_custom_folder_container_widget) left_layout.addWidget(main_app.filters_and_custom_folder_container_widget)
@@ -199,7 +189,6 @@ def setup_ui(main_app):
main_app.radio_only_audio = QRadioButton("🎧 Only Audio") main_app.radio_only_audio = QRadioButton("🎧 Only Audio")
main_app.radio_only_links = QRadioButton("🔗 Only Links") main_app.radio_only_links = QRadioButton("🔗 Only Links")
main_app.radio_more = QRadioButton("More") main_app.radio_more = QRadioButton("More")
main_app.radio_all.setChecked(True) main_app.radio_all.setChecked(True)
for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]: for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]:
main_app.radio_group.addButton(btn) main_app.radio_group.addButton(btn)
@@ -211,6 +200,24 @@ def setup_ui(main_app):
file_filter_layout.addLayout(radio_button_layout) file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout) left_layout.addLayout(file_filter_layout)
# --- Booru Inputs Container ---
main_app.booru_inputs_widget = QWidget()
booru_inputs_layout = QHBoxLayout(main_app.booru_inputs_widget)
booru_inputs_layout.setContentsMargins(0, 5, 0, 0)
main_app.api_key_label = QLabel("API Key:")
main_app.api_key_input = QLineEdit()
main_app.api_key_input.setPlaceholderText("Danbooru or Gelbooru API Key")
main_app.user_id_label = QLabel("User ID:")
main_app.user_id_input = QLineEdit()
main_app.user_id_input.setPlaceholderText("Danbooru Username or Gelbooru User ID")
booru_inputs_layout.addWidget(main_app.api_key_label)
booru_inputs_layout.addWidget(main_app.api_key_input, 1)
booru_inputs_layout.addSpacing(10)
booru_inputs_layout.addWidget(main_app.user_id_label)
booru_inputs_layout.addWidget(main_app.user_id_input, 1)
left_layout.addWidget(main_app.booru_inputs_widget)
main_app.booru_inputs_widget.setVisible(False)
# --- Checkboxes Group --- # --- Checkboxes Group ---
checkboxes_group_layout = QVBoxLayout() checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10) checkboxes_group_layout.setSpacing(10)
@@ -234,40 +241,42 @@ def setup_ui(main_app):
row1_layout.addStretch(1) row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout) checkboxes_group_layout.addLayout(row1_layout)
# --- Advanced Settings --- # --- Advanced Settings Container ---
main_app.advanced_settings_widget = QWidget()
advanced_settings_layout = QVBoxLayout(main_app.advanced_settings_widget)
advanced_settings_layout.setContentsMargins(0, 0, 0, 0)
advanced_settings_layout.setSpacing(10)
advanced_settings_label = QLabel("⚙️ Advanced Settings:") advanced_settings_label = QLabel("⚙️ Advanced Settings:")
checkboxes_group_layout.addWidget(advanced_settings_label) advanced_settings_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10)
# --- REORDERED CHECKBOXES --- main_app.advanced_row1_layout = QHBoxLayout()
main_app.advanced_row1_layout.setSpacing(10)
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders) main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True) main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox) main_app.advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix") main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox) main_app.advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt") main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False) main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders) main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox) main_app.advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
# --- Original Cookie Controls (for non-SimpCity sites) ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie") main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting) main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit() main_app.cookie_text_input = QLineEdit()
main_app.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") main_app.cookie_text_input.setPlaceholderText("Cookie string or path from Browse...")
main_app.cookie_text_input.setText(main_app.cookie_text_setting) main_app.cookie_text_input.setText(main_app.cookie_text_setting)
advanced_row1_layout.addWidget(main_app.use_cookie_checkbox)
advanced_row1_layout.addWidget(main_app.cookie_text_input, 2)
main_app.cookie_browse_button = QPushButton("Browse...") main_app.cookie_browse_button = QPushButton("Browse...")
main_app.cookie_browse_button.setFixedWidth(int(80 * scale)) main_app.cookie_browse_button.setFixedWidth(int(80 * scale))
advanced_row1_layout.addWidget(main_app.cookie_browse_button) main_app.advanced_row1_layout.addWidget(main_app.use_cookie_checkbox)
advanced_row1_layout.addStretch(1) main_app.advanced_row1_layout.addWidget(main_app.cookie_text_input, 2)
checkboxes_group_layout.addLayout(advanced_row1_layout) main_app.advanced_row1_layout.addWidget(main_app.cookie_browse_button)
main_app.advanced_row1_layout.addStretch(1)
advanced_settings_layout.addLayout(main_app.advanced_row1_layout)
advanced_row2_layout = QHBoxLayout() advanced_row2_layout = QHBoxLayout()
advanced_row2_layout.setSpacing(10) advanced_row2_layout.setSpacing(10)
multithreading_layout = QHBoxLayout() multithreading_layout = QHBoxLayout()
@@ -287,10 +296,55 @@ def setup_ui(main_app):
main_app.manga_mode_checkbox = QCheckBox("Renaming Mode") main_app.manga_mode_checkbox = QCheckBox("Renaming Mode")
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox) advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1) advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout) advanced_settings_layout.addLayout(advanced_row2_layout)
checkboxes_group_layout.addWidget(main_app.advanced_settings_widget)
# --- SimpCity Settings Container (with its own cookie controls) ---
main_app.simpcity_settings_widget = QWidget()
simpcity_settings_layout = QVBoxLayout(main_app.simpcity_settings_widget)
simpcity_settings_layout.setContentsMargins(0, 0, 0, 0)
simpcity_settings_layout.setSpacing(10)
simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:")
simpcity_settings_layout.addWidget(simpcity_settings_label)
# Checkbox row
simpcity_checkboxes_layout = QHBoxLayout()
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_bunkr_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_gofile_cb)
simpcity_checkboxes_layout.addStretch(1)
simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
# --- START NEW CODE ---
# Create the second, dedicated set of cookie controls for SimpCity
simpcity_cookie_layout = QHBoxLayout()
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
simpcity_cookie_label = QLabel("Cookie:")
main_app.simpcity_cookie_text_input = QLineEdit()
main_app.simpcity_cookie_text_input.setPlaceholderText("Cookie string or path... (Required)")
main_app.simpcity_cookie_browse_button = QPushButton("Browse...")
main_app.simpcity_cookie_browse_button.setFixedWidth(int(80 * scale))
simpcity_cookie_layout.addWidget(simpcity_cookie_label)
simpcity_cookie_layout.addWidget(main_app.simpcity_cookie_text_input, 1) # Stretch factor
simpcity_cookie_layout.addWidget(main_app.simpcity_cookie_browse_button)
simpcity_settings_layout.addLayout(simpcity_cookie_layout)
checkboxes_group_layout.addWidget(main_app.simpcity_settings_widget)
main_app.simpcity_settings_widget.setVisible(False)
left_layout.addLayout(checkboxes_group_layout) left_layout.addLayout(checkboxes_group_layout)
# --- Action Buttons --- # --- Action Buttons & Remaining UI ---
# ... (The rest of the setup_ui function remains unchanged)
main_app.standard_action_buttons_widget = QWidget() main_app.standard_action_buttons_widget = QWidget()
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget) btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
btn_layout.setContentsMargins(0, 10, 0, 0) btn_layout.setContentsMargins(0, 10, 0, 0)
@@ -326,8 +380,6 @@ def setup_ui(main_app):
main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget) main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget)
left_layout.addWidget(main_app.bottom_action_buttons_stack) left_layout.addWidget(main_app.bottom_action_buttons_stack)
left_layout.addSpacing(10) left_layout.addSpacing(10)
# --- Known Names Layout ---
known_chars_label_layout = QHBoxLayout() known_chars_label_layout = QHBoxLayout()
known_chars_label_layout.setSpacing(10) known_chars_label_layout.setSpacing(10)
main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
@@ -376,8 +428,6 @@ def setup_ui(main_app):
char_manage_layout.addWidget(main_app.support_button, 0) char_manage_layout.addWidget(main_app.support_button, 0)
left_layout.addLayout(char_manage_layout) left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0) left_layout.addStretch(0)
# --- Right Panel (Logs) ---
right_panel_widget.setLayout(right_layout) right_panel_widget.setLayout(right_layout)
log_title_layout = QHBoxLayout() log_title_layout = QHBoxLayout()
main_app.progress_log_label = QLabel("📜 Progress Log:") main_app.progress_log_label = QLabel("📜 Progress Log:")
@@ -387,32 +437,31 @@ def setup_ui(main_app):
main_app.link_search_input.setPlaceholderText("Search Links...") main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False) main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input) log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("<EFBFBD>") main_app.link_search_button = QPushButton("🔎")
main_app.link_search_button.setVisible(False) main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale)) main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button) log_title_layout.addWidget(main_app.link_search_button)
discord_controls_layout = QHBoxLayout() discord_controls_layout = QHBoxLayout()
main_app.discord_scope_toggle_button = QPushButton("Scope: Files") main_app.discord_scope_toggle_button = QPushButton("Scope: Files")
main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default main_app.discord_scope_toggle_button.setVisible(False)
discord_controls_layout.addWidget(main_app.discord_scope_toggle_button) discord_controls_layout.addWidget(main_app.discord_scope_toggle_button)
main_app.discord_message_limit_input = QLineEdit(main_app) main_app.discord_message_limit_input = QLineEdit(main_app)
main_app.discord_message_limit_input.setPlaceholderText("Msg Limit") main_app.discord_message_limit_input.setPlaceholderText("Msg Limit")
main_app.discord_message_limit_input.setToolTip("Optional: Limit the number of recent messages to process.") main_app.discord_message_limit_input.setToolTip("Optional: Limit the number of recent messages to process.")
main_app.discord_message_limit_input.setValidator(QIntValidator(1, 9999999, main_app)) main_app.discord_message_limit_input.setValidator(QIntValidator(1, 9999999, main_app))
main_app.discord_message_limit_input.setFixedWidth(int(80 * scale)) main_app.discord_message_limit_input.setFixedWidth(int(80 * scale))
main_app.discord_message_limit_input.setVisible(False) # Hide it by default main_app.discord_message_limit_input.setVisible(False)
discord_controls_layout.addWidget(main_app.discord_message_limit_input) discord_controls_layout.addWidget(main_app.discord_message_limit_input)
log_title_layout.addLayout(discord_controls_layout) log_title_layout.addLayout(discord_controls_layout)
main_app.manga_rename_toggle_button = QPushButton() main_app.manga_rename_toggle_button = QPushButton()
main_app.manga_rename_toggle_button.setVisible(False) main_app.manga_rename_toggle_button.setVisible(False)
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale)) main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
main_app._update_manga_filename_style_button_text() main_app._update_manga_filename_style_button_text()
log_title_layout.addWidget(main_app.manga_rename_toggle_button) log_title_layout.addWidget(main_app.manga_rename_toggle_button)
main_app.custom_rename_dialog_button = QPushButton("Open Dialog")
main_app.custom_rename_dialog_button.setVisible(False)
main_app.custom_rename_dialog_button.clicked.connect(main_app._show_custom_rename_dialog)
log_title_layout.addWidget(main_app.custom_rename_dialog_button)
main_app.manga_date_prefix_input = QLineEdit() main_app.manga_date_prefix_input = QLineEdit()
main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames")
main_app.manga_date_prefix_input.setVisible(False) main_app.manga_date_prefix_input.setVisible(False)
@@ -475,26 +524,17 @@ def setup_ui(main_app):
main_app.file_progress_label.setWordWrap(True) main_app.file_progress_label.setWordWrap(True)
main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(main_app.file_progress_label) right_layout.addWidget(main_app.file_progress_label)
# --- Final Assembly ---
main_app.main_splitter.addWidget(left_scroll_area) main_app.main_splitter.addWidget(left_scroll_area)
main_app.main_splitter.addWidget(right_panel_widget) main_app.main_splitter.addWidget(right_panel_widget)
if main_app.width() >= 1920: if main_app.width() >= 1920:
# For wider resolutions, give more space to the log panel (right).
main_app.main_splitter.setStretchFactor(0, 4) main_app.main_splitter.setStretchFactor(0, 4)
main_app.main_splitter.setStretchFactor(1, 6) main_app.main_splitter.setStretchFactor(1, 6)
else: else:
# Default for lower resolutions, giving more space to controls (left).
main_app.main_splitter.setStretchFactor(0, 7) main_app.main_splitter.setStretchFactor(0, 7)
main_app.main_splitter.setStretchFactor(1, 3) main_app.main_splitter.setStretchFactor(1, 3)
top_level_layout = QHBoxLayout(main_app) top_level_layout = QHBoxLayout(main_app)
top_level_layout.setContentsMargins(0, 0, 0, 0) top_level_layout.setContentsMargins(0, 0, 0, 0)
top_level_layout.addWidget(main_app.main_splitter) top_level_layout.addWidget(main_app.main_splitter)
# --- Initial UI State Updates ---
main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked()) main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked())
main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked()) main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked())
main_app.update_multithreading_label(main_app.thread_count_input.text()) main_app.update_multithreading_label(main_app.thread_count_input.text())
@@ -510,7 +550,6 @@ def setup_ui(main_app):
if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton(): if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton():
main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True) main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True)
main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled) main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled)
main_app._update_manga_filename_style_button_text() main_app._update_manga_filename_style_button_text()
main_app._update_skip_scope_button_text() main_app._update_skip_scope_button_text()
main_app._update_char_filter_scope_button_text() main_app._update_char_filter_scope_button_text()
@@ -552,6 +591,9 @@ def get_dark_theme(scale=1):
border-radius: 4px; border-radius: 4px;
font-size: {font_size}pt; font-size: {font_size}pt;
}} }}
QLineEdit::placeholder {{
color: #8A8A8A; /* A muted grey color for placeholder text */
}}
QTextEdit {{ QTextEdit {{
font-family: Consolas, Courier New, monospace; font-family: Consolas, Courier New, monospace;
}} }}

View File

@@ -168,6 +168,7 @@ def match_folders_from_title(title, names_to_match, unwanted_keywords):
def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keywords): def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keywords):
""" """
Matches folder names from a filename, prioritizing longer and more specific aliases. Matches folder names from a filename, prioritizing longer and more specific aliases.
It returns immediately after finding the first (longest) match.
Args: Args:
filename (str): The filename to check. filename (str): The filename to check.
@@ -175,15 +176,14 @@ def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keyw
unwanted_keywords (set): A set of folder names to ignore. unwanted_keywords (set): A set of folder names to ignore.
Returns: Returns:
list: A sorted list of matched primary folder names. list: A list containing the single best folder name match, or an empty list.
""" """
if not filename or not names_to_match: if not filename or not names_to_match:
return [] return []
filename_lower = filename.lower() filename_lower = filename.lower()
matched_primary_names = set()
# Create a flat list of (alias, primary_name) tuples to sort by alias length # Create a flat list of (alias, primary_name) tuples
alias_map_to_primary = [] alias_map_to_primary = []
for name_obj in names_to_match: for name_obj in names_to_match:
primary_name = name_obj.get("name") primary_name = name_obj.get("name")
@@ -200,8 +200,11 @@ def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keyw
# Sort by alias length, descending, to match longer aliases first # Sort by alias length, descending, to match longer aliases first
alias_map_to_primary.sort(key=lambda x: len(x[0]), reverse=True) alias_map_to_primary.sort(key=lambda x: len(x[0]), reverse=True)
# <<< MODIFICATION: Return the FIRST match found, which will be the longest >>>
for alias_lower, primary_name_for_alias in alias_map_to_primary: for alias_lower, primary_name_for_alias in alias_map_to_primary:
if filename_lower.startswith(alias_lower): if alias_lower in filename_lower:
matched_primary_names.add(primary_name_for_alias) # Found the longest possible alias that is a substring. Return immediately.
return [primary_name_for_alias]
return sorted(list(matched_primary_names))
# If the loop finishes without any matches, return an empty list.
return []

BIN
yt-dlp.exe Normal file

Binary file not shown.