29 Commits

Author SHA1 Message Date
Yuvi9587
2785fc1121 Update EmptyPopupDialog.py 2025-07-19 20:27:55 -07:00
Yuvi9587
fbdae61b80 Commit 2025-07-19 03:28:32 -07:00
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
24 changed files with 1316 additions and 1090 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1,3 @@
github: [Yuvi9587] github: [Yuvi9587]
ko_fi: yuvi427183
buy_me_a_coffee: yuvi9587

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@@ -41,6 +41,7 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
</div> </div>
--- ---
## Feature Overview ## Feature Overview
@@ -208,4 +209,9 @@ This project is under the Custom Licence
</a> </a>
</table> </table>
👉 See [features.md](features.md) for the full feature list. <p align="center">
<a href="https://buymeacoffee.com/yuvi9587">
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
</a>
</p>

View File

@@ -72,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
# --- File Type Extensions --- # --- File Type Extensions ---
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {
@@ -113,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"fri", "friday", "sat", "saturday", "sun", "sunday" "fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need # add more according to need
} }
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

@@ -3,8 +3,6 @@ import traceback
from urllib.parse import urlparse from urllib.parse import urlparse
import json # Ensure json is imported import json # Ensure json is imported
import requests import requests
# (Keep the rest of your imports)
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_POST_TITLE STYLE_DATE_POST_TITLE
@@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
raise RuntimeError("Fetch operation cancelled by user while paused.") raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") logger(" Post fetching resumed.")
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags" fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}' paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
@@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(log_message) logger(log_message)
try: try:
# We can now remove the streaming logic as the response will be small and fast.
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}" post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...") logger(f" Fetching full content for post ID {post_id}...")
try: try:
# Use streaming here as a precaution for single posts that are still very large.
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response: with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status() response.raise_for_status()
response_body = b"" response_body = b""
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
response_body += chunk response_body += chunk
full_post_data = json.loads(response_body) full_post_data = json.loads(response_body)
# The API sometimes wraps the post in a list, handle that.
if isinstance(full_post_data, list) and full_post_data: if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0] return full_post_data[0]
return full_post_data return full_post_data
@@ -134,14 +126,10 @@ def download_from_api(
'User-Agent': 'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json' 'Accept': 'application/json'
} }
# --- ADD THIS BLOCK ---
# Ensure processed_post_ids is a set for fast lookups
if processed_post_ids is None: if processed_post_ids is None:
processed_post_ids = set() processed_post_ids = set()
else: else:
processed_post_ids = set(processed_post_ids) processed_post_ids = set(processed_post_ids)
# --- END OF ADDITION ---
service, user_id, target_post_id = extract_post_info(api_url_input) service, user_id, target_post_id = extract_post_info(api_url_input)
@@ -158,11 +146,9 @@ def download_from_api(
if use_cookie and app_base_dir: if use_cookie and app_base_dir:
cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain) cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
if target_post_id: if target_post_id:
# --- ADD THIS CHECK FOR RESTORE ---
if target_post_id in processed_post_ids: if target_post_id in processed_post_ids:
logger(f" Skipping already processed target post ID: {target_post_id}") logger(f" Skipping already processed target post ID: {target_post_id}")
return return
# --- END OF ADDITION ---
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}" direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}") logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try: try:
@@ -248,14 +234,12 @@ def download_from_api(
break break
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:
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
if processed_post_ids: if processed_post_ids:
original_count = len(all_posts_for_manga_mode) original_count = len(all_posts_for_manga_mode)
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids] all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(all_posts_for_manga_mode) skipped_count = original_count - len(all_posts_for_manga_mode)
if skipped_count > 0: if skipped_count > 0:
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.") logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
# --- END OF ADDITION ---
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...") logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple(post): def sort_key_tuple(post):
@@ -326,15 +310,12 @@ def download_from_api(
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}") logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc() traceback.print_exc()
break break
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
if processed_post_ids: if processed_post_ids:
original_count = len(posts_batch) original_count = len(posts_batch)
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids] posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch) skipped_count = original_count - len(posts_batch)
if skipped_count > 0: if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.") logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
# --- END OF ADDITION ---
if not posts_batch: if not posts_batch:
if target_post_id and not processed_target_post_flag: if target_post_id and not processed_target_post_flag:

View File

