diff --git a/Known.txt b/Known.txt index e69de29..50b2851 100644 --- a/Known.txt +++ b/Known.txt @@ -0,0 +1 @@ +([Yor], Yor Briar, Yor Forger) diff --git a/downloader_utils.py b/downloader_utils.py index a4de183..f5fe8ca 100644 --- a/downloader_utils.py +++ b/downloader_utils.py @@ -63,6 +63,49 @@ ARCHIVE_EXTENSIONS = { '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2' } +# --- Cookie Helper Functions --- +def parse_cookie_string(cookie_string): + """Parses a 'name=value; name2=value2' cookie string into a dict.""" + cookies = {} + if cookie_string: + for item in cookie_string.split(';'): + parts = item.split('=', 1) + if len(parts) == 2: + name = parts[0].strip() + value = parts[1].strip() + if name: # Ensure name is not empty + cookies[name] = value + return cookies if cookies else None + +def load_cookies_from_netscape_file(filepath, logger_func): + """Loads cookies from a Netscape-formatted cookies.txt file.""" + cookies = {} + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('\t') + if len(parts) == 7: + # Netscape format: domain, flag, path, secure, expiration, name, value + name = parts[5] + value = parts[6] + if name: # Ensure name is not empty + cookies[name] = value + # else: + # logger_func(f" đŸĒ Cookie file line {line_num} malformed (expected 7 tab-separated parts): '{line[:50]}...'") + logger_func(f" đŸĒ Loaded {len(cookies)} cookies from '{os.path.basename(filepath)}'.") + return cookies if cookies else None + except FileNotFoundError: + logger_func(f" đŸĒ Cookie file '{os.path.basename(filepath)}' not found at expected location.") + return None + except Exception as e: + logger_func(f" đŸĒ Error parsing cookie file '{os.path.basename(filepath)}': {e}") + return None + +# --- End Cookie Helper Functions --- + def is_title_match_for_character(post_title, character_name_filter): if not post_title or not character_name_filter: return False @@ -231,12 +274,31 @@ def extract_post_info(url_string): return None, None, None -def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None): +def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_cookie_file_path, app_base_dir, logger_func): + """Prepares a cookie dictionary from text input or cookies.txt file.""" + if not use_cookie_flag: + return None + + if cookie_text_input: + logger_func(" đŸĒ Using cookies from UI text input.") + return parse_cookie_string(cookie_text_input) + elif selected_cookie_file_path: + logger_func(f" đŸĒ Attempting to load cookies from selected file: '{os.path.basename(selected_cookie_file_path)}'...") + return load_cookies_from_netscape_file(selected_cookie_file_path, logger_func) + elif app_base_dir: + cookies_filepath = os.path.join(app_base_dir, "cookies.txt") + logger_func(f" đŸĒ No UI text or specific file selected. Attempting to load default '{os.path.basename(cookies_filepath)}' from app directory...") + return load_cookies_from_netscape_file(cookies_filepath, logger_func) + else: + logger_func(" đŸĒ Cookie usage enabled, but no text input, specific file, or app base directory provided for cookies.txt.") + return None + +def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None): if cancellation_event and cancellation_event.is_set(): logger(" Fetch cancelled before request.") raise RuntimeError("Fetch operation cancelled by user.") - if pause_event and pause_event.is_set(): + if pause_event and pause_event.is_set(): # type: ignore logger(" Post fetching paused...") while pause_event.is_set(): if cancellation_event and cancellation_event.is_set(): @@ -248,7 +310,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev paginated_url = f'{api_url_base}?o={offset}' logger(f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})") try: - response = requests.get(paginated_url, headers=headers, timeout=(10, 60)) + response = requests.get(paginated_url, headers=headers, timeout=(10, 60), cookies=cookies_dict) response.raise_for_status() if 'application/json' not in response.headers.get('Content-Type', '').lower(): logger(f"âš ī¸ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}") @@ -266,12 +328,12 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev except Exception as e: raise RuntimeError(f"Unexpected error fetching offset {offset} ({paginated_url}): {e}") -def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None): +def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None): if cancellation_event and cancellation_event.is_set(): logger(" Comment fetch cancelled before request.") raise RuntimeError("Comment fetch operation cancelled by user.") - if pause_event and pause_event.is_set(): + if pause_event and pause_event.is_set(): # type: ignore logger(" Comment fetching paused...") while pause_event.is_set(): if cancellation_event and cancellation_event.is_set(): @@ -283,7 +345,7 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, comments_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}/comments" logger(f" Fetching comments: {comments_api_url}") try: - response = requests.get(comments_api_url, headers=headers, timeout=(10, 30)) # Shorter timeout for comments + response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict) response.raise_for_status() if 'application/json' not in response.headers.get('Content-Type', '').lower(): logger(f"âš ī¸ Unexpected content type from comments API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}") @@ -301,7 +363,8 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, except Exception as e: raise RuntimeError(f"Unexpected error fetching comments for post {post_id} ({comments_api_url}): {e}") -def download_from_api(api_url_input, logger=print, start_page=None, end_page=None, manga_mode=False, cancellation_event=None, pause_event=None): +def download_from_api(api_url_input, logger=print, start_page=None, end_page=None, manga_mode=False, + cancellation_event=None, pause_event=None, use_cookie=False, cookie_text="", selected_cookie_file=None, app_base_dir=None): headers = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'} service, user_id, target_post_id = extract_post_info(api_url_input) @@ -326,6 +389,11 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non api_domain = "kemono.su" api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}" + + cookies_for_api = None + if use_cookie and app_base_dir: # app_base_dir is needed for cookies.txt path + cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger) + page_size = 50 if is_creator_feed_for_manga: @@ -334,7 +402,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non current_offset_manga = 0 while True: if pause_event and pause_event.is_set(): - logger(" Manga mode post fetching paused...") + logger(" Manga mode post fetching paused...") # type: ignor while pause_event.is_set(): if cancellation_event and cancellation_event.is_set(): logger(" Manga mode post fetching cancelled while paused.") @@ -345,7 +413,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non logger(" Manga mode post fetching cancelled.") break try: - posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event) + posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) if not isinstance(posts_batch_manga, list): logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.") break @@ -413,7 +481,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non while True: if pause_event and pause_event.is_set(): - logger(" Post fetching loop paused...") + logger(" Post fetching loop paused...") # type: ignore while pause_event.is_set(): if cancellation_event and cancellation_event.is_set(): logger(" Post fetching loop cancelled while paused.") @@ -432,7 +500,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non break try: - posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event) + posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) if not isinstance(posts_batch, list): logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).") break @@ -529,6 +597,10 @@ class PostProcessorWorker: char_filter_scope=CHAR_SCOPE_FILES, remove_from_filename_words_list=None, allow_multipart_download=True, + cookie_text="", # Added missing parameter + use_cookie=False, # Added missing parameter + selected_cookie_file=None, # Added missing parameter + app_base_dir=None, # New parameter for app's base directory manga_date_file_counter_ref=None, # New parameter for date-based manga naming ): self.post = post_data @@ -574,7 +646,11 @@ class PostProcessorWorker: self.remove_from_filename_words_list = remove_from_filename_words_list if remove_from_filename_words_list is not None else [] self.allow_multipart_download = allow_multipart_download self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store the reference - + self.selected_cookie_file = selected_cookie_file # Store selected cookie file path + self.app_base_dir = app_base_dir # Store app base dir + self.cookie_text = cookie_text # Store cookie text + self.use_cookie = use_cookie # Store cookie setting + if self.compress_images and Image is None: self.logger("âš ī¸ Image compression disabled: Pillow library not found.") self.compress_images = False @@ -636,6 +712,9 @@ class PostProcessorWorker: if self.check_cancel() or (skip_event and skip_event.is_set()): return 0, 1, "", False file_url = file_info.get('url') + cookies_to_use_for_file = None + if self.use_cookie: # This flag comes from the checkbox + cookies_to_use_for_file = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger) api_original_filename = file_info.get('_original_name_for_log', file_info.get('name')) # This is the ideal name for the file if it were to be saved in the main target_folder_path. @@ -777,7 +856,7 @@ class PostProcessorWorker: self._emit_signal('file_download_status', True) - response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True) + response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file) response.raise_for_status() total_size_bytes = int(response.headers.get('Content-Length', 0)) @@ -795,7 +874,7 @@ class PostProcessorWorker: mp_save_path_base_for_part = os.path.join(target_folder_path, filename_to_save_in_main_path) mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts( file_url, mp_save_path_base_for_part, total_size_bytes, num_parts_for_file, headers, api_original_filename, - emitter_for_multipart=self.emitter, # Pass the worker's emitter + emitter_for_multipart=self.emitter, cookies_for_chunk_session=cookies_to_use_for_file, # Pass cookies cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger, pause_event=self.pause_event # Pass pause_event ) @@ -1140,7 +1219,10 @@ class PostProcessorWorker: comments_data = fetch_post_comments( api_domain_for_comments, self.service, self.user_id, post_id, - headers, self.logger, self.cancellation_event, self.pause_event # Pass pause_event + headers, self.logger, self.cancellation_event, self.pause_event, # Pass pause_event + cookies_dict=prepare_cookies_for_request( # Prepare cookies for this API call + self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger + ) ) if comments_data: self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.") @@ -1536,6 +1618,8 @@ class DownloadThread(QThread): char_filter_scope=CHAR_SCOPE_FILES, # manga_date_file_counter_ref removed from here remove_from_filename_words_list=None, allow_multipart_download=True, + selected_cookie_file=None, # New parameter for selected cookie file + app_base_dir=None, # New parameter manga_date_file_counter_ref=None, # New parameter ): super().__init__() @@ -1580,6 +1664,10 @@ class DownloadThread(QThread): self.char_filter_scope = char_filter_scope self.remove_from_filename_words_list = remove_from_filename_words_list self.allow_multipart_download = allow_multipart_download + self.selected_cookie_file = selected_cookie_file # Store selected cookie file + self.app_base_dir = app_base_dir # Store app base dir + self.cookie_text = cookie_text # Store cookie text + self.use_cookie = use_cookie # Store cookie setting self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread # self.manga_date_scan_dir = manga_date_scan_dir # Store scan directory if self.compress_images and Image is None: @@ -1660,8 +1748,12 @@ class DownloadThread(QThread): start_page=self.start_page, end_page=self.end_page, manga_mode=self.manga_mode_active, - cancellation_event=self.cancellation_event, - pause_event=self.pause_event # Pass pause_event + cancellation_event=self.cancellation_event, # type: ignore + pause_event=self.pause_event, # Pass pause_event + use_cookie=self.use_cookie, # Pass cookie settings for API calls + cookie_text=self.cookie_text, + selected_cookie_file=self.selected_cookie_file, + app_base_dir=self.app_base_dir ) for posts_batch_data in post_generator: @@ -1704,6 +1796,10 @@ class DownloadThread(QThread): char_filter_scope=self.char_filter_scope, remove_from_filename_words_list=self.remove_from_filename_words_list, allow_multipart_download=self.allow_multipart_download, + selected_cookie_file=self.selected_cookie_file, # Pass selected cookie file + app_base_dir=self.app_base_dir, # Pass app_base_dir + cookie_text=self.cookie_text, # Pass cookie text + use_cookie=self.use_cookie, # Pass cookie setting to worker manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref ) try: diff --git a/main.py b/main.py index fde7224..b9ec5c8 100644 --- a/main.py +++ b/main.py @@ -103,6 +103,8 @@ STYLE_DATE_BASED = "date_based" # New style for date-based naming SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1" ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1" +USE_COOKIE_KEY = "useCookieV1" # New setting key +COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1" # CHAR_SCOPE_TITLE, CHAR_SCOPE_FILES, CHAR_SCOPE_BOTH, CHAR_SCOPE_COMMENTS are already defined or imported @@ -503,6 +505,7 @@ class DownloaderApp(QWidget): # sys.executable is the path to the .exe file app_base_dir = os.path.dirname(sys.executable) else: + # This is the directory where main.py (and thus potentially cookies.txt) resides # Application is running as a script # __file__ is the path to the script file app_base_dir = os.path.dirname(os.path.abspath(__file__)) @@ -518,6 +521,7 @@ class DownloaderApp(QWidget): self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 + self.selected_cookie_filepath = None # For storing path from browse button self.retryable_failed_files_info = [] # For storing info about files that failed but can be retried self.is_paused = False # New state for pause functionality @@ -578,6 +582,11 @@ class DownloaderApp(QWidget): self.char_filter_scope = self.settings.value(CHAR_FILTER_SCOPE_KEY, CHAR_SCOPE_FILES, type=str) # Default to Files # Always default multi-part download to OFF on launch, ignoring any saved setting. self.allow_multipart_download_setting = False + + # Ensure "Use Cookie" is unchecked and text is empty by default on every launch + self.use_cookie_setting = False # Always default to False on launch + self.cookie_text_setting = "" # Always default to empty on launch + print(f"â„šī¸ Known.txt will be loaded/saved at: {self.config_file}") @@ -597,6 +606,8 @@ class DownloaderApp(QWidget): self.log_signal.emit(f"â„šī¸ Skip words scope loaded: '{self.skip_words_scope}'") self.log_signal.emit(f"â„šī¸ Character filter scope loaded: '{self.char_filter_scope}'") self.log_signal.emit(f"â„šī¸ Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'} on launch") + self.log_signal.emit(f"â„šī¸ Cookie text defaults to: Empty on launch") + self.log_signal.emit(f"â„šī¸ 'Use Cookie' setting defaults to: Disabled on launch") def _connect_signals(self): @@ -610,6 +621,12 @@ class DownloaderApp(QWidget): if hasattr(self, 'character_input'): # Connect live update for character input self.character_input.textChanged.connect(self._on_character_input_changed_live) # Timer for processing the worker queue + if hasattr(self, 'use_cookie_checkbox'): + self.use_cookie_checkbox.toggled.connect(self._update_cookie_input_visibility) + if hasattr(self, 'cookie_browse_button'): # Connect the new browse button + self.cookie_browse_button.clicked.connect(self._browse_cookie_file) + if hasattr(self, 'cookie_text_input'): # Connect text changed for manual clear detection + self.cookie_text_input.textChanged.connect(self._handle_cookie_text_manual_change) self.gui_update_timer.timeout.connect(self._process_worker_queue) self.gui_update_timer.start(100) # Check queue every 100ms @@ -749,14 +766,41 @@ class DownloaderApp(QWidget): content = line[1:-1].strip() parts = [p.strip() for p in content.split(',') if p.strip()] if parts: - primary_name = parts[0] - # Aliases include the primary name for matching convenience - unique_aliases = sorted(list(set([primary_name] + parts))) - parsed_known_objects.append({ - "name": primary_name, - "is_group": True, - "aliases": unique_aliases - }) + potential_primary_name = None + all_aliases_in_line = [] + remaining_parts = list(parts) # Create a mutable copy + + # First, find and process the bracketed primary name + for i, part_check_brackets in enumerate(parts): + if part_check_brackets.startswith('[') and part_check_brackets.endswith(']'): + potential_primary_name = part_check_brackets[1:-1].strip() + if potential_primary_name: + all_aliases_in_line.append(potential_primary_name) + remaining_parts.pop(i) # Remove the processed primary name part + break # Found the bracketed name + + # If no bracketed name was found, use the first part as primary (fallback) + if not potential_primary_name and parts: + potential_primary_name = parts[0].strip() + all_aliases_in_line.append(potential_primary_name) + if remaining_parts and remaining_parts[0] == potential_primary_name: # Avoid double-adding if it was the first + remaining_parts.pop(0) + + # Add remaining parts as unique aliases + for part in remaining_parts: + cleaned_part = part.strip() + if cleaned_part and cleaned_part not in all_aliases_in_line: + all_aliases_in_line.append(cleaned_part) + + if not potential_primary_name: # Should not happen if parts is not empty + if hasattr(self, 'log_signal'): self.log_signal.emit(f"âš ī¸ Could not determine primary name in Known.txt on line {line_num}: '{line}'") + continue + else: # This is the 'else' statement from the error + parsed_known_objects.append({ # This block needs to be indented + "name": potential_primary_name, + "is_group": True, + "aliases": all_aliases_in_line # Already unique and primary is first + }) else: if hasattr(self, 'log_signal'): self.log_signal.emit(f"âš ī¸ Empty group found in Known.txt on line {line_num}: '{line}'") else: @@ -787,14 +831,14 @@ class DownloaderApp(QWidget): # Add default entries if the list is empty after loading (meaning file didn't exist) if not KNOWN_NAMES: default_entry = { - "name": "Boa Hancock", + "name": "Yor", "is_group": True, - "aliases": sorted(list(set(["Boa Hancock", "Boa", "Hancock", "Snakequeen"]))) # Ensure unique and sorted aliases + "aliases": sorted(list(set(["Yor Forger", "Yor", "Yor Briar"]))) # Ensure unique and sorted aliases } KNOWN_NAMES.append(default_entry) # Add more defaults here if needed self.save_known_names() # Save to disk immediately if file was created with defaults - self.log_signal.emit("â„šī¸ Added default entry for 'Boa Hancock'.") + self.log_signal.emit("â„šī¸ Added default entry for 'Yor Forger'.") self.character_list.addItems([entry["name"] for entry in KNOWN_NAMES]) @@ -805,11 +849,15 @@ class DownloaderApp(QWidget): with open(self.config_file, 'w', encoding='utf-8') as f: for entry in KNOWN_NAMES: if entry["is_group"] and len(entry["aliases"]) > 1: - # Join all aliases with ", " for readability - joined_aliases = ", ".join(entry["aliases"]) - f.write(f"({joined_aliases})\n") + # New format: Mark primary name (first alias) with brackets + primary_name = entry['aliases'][0] + other_aliases = entry['aliases'][1:] + formatted_aliases_for_file = [f"[{primary_name}]"] + other_aliases + f.write(f"({', '.join(formatted_aliases_for_file)})\n") + elif entry["is_group"] and len(entry["aliases"]) == 1: # Group with only one name (the primary) + f.write(f"([{entry['aliases'][0]}])\n") # Save as ([PrimaryName]) else: # Simple entry or group with only one alias (the name itself) - f.write(entry["name"] + '\n') + f.write(entry["name"] + '\n') # Non-grouped items are saved as plain names if hasattr(self, 'log_signal'): self.log_signal.emit(f"💾 Saved {len(KNOWN_NAMES)} known entries to {self.config_file}") except Exception as e: log_msg = f"❌ Error saving config '{self.config_file}': {e}" @@ -822,6 +870,8 @@ class DownloaderApp(QWidget): self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope) self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope) self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting) + self.settings.setValue(COOKIE_TEXT_KEY, self.cookie_text_input.text() if hasattr(self, 'cookie_text_input') else "") + self.settings.setValue(USE_COOKIE_KEY, self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) self.settings.sync() should_exit = True @@ -1113,6 +1163,30 @@ class DownloaderApp(QWidget): ) self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders) advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox) + + self.use_cookie_checkbox = QCheckBox("Use Cookie") + self.use_cookie_checkbox.setToolTip("If checked, will attempt to use cookies from 'cookies.txt' (Netscape format)\n" + "in the application directory for requests.\n" + "Useful for accessing content that requires login on Kemono/Coomer.") + self.use_cookie_checkbox.setChecked(self.use_cookie_setting) # Set from loaded setting + + self.cookie_text_input = QLineEdit() + self.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") + self.cookie_text_input.setMinimumHeight(28) # Slightly increase height for better visibility + self.cookie_text_input.setToolTip("Enter your cookie string directly.\n" + "This will be used if 'Use Cookie' is checked AND 'cookies.txt' is not found or this field is not empty.\n" + "The format depends on how the backend will parse it (e.g., 'name1=value1; name2=value2').") + self.cookie_text_input.setText(self.cookie_text_setting) # Set from loaded setting + + advanced_row1_layout.addWidget(self.use_cookie_checkbox) + advanced_row1_layout.addWidget(self.cookie_text_input, 2) # Stretch factor 2 + + self.cookie_browse_button = QPushButton("Browse...") + self.cookie_browse_button.setToolTip("Browse for a cookie file (Netscape format, typically cookies.txt).\nThis will be used if 'Use Cookie' is checked and the text field above is empty.") + self.cookie_browse_button.setFixedWidth(80) # Make it a bit compact + self.cookie_browse_button.setStyleSheet("padding: 4px 8px;") + advanced_row1_layout.addWidget(self.cookie_browse_button) + advanced_row1_layout.addStretch(1) checkboxes_group_layout.addLayout(advanced_row1_layout) @@ -1354,6 +1428,7 @@ class DownloaderApp(QWidget): self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) if hasattr(self, 'link_input'): self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)) # Also trigger manga UI update self.load_known_names_from_util() + self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) # Initial visibility self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) if hasattr(self, 'radio_group') and self.radio_group.checkedButton(): self._handle_filter_mode_change(self.radio_group.checkedButton(), True) @@ -1362,9 +1437,28 @@ class DownloaderApp(QWidget): self._update_char_filter_scope_button_text() self._update_multithreading_for_date_mode() # Ensure correct initial state + def _browse_cookie_file(self): + """Opens a file dialog to select a cookie file.""" + # Start in the user's documents directory or current app dir if not available + start_dir = QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) + if not start_dir: + start_dir = os.path.dirname(self.config_file) # App directory + + filepath, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", start_dir, "Text files (*.txt);;All files (*)") + if filepath: + self.selected_cookie_filepath = filepath + self.log_signal.emit(f"â„šī¸ Selected cookie file: {filepath}") + if hasattr(self, 'cookie_text_input'): + # Block signals temporarily to prevent textChanged handler from misinterpreting this programmatic change + self.cookie_text_input.blockSignals(True) + self.cookie_text_input.setText(filepath) + self.cookie_text_input.setReadOnly(True) + self.cookie_text_input.setPlaceholderText("") # No placeholder when showing a path + self.cookie_text_input.blockSignals(False) + def _center_on_screen(self): """Centers the widget on the screen.""" - # Updated to use availableGeometry and center more reliably + # Updated to use availableGeometry and center more reliably try: primary_screen = QApplication.primaryScreen() if not primary_screen: @@ -1381,6 +1475,19 @@ class DownloaderApp(QWidget): except Exception as e: self.log_signal.emit(f"âš ī¸ Error centering window: {e}") + def _handle_cookie_text_manual_change(self, text): + """Handles manual changes to the cookie text input, especially clearing a browsed path.""" + if not hasattr(self, 'cookie_text_input') or not hasattr(self, 'use_cookie_checkbox'): + return + + # If a file was selected AND the text field is now empty (user deleted the path) + if self.selected_cookie_filepath and not text.strip() and self.use_cookie_checkbox.isChecked(): + self.selected_cookie_filepath = None + self.cookie_text_input.setReadOnly(False) + self.cookie_text_input.setPlaceholderText("Cookie string (if no cookies.txt)") + self.log_signal.emit("â„šī¸ Browsed cookie file path cleared from input. Switched to manual cookie string mode.") + + def get_dark_theme(self): return """ QWidget { background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; } @@ -2105,6 +2212,10 @@ class DownloaderApp(QWidget): if self.use_subfolder_per_post_checkbox: self.use_subfolder_per_post_checkbox.setEnabled(not is_only_links and not is_only_archives) + if hasattr(self, 'use_cookie_checkbox'): + self.use_cookie_checkbox.setEnabled(not is_only_links) # Cookies might be relevant for archives + + enable_character_filter_related_widgets = checked and not is_only_links and not is_only_archives @@ -2119,6 +2230,38 @@ class DownloaderApp(QWidget): self.update_custom_folder_visibility() + def _update_cookie_input_visibility(self, checked): + cookie_text_input_exists = hasattr(self, 'cookie_text_input') + cookie_browse_button_exists = hasattr(self, 'cookie_browse_button') + + if cookie_text_input_exists or cookie_browse_button_exists: + is_only_links = self.radio_only_links and self.radio_only_links.isChecked() + + # Cookie text input and browse button are visible if "Use Cookie" is checked + if cookie_text_input_exists: self.cookie_text_input.setVisible(checked) + if cookie_browse_button_exists: self.cookie_browse_button.setVisible(checked) + + can_enable_cookie_text = checked and not is_only_links + enable_state_for_fields = can_enable_cookie_text and (self.download_btn.isEnabled() or self.is_paused) + + if cookie_text_input_exists: + # Text input is always enabled if its parent "Use Cookie" is checked and conditions met, + # unless a file path is displayed (then it's read-only). + self.cookie_text_input.setEnabled(enable_state_for_fields) + if self.selected_cookie_filepath and checked: # If a file is selected and "Use Cookie" is on + self.cookie_text_input.setText(self.selected_cookie_filepath) + self.cookie_text_input.setReadOnly(True) + self.cookie_text_input.setPlaceholderText("") + elif checked: # "Use Cookie" is on, but no file selected + self.cookie_text_input.setReadOnly(False) + self.cookie_text_input.setPlaceholderText("Cookie string (if no cookies.txt)") + + if cookie_browse_button_exists: self.cookie_browse_button.setEnabled(enable_state_for_fields) + + if not checked: # If "Use Cookie" is unchecked, clear the selected file path + self.selected_cookie_filepath = None + + def update_page_range_enabled_state(self): url_text = self.link_input.text().strip() if self.link_input else "" _, _, post_id = extract_post_info(url_text) @@ -2339,6 +2482,7 @@ class DownloaderApp(QWidget): use_multithreading_enabled_by_checkbox = self.use_multithreading_checkbox.isChecked() try: + # num_threads_from_gui is used for post workers or file workers depending on context num_threads_from_gui = int(self.thread_count_input.text().strip()) if num_threads_from_gui < 1: num_threads_from_gui = 1 except ValueError: @@ -2395,6 +2539,10 @@ class DownloaderApp(QWidget): raw_remove_filename_words = self.remove_from_filename_input.text().strip() if hasattr(self, 'remove_from_filename_input') else "" allow_multipart = self.allow_multipart_download_setting # Use the internal setting remove_from_filename_words_list = [word.strip() for word in raw_remove_filename_words.split(',') if word.strip()] + use_cookie_from_checkbox = self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False + app_base_dir_for_cookies = os.path.dirname(self.config_file) # Directory of Known.txt + cookie_text_from_input = self.cookie_text_input.text().strip() if hasattr(self, 'cookie_text_input') and use_cookie_from_checkbox else "" + selected_cookie_file_path_for_backend = self.selected_cookie_filepath if use_cookie_from_checkbox and self.selected_cookie_filepath else None current_skip_words_scope = self.get_skip_words_scope() current_char_filter_scope = self.get_char_filter_scope() manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False @@ -2403,6 +2551,10 @@ class DownloaderApp(QWidget): backend_filter_mode = self.get_filter_mode() user_selected_filter_text = self.radio_group.checkedButton().text() if self.radio_group.checkedButton() else "All" + # If a file path is selected, cookie_text_from_input should be considered empty for backend logic + if selected_cookie_file_path_for_backend: + cookie_text_from_input = "" + if backend_filter_mode == 'archive': effective_skip_zip = False effective_skip_rar = False @@ -2646,6 +2798,7 @@ class DownloaderApp(QWidget): f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}" # Removed duplicate file handling log ]) else: + # If only_links, cookie might still be relevant for accessing the page log_messages.append(f" Mode: Extracting Links Only") log_messages.append(f" Show External Links: {'Enabled' if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive' else 'Disabled'}") @@ -2657,6 +2810,11 @@ class DownloaderApp(QWidget): log_messages.append(f" â†ŗ Manga Character Filter (for naming/folder): {', '.join(item['name'] for item in filter_character_list_to_pass)}") log_messages.append(f" â†ŗ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).") + log_messages.append(f" Use Cookie ('cookies.txt'): {'Enabled' if use_cookie_from_checkbox else 'Disabled'}") + if use_cookie_from_checkbox and cookie_text_from_input: + log_messages.append(f" â†ŗ Cookie Text Provided: Yes (length: {len(cookie_text_from_input)})") + elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend: + log_messages.append(f" â†ŗ Cookie File Selected: {os.path.basename(selected_cookie_file_path_for_backend)}") should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url # Adjust log message if date-based manga mode forced single thread if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not post_id_from_url: @@ -2713,6 +2871,10 @@ class DownloaderApp(QWidget): 'num_file_threads_for_worker': effective_num_file_threads_per_worker, 'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread, 'allow_multipart_download': allow_multipart, + 'cookie_text': cookie_text_from_input, # Pass cookie text + 'selected_cookie_file': selected_cookie_file_path_for_backend, # Pass selected cookie file + 'app_base_dir': app_base_dir_for_cookies, # Pass app base dir + 'use_cookie': use_cookie_from_checkbox, # Pass cookie setting # 'duplicate_file_mode' and session-wide tracking removed } @@ -2736,7 +2898,7 @@ class DownloaderApp(QWidget): 'start_page', 'end_page', 'target_post_id_from_initial_url', 'manga_date_file_counter_ref', # Ensure this is passed for single thread mode 'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', - 'allow_multipart_download' + 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file ] args_template['skip_current_file_flag'] = None single_thread_args = {key: args_template[key] for key in dt_expected_keys if key in args_template} @@ -2938,7 +3100,7 @@ class DownloaderApp(QWidget): 'cancellation_event', 'downloaded_files', 'downloaded_file_hashes', 'downloaded_files_lock', 'downloaded_file_hashes_lock', 'remove_from_filename_words_list', 'dynamic_character_filter_holder', # Added holder 'skip_words_list', 'skip_words_scope', 'char_filter_scope', - 'show_external_links', 'extract_links_only', 'allow_multipart_download', + 'show_external_links', 'extract_links_only', 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', # Added selected_cookie_file 'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref', 'manga_mode_active', 'manga_filename_style' ] @@ -2947,7 +3109,7 @@ class DownloaderApp(QWidget): 'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list', 'show_external_links', 'extract_links_only', 'duplicate_file_mode', # Added duplicate_file_mode here 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style', - 'manga_date_file_counter_ref' # Add this + 'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file } # --- Batching Logic --- @@ -3066,7 +3228,10 @@ class DownloaderApp(QWidget): self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox, self.manga_mode_checkbox, self.manga_rename_toggle_button, # Visibility handled by update_ui_for_manga_mode + self.cookie_browse_button, # Add cookie browse button self.multipart_toggle_button, + self.cookie_text_input, # Add cookie text input + self.use_cookie_checkbox, # Add cookie checkbox here self.external_links_checkbox ] @@ -3082,7 +3247,7 @@ class DownloaderApp(QWidget): self.skip_zip_checkbox, self.skip_rar_checkbox, self.download_thumbnails_checkbox, self.compress_images_checkbox, self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox, self.use_multithreading_checkbox, self.thread_count_input, self.thread_count_label, - self.external_links_checkbox, self.manga_mode_checkbox, self.manga_rename_toggle_button, + self.external_links_checkbox, self.manga_mode_checkbox, self.manga_rename_toggle_button, self.use_cookie_checkbox, self.cookie_text_input, self.cookie_browse_button, self.multipart_toggle_button, self.character_search_input, self.new_char_input, self.add_char_button, self.delete_char_button, self.reset_button @@ -3104,10 +3269,19 @@ class DownloaderApp(QWidget): if self.external_links_checkbox: is_only_links = self.radio_only_links and self.radio_only_links.isChecked() - self.external_links_checkbox.setEnabled(enabled and not is_only_links) - if self.is_paused and not is_only_links: # Also re-enable if paused and not in link mode + is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked() + can_enable_ext_links = enabled and not is_only_links and not is_only_archives + self.external_links_checkbox.setEnabled(can_enable_ext_links) + if self.is_paused and not is_only_links and not is_only_archives: self.external_links_checkbox.setEnabled(True) + # Handle "Use Cookie" checkbox and text input + if hasattr(self, 'use_cookie_checkbox'): + self.use_cookie_checkbox.setEnabled(enabled or self.is_paused) + self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked()) # This will handle cookie_text_input's enabled state + + if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setEnabled(enabled or self.is_paused) + if self.log_verbosity_toggle_button: self.log_verbosity_toggle_button.setEnabled(True) # New button, always enabled multithreading_currently_on = self.use_multithreading_checkbox.isChecked() @@ -3176,6 +3350,13 @@ class DownloaderApp(QWidget): self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True); self.external_links_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) + if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(self.use_cookie_setting) # Reset to loaded or False + + # For soft reset, if a cookie file was selected, keep it displayed if "Use Cookie" remains checked. + # Otherwise, clear it. The _update_cookie_input_visibility will handle the display. + if not (hasattr(self, 'use_cookie_checkbox') and self.use_cookie_checkbox.isChecked()): + self.selected_cookie_filepath = None + if hasattr(self, 'cookie_text_input'): self.cookie_text_input.setText(self.cookie_text_setting if self.use_cookie_setting else "") # Reset to loaded or empty # 2. Reset internal state for UI-managed settings to app defaults (not from QSettings) self.allow_multipart_download_setting = False # Default to OFF @@ -3215,6 +3396,7 @@ class DownloaderApp(QWidget): # Explicitly call these to ensure they reflect changes from preserved inputs self.update_custom_folder_visibility(self.link_input.text()) self.update_page_range_enabled_state() + self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) # update_ui_for_manga_mode is called within set_ui_enabled self.log_signal.emit("✅ Soft UI reset complete. Preserved URL and Directory (if provided).") @@ -3547,6 +3729,12 @@ class DownloaderApp(QWidget): self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True); self.external_links_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) + if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(False) # Default to False on full reset + + # On full reset, always clear the selected cookie file path + self.selected_cookie_filepath = None + + if hasattr(self, 'cookie_text_input'): self.cookie_text_input.clear() # Clear cookie text on full reset # Reset old summarization state (if any remnants) and new bold list state self.missed_title_key_terms_count.clear() @@ -3567,6 +3755,7 @@ class DownloaderApp(QWidget): self._update_char_filter_scope_button_text() self.current_log_view = 'progress' # Reset to progress log view + self._update_cookie_input_visibility(False) # Hide cookie text input on full reset if self.log_view_stack: self.log_view_stack.setCurrentIndex(0) if self.progress_log_label: self.progress_log_label.setText("📜 Progress Log:") diff --git a/multipart_downloader.py b/multipart_downloader.py index befb139..d78ad83 100644 --- a/multipart_downloader.py +++ b/multipart_downloader.py @@ -13,7 +13,7 @@ DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256KB for iter_content within a chunk d def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, headers, - part_num, total_parts, progress_data, cancellation_event, skip_event, pause_event, global_emit_time_ref, # Added global_emit_time_ref + part_num, total_parts, progress_data, cancellation_event, skip_event, pause_event, global_emit_time_ref, cookies_for_chunk, # Added cookies_for_chunk logger_func, emitter=None, api_original_filename=None): # Renamed logger, signals to emitter """Downloads a single chunk of a file and writes it to the temp file.""" if cancellation_event and cancellation_event.is_set(): @@ -78,8 +78,8 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, # Enhanced log message for chunk start log_msg = f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}" logger_func(log_msg) - print(f"DEBUG_MULTIPART: {log_msg}") # Direct console print for debugging - response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True) + # print(f"DEBUG_MULTIPART: {log_msg}") # Direct console print for debugging + response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk) response.raise_for_status() # For 0-byte files, if end_byte was -1, we expect 0 content. @@ -159,7 +159,8 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename, - emitter_for_multipart, cancellation_event, skip_event, logger_func, pause_event): # Added pause_event + emitter_for_multipart, cookies_for_chunk_session, # Added cookies_for_chunk_session + cancellation_event, skip_event, logger_func, pause_event): """ Downloads a file in multiple parts concurrently. Returns: (download_successful_flag, downloaded_bytes, calculated_file_hash, temp_file_handle_or_None) @@ -221,7 +222,7 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, _download_individual_chunk, chunk_url=file_url, temp_file_path=temp_file_path, start_byte=start, end_byte=end, headers=headers, part_num=i, total_parts=num_parts, progress_data=progress_data, cancellation_event=cancellation_event, skip_event=skip_event, global_emit_time_ref=progress_data['last_global_emit_time'], - pause_event=pause_event, logger_func=logger_func, emitter=emitter_for_multipart, # Pass pause_event and emitter + pause_event=pause_event, cookies_for_chunk=cookies_for_chunk_session, logger_func=logger_func, emitter=emitter_for_multipart, api_original_filename=api_original_filename )) diff --git a/readme.md b/readme.md index a68c2f4..9110a66 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -

