mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdae61b80 | ||
|
|
33133eb275 | ||
|
|
3935cbeea4 |
@@ -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 = {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,12 +141,10 @@ 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)
|
||||||
@@ -178,13 +153,20 @@ class DownloadManager:
|
|||||||
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
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# --- Standard Library Imports ---
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
@@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError,
|
|||||||
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
|
||||||
@@ -74,7 +66,7 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
def __init__(self, post_data, download_root, known_names,
|
def __init__(self, post_data, download_root, known_names,
|
||||||
filter_character_list, emitter,
|
filter_character_list, emitter,
|
||||||
unwanted_keywords, filter_mode, skip_zip, skip_rar,
|
unwanted_keywords, filter_mode, skip_zip,
|
||||||
use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
|
use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
|
||||||
compress_images, download_thumbnails, service, user_id, pause_event,
|
compress_images, download_thumbnails, service, user_id, pause_event,
|
||||||
api_url_input, cancellation_event,
|
api_url_input, cancellation_event,
|
||||||
@@ -121,7 +113,6 @@ class PostProcessorWorker:
|
|||||||
self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
|
self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
|
||||||
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.target_post_id_from_initial_url = target_post_id_from_initial_url
|
self.target_post_id_from_initial_url = target_post_id_from_initial_url
|
||||||
@@ -394,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:
|
||||||
@@ -568,15 +555,12 @@ 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
|
||||||
|
|
||||||
# --- Final Corrected Duplicate Handling Logic ---
|
|
||||||
should_skip = False
|
should_skip = False
|
||||||
with self.downloaded_hash_counts_lock:
|
with self.downloaded_hash_counts_lock:
|
||||||
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
|
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
|
||||||
|
|
||||||
# Default to not skipping
|
|
||||||
decision_to_skip = False
|
decision_to_skip = False
|
||||||
|
|
||||||
# Apply logic based on mode
|
|
||||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||||
if current_count >= 1:
|
if current_count >= 1:
|
||||||
decision_to_skip = True
|
decision_to_skip = True
|
||||||
@@ -587,12 +571,10 @@ class PostProcessorWorker:
|
|||||||
decision_to_skip = True
|
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.")
|
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
|
||||||
|
|
||||||
# If we are NOT skipping this file, we MUST increment the count.
|
|
||||||
if not decision_to_skip:
|
if not decision_to_skip:
|
||||||
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
|
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
|
||||||
|
|
||||||
should_skip = decision_to_skip
|
should_skip = decision_to_skip
|
||||||
# --- End of Final Corrected Logic ---
|
|
||||||
|
|
||||||
if should_skip:
|
if should_skip:
|
||||||
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):
|
||||||
@@ -684,9 +666,14 @@ class PostProcessorWorker:
|
|||||||
else:
|
else:
|
||||||
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.")
|
||||||
details_for_failure = {
|
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
|
||||||
}
|
}
|
||||||
if is_permanent_error:
|
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
|
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure
|
||||||
@@ -695,6 +682,7 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
|
|
||||||
def process(self):
|
def process(self):
|
||||||
|
|
||||||
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')}"):
|
||||||
@@ -729,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
|
||||||
@@ -1044,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)
|
||||||
@@ -1054,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':
|
||||||
@@ -1064,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"
|
||||||
@@ -1111,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}"
|
||||||
@@ -1129,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...")
|
||||||
@@ -1160,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)
|
||||||
@@ -1264,13 +1293,9 @@ 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]}")
|
||||||
|
|
||||||
# --- START: Conditionally de-duplicate files from API response ---
|
|
||||||
# Only de-duplicate by URL if we are in the default hash-skipping mode.
|
|
||||||
# If the user wants to keep everything, we must process all entries from the API.
|
|
||||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||||
unique_files_by_url = {}
|
unique_files_by_url = {}
|
||||||
for file_info in all_files_from_post_api:
|
for file_info in all_files_from_post_api:
|
||||||
# Use the file URL as a unique key to avoid processing the same file multiple times
|
|
||||||
file_url = file_info.get('url')
|
file_url = file_info.get('url')
|
||||||
if file_url and file_url not in unique_files_by_url:
|
if file_url and file_url not in unique_files_by_url:
|
||||||
unique_files_by_url[file_url] = file_info
|
unique_files_by_url[file_url] = file_info
|
||||||
@@ -1281,7 +1306,6 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
if new_count < original_count:
|
if new_count < original_count:
|
||||||
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.")
|
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.")
|
||||||
# --- END: Conditionally de-duplicate files from 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})...")
|
||||||
@@ -1614,7 +1638,7 @@ 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,
|
||||||
@@ -1654,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):
|
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
|
||||||
@@ -1667,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
|
||||||
@@ -1717,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 []
|
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).")
|
||||||
@@ -1730,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
|
||||||
@@ -1739,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)
|
||||||
@@ -1749,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,
|
||||||
@@ -1762,118 +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,
|
||||||
keep_duplicates_mode=self.keep_duplicates_mode,
|
'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
|
||||||
keep_duplicates_limit=self.keep_duplicates_limit,
|
'scan_content_for_images': self.scan_content_for_images,
|
||||||
downloaded_hash_counts=self.downloaded_hash_counts,
|
'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
|
||||||
downloaded_hash_counts_lock=self.downloaded_hash_counts_lock,
|
'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
|
||||||
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words,
|
'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
|
||||||
session_file_path=self.session_file_path,
|
'keep_in_post_duplicates': self.keep_in_post_duplicates,
|
||||||
session_lock=self.session_lock,
|
'keep_duplicates_mode': self.keep_duplicates_mode,
|
||||||
text_only_scope=self.text_only_scope,
|
'keep_duplicates_limit': self.keep_duplicates_limit,
|
||||||
text_export_format=self.text_export_format,
|
'downloaded_hash_counts': self.downloaded_hash_counts,
|
||||||
single_pdf_mode=self.single_pdf_mode,
|
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
|
||||||
project_root_dir=self.project_root_dir
|
'session_file_path': self.session_file_path,
|
||||||
)
|
'session_lock': self.session_lock,
|
||||||
try:
|
'text_only_scope': self.text_only_scope,
|
||||||
(dl_count, skip_count, kept_originals_this_post,
|
'text_export_format': self.text_export_format,
|
||||||
retryable_failures, permanent_failures,
|
'single_pdf_mode': self.single_pdf_mode,
|
||||||
history_data, temp_filepath) = post_processing_worker.process()
|
'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)
|
||||||
@@ -1884,14 +1904,8 @@ class DownloadThread(QThread):
|
|||||||
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
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,15 +15,18 @@ 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()
|
||||||
@@ -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 =""
|
current_theme_style = ""
|
||||||
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
|
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||||
if hasattr (self .parent_app ,'get_dark_theme'):
|
current_theme_style = get_dark_theme(scale)
|
||||||
current_theme_style =self .parent_app .get_dark_theme ()
|
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; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.setStyleSheet(current_theme_style)
|
||||||
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 )
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
# KeepDuplicatesDialog.py
|
|
||||||
|
|
||||||
# --- PyQt5 Imports ---
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
|
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
|
||||||
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
|
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
|
||||||
)
|
)
|
||||||
from PyQt5.QtGui import QIntValidator
|
from PyQt5.QtGui import QIntValidator
|
||||||
|
|
||||||
# --- Local Application Imports ---
|
|
||||||
from ...i18n.translator import get_translation
|
from ...i18n.translator import get_translation
|
||||||
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
|
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
|
||||||
|
|
||||||
@@ -25,8 +20,6 @@ class KeepDuplicatesDialog(QDialog):
|
|||||||
|
|
||||||
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
|
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
|
||||||
self.parent_app._apply_theme_to_widget(self)
|
self.parent_app._apply_theme_to_widget(self)
|
||||||
|
|
||||||
# Set the initial state based on current settings
|
|
||||||
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
|
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
|
||||||
self.radio_keep_everything.setChecked(True)
|
self.radio_keep_everything.setChecked(True)
|
||||||
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
|
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
|
||||||
@@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog):
|
|||||||
options_group = QGroupBox()
|
options_group = QGroupBox()
|
||||||
options_layout = QVBoxLayout(options_group)
|
options_layout = QVBoxLayout(options_group)
|
||||||
self.button_group = QButtonGroup(self)
|
self.button_group = QButtonGroup(self)
|
||||||
|
|
||||||
# --- Skip by Hash Option ---
|
|
||||||
self.radio_skip_by_hash = QRadioButton()
|
self.radio_skip_by_hash = QRadioButton()
|
||||||
self.button_group.addButton(self.radio_skip_by_hash)
|
self.button_group.addButton(self.radio_skip_by_hash)
|
||||||
options_layout.addWidget(self.radio_skip_by_hash)
|
options_layout.addWidget(self.radio_skip_by_hash)
|
||||||
|
|
||||||
# --- Keep Everything Option with Limit Input ---
|
|
||||||
keep_everything_layout = QHBoxLayout()
|
keep_everything_layout = QHBoxLayout()
|
||||||
self.radio_keep_everything = QRadioButton()
|
self.radio_keep_everything = QRadioButton()
|
||||||
self.button_group.addButton(self.radio_keep_everything)
|
self.button_group.addButton(self.radio_keep_everything)
|
||||||
@@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog):
|
|||||||
options_layout.addLayout(keep_everything_layout)
|
options_layout.addLayout(keep_everything_layout)
|
||||||
|
|
||||||
main_layout.addWidget(options_group)
|
main_layout.addWidget(options_group)
|
||||||
|
|
||||||
# --- OK and Cancel buttons ---
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
self.ok_button = QPushButton()
|
self.ok_button = QPushButton()
|
||||||
self.cancel_button = QPushButton()
|
self.cancel_button = QPushButton()
|
||||||
@@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog):
|
|||||||
button_layout.addWidget(self.ok_button)
|
button_layout.addWidget(self.ok_button)
|
||||||
button_layout.addWidget(self.cancel_button)
|
button_layout.addWidget(self.cancel_button)
|
||||||
main_layout.addLayout(button_layout)
|
main_layout.addLayout(button_layout)
|
||||||
|
|
||||||
# --- Connections ---
|
|
||||||
self.ok_button.clicked.connect(self.accept)
|
self.ok_button.clicked.connect(self.accept)
|
||||||
self.cancel_button.clicked.connect(self.reject)
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
|
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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', '', font_path, uni=True)
|
||||||
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
|
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||||
except Exception as font_error:
|
except Exception as font_error:
|
||||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
|
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||||
logger(" PDF may not support all characters. Falling back to default Arial font.")
|
default_font_family = 'Arial'
|
||||||
pdf.set_font('Arial', '', 12)
|
|
||||||
pdf.set_font('Arial', 'B', 16)
|
|
||||||
|
|
||||||
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
|
pdf.add_page()
|
||||||
|
|
||||||
for post in posts_data:
|
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||||
pdf.add_page()
|
|
||||||
# Post Title
|
|
||||||
pdf.set_font('DejaVu', 'B', 16)
|
|
||||||
|
|
||||||
# vvv THIS LINE IS CORRECTED vvv
|
for i, post in enumerate(posts_data):
|
||||||
# We explicitly set align='L' and remove the incorrect positional arguments.
|
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.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||||
|
pdf.ln(5)
|
||||||
|
|
||||||
pdf.ln(5) # Add a little space after the title
|
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', ''))
|
||||||
|
|
||||||
# Post Content
|
pdf.set_font(default_font_family, '', 10)
|
||||||
pdf.set_font('DejaVu', '', 12)
|
pdf.write(8, "Comment by: ")
|
||||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
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)
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user