@@ -1,13 +1,9 @@
# --- Standard Library Imports ---
import threading import threading
import time import time
import os import os
import json import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future from concurrent.futures import ThreadPoolExecutor, as_completed, Future
# --- Local Application Imports ---
# These imports reflect the new, organized project structure.
from .api_client import download_from_api from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread from .workers import PostProcessorWorker, DownloadThread
from ..config.constants import ( from ..config.constants import (
@@ -36,8 +32,6 @@ class DownloadManager:
self.progress_queue = progress_queue self.progress_queue = progress_queue
self.thread_pool = None self.thread_pool = None
self.active_futures = [] self.active_futures = []
# --- Session State ---
self.cancellation_event = threading.Event() self.cancellation_event = threading.Event()
self.pause_event = threading.Event() self.pause_event = threading.Event()
self.is_running = False self.is_running = False
@@ -64,8 +58,6 @@ class DownloadManager:
if self.is_running: if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
# --- Reset state for the new session ---
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
self.pause_event.clear() self.pause_event.clear()
@@ -75,8 +67,6 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
is_single_post = bool(config.get('target_post_id_from_initial_url')) is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True) use_multithreading = config.get('use_multithreading', True)
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
@@ -84,7 +74,6 @@ class DownloadManager:
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
# Start a separate thread to manage fetching and queuing to the thread pool
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data), args=(config, restore_data),
@@ -92,16 +81,11 @@ class DownloadManager:
) )
fetcher_thread.start() fetcher_thread.start()
else: else:
# For single posts or sequential manga mode, use a single worker thread
# which is simpler and ensures order.
self._start_single_threaded_session(config) self._start_single_threaded_session(config)
def _start_single_threaded_session(self, config): def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread.""" """Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...") self._log(" Initializing single-threaded download process...")
# The original DownloadThread is now a pure Python thread, not a QThread.
# We run its `run` method in a standard Python thread.
self.worker_thread = threading.Thread( self.worker_thread = threading.Thread(
target=self._run_single_worker, target=self._run_single_worker,
args=(config,), args=(config,),
@@ -112,7 +96,6 @@ class DownloadManager:
def _run_single_worker(self, config): def _run_single_worker(self, config):
"""Target function for the single-worker thread.""" """Target function for the single-worker thread."""
try: try:
# Pass the queue directly to the worker for it to send updates
worker = DownloadThread(config, self.progress_queue) worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread worker.run() # This is the main blocking call for this thread
except Exception as e: except Exception as e:
@@ -129,9 +112,6 @@ class DownloadManager:
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
# Fetch posts
# In a real implementation, this would call `api_client.download_from_api`
if restore_data: if restore_data:
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids']) processed_ids = set(restore_data['processed_post_ids'])
@@ -149,12 +129,9 @@ class DownloadManager:
if not posts_to_process: if not posts_to_process:
self._log("✅ No new posts to process.") self._log("✅ No new posts to process.")
return return
# Submit tasks to the pool
for post_data in posts_to_process: for post_data in posts_to_process:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
break break
# Each PostProcessorWorker gets the queue to send its own updates
worker = PostProcessorWorker(post_data, config, self.progress_queue) worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process) future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result) future.add_done_callback(self._handle_future_result)
@@ -164,27 +141,32 @@ class DownloadManager:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc()) self._log(traceback.format_exc())
finally: finally:
# Wait for all submitted tasks to complete before shutting down
if self.thread_pool: if self.thread_pool:
self.thread_pool.shutdown(wait=True) self.thread_pool.shutdown(wait=True)
self.is_running = False self.is_running = False
self._log("🏁 All processing tasks have completed.") self._log("🏁 All processing tasks have completed.")
# Emit final signal
self.progress_queue.put({ self.progress_queue.put({
'type': 'finished', 'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
}) })
def _get_all_posts(self, config): def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client.""" """Helper to fetch all posts using the API client."""
all_posts = [] all_posts = []
# This generator yields batches of posts
post_generator = download_from_api( post_generator = download_from_api(
api_url_input=config['api_url'], api_url_input=config['api_url'],
logger=self._log, logger=self._log,
# ... pass other relevant config keys ... start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event, cancellation_event=self.cancellation_event,
pause_event=self.pause_event pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=config.get('processed_post_ids', [])
) )
for batch in post_generator: for batch in post_generator:
all_posts.extend(batch) all_posts.extend(batch)
@@ -203,14 +185,11 @@ class DownloadManager:
self.total_skips += 1 self.total_skips += 1
else: else:
result = future.result() result = future.result()
# Unpack result tuple from the worker
(dl_count, skip_count, kept_originals, (dl_count, skip_count, kept_originals,
retryable, permanent, history) = result retryable, permanent, history) = result
self.total_downloads += dl_count self.total_downloads += dl_count
self.total_skips += skip_count self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals) self.all_kept_original_filenames.extend(kept_originals)
# Queue up results for UI to handle
if retryable: if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)}) self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent: if permanent:
@@ -221,8 +200,6 @@ class DownloadManager:
except Exception as e: except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}") self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped self.total_skips += 1 # Count errored posts as skipped
# Update overall progress
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def cancel_session(self): def cancel_session(self):
@@ -231,11 +208,7 @@ class DownloadManager:
return return
self._log("⚠️ Cancellation requested by user...") self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set() self.cancellation_event.set()
# For single thread mode, the worker checks the event
# For multi-thread mode, shut down the pool
if self.thread_pool: if self.thread_pool:
# Don't wait, just cancel pending futures and let the fetcher thread exit
self.thread_pool.shutdown(wait=False, cancel_futures=True) self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False self.is_running = False

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import os import os
import queue import queue
import re import re
@@ -9,21 +8,18 @@ import uuid
import http import http
import html import html
import json import json
from collections import deque from collections import deque, defaultdict
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future
from io import BytesIO from io import BytesIO
from urllib .parse import urlparse from urllib .parse import urlparse
import requests import requests
# --- Third-Party Library Imports ---
try: try:
from PIL import Image from PIL import Image
except ImportError: except ImportError:
Image = None Image = None
#
try: try:
from fpdf import FPDF from fpdf import FPDF
# Add a simple class to handle the header/footer for stories
class PDF(FPDF): class PDF(FPDF):
def header(self): def header(self):
pass # No header pass # No header
@@ -39,16 +35,12 @@ try:
from docx import Document from docx import Document
except ImportError: except ImportError:
Document = None Document = None
# --- PyQt5 Imports ---
from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
# --- Local Application Imports ---
from .api_client import download_from_api, fetch_post_comments from .api_client import download_from_api, fetch_post_comments
from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
from ..services.drive_downloader import ( from ..services.drive_downloader import (
download_mega_file, download_gdrive_file, download_dropbox_file download_mega_file, download_gdrive_file, download_dropbox_file
) )
# Corrected Imports:
from ..utils.file_utils import ( from ..utils.file_utils import (
is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES, is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
clean_filename, clean_folder_name clean_filename, clean_folder_name
@@ -71,105 +63,113 @@ class PostProcessorSignals (QObject ):
worker_finished_signal = pyqtSignal(tuple) worker_finished_signal = pyqtSignal(tuple)
class PostProcessorWorker: class PostProcessorWorker:
def __init__ (self ,post_data ,download_root ,known_names ,
filter_character_list ,emitter , def __init__(self, post_data, download_root, known_names,
unwanted_keywords ,filter_mode ,skip_zip ,skip_rar , filter_character_list, emitter,
use_subfolders ,use_post_subfolders ,target_post_id_from_initial_url ,custom_folder_name , unwanted_keywords, filter_mode, skip_zip,
compress_images ,download_thumbnails ,service ,user_id ,pause_event , use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
api_url_input ,cancellation_event , compress_images, download_thumbnails, service, user_id, pause_event,
downloaded_files ,downloaded_file_hashes ,downloaded_files_lock ,downloaded_file_hashes_lock , api_url_input, cancellation_event,
dynamic_character_filter_holder =None ,skip_words_list =None , downloaded_files, downloaded_file_hashes, downloaded_files_lock, downloaded_file_hashes_lock,
skip_words_scope =SKIP_SCOPE_FILES , dynamic_character_filter_holder=None, skip_words_list=None,
show_external_links =False , skip_words_scope=SKIP_SCOPE_FILES,
extract_links_only =False , show_external_links=False,
num_file_threads =4 ,skip_current_file_flag =None , extract_links_only=False,
manga_mode_active =False , num_file_threads=4, skip_current_file_flag=None,
manga_filename_style =STYLE_POST_TITLE , manga_mode_active=False,
char_filter_scope =CHAR_SCOPE_FILES , manga_filename_style=STYLE_POST_TITLE,
remove_from_filename_words_list =None , char_filter_scope=CHAR_SCOPE_FILES,
allow_multipart_download =True , remove_from_filename_words_list=None,
cookie_text ="", allow_multipart_download=True,
use_cookie =False , cookie_text="",
override_output_dir =None , use_cookie=False,
selected_cookie_file =None , override_output_dir=None,
app_base_dir =None , selected_cookie_file=None,
manga_date_prefix =MANGA_DATE_PREFIX_DEFAULT , app_base_dir=None,
manga_date_file_counter_ref =None , manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT,
scan_content_for_images =False , manga_date_file_counter_ref=None,
creator_download_folder_ignore_words =None , scan_content_for_images=False,
manga_global_file_counter_ref =None , creator_download_folder_ignore_words=None,
use_date_prefix_for_subfolder=False, manga_global_file_counter_ref=None,
keep_in_post_duplicates=False, use_date_prefix_for_subfolder=False,
session_file_path=None, keep_in_post_duplicates=False,
session_lock=None, keep_duplicates_mode=DUPLICATE_HANDLING_HASH,
text_only_scope=None, keep_duplicates_limit=0,
text_export_format='txt', downloaded_hash_counts=None,
single_pdf_mode=False, downloaded_hash_counts_lock=None,
project_root_dir=None, session_file_path=None,
processed_post_ids=None session_lock=None,
): text_only_scope=None,
self .post =post_data text_export_format='txt',
self .download_root =download_root single_pdf_mode=False,
self .known_names =known_names project_root_dir=None,
self .filter_character_list_objects_initial =filter_character_list if filter_character_list else [] processed_post_ids=None
self .dynamic_filter_holder =dynamic_character_filter_holder ):
self .unwanted_keywords =unwanted_keywords if unwanted_keywords is not None else set () self.post = post_data
self .filter_mode =filter_mode self.download_root = download_root
self .skip_zip =skip_zip self.known_names = known_names
self .skip_rar =skip_rar self.filter_character_list_objects_initial = filter_character_list if filter_character_list else []
self .use_subfolders =use_subfolders self.dynamic_filter_holder = dynamic_character_filter_holder
self .use_post_subfolders =use_post_subfolders self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
self .target_post_id_from_initial_url =target_post_id_from_initial_url self.filter_mode = filter_mode
self .custom_folder_name =custom_folder_name self.skip_zip = skip_zip
self .compress_images =compress_images self.use_subfolders = use_subfolders
self .download_thumbnails =download_thumbnails self.use_post_subfolders = use_post_subfolders
self .service =service self.target_post_id_from_initial_url = target_post_id_from_initial_url
self .user_id =user_id self.custom_folder_name = custom_folder_name
self .api_url_input =api_url_input self.compress_images = compress_images
self .cancellation_event =cancellation_event self.download_thumbnails = download_thumbnails
self .pause_event =pause_event self.service = service
self .emitter =emitter self.user_id = user_id
if not self .emitter : self.api_url_input = api_url_input
raise ValueError ("PostProcessorWorker requires an emitter (signals object or queue).") self.cancellation_event = cancellation_event
self .skip_current_file_flag =skip_current_file_flag self.pause_event = pause_event
self .downloaded_files =downloaded_files if downloaded_files is not None else set () self.emitter = emitter
self .downloaded_file_hashes =downloaded_file_hashes if downloaded_file_hashes is not None else set () if not self.emitter:
self .downloaded_files_lock =downloaded_files_lock if downloaded_files_lock is not None else threading .Lock () raise ValueError("PostProcessorWorker requires an emitter (signals object or queue).")
self .downloaded_file_hashes_lock =downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading .Lock () self.skip_current_file_flag = skip_current_file_flag
self .skip_words_list =skip_words_list if skip_words_list is not None else [] self.downloaded_files = downloaded_files if downloaded_files is not None else set()
self .skip_words_scope =skip_words_scope self.downloaded_file_hashes = downloaded_file_hashes if downloaded_file_hashes is not None else set()
self .show_external_links =show_external_links self.downloaded_files_lock = downloaded_files_lock if downloaded_files_lock is not None else threading.Lock()
self .extract_links_only =extract_links_only self.downloaded_file_hashes_lock = downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading.Lock()
self .num_file_threads =num_file_threads self.skip_words_list = skip_words_list if skip_words_list is not None else []
self .manga_mode_active =manga_mode_active self.skip_words_scope = skip_words_scope
self .manga_filename_style =manga_filename_style self.show_external_links = show_external_links
self .char_filter_scope =char_filter_scope self.extract_links_only = extract_links_only
self .remove_from_filename_words_list =remove_from_filename_words_list if remove_from_filename_words_list is not None else [] self.num_file_threads = num_file_threads
self .allow_multipart_download =allow_multipart_download self.manga_mode_active = manga_mode_active
self .manga_date_file_counter_ref =manga_date_file_counter_ref self.manga_filename_style = manga_filename_style
self .selected_cookie_file =selected_cookie_file self.char_filter_scope = char_filter_scope
self .app_base_dir =app_base_dir self.remove_from_filename_words_list = remove_from_filename_words_list if remove_from_filename_words_list is not None else []
self .cookie_text =cookie_text self.allow_multipart_download = allow_multipart_download
self .manga_date_prefix =manga_date_prefix self.manga_date_file_counter_ref = manga_date_file_counter_ref
self .manga_global_file_counter_ref =manga_global_file_counter_ref self.selected_cookie_file = selected_cookie_file
self .use_cookie =use_cookie self.app_base_dir = app_base_dir
self .override_output_dir =override_output_dir self.cookie_text = cookie_text
self .scan_content_for_images =scan_content_for_images self.manga_date_prefix = manga_date_prefix
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words self.manga_global_file_counter_ref = manga_global_file_counter_ref
self.use_cookie = use_cookie
self.override_output_dir = override_output_dir
self.scan_content_for_images = scan_content_for_images
self.creator_download_folder_ignore_words = creator_download_folder_ignore_words
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
self.keep_in_post_duplicates = keep_in_post_duplicates self.keep_in_post_duplicates = keep_in_post_duplicates
self.keep_duplicates_mode = keep_duplicates_mode
self.keep_duplicates_limit = keep_duplicates_limit
self.downloaded_hash_counts = downloaded_hash_counts if downloaded_hash_counts is not None else defaultdict(int)
self.downloaded_hash_counts_lock = downloaded_hash_counts_lock if downloaded_hash_counts_lock is not None else threading.Lock()
self.session_file_path = session_file_path self.session_file_path = session_file_path
self.session_lock = session_lock self.session_lock = session_lock
self.text_only_scope = text_only_scope self.text_only_scope = text_only_scope
self.text_export_format = text_export_format self.text_export_format = text_export_format
self.single_pdf_mode = single_pdf_mode self.single_pdf_mode = single_pdf_mode
self.project_root_dir = project_root_dir self.project_root_dir = project_root_dir
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] self.processed_post_ids = processed_post_ids if processed_post_ids is not None else []
if self .compress_images and Image is None : if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found.")
self.compress_images = False
self .logger ("⚠️ Image compression disabled: Pillow library not found.")
self .compress_images =False
def _emit_signal (self ,signal_type_str ,*payload_args ): def _emit_signal (self ,signal_type_str ,*payload_args ):
"""Helper to emit signal either directly or via queue.""" """Helper to emit signal either directly or via queue."""
if isinstance (self .emitter ,queue .Queue ): if isinstance (self .emitter ,queue .Queue ):
@@ -179,6 +179,7 @@ class PostProcessorWorker:
signal_attr .emit (*payload_args ) signal_attr .emit (*payload_args )
else : else :
print (f"(Worker Log - Unrecognized Emitter for {signal_type_str }): {payload_args [0 ]if payload_args else ''}") print (f"(Worker Log - Unrecognized Emitter for {signal_type_str }): {payload_args [0 ]if payload_args else ''}")
def logger (self ,message ): def logger (self ,message ):
self ._emit_signal ('progress',message ) self ._emit_signal ('progress',message )
def check_cancel (self ): def check_cancel (self ):
@@ -384,13 +385,9 @@ class PostProcessorWorker:
if not is_audio_type: if not is_audio_type:
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).") self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
if self.skip_zip and is_zip(api_original_filename): if (self.skip_zip) and is_archive(api_original_filename):
self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).") self.logger(f" -> Pref Skip: '{api_original_filename}' (Archive).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
if self.skip_rar and is_rar(api_original_filename):
self.logger(f" -> Pref Skip: '{api_original_filename}' (RAR).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
try: try:
os.makedirs(target_folder_path, exist_ok=True) os.makedirs(target_folder_path, exist_ok=True)
except OSError as e: except OSError as e:
@@ -408,6 +405,7 @@ class PostProcessorWorker:
total_size_bytes = 0 total_size_bytes = 0
download_successful_flag = False download_successful_flag = False
last_exception_for_retry_later = None last_exception_for_retry_later = None
is_permanent_error = False
data_to_write_io = None data_to_write_io = None
response_for_this_attempt = None response_for_this_attempt = None
@@ -512,12 +510,14 @@ class PostProcessorWorker:
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
last_exception_for_retry_later = e last_exception_for_retry_later = e
is_permanent_error = True
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
break break
except Exception as e: except Exception as e:
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}") self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
last_exception_for_retry_later = e last_exception_for_retry_later = e
is_permanent_error = True
break break
finally: finally:
if response_for_this_attempt: if response_for_this_attempt:
@@ -544,7 +544,6 @@ class PostProcessorWorker:
self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}") self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}")
if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag): if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag):
self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.")
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
@@ -556,20 +555,34 @@ class PostProcessorWorker:
if self._check_pause(f"Post-download hash check for '{api_original_filename}'"): if self._check_pause(f"Post-download hash check for '{api_original_filename}'"):
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
### START OF CHANGE 1: INSERT THIS NEW BLOCK ### should_skip = False
with self.downloaded_file_hashes_lock: with self.downloaded_hash_counts_lock:
if calculated_file_hash in self.downloaded_file_hashes: current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
self.logger(f" -> Skip (Content Duplicate): '{api_original_filename}' is identical to a file already downloaded. Discarding.")
# Clean up the downloaded temporary file as it's a duplicate. decision_to_skip = False
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try: if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
os.remove(downloaded_part_file_path) if current_count >= 1:
except OSError: decision_to_skip = True
pass self.logger(f" -> Skip (Content Duplicate): '{api_original_filename}' is identical to a file already downloaded. Discarding.")
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
elif self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL and self.keep_duplicates_limit > 0:
if current_count >= self.keep_duplicates_limit:
decision_to_skip = True
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
if not decision_to_skip:
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
should_skip = decision_to_skip
if should_skip:
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try:
os.remove(downloaded_part_file_path)
except OSError: pass
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
# If the content is unique, we proceed to save.
# Now, handle FILENAME collisions by adding a numeric suffix if needed.
effective_save_folder = target_folder_path effective_save_folder = target_folder_path
base_name, extension = os.path.splitext(filename_to_save_in_main_path) base_name, extension = os.path.splitext(filename_to_save_in_main_path)
counter = 1 counter = 1
@@ -603,8 +616,6 @@ class PostProcessorWorker:
with self.downloaded_file_hashes_lock: with self.downloaded_file_hashes_lock:
self.downloaded_file_hashes.add(calculated_file_hash) self.downloaded_file_hashes.add(calculated_file_hash)
with self.downloaded_files_lock:
self.downloaded_files.add(final_filename_on_disk)
final_filename_saved_for_return = final_filename_on_disk final_filename_saved_for_return = final_filename_on_disk
self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'") self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'")
@@ -629,15 +640,12 @@ class PostProcessorWorker:
except Exception as save_err: except Exception as save_err:
self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}") self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}")
# --- START OF THE FIX ---
# If saving/renaming fails, try to clean up the orphaned .part file.
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
self.logger(f" Cleaned up temporary file after save error: {os.path.basename(downloaded_part_file_path)}") self.logger(f" Cleaned up temporary file after save error: {os.path.basename(downloaded_part_file_path)}")
except OSError as e_rem: except OSError as e_rem:
self.logger(f" ⚠️ Could not clean up temporary file '{os.path.basename(downloaded_part_file_path)}' after save error: {e_rem}") self.logger(f" ⚠️ Could not clean up temporary file '{os.path.basename(downloaded_part_file_path)}' after save error: {e_rem}")
# --- END OF THE FIX ---
if os.path.exists(final_save_path): if os.path.exists(final_save_path):
try: try:
@@ -656,22 +664,30 @@ class PostProcessorWorker:
if data_to_write_io and hasattr(data_to_write_io, 'close'): if data_to_write_io and hasattr(data_to_write_io, 'close'):
data_to_write_io.close() data_to_write_io.close()
else: else:
# This is the path if the download was not successful after all retries
self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.") self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.")
retry_later_details = { details_for_failure = {
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'file_info': file_info,
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'target_folder_path': target_folder_path,
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post 'headers': headers,
'original_post_id_for_log': original_post_id_for_log,
'post_title': post_title,
'file_index_in_post': file_index_in_post,
'num_files_in_this_post': num_files_in_this_post,
'forced_filename_override': filename_to_save_in_main_path
} }
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details if is_permanent_error:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure
else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
def process(self): def process(self):
# Default "empty" result tuple. It will be updated before any return path.
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
try: try:
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"):
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
return result_tuple # Return for the direct caller return result_tuple
if self.check_cancel(): if self.check_cancel():
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
return result_tuple return result_tuple
@@ -701,7 +717,8 @@ class PostProcessorWorker:
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words) effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
post_content_html = post_data.get('content', '') post_content_html = post_data.get('content', '')
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---") if not self.extract_links_only:
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---")
num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0) num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0)
post_is_candidate_by_title_char_match = False post_is_candidate_by_title_char_match = False
@@ -1016,7 +1033,9 @@ class PostProcessorWorker:
return result_tuple return result_tuple
raw_text_content = "" raw_text_content = ""
comments_data = []
final_post_data = post_data final_post_data = post_data
if self.text_only_scope == 'content' and 'content' not in final_post_data: if self.text_only_scope == 'content' and 'content' not in final_post_data:
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...") self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
parsed_url = urlparse(self.api_url_input) parsed_url = urlparse(self.api_url_input)
@@ -1026,6 +1045,7 @@ class PostProcessorWorker:
full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies) full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
if full_data: if full_data:
final_post_data = full_data final_post_data = full_data
if self.text_only_scope == 'content': if self.text_only_scope == 'content':
raw_text_content = final_post_data.get('content', '') raw_text_content = final_post_data.get('content', '')
elif self.text_only_scope == 'comments': elif self.text_only_scope == 'comments':
@@ -1036,46 +1056,46 @@ class PostProcessorWorker:
if comments_data: if comments_data:
comment_texts = [] comment_texts = []
for comment in comments_data: for comment in comments_data:
user = comment.get('user', {}).get('name', 'Unknown User') user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('updated', 'No Date') timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', '')) body = strip_html_tags(comment.get('content', ''))
comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n")
raw_text_content = "\n".join(comment_texts) raw_text_content = "\n".join(comment_texts)
else:
raw_text_content = ""
except Exception as e: except Exception as e:
self.logger(f" ❌ Error fetching comments for text-only mode: {e}") self.logger(f" ❌ Error fetching comments for text-only mode: {e}")
if not raw_text_content or not raw_text_content.strip(): cleaned_text = ""
if self.text_only_scope == 'content':
if not raw_text_content:
cleaned_text = ""
else:
text_with_newlines = re.sub(r'(?i)</p>|<br\s*/?>', '\n', raw_text_content)
just_text = re.sub(r'<.*?>', '', text_with_newlines)
cleaned_text = html.unescape(just_text).strip()
else:
cleaned_text = raw_text_content
cleaned_text = cleaned_text.replace('', '...')
if not cleaned_text.strip():
self.logger(" -> Skip Saving Text: No content/comments found or fetched.") self.logger(" -> Skip Saving Text: No content/comments found or fetched.")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple return result_tuple
paragraph_pattern = re.compile(r'<p.*?>(.*?)</p>', re.IGNORECASE | re.DOTALL)
html_paragraphs = paragraph_pattern.findall(raw_text_content)
cleaned_text = ""
if not html_paragraphs:
self.logger(" ⚠️ No <p> tags found. Falling back to basic HTML cleaning for the whole block.")
text_with_br = re.sub(r'<br\s*/?>', '\n', raw_text_content, flags=re.IGNORECASE)
cleaned_text = re.sub(r'<.*?>', '', text_with_br)
else:
cleaned_paragraphs_list = []
for p_content in html_paragraphs:
p_with_br = re.sub(r'<br\s*/?>', '\n', p_content, flags=re.IGNORECASE)
p_cleaned = re.sub(r'<.*?>', '', p_with_br)
p_final = html.unescape(p_cleaned).strip()
if p_final:
cleaned_paragraphs_list.append(p_final)
cleaned_text = '\n\n'.join(cleaned_paragraphs_list)
cleaned_text = cleaned_text.replace('', '...')
if self.single_pdf_mode: if self.single_pdf_mode:
if not cleaned_text:
result_tuple = (0, 0, [], [], [], None, None)
return result_tuple
content_data = { content_data = {
'title': post_title, 'title': post_title,
'content': cleaned_text,
'published': self.post.get('published') or self.post.get('added') 'published': self.post.get('published') or self.post.get('added')
} }
if self.text_only_scope == 'comments':
if not comments_data: return (0, 0, [], [], [], None, None)
content_data['comments'] = comments_data
else:
if not cleaned_text.strip(): return (0, 0, [], [], [], None, None)
content_data['content'] = cleaned_text
temp_dir = os.path.join(self.app_base_dir, "appdata") temp_dir = os.path.join(self.app_base_dir, "appdata")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json"
@@ -1083,13 +1103,11 @@ class PostProcessorWorker:
try: try:
with open(temp_filepath, 'w', encoding='utf-8') as f: with open(temp_filepath, 'w', encoding='utf-8') as f:
json.dump(content_data, f, indent=2) json.dump(content_data, f, indent=2)
self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
result_tuple = (0, 0, [], [], [], None, temp_filepath) return (0, 0, [], [], [], None, temp_filepath)
return result_tuple
except Exception as e: except Exception as e:
self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") self.logger(f" ❌ Failed to write temporary file for single PDF: {e}")
result_tuple = (0, 0, [], [], [], None, None) return (0, 0, [], [], [], None, None)
return result_tuple
else: else:
file_extension = self.text_export_format file_extension = self.text_export_format
txt_filename = clean_filename(post_title) + f".{file_extension}" txt_filename = clean_filename(post_title) + f".{file_extension}"
@@ -1101,27 +1119,63 @@ class PostProcessorWorker:
while os.path.exists(final_save_path): while os.path.exists(final_save_path):
final_save_path = f"{base}_{counter}{ext}" final_save_path = f"{base}_{counter}{ext}"
counter += 1 counter += 1
if file_extension == 'pdf': if file_extension == 'pdf':
if FPDF: if FPDF:
self.logger(f" Converting to PDF...") self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
pdf = PDF() pdf = PDF()
font_path = "" font_path = ""
bold_font_path = ""
if self.project_root_dir: if self.project_root_dir:
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
try: try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True) pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.set_font('DejaVu', '', 12) pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
default_font_family = 'DejaVu'
except Exception as font_error: except Exception as font_error:
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
pdf.set_font('Arial', '', 12) default_font_family = 'Arial'
pdf.add_page() pdf.add_page()
pdf.multi_cell(0, 5, cleaned_text) pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(0, 10, post_title)
pdf.ln(10)
if self.text_only_scope == 'comments':
if not comments_data:
self.logger(" -> Skip PDF Creation: No comments to process.")
return (0, num_potential_files_in_post, [], [], [], None, None)
for i, comment in enumerate(comments_data):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, user)
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if i < len(comments_data) - 1:
pdf.ln(5)
pdf.cell(0, 0, '', border='T')
pdf.ln(5)
else:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(0, 7, cleaned_text)
pdf.output(final_save_path) pdf.output(final_save_path)
else: else:
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".txt" final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
elif file_extension == 'docx': elif file_extension == 'docx':
if Document: if Document:
self.logger(f" Converting to DOCX...") self.logger(f" Converting to DOCX...")
@@ -1132,12 +1186,15 @@ class PostProcessorWorker:
self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.") self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".txt" final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
else:
else: # TXT file
with open(final_save_path, 'w', encoding='utf-8') as f: with open(final_save_path, 'w', encoding='utf-8') as f:
f.write(cleaned_text) f.write(cleaned_text)
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'") self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None) result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
return result_tuple return result_tuple
except Exception as e: except Exception as e:
self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
@@ -1236,6 +1293,20 @@ class PostProcessorWorker:
else: else:
self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}") self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}")
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
unique_files_by_url = {}
for file_info in all_files_from_post_api:
file_url = file_info.get('url')
if file_url and file_url not in unique_files_by_url:
unique_files_by_url[file_url] = file_info
original_count = len(all_files_from_post_api)
all_files_from_post_api = list(unique_files_by_url.values())
new_count = len(all_files_from_post_api)
if new_count < original_count:
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.")
if self.scan_content_for_images and post_content_html and not self.extract_links_only: if self.scan_content_for_images and post_content_html and not self.extract_links_only:
self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...") self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...")
parsed_input_url = urlparse(self.api_url_input) parsed_input_url = urlparse(self.api_url_input)
@@ -1528,9 +1599,7 @@ class PostProcessorWorker:
'service': self.service, 'user_id': self.user_id, 'service': self.service, 'user_id': self.user_id,
} }
if self.check_cancel(): if not self.check_cancel():
self.logger(f" Post {post_id} processing interrupted/cancelled.")
else:
self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}") self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}")
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0: if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
@@ -1542,18 +1611,14 @@ class PostProcessorWorker:
except OSError as e_rmdir: except OSError as e_rmdir:
self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}") self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
# After all processing, set the final result tuple for the normal execution path
result_tuple = (total_downloaded_this_post, total_skipped_this_post, result_tuple = (total_downloaded_this_post, total_skipped_this_post,
kept_original_filenames_for_log, retryable_failures_this_post, kept_original_filenames_for_log, retryable_failures_this_post,
permanent_failures_this_post, history_data_for_this_post, permanent_failures_this_post, history_data_for_this_post,
None) None)
finally: finally:
# This block is GUARANTEED to execute, sending the signal for multi-threaded mode.
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
# This line is the critical fix. It ensures the method always returns a tuple
# for the single-threaded mode that directly calls it.
return result_tuple return result_tuple
class DownloadThread(QThread): class DownloadThread(QThread):
@@ -1573,12 +1638,12 @@ class DownloadThread(QThread):
def __init__(self, api_url_input, output_dir, known_names_copy, def __init__(self, api_url_input, output_dir, known_names_copy,
cancellation_event, cancellation_event,
pause_event, filter_character_list=None, dynamic_character_filter_holder=None, pause_event, filter_character_list=None, dynamic_character_filter_holder=None,
filter_mode='all', skip_zip=True, skip_rar=True, filter_mode='all', skip_zip=True,
use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False, use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False,
download_thumbnails=False, service=None, user_id=None, download_thumbnails=False, service=None, user_id=None,
downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None, downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None,
skip_words_list=None, skip_words_list=None,
skip_words_scope=SKIP_SCOPE_FILES, skip_words_scope='files',
show_external_links=False, show_external_links=False,
extract_links_only=False, extract_links_only=False,
num_file_threads_for_worker=1, num_file_threads_for_worker=1,
@@ -1587,10 +1652,10 @@ class DownloadThread(QThread):
target_post_id_from_initial_url=None, target_post_id_from_initial_url=None,
manga_mode_active=False, manga_mode_active=False,
unwanted_keywords=None, unwanted_keywords=None,
manga_filename_style=STYLE_POST_TITLE, manga_filename_style='post_title',
char_filter_scope=CHAR_SCOPE_FILES, char_filter_scope='files',
remove_from_filename_words_list=None, remove_from_filename_words_list=None,
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, manga_date_prefix='',
allow_multipart_download=True, allow_multipart_download=True,
selected_cookie_file=None, selected_cookie_file=None,
override_output_dir=None, override_output_dir=None,
@@ -1602,6 +1667,10 @@ class DownloadThread(QThread):
creator_download_folder_ignore_words=None, creator_download_folder_ignore_words=None,
use_date_prefix_for_subfolder=False, use_date_prefix_for_subfolder=False,
keep_in_post_duplicates=False, keep_in_post_duplicates=False,
keep_duplicates_mode='hash',
keep_duplicates_limit=0,
downloaded_hash_counts=None,
downloaded_hash_counts_lock=None,
cookie_text="", cookie_text="",
session_file_path=None, session_file_path=None,
session_lock=None, session_lock=None,
@@ -1609,7 +1678,8 @@ class DownloadThread(QThread):
text_export_format='txt', text_export_format='txt',
single_pdf_mode=False, single_pdf_mode=False,
project_root_dir=None, project_root_dir=None,
processed_post_ids=None): # Add processed_post_ids here processed_post_ids=None,
start_offset=0):
super().__init__() super().__init__()
self.api_url_input = api_url_input self.api_url_input = api_url_input
self.output_dir = output_dir self.output_dir = output_dir
@@ -1622,7 +1692,6 @@ class DownloadThread(QThread):
self.dynamic_filter_holder = dynamic_character_filter_holder self.dynamic_filter_holder = dynamic_character_filter_holder
self.filter_mode = filter_mode self.filter_mode = filter_mode
self.skip_zip = skip_zip self.skip_zip = skip_zip
self.skip_rar = skip_rar
self.use_subfolders = use_subfolders self.use_subfolders = use_subfolders
self.use_post_subfolders = use_post_subfolders self.use_post_subfolders = use_post_subfolders
self.custom_folder_name = custom_folder_name self.custom_folder_name = custom_folder_name
@@ -1660,6 +1729,10 @@ class DownloadThread(QThread):
self.creator_download_folder_ignore_words = creator_download_folder_ignore_words self.creator_download_folder_ignore_words = creator_download_folder_ignore_words
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
self.keep_in_post_duplicates = keep_in_post_duplicates self.keep_in_post_duplicates = keep_in_post_duplicates
self.keep_duplicates_mode = keep_duplicates_mode
self.keep_duplicates_limit = keep_duplicates_limit
self.downloaded_hash_counts = downloaded_hash_counts
self.downloaded_hash_counts_lock = downloaded_hash_counts_lock
self.manga_global_file_counter_ref = manga_global_file_counter_ref self.manga_global_file_counter_ref = manga_global_file_counter_ref
self.session_file_path = session_file_path self.session_file_path = session_file_path
self.session_lock = session_lock self.session_lock = session_lock
@@ -1668,7 +1741,8 @@ class DownloadThread(QThread):
self.text_export_format = text_export_format self.text_export_format = text_export_format
self.single_pdf_mode = single_pdf_mode self.single_pdf_mode = single_pdf_mode
self.project_root_dir = project_root_dir self.project_root_dir = project_root_dir
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] # Add this line self.processed_post_ids_set = set(processed_post_ids) if processed_post_ids is not None else set()
self.start_offset = start_offset
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
@@ -1681,7 +1755,9 @@ class DownloadThread(QThread):
def run(self): def run(self):
""" """
The main execution method for the single-threaded download process. The main execution method for the download process.
This version correctly uses the central `download_from_api` function
and explicitly maps all arguments to the PostProcessorWorker to prevent TypeErrors.
""" """
grand_total_downloaded_files = 0 grand_total_downloaded_files = 0
grand_total_skipped_files = 0 grand_total_skipped_files = 0
@@ -1690,7 +1766,6 @@ class DownloadThread(QThread):
worker_signals_obj = PostProcessorSignals() worker_signals_obj = PostProcessorSignals()
try: try:
# Connect signals
worker_signals_obj.progress_signal.connect(self.progress_signal) worker_signals_obj.progress_signal.connect(self.progress_signal)
worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal) worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal)
worker_signals_obj.file_progress_signal.connect(self.file_progress_signal) worker_signals_obj.file_progress_signal.connect(self.file_progress_signal)
@@ -1700,6 +1775,7 @@ class DownloadThread(QThread):
worker_signals_obj.worker_finished_signal.connect(lambda result: None) worker_signals_obj.worker_finished_signal.connect(lambda result: None)
self.logger(" Starting post fetch (single-threaded download process)...") self.logger(" Starting post fetch (single-threaded download process)...")
post_generator = download_from_api( post_generator = download_from_api(
self.api_url_input, self.api_url_input,
logger=self.logger, logger=self.logger,
@@ -1713,114 +1789,111 @@ class DownloadThread(QThread):
selected_cookie_file=self.selected_cookie_file, selected_cookie_file=self.selected_cookie_file,
app_base_dir=self.app_base_dir, app_base_dir=self.app_base_dir,
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None, manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
# --- FIX: ADDED A COMMA to the line above --- processed_post_ids=self.processed_post_ids_set
processed_post_ids=self.processed_post_ids
) )
for posts_batch_data in post_generator: for posts_batch_data in post_generator:
if self.isInterruptionRequested(): if self.isInterruptionRequested():
was_process_cancelled = True was_process_cancelled = True
break break
for individual_post_data in posts_batch_data: for individual_post_data in posts_batch_data:
if self.isInterruptionRequested(): if self.isInterruptionRequested():
was_process_cancelled = True was_process_cancelled = True
break break
post_processing_worker = PostProcessorWorker( worker_args = {
post_data=individual_post_data, 'post_data': individual_post_data,
download_root=self.output_dir, 'emitter': worker_signals_obj,
known_names=self.known_names, 'download_root': self.output_dir,
filter_character_list=self.filter_character_list_objects_initial, 'known_names': self.known_names,
dynamic_character_filter_holder=self.dynamic_filter_holder, 'filter_character_list': self.filter_character_list_objects_initial,
unwanted_keywords=self.unwanted_keywords, 'dynamic_character_filter_holder': self.dynamic_filter_holder,
filter_mode=self.filter_mode, 'target_post_id_from_initial_url': self.initial_target_post_id,
skip_zip=self.skip_zip, skip_rar=self.skip_rar, 'num_file_threads': self.num_file_threads_for_worker,
use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders, 'processed_post_ids': list(self.processed_post_ids_set),
target_post_id_from_initial_url=self.initial_target_post_id, 'unwanted_keywords': self.unwanted_keywords,
custom_folder_name=self.custom_folder_name, 'filter_mode': self.filter_mode,
compress_images=self.compress_images, download_thumbnails=self.download_thumbnails, 'skip_zip': self.skip_zip,
service=self.service, user_id=self.user_id, 'use_subfolders': self.use_subfolders,
api_url_input=self.api_url_input, 'use_post_subfolders': self.use_post_subfolders,
pause_event=self.pause_event, 'custom_folder_name': self.custom_folder_name,
cancellation_event=self.cancellation_event, 'compress_images': self.compress_images,
emitter=worker_signals_obj, 'download_thumbnails': self.download_thumbnails,
downloaded_files=self.downloaded_files, 'service': self.service,
downloaded_file_hashes=self.downloaded_file_hashes, 'user_id': self.user_id,
downloaded_files_lock=self.downloaded_files_lock, 'api_url_input': self.api_url_input,
downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, 'pause_event': self.pause_event,
skip_words_list=self.skip_words_list, 'cancellation_event': self.cancellation_event,
skip_words_scope=self.skip_words_scope, 'downloaded_files': self.downloaded_files,
show_external_links=self.show_external_links, 'downloaded_file_hashes': self.downloaded_file_hashes,
extract_links_only=self.extract_links_only, 'downloaded_files_lock': self.downloaded_files_lock,
num_file_threads=self.num_file_threads_for_worker, 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
skip_current_file_flag=self.skip_current_file_flag, 'skip_words_list': self.skip_words_list,
manga_mode_active=self.manga_mode_active, 'skip_words_scope': self.skip_words_scope,
manga_filename_style=self.manga_filename_style, 'show_external_links': self.show_external_links,
manga_date_prefix=self.manga_date_prefix, 'extract_links_only': self.extract_links_only,
char_filter_scope=self.char_filter_scope, 'skip_current_file_flag': self.skip_current_file_flag,
remove_from_filename_words_list=self.remove_from_filename_words_list, 'manga_mode_active': self.manga_mode_active,
allow_multipart_download=self.allow_multipart_download, 'manga_filename_style': self.manga_filename_style,
selected_cookie_file=self.selected_cookie_file, 'char_filter_scope': self.char_filter_scope,
app_base_dir=self.app_base_dir, 'remove_from_filename_words_list': self.remove_from_filename_words_list,
cookie_text=self.cookie_text, 'allow_multipart_download': self.allow_multipart_download,
override_output_dir=self.override_output_dir, 'cookie_text': self.cookie_text,
manga_global_file_counter_ref=self.manga_global_file_counter_ref, 'use_cookie': self.use_cookie,
use_cookie=self.use_cookie, 'override_output_dir': self.override_output_dir,
manga_date_file_counter_ref=self.manga_date_file_counter_ref, 'selected_cookie_file': self.selected_cookie_file,
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder, 'app_base_dir': self.app_base_dir,
keep_in_post_duplicates=self.keep_in_post_duplicates, 'manga_date_prefix': self.manga_date_prefix,
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words, 'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
session_file_path=self.session_file_path, 'scan_content_for_images': self.scan_content_for_images,
session_lock=self.session_lock, 'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
text_only_scope=self.text_only_scope, 'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
text_export_format=self.text_export_format, 'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
single_pdf_mode=self.single_pdf_mode, 'keep_in_post_duplicates': self.keep_in_post_duplicates,
project_root_dir=self.project_root_dir 'keep_duplicates_mode': self.keep_duplicates_mode,
) 'keep_duplicates_limit': self.keep_duplicates_limit,
try: 'downloaded_hash_counts': self.downloaded_hash_counts,
(dl_count, skip_count, kept_originals_this_post, 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
retryable_failures, permanent_failures, 'session_file_path': self.session_file_path,
history_data, temp_filepath) = post_processing_worker.process() 'session_lock': self.session_lock,
'text_only_scope': self.text_only_scope,
'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_mode,
'project_root_dir': self.project_root_dir,
}
grand_total_downloaded_files += dl_count post_processing_worker = PostProcessorWorker(**worker_args)
grand_total_skipped_files += skip_count
if kept_originals_this_post: (dl_count, skip_count, kept_originals_this_post,
grand_list_of_kept_original_filenames.extend(kept_originals_this_post) retryable_failures, permanent_failures,
if retryable_failures: history_data, temp_filepath) = post_processing_worker.process()
self.retryable_file_failed_signal.emit(retryable_failures)
if history_data:
if len(self.history_candidates_buffer) < 8:
self.post_processed_for_history_signal.emit(history_data)
if permanent_failures:
self.permanent_file_failed_signal.emit(permanent_failures)
if self.single_pdf_mode and temp_filepath: grand_total_downloaded_files += dl_count
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}") grand_total_skipped_files += skip_count
if kept_originals_this_post:
grand_list_of_kept_original_filenames.extend(kept_originals_this_post)
if retryable_failures:
self.retryable_file_failed_signal.emit(retryable_failures)
if history_data:
self.post_processed_for_history_signal.emit(history_data)
if permanent_failures:
self.permanent_file_failed_signal.emit(permanent_failures)
if self.single_pdf_mode and temp_filepath:
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}")
except Exception as proc_err:
post_id_for_err = individual_post_data.get('id', 'N/A')
self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}")
traceback.print_exc()
num_potential_files_est = len(individual_post_data.get('attachments', [])) + (
1 if individual_post_data.get('file') else 0)
grand_total_skipped_files += num_potential_files_est
if self.skip_current_file_flag and self.skip_current_file_flag.is_set():
self.skip_current_file_flag.clear()
self.logger(" Skip current file flag was processed and cleared by DownloadThread.")
self.msleep(10)
if was_process_cancelled: if was_process_cancelled:
break break
if not was_process_cancelled and not self.isInterruptionRequested(): if not was_process_cancelled and not self.isInterruptionRequested():
self.logger("✅ All posts processed or end of content reached by DownloadThread.") self.logger("✅ All posts processed or end of content reached by DownloadThread.")
except Exception as main_thread_err: except Exception as main_thread_err:
self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}") self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}")
traceback.print_exc() traceback.print_exc()
finally: finally:
try: try:
# Disconnect signals
if worker_signals_obj: if worker_signals_obj:
worker_signals_obj.progress_signal.disconnect(self.progress_signal) worker_signals_obj.progress_signal.disconnect(self.progress_signal)
worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal) worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal)
@@ -1830,15 +1903,9 @@ class DownloadThread(QThread):
worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal) worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal)
except (TypeError, RuntimeError) as e: except (TypeError, RuntimeError) as e:
self.logger(f" Note during DownloadThread signal disconnection: {e}") self.logger(f" Note during DownloadThread signal disconnection: {e}")
# Emit the final signal with all collected results
self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames) self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames)
def receive_add_character_result (self ,result ):
with QMutexLocker (self .prompt_mutex ):
self ._add_character_response =result
self .logger (f" (DownloadThread) Received character prompt response: {'Yes (added/confirmed)'if result else 'No (declined/failed)'}")
class InterruptedError(Exception): class InterruptedError(Exception):
"""Custom exception for handling cancellations gracefully.""" """Custom exception for handling cancellations gracefully."""
pass pass

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,7 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
# --- Asset Management ---
# This global variable will cache the icon so we don't have to load it from disk every time.
_app_icon_cache = None _app_icon_cache = None
def get_app_icon_object(): def get_app_icon_object():
@@ -22,17 +16,11 @@ def get_app_icon_object():
if _app_icon_cache and not _app_icon_cache.isNull(): if _app_icon_cache and not _app_icon_cache.isNull():
return _app_icon_cache return _app_icon_cache
# Declare a single variable to hold the base directory path.
app_base_dir = "" app_base_dir = ""
# Determine the project's base directory, whether running from source or as a bundled app
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# The application is frozen (e.g., with PyInstaller).
# The base directory is the one containing the executable.
app_base_dir = os.path.dirname(sys.executable) app_base_dir = os.path.dirname(sys.executable)
else: else:
# The application is running from a .py file.
# This path navigates up from src/ui/assets.py to the project root.
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico') icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
@@ -40,7 +28,6 @@ def get_app_icon_object():
if os.path.exists(icon_path): if os.path.exists(icon_path):
_app_icon_cache = QIcon(icon_path) _app_icon_cache = QIcon(icon_path)
else: else:
# If the icon isn't found, especially in a frozen app, check the _MEIPASS directory as a fallback.
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico') fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
if os.path.exists(fallback_icon_path): if os.path.exists(fallback_icon_path):

View File

@@ -1,18 +1,10 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout QPushButton, QVBoxLayout
) )
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
# --- Constants for Dialog Choices ---
# These were moved from main.py to be self-contained within this module's context.
CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
self.parent_app = parent_app self.parent_app = parent_app
self.setModal(True) self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list self.new_filter_objects_list = new_filter_objects_list
# Default choice if the dialog is closed without a button press
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- 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)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0 scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350 base_min_w, base_min_h = 480, 350
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
self.names_list_widget = QListWidget() self.names_list_widget = QListWidget()
self._populate_list() self._populate_list()
main_layout.addWidget(self.names_list_widget) main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout() selection_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)
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
selection_buttons_layout.addWidget(self.deselect_all_button) selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch() selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout) main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton() self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected) self.add_selected_button.clicked.connect(self._accept_add_selected)
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
sensible default if no items are selected but the "Add" button is clicked. sensible default if no items are selected but the "Add" button is clicked.
""" """
super().exec_() super().exec_()
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
if isinstance(self.user_choice, list) and not self.user_choice: if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice

View File

@@ -1,17 +1,10 @@
# --- Standard Library Imports ---
from collections import defaultdict from collections import defaultdict
# --- PyQt5 Imports ---
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 QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
) )
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
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
@@ -20,8 +13,6 @@ class DownloadExtractedLinksDialog(QDialog):
A dialog to select and initiate the download for extracted, supported links A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox. from external cloud services like Mega, Google Drive, and Dropbox.
""" """
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list) download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None): def __init__(self, links_data, parent_app, parent=None):
@@ -36,29 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.links_data = links_data self.links_data = links_data
self.parent_app = parent_app self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Set window size dynamically based on the parent window's size base_width, base_height = 600, 450
if parent: self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
parent_width = parent.width() self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -76,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection) self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list() self._populate_list()
layout.addWidget(self.links_list_widget) layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked)) self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
@@ -108,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower()) sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles: for post_title_key in sorted_post_titles:
# Add a non-selectable header for each post
header_item = QListWidgetItem(f"{post_title_key}") header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags) header_item.setFlags(Qt.NoItemFlags)
font = header_item.font() font = header_item.font()
@@ -116,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
font.setPointSize(font.pointSize() + 1) font.setPointSize(font.pointSize() + 1)
header_item.setFont(font) header_item.setFont(font)
self.links_list_widget.addItem(header_item) self.links_list_widget.addItem(header_item)
# Add checkable items for each link within that post
for link_info_data in grouped_links[post_title_key]: for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper() platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})" display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
@@ -147,19 +117,13 @@ class DownloadExtractedLinksDialog(QDialog):
is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark" is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme: if is_dark_theme:
# 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("")
# Set header text color based on theme
header_color = Qt.cyan if is_dark_theme else Qt.blue header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()): for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i) item = self.links_list_widget.item(i)
# Headers are not checkable (they have no checkable flag)
if not item.flags() & Qt.ItemIsUserCheckable: if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color) item.setForeground(header_color)
@@ -186,4 +150,4 @@ class DownloadExtractedLinksDialog(QDialog):
self, self,
self._tr("no_selection_title", "No Selection"), self._tr("no_selection_title", "No Selection"),
self._tr("no_selection_message_links", "Please select at least one link to download.") self._tr("no_selection_message_links", "Please select at least one link to download.")
) )

View File

@@ -1,16 +1,12 @@
# --- Standard Library Imports ---
import os import os
import time import time
import json import json
# --- PyQt5 Imports ---
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, QScrollArea, QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox, QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox QFileDialog, QMessageBox
) )
# --- Local Application Imports ---
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
@@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ):
self .first_processed_entries =first_processed_entries self .first_processed_entries =first_processed_entries
self .setModal (True ) self .setModal (True )
self._apply_theme() self._apply_theme()
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
creator_name_cache = getattr(parent_app, 'creator_name_cache', None) creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache: if creator_name_cache:
# Patch left pane (files)
for entry in self.last_3_downloaded_entries: for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'): if not entry.get('creator_display_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', '')) user_id = str(entry.get('user_id', ''))
key = (service, user_id) key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series')) entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
# Patch right pane (posts)
for entry in self.first_processed_entries: for entry in self.first_processed_entries:
if not entry.get('creator_name'): if not entry.get('creator_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()

View File

@@ -1003,4 +1003,4 @@ class EmptyPopupDialog (QDialog ):
else : else :
if unique_key in self .globally_selected_creators : if unique_key in self .globally_selected_creators :
del self .globally_selected_creators [unique_key ] del self .globally_selected_creators [unique_key ]
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))

View File

@@ -42,13 +42,15 @@ class ErrorFilesDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically # --- START OF FIX ---
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 # Get the user-defined scale factor from the parent application.
scale_factor = screen_height / 1080.0 scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_min_w, base_min_h = 500, 300
scaled_min_w = int(base_min_w * scale_factor) # Define base dimensions and apply the correct scale factor.
scaled_min_h = int(base_min_h * scale_factor) base_width, base_height = 550, 400
self.setMinimumSize(scaled_min_w, scaled_min_h) 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))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -232,4 +234,4 @@ class ErrorFilesDialog(QDialog):
self, self,
self._tr("error_files_export_error_title", "Export Error"), self._tr("error_files_export_error_title", "Export Error"),
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e)) self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
) )

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import html import html
import os import os
import sys import sys
@@ -8,8 +7,6 @@ import traceback
import json import json
import re import re
from collections import defaultdict from collections import defaultdict
# --- Third-Party Library Imports ---
import requests import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -17,12 +14,9 @@ from PyQt5.QtWidgets import (
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox QWidget, QCheckBox
) )
# --- 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
from ...utils.network_utils import prepare_cookies_for_request from ...utils.network_utils import prepare_cookies_for_request
# Corrected Import: Import CookieHelpDialog directly from its own module
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api from ...core.api_client import download_from_api
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme

View File

@@ -1,16 +1,11 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget QStackedWidget, QScrollArea, QFrame, QWidget
) )
# --- Local Application Imports ---
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
@@ -20,17 +15,20 @@ class TourStepWidget(QWidget):
A custom widget representing a single step or page in the feature guide. A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding 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, scale=1.0):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) layout.setSpacing(10)
title_font_size = int(14 * scale)
content_font_size = int(11 * scale)
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.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label) layout.addWidget(title_label)
scroll_area = QScrollArea() scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True) scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setFrameShape(QFrame.NoFrame)
@@ -42,8 +40,8 @@ class TourStepWidget(QWidget):
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) # Allow opening links in the content content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label) scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1) layout.addWidget(scroll_area, 1)
@@ -56,27 +54,38 @@ class HelpGuideDialog (QDialog ):
self .steps_data =steps_data self .steps_data =steps_data
self .parent_app =parent_app self .parent_app =parent_app
app_icon =get_app_icon_object () scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
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)
self .setModal (True ) self.setModal(True)
self .setFixedSize (650 ,600 ) self.resize(int(650 * scale), int(600 * scale))
dialog_font_size = int(11 * scale)
current_theme_style = ""
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale)
else:
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
QPushButton {{
background-color: #E1E1E1;
color: #1E1E1E;
border: 1px solid #ADADAD;
padding: {int(8*scale)}px {int(15*scale)}px;
border-radius: 4px;
min-height: {int(25*scale)}px;
font-size: {dialog_font_size}pt;
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
"""
current_theme_style ="" self.setStyleSheet(current_theme_style)
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
if hasattr (self .parent_app ,'get_dark_theme'):
current_theme_style =self .parent_app .get_dark_theme ()
self .setStyleSheet (current_theme_style if current_theme_style else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self ._init_ui () self ._init_ui ()
if self .parent_app : if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
@@ -97,10 +106,11 @@ class HelpGuideDialog (QDialog ):
main_layout .addWidget (self .stacked_widget ,1 ) main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[] self .tour_steps_widgets =[]
for title ,content in self .steps_data : scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
step_widget =TourStepWidget (title ,content ) for title, content in self.steps_data:
self .tour_steps_widgets .append (step_widget ) step_widget = TourStepWidget(title, content, scale=scale)
self .stacked_widget .addWidget (step_widget ) self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
@@ -115,7 +125,6 @@ class HelpGuideDialog (QDialog ):
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS assets_base_dir =sys ._MEIPASS
else : else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
@@ -126,7 +135,9 @@ class HelpGuideDialog (QDialog ):
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
icon_size =QSize (24 ,24 ) scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size ) self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size ) self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size ) self .Discord_button .setIconSize (icon_size )