Kemono Downloader v3.4.0

+

Kemono Downloader v3.5.0

Kemono Downloader @@ -11,8 +11,31 @@ Built with **PyQt5**, this tool is ideal for users who want deep filtering, cust --- -## ✨ What's New in v3.4.0? +## ✨ What's New in v3.5.0? +Version 3.5.0 focuses on enhancing access to content and providing even smarter organization: + +### đŸĒ Enhanced Cookie Management + +- **Access Restricted Content:** Seamlessly download from Kemono/Coomer as if you were logged in by using your browser's cookies. +- **Flexible Input:** + - Directly paste your cookie string (e.g., `name1=value1; name2=value2`). + - Browse and load cookies from a `cookies.txt` file (Netscape format). + - Automatic fallback to a `cookies.txt` file in the application directory if "Use Cookie" is enabled and no other source is specified. +- **Easy Activation:** A simple "Use Cookie" checkbox in the UI controls this feature. +- *Important Note: Cookie settings (text, file path, and enabled state) are configured per session and are not saved when the application is closed. You will need to re-apply them on each launch if needed.* + +--- + +### đŸ—‚ī¸ Advanced `Known.txt` for Smart Folder Organization + +- **Fine-Grained Control:** Take your automatic folder organization to the next level with a personalized list of names, series titles, and keywords in `Known.txt`. +- **Primary Names & Aliases:** Define a main folder name and link multiple aliases to it. For example, `([Power], powwr, pwr, Blood devil)` ensures any post matching "Power" or "powwr" (in title or filename, depending on settings) gets saved into a "Power" folder. Simple entries like `My Series` are also supported. +- **Intelligent Fallback:** When "Separate Folders by Name/Title" is active, and if a post doesn't match any specific "Filter by Character(s)" input, the downloader consults `Known.txt` to find a matching primary name for folder creation. +- **User-Friendly Management:** Add or remove primary names directly through the UI, or click "Open Known.txt" for advanced editing (e.g., setting up aliases). + +--- +## ✨ What's in v3.4.0? (Previous Update) This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience: ### 📖 Enhanced Manga/Comic Mode @@ -185,9 +208,12 @@ This version brings significant enhancements to manga/comic downloading, filteri ### đŸ—ƒī¸ Config System -- **Known.txt:** - - Stores names for smart folder suggestions - - Supports aliases via `(alias1, alias2)` +- **`Known.txt` for Smart Folder Naming:** + - A user-editable file (`Known.txt`) stores a list of preferred names, series titles, or keywords. + - Used as a fallback for folder creation when "Separate Folders by Name/Title" is enabled, helping to group content logically even without explicit character filters. + - **Supports primary names and aliases:** + - Simple entries: `My Favorite Series` + - Grouped entries with a primary name for the folder: `([Primary Name], alias1, alias2)` - **Stored in Standard App Data Path** @@ -234,7 +260,7 @@ pyinstaller --name "Kemono Downloader" --onefile --windowed --icon="Kemono.ico" ## **🗂 Config Files** - `Known.txt` — character/show names used for folder organization -- Supports grouped names in format: `(Name1, Name2)` +- Supports simple names (e.g., `My Series`) and grouped names with a primary folder name and aliases (e.g., `([Primary Folder Name], alias1, alias2)`). ***