From d7faccce18562fb3b45a31f0aa466cd50309bd5c Mon Sep 17 00:00:00 2001 From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:37:28 -0700 Subject: [PATCH] Commit --- src/core/workers.py | 11 ++ src/services/drive_downloader.py | 235 ++++++++++++++++++++++-------- src/ui/dialogs/HelpGuideDialog.py | 203 ++++++++++++-------------- 3 files changed, 278 insertions(+), 171 deletions(-) diff --git a/src/core/workers.py b/src/core/workers.py index 9a60aab..585837e 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -752,6 +752,17 @@ class PostProcessorWorker: effective_unwanted_keywords_for_folder_naming = self.unwanted_keywords.copy() is_full_creator_download_no_char_filter = not self.target_post_id_from_initial_url and not current_character_filters + + if (self.show_external_links or self.extract_links_only): + embed_data = post_data.get('embed') + if isinstance(embed_data, dict) and embed_data.get('url'): + embed_url = embed_data['url'] + embed_subject = embed_data.get('subject', embed_url) # Use subject as link text, fallback to URL + platform = get_link_platform(embed_url) + + self.logger(f" 🔗 Found embed link: {embed_url}") + self._emit_signal('external_link', post_title, embed_subject, embed_url, platform, "") + if is_full_creator_download_no_char_filter and self.creator_download_folder_ignore_words: self.logger(f" Applying creator download specific folder ignore words ({len(self.creator_download_folder_ignore_words)} words).") effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words) diff --git a/src/services/drive_downloader.py b/src/services/drive_downloader.py index 4d01a09..6e5676c 100644 --- a/src/services/drive_downloader.py +++ b/src/services/drive_downloader.py @@ -3,15 +3,19 @@ import os import re import traceback import json +import base64 +import time from urllib.parse import urlparse, urlunparse, parse_qs, urlencode # --- Third-Party Library Imports --- +# Make sure to install these: pip install requests pycryptodome gdown import requests + try: - from mega import Mega - MEGA_AVAILABLE = True + from Crypto.Cipher import AES + PYCRYPTODOME_AVAILABLE = True except ImportError: - MEGA_AVAILABLE = False + PYCRYPTODOME_AVAILABLE = False try: import gdown @@ -19,17 +23,15 @@ try: except ImportError: GDRIVE_AVAILABLE = False -# --- Helper Functions --- +# --- Constants --- +MEGA_API_URL = "https://g.api.mega.co.nz" + +# --- Helper Functions (Original and New) --- def _get_filename_from_headers(headers): """ Extracts a filename from the Content-Disposition header. - - Args: - headers (dict): A dictionary of HTTP response headers. - - Returns: - str or None: The extracted filename, or None if not found. + (This is from your original file and is kept for Dropbox downloads) """ cd = headers.get('content-disposition') if not cd: @@ -37,64 +39,180 @@ def _get_filename_from_headers(headers): fname_match = re.findall('filename="?([^"]+)"?', cd) if fname_match: - # Sanitize the filename to prevent directory traversal issues - # and remove invalid characters for most filesystems. sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip()) return sanitized_name return None -# --- Main Service Downloader Functions --- +# --- NEW: Helper functions for Mega decryption --- + +def urlb64_to_b64(s): + """Converts a URL-safe base64 string to a standard base64 string.""" + s = s.replace('-', '+').replace('_', '/') + s += '=' * (-len(s) % 4) + return s + +def b64_to_bytes(s): + """Decodes a URL-safe base64 string to bytes.""" + return base64.b64decode(urlb64_to_b64(s)) + +def bytes_to_hex(b): + """Converts bytes to a hex string.""" + return b.hex() + +def hex_to_bytes(h): + """Converts a hex string to bytes.""" + return bytes.fromhex(h) + +def hrk2hk(hex_raw_key): + """Derives the final AES key from the raw key components for Mega.""" + key_part1 = int(hex_raw_key[0:16], 16) + key_part2 = int(hex_raw_key[16:32], 16) + key_part3 = int(hex_raw_key[32:48], 16) + key_part4 = int(hex_raw_key[48:64], 16) + + final_key_part1 = key_part1 ^ key_part3 + final_key_part2 = key_part2 ^ key_part4 + + return f'{final_key_part1:016x}{final_key_part2:016x}' + +def decrypt_at(at_b64, key_bytes): + """Decrypts the 'at' attribute to get file metadata.""" + at_bytes = b64_to_bytes(at_b64) + iv = b'\0' * 16 + cipher = AES.new(key_bytes, AES.MODE_CBC, iv) + decrypted_at = cipher.decrypt(at_bytes) + return decrypted_at.decode('utf-8').strip('\0').replace('MEGA', '') + +# --- NEW: Core Logic for Mega Downloads --- + +def get_mega_file_info(file_id, file_key, session, logger_func): + """Fetches file metadata and the temporary download URL from the Mega API.""" + try: + hex_raw_key = bytes_to_hex(b64_to_bytes(file_key)) + hex_key = hrk2hk(hex_raw_key) + key_bytes = hex_to_bytes(hex_key) + + # Request file attributes + payload = [{"a": "g", "p": file_id}] + response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20) + response.raise_for_status() + res_json = response.json() + + if isinstance(res_json, list) and isinstance(res_json[0], int) and res_json[0] < 0: + logger_func(f" [Mega] ❌ API Error: {res_json[0]}. The link may be invalid or removed.") + return None + + file_size = res_json[0]['s'] + at_b64 = res_json[0]['at'] + + # Decrypt attributes to get the file name + at_dec_json_str = decrypt_at(at_b64, key_bytes) + at_dec_json = json.loads(at_dec_json_str) + file_name = at_dec_json['n'] + + # Request the temporary download URL + payload = [{"a": "g", "g": 1, "p": file_id}] + response = session.post(f"{MEGA_API_URL}/cs", json=payload, timeout=20) + response.raise_for_status() + res_json = response.json() + dl_temp_url = res_json[0]['g'] + + return { + 'file_name': file_name, + 'file_size': file_size, + 'dl_url': dl_temp_url, + 'hex_raw_key': hex_raw_key + } + except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as e: + logger_func(f" [Mega] ❌ Failed to get file info: {e}") + return None + +def download_and_decrypt_mega_file(info, download_path, logger_func): + """Downloads the file and decrypts it chunk by chunk, reporting progress.""" + file_name = info['file_name'] + file_size = info['file_size'] + dl_url = info['dl_url'] + hex_raw_key = info['hex_raw_key'] + + final_path = os.path.join(download_path, file_name) + + if os.path.exists(final_path) and os.path.getsize(final_path) == file_size: + logger_func(f" [Mega] â„šī¸ File '{file_name}' already exists with the correct size. Skipping.") + return + + # Prepare for decryption + key = hex_to_bytes(hrk2hk(hex_raw_key)) + iv_hex = hex_raw_key[32:48] + '0000000000000000' + iv_bytes = hex_to_bytes(iv_hex) + cipher = AES.new(key, AES.MODE_CTR, initial_value=iv_bytes, nonce=b'') + + try: + with requests.get(dl_url, stream=True, timeout=(15, 300)) as r: + r.raise_for_status() + downloaded_bytes = 0 + last_log_time = time.time() + + with open(final_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + if not chunk: + continue + decrypted_chunk = cipher.decrypt(chunk) + f.write(decrypted_chunk) + downloaded_bytes += len(chunk) + + # Log progress every second + current_time = time.time() + if current_time - last_log_time > 1: + progress_percent = (downloaded_bytes / file_size) * 100 if file_size > 0 else 0 + logger_func(f" [Mega] Downloading '{file_name}': {downloaded_bytes/1024/1024:.2f}MB / {file_size/1024/1024:.2f}MB ({progress_percent:.1f}%)") + last_log_time = current_time + + logger_func(f" [Mega] ✅ Successfully downloaded '{file_name}' to '{download_path}'") + except requests.RequestException as e: + logger_func(f" [Mega] ❌ Download failed for '{file_name}': {e}") + except IOError as e: + logger_func(f" [Mega] ❌ Could not write to file '{final_path}': {e}") + except Exception as e: + logger_func(f" [Mega] ❌ An unexpected error occurred during download/decryption: {e}") + + +# --- REPLACEMENT Main Service Downloader Function for Mega --- def download_mega_file(mega_url, download_path, logger_func=print): """ - Downloads a file from a Mega.nz URL. - Handles both public links and links that include a decryption key. + Downloads a file from a Mega.nz URL using direct requests and decryption. + This replaces the old mega.py implementation. """ - if not MEGA_AVAILABLE: - logger_func("❌ Mega download failed: 'mega.py' library is not installed.") + if not PYCRYPTODOME_AVAILABLE: + logger_func("❌ Mega download failed: 'pycryptodome' library is not installed. Please run: pip install pycryptodome") return - logger_func(f" [Mega] Initializing Mega client...") - try: - mega = Mega() - # Anonymous login is sufficient for public links - m = mega.login() + logger_func(f" [Mega] Initializing download for: {mega_url}") + + # Regex to capture file ID and key from both old and new URL formats + match = re.search(r'mega(?:\.co)?\.nz/(?:file/|#!)?([a-zA-Z0-9]+)(?:#|!)([a-zA-Z0-9_.-]+)', mega_url) + if not match: + logger_func(f" [Mega] ❌ Error: Invalid Mega URL format.") + return + + file_id = match.group(1) + file_key = match.group(2) - # --- MODIFIED PART: Added error handling for invalid links --- - try: - file_details = m.find(mega_url) - if file_details is None: - logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}") - return - except (ValueError, json.JSONDecodeError) as e: - # This block catches the "Expecting value" error - logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}") - return - except Exception as e: - # Catch other potential errors from the mega.py library - logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}") - return - # --- END OF MODIFIED PART --- + session = requests.Session() + session.headers.update({'User-Agent': 'Kemono-Downloader-PyQt/1.0'}) + + file_info = get_mega_file_info(file_id, file_key, session, logger_func) + if not file_info: + logger_func(f" [Mega] ❌ Failed to get file info. The link may be invalid or expired. Aborting.") + return - filename = file_details[1]['a']['n'] - logger_func(f" [Mega] File found: '{filename}'. Starting download...") + logger_func(f" [Mega] File found: '{file_info['file_name']}' (Size: {file_info['file_size'] / 1024 / 1024:.2f} MB)") + + download_and_decrypt_mega_file(file_info, download_path, logger_func) - # Sanitize filename before saving - safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip() - final_path = os.path.join(download_path, safe_filename) - # Check if file already exists - if os.path.exists(final_path): - logger_func(f" [Mega] â„šī¸ File '{safe_filename}' already exists. Skipping download.") - return - - # Start the download - m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename) - logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'") - - except Exception as e: - logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}") +# --- ORIGINAL Functions for Google Drive and Dropbox (Unchanged) --- def download_gdrive_file(url, download_path, logger_func=print): """Downloads a file from a Google Drive link.""" @@ -103,12 +221,9 @@ def download_gdrive_file(url, download_path, logger_func=print): return try: logger_func(f" [G-Drive] Starting download for: {url}") - # --- MODIFIED PART: Added a message and set quiet=True --- logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.") - # By setting quiet=True, the progress bar will no longer be printed to the terminal. output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True) - # --- END OF MODIFIED PART --- if output_path and os.path.exists(output_path): logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'") @@ -120,15 +235,9 @@ def download_gdrive_file(url, download_path, logger_func=print): def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): """ Downloads a file from a public Dropbox link by modifying the URL for direct download. - - Args: - dropbox_link (str): The public Dropbox link to the file. - download_path (str): The directory to save the downloaded file. - logger_func (callable): Function to use for logging. """ logger_func(f" [Dropbox] Attempting to download: {dropbox_link}") - # Modify the Dropbox URL to force a direct download instead of showing the preview page. parsed_url = urlparse(dropbox_link) query_params = parse_qs(parsed_url.query) query_params['dl'] = ['1'] @@ -145,13 +254,11 @@ def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r: r.raise_for_status() - # Determine filename from headers or URL filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file" full_save_path = os.path.join(download_path, filename) logger_func(f" [Dropbox] Starting download of '{filename}'...") - # Write file to disk in chunks with open(full_save_path, 'wb') as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) diff --git a/src/ui/dialogs/HelpGuideDialog.py b/src/ui/dialogs/HelpGuideDialog.py index 7fb196a..7fa2e6f 100644 --- a/src/ui/dialogs/HelpGuideDialog.py +++ b/src/ui/dialogs/HelpGuideDialog.py @@ -4,7 +4,7 @@ from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, - QStackedWidget, QScrollArea, QFrame, QWidget + QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea ) from ...i18n.translator import get_translation from ..main_window import get_app_icon_object @@ -46,13 +46,12 @@ class TourStepWidget(QWidget): layout.addWidget(scroll_area, 1) -class HelpGuideDialog (QDialog ): - """A multi-page dialog for displaying the feature guide.""" - def __init__ (self ,steps_data ,parent_app ,parent =None ): - super ().__init__ (parent ) - self .current_step =0 - self .steps_data =steps_data - self .parent_app =parent_app +class HelpGuideDialog(QDialog): + """A multi-page dialog for displaying the feature guide with a navigation list.""" + def __init__(self, steps_data, parent_app, parent=None): + super().__init__(parent) + self.steps_data = steps_data + self.parent_app = parent_app scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0 @@ -61,7 +60,7 @@ class HelpGuideDialog (QDialog ): self.setWindowIcon(app_icon) self.setModal(True) - self.resize(int(650 * scale), int(600 * scale)) + self.resize(int(800 * scale), int(650 * scale)) dialog_font_size = int(11 * scale) @@ -69,6 +68,7 @@ class HelpGuideDialog (QDialog ): if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": current_theme_style = get_dark_theme(scale) else: + # Basic light theme fallback current_theme_style = f""" QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }} QLabel {{ color: #1E1E1E; }} @@ -86,118 +86,107 @@ class HelpGuideDialog (QDialog ): """ self.setStyleSheet(current_theme_style) - self ._init_ui () - if self .parent_app : - self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) + self._init_ui() + if self.parent_app: + self.move(self.parent_app.geometry().center() - self.rect().center()) - def _tr (self ,key ,default_text =""): + def _tr(self, key, default_text=""): """Helper to get translation based on current app language.""" - if callable (get_translation )and self .parent_app : - return get_translation (self .parent_app .current_selected_language ,key ,default_text ) - return default_text + if callable(get_translation) and self.parent_app: + return get_translation(self.parent_app.current_selected_language, key, default_text) + return default_text + def _init_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(15, 15, 15, 15) + main_layout.setSpacing(10) - def _init_ui (self ): - main_layout =QVBoxLayout (self ) - main_layout .setContentsMargins (0 ,0 ,0 ,0 ) - main_layout .setSpacing (0 ) + # Title + title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide")) + scale = getattr(self.parent_app, 'scale_factor', 1.0) + title_font_size = int(16 * scale) + title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;") + title_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title_label) - self .stacked_widget =QStackedWidget () - main_layout .addWidget (self .stacked_widget ,1 ) + # Content Layout (Navigation + Stacked Pages) + content_layout = QHBoxLayout() + main_layout.addLayout(content_layout, 1) - self .tour_steps_widgets =[] - scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0 - for title, content in self.steps_data: - step_widget = TourStepWidget(title, content, scale=scale) - self.tour_steps_widgets.append(step_widget) + self.nav_list = QListWidget() + self.nav_list.setFixedWidth(int(220 * scale)) + self.nav_list.setStyleSheet(f""" + QListWidget {{ + background-color: #2E2E2E; + border: 1px solid #4A4A4A; + border-radius: 4px; + font-size: {int(11 * scale)}pt; + }} + QListWidget::item {{ + padding: 10px; + border-bottom: 1px solid #4A4A4A; + }} + QListWidget::item:selected {{ + background-color: #87CEEB; + color: #2E2E2E; + font-weight: bold; + }} + """) + content_layout.addWidget(self.nav_list) + + self.stacked_widget = QStackedWidget() + content_layout.addWidget(self.stacked_widget) + + for title_key, content_key in self.steps_data: + title = self._tr(title_key, title_key) + content = self._tr(content_key, f"Content for {content_key} not found.") + + self.nav_list.addItem(title) + + step_widget = TourStepWidget(title, content, scale=scale) self.stacked_widget.addWidget(step_widget) - self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) + self.nav_list.currentRowChanged.connect(self.stacked_widget.setCurrentIndex) + if self.nav_list.count() > 0: + self.nav_list.setCurrentRow(0) - buttons_layout =QHBoxLayout () - buttons_layout .setContentsMargins (15 ,10 ,15 ,15 ) - buttons_layout .setSpacing (10 ) + # Footer Layout (Social links and Close button) + footer_layout = QHBoxLayout() + footer_layout.setContentsMargins(0, 10, 0, 0) + + # Social Media Icons + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + assets_base_dir = sys._MEIPASS + else: + assets_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) - self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back")) - self .back_button .clicked .connect (self ._previous_step ) - self .back_button .setEnabled (False ) + github_icon_path = os.path.join(assets_base_dir, "assets", "github.png") + instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png") + discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png") - if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): - assets_base_dir =sys ._MEIPASS - else : - assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + self.github_button = QPushButton(QIcon(github_icon_path), "") + self.instagram_button = QPushButton(QIcon(instagram_icon_path), "") + self.discord_button = QPushButton(QIcon(discord_icon_path), "") - github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") - instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png") - discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png") - - self .github_button =QPushButton (QIcon (github_icon_path ),"") - self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") - self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") - - scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0 icon_dim = int(24 * scale) icon_size = QSize(icon_dim, icon_dim) - self .github_button .setIconSize (icon_size ) - self .instagram_button .setIconSize (icon_size ) - self .Discord_button .setIconSize (icon_size ) + + for button, tooltip_key, url in [ + (self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi9587"), + (self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"), + (self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN") + ]: + button.setIconSize(icon_size) + button.setToolTip(self._tr(tooltip_key)) + button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8) + button.setStyleSheet("background-color: transparent; border: none;") + button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u))) + footer_layout.addWidget(button) - self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next")) - self .next_button .clicked .connect (self ._next_step_action ) - self .next_button .setDefault (True ) - self .github_button .clicked .connect (self ._open_github_link ) - self .instagram_button .clicked .connect (self ._open_instagram_link ) - self .Discord_button .clicked .connect (self ._open_Discord_link ) - self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)")) - self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)")) - self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)")) + footer_layout.addStretch(1) + self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish")) + self.finish_button.clicked.connect(self.accept) + footer_layout.addWidget(self.finish_button) - social_layout =QHBoxLayout () - social_layout .setSpacing (10 ) - social_layout .addWidget (self .github_button ) - social_layout .addWidget (self .instagram_button ) - social_layout .addWidget (self .Discord_button ) - - while buttons_layout .count (): - item =buttons_layout .takeAt (0 ) - if item .widget (): - item .widget ().setParent (None ) - elif item .layout (): - pass - buttons_layout .addLayout (social_layout ) - buttons_layout .addStretch (1 ) - buttons_layout .addWidget (self .back_button ) - buttons_layout .addWidget (self .next_button ) - main_layout .addLayout (buttons_layout ) - self ._update_button_states () - - def _next_step_action (self ): - if self .current_step 0 : - self .current_step -=1 - self .stacked_widget .setCurrentIndex (self .current_step ) - self ._update_button_states () - - def _update_button_states (self ): - if self .current_step ==len (self .tour_steps_widgets )-1 : - self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish")) - else : - self .next_button .setText (self ._tr ("tour_dialog_next_button","Next")) - self .back_button .setEnabled (self .current_step >0 ) - - def _open_github_link (self ): - QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587")) - - def _open_instagram_link (self ): - QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/")) - - def _open_Discord_link (self ): - QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN")) \ No newline at end of file + main_layout.addLayout(footer_layout) \ No newline at end of file