View File

@@ -0,0 +1,107 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
class KeepDuplicatesDialog(QDialog):
"""A dialog to choose the duplicate handling method, with a limit option."""
def __init__(self, current_mode, current_limit, parent=None):
super().__init__(parent)
self.parent_app = parent
self.selected_mode = current_mode
self.limit = current_limit
self._init_ui()
self._retranslate_ui()
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
else:
self.radio_skip_by_hash.setChecked(True)
self.limit_input.setEnabled(False)
def _init_ui(self):
"""Initializes the UI components."""
main_layout = QVBoxLayout(self)
info_label = QLabel()
info_label.setWordWrap(True)
main_layout.addWidget(info_label)
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
keep_everything_layout.addWidget(self.radio_keep_everything)
keep_everything_layout.addStretch(1)
self.limit_label = QLabel()
self.limit_input = QLineEdit()
self.limit_input.setValidator(QIntValidator(0, 99))
self.limit_input.setFixedWidth(50)
keep_everything_layout.addWidget(self.limit_label)
keep_everything_layout.addWidget(self.limit_input)
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
button_layout.addStretch(1)
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -14,7 +14,6 @@ class KnownNamesFilterDialog(QDialog):
""" """
A dialog to select names from the Known.txt list to add to the main A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads. to reuse their saved names and groups for filtering downloads.
""" """
@@ -38,13 +37,16 @@ class KnownNamesFilterDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically # --- START OF FIX ---
screen_geometry = QApplication.primaryScreen().availableGeometry() # Get the user-defined scale factor from the parent application
# instead of calculating an independent one.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450 base_width, base_height = 460, 450
scale_factor_h = screen_geometry.height() / 1080.0 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor)) # --- END OF FIX ---
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -153,4 +155,4 @@ class KnownNamesFilterDialog(QDialog):
def get_selected_entries(self): def get_selected_entries(self):
"""Returns the list of known name entries selected by the user.""" """Returns the list of known name entries selected by the user."""
return self.selected_entries_to_return return self.selected_entries_to_return

View File

@@ -1,34 +1,33 @@
# SinglePDF.py
import os import os
import re
try: try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True FPDF_AVAILABLE = True
except ImportError: except ImportError:
FPDF_AVAILABLE = False FPDF_AVAILABLE = False
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
class PDF(FPDF): class PDF(FPDF):
"""Custom PDF class to handle headers and footers.""" """Custom PDF class to handle headers and footers."""
def header(self): def header(self):
# No header pass
pass
def footer(self): def footer(self):
# Position at 1.5 cm from bottom
self.set_y(-15) self.set_y(-15)
self.set_font('DejaVu', '', 8) if self.font_family:
# Page number self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print): def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
""" """
Creates a single PDF from a list of post titles and content. Creates a single, continuous PDF, correctly formatting both descriptions and comments.
Args:
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
output_filename (str): The full path for the output PDF file.
font_path (str): Path to the DejaVuSans.ttf font file.
logger (function, optional): A function to log progress and errors. Defaults to print.
""" """
if not FPDF_AVAILABLE: if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
@@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return False return False
pdf = PDF() pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try: try:
if not os.path.exists(font_path): if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
raise RuntimeError("Font file not found.") if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
logger(" PDF may not support all characters. Falling back to default Arial font.")
pdf.set_font('Arial', '', 12)
pdf.set_font('Arial', 'B', 16)
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
for post in posts_data:
pdf.add_page()
# Post Title
pdf.set_font('DejaVu', 'B', 16)
# vvv THIS LINE IS CORRECTED vvv
# We explicitly set align='L' and remove the incorrect positional arguments.
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5) # Add a little space after the title pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font_family = 'Arial'
pdf.add_page()
# Post Content logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
pdf.set_font('DejaVu', '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) for i, post in enumerate(posts_data):
if i > 0:
if 'content' in post:
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.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if 'comments' in post and post['comments']:
comments_list = post['comments']
for comment_index, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if comment_index < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
try: try:
pdf.output(output_filename) pdf.output(output_filename)
@@ -74,4 +105,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return True return True
except Exception as e: except Exception as e:
logger(f"❌ A critical error occurred while saving the final PDF: {e}") logger(f"❌ A critical error occurred while saving the final PDF: {e}")
return False return False

View File

@@ -1,14 +1,35 @@
# src/ui/dialogs/SupportDialog.py # src/ui/dialogs/SupportDialog.py
from PyQt5.QtWidgets import ( # --- Standard Library Imports ---
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox import sys
) import os
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
# Assuming execution from project root, so we can import from utils # --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QPixmap
# --- 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 dialog to show support and donation options.
@@ -17,11 +38,16 @@ class SupportDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.parent_app = parent self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer") self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(400) self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout # Main layout
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
layout.setSpacing(15) main_layout.setSpacing(15)
# Title Label # Title Label
title_label = QLabel("Thank You for Your Support!") title_label = QLabel("Thank You for Your Support!")
@@ -30,7 +56,7 @@ class SupportDialog(QDialog):
font.setBold(True) font.setBold(True)
title_label.setFont(font) title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label) main_layout.addWidget(title_label)
# Informational Text # Informational Text
info_label = QLabel( info_label = QLabel(
@@ -39,50 +65,86 @@ class SupportDialog(QDialog):
) )
info_label.setWordWrap(True) info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter) info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label) main_layout.addWidget(info_label)
# Separator # Separator
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) main_layout.addWidget(line)
# Donation Options # --- Donation Options Layout (using a grid for icons and text) ---
options_layout = QVBoxLayout() options_layout = QGridLayout()
options_layout.setSpacing(10) options_layout.setSpacing(18)
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally
options_layout.setColumnStretch(3, 1)
link_font = self.font()
link_font.setPointSize(12)
link_font.setBold(True)
scale = getattr(self.parent_app, 'scale_factor', 1.0)
icon_size = int(32 * scale)
# --- Ko-fi --- # --- Ko-fi ---
kofi_label = QLabel( kofi_icon_label = QLabel()
kofi_pixmap = QPixmap(get_asset_path("kofi.png"))
if not kofi_pixmap.isNull():
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
kofi_text_label = QLabel(
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">' '<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">'
'☕ Buy me a Ko-fi' '☕ Buy me a Ko-fi'
'</a>' '</a>'
) )
kofi_label.setOpenExternalLinks(True) kofi_text_label.setOpenExternalLinks(True)
kofi_label.setAlignment(Qt.AlignCenter) kofi_text_label.setFont(link_font)
font.setPointSize(12)
kofi_label.setFont(font)
options_layout.addWidget(kofi_label)
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 Sponsors ---
github_label = QLabel( github_icon_label = QLabel()
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #C9D1D9; text-decoration: none;">' 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' '💜 Sponsor on GitHub'
'</a>' '</a>'
) )
github_label.setOpenExternalLinks(True) github_text_label.setOpenExternalLinks(True)
github_label.setAlignment(Qt.AlignCenter) github_text_label.setFont(link_font)
github_label.setFont(font)
options_layout.addWidget(github_label)
layout.addLayout(options_layout) 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) self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject) self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box) main_layout.addWidget(self.button_box)
self.setLayout(layout) self.setLayout(main_layout)
self._apply_theme()
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
@@ -90,4 +152,4 @@ class SupportDialog(QDialog):
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("")

View File

@@ -1,15 +1,10 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
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
) )
# --- Local Application Imports ---
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
@@ -58,8 +53,6 @@ class TourDialog(QDialog):
""" """
tour_finished_normally = pyqtSignal() tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal() tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV19"
@@ -98,8 +91,6 @@ class TourDialog(QDialog):
self.stacked_widget = QStackedWidget() self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1) main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
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"),
@@ -120,8 +111,6 @@ class TourDialog(QDialog):
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 ---
bottom_controls_layout = QVBoxLayout() bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12) bottom_controls_layout.setSpacing(12)

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
# Sets of file extensions for quick type checking # Sets of file extensions for quick type checking
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

View File

@@ -24,19 +24,14 @@ def setup_ui(main_app):
Args: Args:
main_app: The instance of the main DownloaderApp. main_app: The instance of the main DownloaderApp.
""" """
# --- START: Modified Scaling Logic ---
# Force a fixed scale factor to disable UI scaling on high-DPI screens.
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0)) scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
main_app.scale_factor = scale main_app.scale_factor = scale
# --- Set the global font size for the application ---
default_font = QApplication.font() default_font = QApplication.font()
base_font_size = 9 # Use a standard base size base_font_size = 9 # Use a standard base size
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)
# --- END: Modified Scaling Logic ---
# --- Set the global font size for the application ---
default_font = QApplication.font() default_font = QApplication.font()
base_font_size = 9 # Use a standard base size base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale)) default_font.setPointSize(int(base_font_size * scale))
@@ -221,12 +216,10 @@ def setup_ui(main_app):
checkboxes_group_layout.setSpacing(10) checkboxes_group_layout.setSpacing(10)
row1_layout = QHBoxLayout() row1_layout = QHBoxLayout()
row1_layout.setSpacing(10) row1_layout.setSpacing(10)
main_app.skip_zip_checkbox = QCheckBox("Skip .zip") main_app.skip_zip_checkbox = QCheckBox("Skip archives")
main_app.skip_zip_checkbox.setToolTip("Skip Common Archives (Eg.. Zip, Rar, 7z)")
main_app.skip_zip_checkbox.setChecked(True) main_app.skip_zip_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_zip_checkbox) row1_layout.addWidget(main_app.skip_zip_checkbox)
main_app.skip_rar_checkbox = QCheckBox("Skip .rar")
main_app.skip_rar_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_rar_checkbox)
main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
row1_layout.addWidget(main_app.download_thumbnails_checkbox) row1_layout.addWidget(main_app.download_thumbnails_checkbox)
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images") main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
@@ -246,7 +239,7 @@ def setup_ui(main_app):
checkboxes_group_layout.addWidget(advanced_settings_label) checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout() advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10) advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title") main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True) main_app.use_subfolders_checkbox.setChecked(True)
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) advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
@@ -559,11 +552,13 @@ def get_dark_theme(scale=1):
border: 1px solid #6A6A6A; border: 1px solid #6A6A6A;
padding: {tooltip_padding}px; padding: {tooltip_padding}px;
border-radius: 3px; border-radius: 3px;
font-size: {font_size}pt;
}} }}
QSplitter::handle {{ background-color: #5A5A5A; }} QSplitter::handle {{ background-color: #5A5A5A; }}
QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }} QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }}
QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }} QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }}
""" """
def apply_theme_to_app(main_app, theme_name, initial_load=False): def apply_theme_to_app(main_app, theme_name, initial_load=False):
""" """
Applies the selected theme and scaling to the main application window. Applies the selected theme and scaling to the main application window.