import json import subprocess import threading import webbrowser from pathlib import Path import markdown import pyglet import requests from packaging import version from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( QApplication, QButtonGroup, QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QProgressBar, QPushButton, QStyle, QTextEdit, QVBoxLayout, QWidget, ) from src import __version__ as APP_VERSION from src.core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes from src.core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url from src.core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions from src.core.ytsage_deno import get_deno_path, setup_deno # Import the new Deno functions from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py AboutDialog, AutoUpdateThread, CustomOptionsDialog, DownloadSettingsDialog, FFmpegCheckDialog, HistoryDialog, PlaylistSelectionDialog, TimeRangeDialog, YTDLPUpdateDialog, ) from src.gui.ytsage_gui_format_table import FormatTableMixin from src.gui.ytsage_gui_video_info import VideoInfoMixin from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS from src.utils.ytsage_logger import logger from src.utils.ytsage_config_manager import ConfigManager from src.utils.ytsage_localization import LocalizationManager, _ from src.utils.ytsage_history_manager import HistoryManager class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins def __init__(self) -> None: super().__init__() # Initialize localization system saved_language = ConfigManager.get("language") or "en" LocalizationManager.initialize(saved_language) # Check for FFmpeg before proceeding if not check_ffmpeg(): self.show_ffmpeg_dialog() # Check for yt-dlp in our app's bin directory or system PATH ytdlp_path = get_yt_dlp_path() if ytdlp_path == "yt-dlp": # Not found in app dir or PATH self.show_ytdlp_setup_dialog() else: logger.info(f"Using yt-dlp from: {ytdlp_path}") # Check for Deno in our app's bin directory deno_path = get_deno_path() if deno_path == "deno": # Not found in app dir self.show_deno_setup_dialog() else: logger.info(f"Using Deno from: {deno_path}") self.version = APP_VERSION self.check_for_updates() # Check for auto-updates if enabled self.check_auto_update_ytdlp() load_saved_path(self) # Load custom icon if ICON_PATH.exists(): self.setWindowIcon(QIcon(str(ICON_PATH))) else: logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.") self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback self.signals = SignalManager() self.download_paused = False self.current_download = None self.download_cancelled = False self.save_thumbnail = False # Initialize thumbnail state self.thumbnail_url = None # Add this to store thumbnail URL self.all_formats = [] # Initialize all_formats self.available_subtitles = {} self.available_automatic_subtitles = {} self.is_playlist = False self.playlist_info = None self.video_info = None self.playlist_entries = [] # Initialize playlist entries self.selected_playlist_items = None # Initialize selection string self.save_description = False # Initialize description state self.embed_chapters = False # Initialize chapters state self.subtitle_filter = "" self.thumbnail_image = None self.video_url = "" self.selected_subtitles = [] # Initialize selected subtitles list # Initialize cookie settings from saved config self._initialize_cookie_settings_from_config() # Initialize proxy settings from config self.proxy_url = ConfigManager.get("proxy_url") self.geo_proxy_url = ConfigManager.get("geo_proxy_url") self.speed_limit_value = None # Store speed limit value self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s) self.download_section = None self.force_keyframes = False # Initialize output format settings self.force_output_format = ConfigManager.get("force_output_format") or False self.preferred_output_format = ConfigManager.get("preferred_output_format") or "mp4" self.force_audio_format = ConfigManager.get("force_audio_format") or False self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best" # Track if video analysis is completed self.analysis_completed = False self.init_ui() self.setStyleSheet( """ QMainWindow { background-color: #15181b; } QWidget { background-color: #15181b; color: #ffffff; } QLineEdit { padding: 8px; border: 2px solid #1b2021; border-radius: 4px; background-color: #1b2021; color: #ffffff; } QPushButton { padding: 8px 15px; background-color: #c90000; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:hover { background-color: #a50000; } QPushButton:pressed { background-color: #800000; } QTableWidget { border: 2px solid #1b2021; border-radius: 4px; background-color: #1b2021; gridline-color: #1b2021; } QHeaderView::section { background-color: #15181b; padding: 5px; border: 1px solid #1b2021; color: #ffffff; } QProgressBar { border: 2px solid #1b2021; border-radius: 4px; text-align: center; color: white; } QProgressBar::chunk { background-color: #c90000; border-radius: 2px; } QLabel { color: #ffffff; } /* Style for filter buttons */ QPushButton.filter-btn { background-color: #1b2021; padding: 5px 10px; margin: 0 5px; } QPushButton.filter-btn:checked { background-color: #c90000; } QPushButton.filter-btn:hover { background-color: #444444; } QPushButton.filter-btn:checked:hover { background-color: #a50000; } /* Modern Scrollbar Styling */ QScrollBar:vertical { border: none; background: #15181b; width: 14px; margin: 15px 0 15px 0; border-radius: 7px; } QScrollBar::handle:vertical { background: #404040; min-height: 30px; border-radius: 7px; } QScrollBar::handle:vertical:hover { background: #505050; } QScrollBar::sub-line:vertical { border: none; background: #15181b; height: 15px; border-top-left-radius: 7px; border-top-right-radius: 7px; subcontrol-position: top; subcontrol-origin: margin; } QScrollBar::add-line:vertical { border: none; background: #15181b; height: 15px; border-bottom-left-radius: 7px; border-bottom-right-radius: 7px; subcontrol-position: bottom; subcontrol-origin: margin; } QScrollBar::sub-line:vertical:hover, QScrollBar::add-line:vertical:hover { background: #404040; } QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { background: none; width: 0; height: 0; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } /* Horizontal Scrollbar */ QScrollBar:horizontal { border: none; background: #15181b; height: 14px; margin: 0 15px 0 15px; border-radius: 7px; } QScrollBar::handle:horizontal { background: #404040; min-width: 30px; border-radius: 7px; } QScrollBar::handle:horizontal:hover { background: #505050; } QScrollBar::sub-line:horizontal { border: none; background: #15181b; width: 15px; border-top-left-radius: 7px; border-bottom-left-radius: 7px; subcontrol-position: left; subcontrol-origin: margin; } QScrollBar::add-line:horizontal { border: none; background: #15181b; width: 15px; border-top-right-radius: 7px; border-bottom-right-radius: 7px; subcontrol-position: right; subcontrol-origin: margin; } QScrollBar::sub-line:horizontal:hover, QScrollBar::add-line:horizontal:hover { background: #404040; } QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { background: none; width: 0; height: 0; } QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; } """ ) self.signals.update_progress.connect(self.update_progress_bar) # After adding format buttons self.video_button.clicked.connect(self.filter_formats) # Connect video button self.audio_button.clicked.connect(self.filter_formats) # Connect audio button # Add connections to handle video/audio mode-specific controls self.video_button.clicked.connect(self.handle_mode_change) self.audio_button.clicked.connect(self.handle_mode_change) # Initialize UI state based on current mode self.handle_mode_change() # Init_sound method is removed, serve no purpose. def play_notification_sound(self) -> None: """Play notification sound asynchronously (non-blocking).""" try: # Check if the notification sound file exists if not SOUND_PATH.exists(): logger.warning(f"Notification sound file not found at: {SOUND_PATH}") return # Play the sound using pyglet # no need for the thread, as .play() is async sound = pyglet.media.load(str(SOUND_PATH), streaming=False) sound.play() logger.debug("Notification sound played") except Exception as e: logger.exception(f"Error playing notification sound: {e}") def _initialize_cookie_settings_from_config(self) -> None: """Initialize cookie settings - cookies are NOT auto-activated on startup. User must explicitly click Apply in the dialog each session.""" # Cookies always start inactive on app launch # User must click Apply in Custom Options dialog to activate them self.cookie_file_path = None self.browser_cookies_option = None logger.debug("Cookie settings initialized - no cookies active (user must apply manually)") def init_ui(self) -> None: self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}") self.setMinimumSize(900, 750) # Main widget and layout main_widget = QWidget() self.setCentralWidget(main_widget) layout = QVBoxLayout(main_widget) layout.setSpacing(8) layout.setContentsMargins(20, 20, 20, 20) # URL input section url_layout = QHBoxLayout() self.url_input = QLineEdit() self.url_input.setPlaceholderText(_("main_ui.url_placeholder")) self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key self.analyze_button = QPushButton(_("buttons.analyze")) self.analyze_button.clicked.connect(self.analyze_url) self.paste_button = QPushButton(_("buttons.paste_url")) self.paste_button.clicked.connect(self.paste_url) url_layout.addWidget(self.url_input, 1) url_layout.addWidget(self.paste_button) url_layout.addWidget(self.analyze_button) layout.addLayout(url_layout) # Video info container video_info_container = QWidget() video_info_layout = QVBoxLayout(video_info_container) video_info_layout.setSpacing(5) video_info_layout.setContentsMargins(0, 0, 0, 0) # Add media info layout (Thumbnail | Video Details) media_info_layout = self.setup_video_info_section() video_info_layout.addLayout(media_info_layout) # Add video info container to main layout layout.addWidget(video_info_container) # --- Add Playlist Info Section Directly to Main Layout --- # Add playlist info label (initially hidden) self.playlist_info_label = self.setup_playlist_info_section() layout.addWidget(self.playlist_info_label) # Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit self.playlist_select_btn = QPushButton(_("buttons.select_videos")) self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog) self.playlist_select_btn.setVisible(False) self.playlist_select_btn.setStyleSheet( """ QPushButton { padding: 6px 12px; background-color: #1d1e22; border: 1px solid #c90000; border-radius: 4px; color: white; font-weight: normal; text-align: left; padding-left: 10px; } QPushButton:hover { background-color: #2a2d36; border-color: #a50000; } """ ) layout.addWidget(self.playlist_select_btn) # --- End Playlist Info Section --- # Format controls section with minimal spacing layout.addSpacing(5) # Format selection layout (horizontal) self.format_layout = QHBoxLayout() # Show formats label self.show_formats_label = QLabel(_("formats.show_formats")) self.show_formats_label.setStyleSheet("color: white;") self.format_layout.addWidget(self.show_formats_label) # Format buttons group self.format_buttons = QButtonGroup(self) self.format_buttons.setExclusive(True) # Video button self.video_button = QPushButton(_("buttons.video")) self.video_button.setCheckable(True) self.video_button.setChecked(True) # Set video as default self.video_button.setStyleSheet( """ QPushButton { padding: 8px 15px; background-color: #1d1e22; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { background-color: #c90000; } QPushButton:hover { background-color: #2a2d36; } QPushButton:checked:hover { background-color: #a50000; } """ ) self.format_buttons.addButton(self.video_button) self.format_layout.addWidget(self.video_button) # Audio button self.audio_button = QPushButton(_("buttons.audio_only")) self.audio_button.setCheckable(True) self.audio_button.setStyleSheet( """ QPushButton { padding: 8px 15px; background-color: #1d1e22; border: none; border-radius: 4px; color: white; font-weight: bold; } QPushButton:checked { background-color: #c90000; } QPushButton:hover { background-color: #2a2d36; } QPushButton:checked:hover { background-color: #a50000; } """ ) self.format_buttons.addButton(self.audio_button) self.format_layout.addWidget(self.audio_button) # Add Merge Subtitles checkbox (Moved here) self.merge_subs_checkbox = QCheckBox(_("main_ui.merge_subtitles")) self.merge_subs_checkbox.setStyleSheet( """ QCheckBox { color: #ffffff; padding: 5px; margin-left: 20px; /* Consistent margin */ } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } /* Add disabled state styling if needed */ QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } """ ) # Initially disable it, will be enabled if subtitles are selected later self.merge_subs_checkbox.setEnabled(False) self.format_layout.addWidget(self.merge_subs_checkbox) # Add Save Thumbnail Checkbox (Moved here) self.save_thumbnail_checkbox = QCheckBox(_("main_ui.save_thumbnail")) self.save_thumbnail_checkbox.setChecked(False) self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_save_thumbnail) self.save_thumbnail_checkbox.setStyleSheet( """ QCheckBox { color: #ffffff; padding: 5px; margin-left: 20px; } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } """ ) self.format_layout.addWidget(self.save_thumbnail_checkbox) # Add Save Description Checkbox (Moved here) self.save_description_checkbox = QCheckBox(_("main_ui.save_description")) self.save_description_checkbox.setChecked(False) self.save_description_checkbox.stateChanged.connect(self.toggle_save_description) self.save_description_checkbox.setStyleSheet( """ QCheckBox { color: #ffffff; padding: 5px; margin-left: 20px; } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } """ ) self.format_layout.addWidget(self.save_description_checkbox) # Add Embed Chapters Checkbox self.embed_chapters_checkbox = QCheckBox(_("main_ui.embed_chapters")) self.embed_chapters_checkbox.setChecked(False) self.embed_chapters_checkbox.stateChanged.connect(self.toggle_embed_chapters) self.embed_chapters_checkbox.setStyleSheet( """ QCheckBox { color: #ffffff; padding: 5px; margin-left: 20px; } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; } QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } """ ) self.format_layout.addWidget(self.embed_chapters_checkbox) self.format_layout.addStretch() layout.addLayout(self.format_layout) # Format table with stretch format_table = self.setup_format_table() layout.addWidget(format_table, stretch=1) # Download section download_layout = QHBoxLayout() # Replace the two separate buttons with a single Custom Options button self.custom_options_btn = QPushButton(_("buttons.custom_options")) self.custom_options_btn.clicked.connect(self.show_custom_options) self.about_btn = QPushButton(_("buttons.about")) self.about_btn.clicked.connect(self.show_about_dialog) self.history_btn = QPushButton(_("buttons.history")) self.history_btn.clicked.connect(self.show_history_dialog) # Add new Time Range button self.time_range_btn = QPushButton(_("buttons.trim_video")) self.time_range_btn.clicked.connect(self.show_time_range_dialog) # --- Rename Path Button to Settings Button --- self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip # --- End Settings Button --- self.download_btn = QPushButton(_("buttons.download")) self.download_btn.clicked.connect(self.start_download) # Add pause and cancel buttons self.pause_btn = QPushButton(_("buttons.pause")) self.pause_btn.clicked.connect(self.toggle_pause) self.pause_btn.setVisible(False) # Hidden initially self.cancel_btn = QPushButton(_("buttons.cancel")) self.cancel_btn.clicked.connect(self.cancel_download) self.cancel_btn.setVisible(False) # Hidden initially # Add all buttons to layout in the correct order download_layout.addWidget(self.custom_options_btn) download_layout.addWidget(self.about_btn) download_layout.addWidget(self.history_btn) download_layout.addWidget(self.time_range_btn) # New button position download_layout.addWidget(self.settings_button) download_layout.addWidget(self.download_btn) download_layout.addWidget(self.pause_btn) download_layout.addWidget(self.cancel_btn) layout.addLayout(download_layout) # Progress section with improved styling progress_layout = QVBoxLayout() self.progress_bar = QProgressBar() self.progress_bar.setStyleSheet( """ QProgressBar { border: 2px solid #3d3d3d; border-radius: 4px; text-align: center; color: white; background-color: #363636; height: 25px; } QProgressBar::chunk { background-color: #ff0000; border-radius: 2px; } """ ) progress_layout.addWidget(self.progress_bar) # Add download details label with improved styling self.download_details_label = QLabel() self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.download_details_label.setStyleSheet( """ QLabel { color: #cccccc; font-size: 12px; padding: 5px; } """ ) progress_layout.addWidget(self.download_details_label) # Create a horizontal layout for status label and open folder button status_layout = QHBoxLayout() self.status_label = QLabel(_("app.ready")) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setStyleSheet( """ QLabel { color: #cccccc; font-size: 12px; padding: 5px; } """ ) status_layout.addWidget(self.status_label) # Add "Open Folder" button (initially hidden) self.open_folder_btn = QPushButton("📁") self.open_folder_btn.setToolTip(_("buttons.open_folder")) self.open_folder_btn.setFixedSize(30, 30) self.open_folder_btn.setStyleSheet( """ QPushButton { background-color: #2a2d2e; color: #cccccc; border: 1px solid #404040; border-radius: 5px; font-size: 16px; padding: 2px; } QPushButton:hover { background-color: #3a3d3e; border: 1px solid #505050; } QPushButton:pressed { background-color: #1a1d1e; } """ ) self.open_folder_btn.clicked.connect(self.open_download_folder) self.open_folder_btn.setVisible(False) # Hidden by default status_layout.addWidget(self.open_folder_btn) progress_layout.addLayout(status_layout) layout.addLayout(progress_layout) # Connect signals self.signals.update_formats.connect(self.update_format_table) self.signals.update_status.connect(self.status_label.setText) self.signals.update_progress.connect(self.update_progress_bar) # Connect new signals self.signals.playlist_info_label_visible.connect(self.playlist_info_label.setVisible) self.signals.playlist_info_label_text.connect(self.playlist_info_label.setText) self.signals.selected_subs_label_text.connect(self.selected_subs_label.setText) self.signals.playlist_select_btn_visible.connect(self.playlist_select_btn.setVisible) self.signals.playlist_select_btn_text.connect(self.playlist_select_btn.setText) # Disable analysis-dependent controls until video is analyzed self.toggle_analysis_dependent_controls(enabled=False) def analyze_url(self) -> None: url = self.url_input.text().strip() if not url: self.signals.update_status.emit(_("main_ui.invalid_url_or_enter")) return # Validate URL before processing is_valid, error_message = validate_video_url(url) if not is_valid: QMessageBox.warning(self, _("main_ui.error_title"), error_message) return # Reset analysis state and disable controls self.analysis_completed = False self.toggle_analysis_dependent_controls(enabled=False) self.signals.update_status.emit(_("main_ui.analyzing_preparing")) threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() def _analyze_url_thread(self, url) -> None: try: self.signals.update_status.emit(_("main_ui.analyzing_extracting_basic")) # Clean up the URL to handle both playlist and video URLs if "list=" in url and "watch?v=" in url: playlist_id = url.split("list=")[1].split("&")[0] url = f"https://www.youtube.com/playlist?list={playlist_id}" # Always use subprocess to call yt-dlp binary (Python package removed) self._analyze_url_with_subprocess(url) except Exception as e: logger.exception(f"Error in analysis: {e}") self.signals.update_status.emit(_("errors.generic_error", error=str(e))) # Ensure playlist UI is hidden on error too # update signal method from QMetaObject.invokeMethod to signals self.signals.playlist_info_label_visible.emit(False) self.signals.playlist_select_btn_visible.emit(False) def paste_url(self) -> None: clipboard = QApplication.clipboard() self.url_input.setText(clipboard.text()) def show_download_settings_dialog(self) -> None: # Renamed method dialog = DownloadSettingsDialog(self.last_path, self.speed_limit_value, self.speed_limit_unit_index, self) if dialog.exec(): # Update Path new_path = dialog.get_selected_path() path_changed = False if new_path != self.last_path: self.last_path = new_path save_path(self, self.last_path) # Save the updated path path_changed = True logger.info(f"Download path updated to: {self.last_path}") # Update Speed Limit new_limit_value = dialog.get_selected_speed_limit() new_unit_index = dialog.get_selected_unit_index() limit_changed = False if new_limit_value != self.speed_limit_value or new_unit_index != self.speed_limit_unit_index: self.speed_limit_value = new_limit_value self.speed_limit_unit_index = new_unit_index limit_changed = True logger.info( f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}" ) # Update Output Format Settings new_force_format = dialog.get_force_format_enabled() new_preferred_format = dialog.get_preferred_format() format_changed = False if new_force_format != self.force_output_format or new_preferred_format != self.preferred_output_format: self.force_output_format = new_force_format self.preferred_output_format = new_preferred_format format_changed = True logger.info(f"Output format settings updated - Force: {self.force_output_format}, Preferred: {self.preferred_output_format}") # Update Audio Format Settings new_force_audio_format = dialog.get_force_audio_format_enabled() new_preferred_audio_format = dialog.get_preferred_audio_format() audio_format_changed = False if new_force_audio_format != self.force_audio_format or new_preferred_audio_format != self.preferred_audio_format: self.force_audio_format = new_force_audio_format self.preferred_audio_format = new_preferred_audio_format audio_format_changed = True logger.info(f"Audio format settings updated - Force: {self.force_audio_format}, Preferred: {self.preferred_audio_format}") # Update Tooltip if anything changed if path_changed or limit_changed or format_changed or audio_format_changed: limit_text = "None" if self.speed_limit_value: limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}" self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: {limit_text}") def start_download(self) -> None: url = self.url_input.text().strip() # --- Use self.last_path instead of reading from QLineEdit --- path = self.last_path if not url or not path: # More specific error message if path is missing if not path: self.status_label.setText(_('download.please_set_path')) elif not url: self.status_label.setText(_('download.please_enter_url')) else: self.status_label.setText(_('download.please_enter_url_and_path')) return # --- End Path Change --- # Validate URL before starting download is_valid, error_message = validate_video_url(url) if not is_valid: QMessageBox.warning(self, _("main_ui.error_title"), error_message) return # Get selected format selected_format = self.get_selected_format() if not selected_format: self.status_label.setText(_('download.please_select_format')) return format_id = selected_format["format_id"] is_audio_only = bool(selected_format.get("is_audio_only")) format_has_audio = bool(selected_format.get("has_audio")) # Show preparation message self.status_label.setText(_('download.preparing')) self.progress_bar.setValue(0) self.open_folder_btn.setVisible(False) # Hide the open folder button on new download # Get resolution for filename resolution = "default" for checkbox in self.format_checkboxes: if checkbox.isChecked(): parts = checkbox.text().split("•") if len(parts) >= 1: resolution = parts[0].strip().lower() break # Get subtitle selection if available - Now get the list selected_subs = self.selected_subtitles if hasattr(self, "selected_subtitles") else [] # Get playlist selection IF in playlist mode - USE STORED VALUE playlist_items_to_download = None if self.is_playlist: playlist_items_to_download = self.selected_playlist_items # Use the stored selection string # --- Use stored speed limit values --- rate_limit = None if self.speed_limit_value: try: limit_value = float(self.speed_limit_value) if self.speed_limit_unit_index == 0: # KB/s rate_limit = f"{int(limit_value * 1024)}" elif self.speed_limit_unit_index == 1: # MB/s rate_limit = f"{int(limit_value * 1024 * 1024)}" except ValueError: # Use a signal to show error in status bar, similar to URL/Path errors self.signals.update_status.emit(_("errors.invalid_speed_limit")) return # --- End speed limit update --- # Save thumbnail if enabled if self.save_thumbnail: # Consider moving thumbnail download *after* successful video download # Or handle errors more gracefully if thumbnail download fails try: self.download_thumbnail_file(url, path) except Exception as e: logger.warning(f"Thumbnail download failed: {e}", exc_info=True) # Optionally inform the user, but don't stop the main download # Create download thread with resolution in output template self.download_thread = DownloadThread( url=url, path=path, format_id=format_id, is_audio_only=is_audio_only, format_has_audio=format_has_audio, subtitle_langs=selected_subs, # Pass the list of selected subs is_playlist=self.is_playlist, # Use the flag directly merge_subs=self.merge_subs_checkbox.isChecked(), enable_sponsorblock=len(self.selected_sponsorblock_categories) > 0, sponsorblock_categories=self.selected_sponsorblock_categories, resolution=resolution, playlist_items=playlist_items_to_download, # Pass the selection string save_description=self.save_description, # Pass the new flag here embed_chapters=self.embed_chapters, # Pass the embed chapters flag cookie_file=self.cookie_file_path, # Pass the cookie file path browser_cookies=self.browser_cookies_option, # Pass the browser cookies option rate_limit=rate_limit, # Pass the calculated rate limit download_section=self.download_section, # Pass the download section force_keyframes=self.force_keyframes, # Pass the force keyframes setting proxy_url=self.proxy_url, # Pass the proxy URL geo_proxy_url=self.geo_proxy_url, # Pass the geo-verification proxy URL force_output_format=self.force_output_format, # Pass force output format setting preferred_output_format=self.preferred_output_format, # Pass preferred format force_audio_format=self.force_audio_format, # Pass force audio format setting preferred_audio_format=self.preferred_audio_format, # Pass preferred audio format ) # Connect signals self.download_thread.progress_signal.connect(self.update_progress_bar) self.download_thread.status_signal.connect(self.status_label.setText) self.download_thread.update_details.connect(self.download_details_label.setText) self.download_thread.finished_signal.connect(self.download_finished) self.download_thread.error_signal.connect(self.download_error) self.download_thread.file_exists_signal.connect(self.file_already_exists) # Reset download state self.download_paused = False self.download_cancelled = False # Show pause/cancel buttons self.pause_btn.setText(_("buttons.pause")) self.pause_btn.setVisible(True) self.cancel_btn.setVisible(True) # Start download thread self.current_download = self.download_thread self.download_thread.start() self.toggle_download_controls(False) def download_finished(self) -> None: self.toggle_download_controls(True) self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.progress_bar.setValue(100) # Set completion message based on the file type of last downloaded file if self.download_thread and self.download_thread.current_filename: filename = Path(self.download_thread.current_filename) ext = filename.suffix.lower() # Video file extensions if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: self.status_label.setText(_('download.video_completed')) # Audio file extensions elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: self.status_label.setText(_('download.audio_completed')) # Subtitle file extensions elif ext in [".vtt", ".srt", ".ass", ".ssa"]: self.status_label.setText(_('download.subtitle_completed')) # Default case else: self.status_label.setText(_('download.completed')) # Show the open folder button self.open_folder_btn.setVisible(True) # Save to history try: if self.download_thread.last_file_path and self.video_info: # Get video information title = self.video_info.get("title", _("video_info.unknown_title")) channel = self.video_info.get("channel", None) or self.video_info.get("uploader", None) duration = self.video_info.get("duration_string", None) # Prepare download options download_options = { "format_id": self.download_thread.format_id, "subtitle_langs": self.download_thread.subtitle_langs, "merge_subs": self.download_thread.merge_subs, "enable_sponsorblock": self.download_thread.enable_sponsorblock, "sponsorblock_categories": self.download_thread.sponsorblock_categories, "save_description": self.download_thread.save_description, "embed_chapters": self.download_thread.embed_chapters, "download_section": self.download_thread.download_section, "force_keyframes": self.download_thread.force_keyframes, } # Add to history HistoryManager.add_entry( title=title, url=self.video_url, thumbnail_url=self.thumbnail_url, file_path=str(self.download_thread.last_file_path), format_id=self.download_thread.format_id, is_audio_only=self.download_thread.is_audio_only, resolution=self.download_thread.resolution, channel=channel, duration=duration, download_options=download_options, ) logger.info(f"Added download to history: {title}") except Exception as e: logger.error(f"Error saving to history: {e}", exc_info=True) # Play notification sound when download completes self.play_notification_sound() def open_download_folder(self) -> None: """Open the folder containing the downloaded file and select it if possible""" try: if self.download_thread and self.download_thread.last_file_path: file_path = Path(self.download_thread.last_file_path) if file_path.exists(): # On Windows, use explorer with /select to highlight the file if subprocess.sys.platform == "win32": subprocess.run(['explorer', '/select,', str(file_path)], creationflags=SUBPROCESS_CREATIONFLAGS) # On macOS, use open with -R to reveal in Finder elif subprocess.sys.platform == "darwin": subprocess.run(['open', '-R', str(file_path)]) # On Linux, try to open the folder (file selection not widely supported) else: folder_path = file_path.parent subprocess.run(['xdg-open', str(folder_path)]) logger.info(f"Opened folder for: {file_path}") else: # If file doesn't exist, just open the download folder folder_path = Path(self.last_path) if folder_path.exists(): if subprocess.sys.platform == "win32": subprocess.run(['explorer', str(folder_path)], creationflags=SUBPROCESS_CREATIONFLAGS) elif subprocess.sys.platform == "darwin": subprocess.run(['open', str(folder_path)]) else: subprocess.run(['xdg-open', str(folder_path)]) else: logger.warning(f"Download folder does not exist: {folder_path}") else: # Fallback to opening the general download folder folder_path = Path(self.last_path) if folder_path.exists(): if subprocess.sys.platform == "win32": subprocess.run(['explorer', str(folder_path)], creationflags=SUBPROCESS_CREATIONFLAGS) elif subprocess.sys.platform == "darwin": subprocess.run(['open', str(folder_path)]) else: subprocess.run(['xdg-open', str(folder_path)]) else: logger.warning(f"Download folder does not exist: {folder_path}") except Exception as e: logger.exception(f"Error opening download folder: {e}") QMessageBox.warning(self, "Error", f"Could not open folder: {str(e)}") def download_error(self, error_message) -> None: self.toggle_download_controls(True) self.pause_btn.setVisible(False) self.cancel_btn.setVisible(False) self.status_label.setText(_("errors.generic_error", error=error_message)) self.download_details_label.setText("") # Clear details label on error def update_progress_bar(self, value) -> None: try: # Ensure the value is an integer int_value = int(value) self.progress_bar.setValue(int_value) except Exception as e: logger.exception(f"Progress bar update error: {e}") def toggle_pause(self) -> None: if self.current_download: self.current_download.paused = not self.current_download.paused if self.current_download.paused: self.pause_btn.setText(_("buttons.resume")) self.signals.update_status.emit(_("download.paused")) else: self.pause_btn.setText(_("buttons.pause")) self.signals.update_status.emit(_("download.resumed")) def check_for_updates(self) -> None: try: # Get the latest version info from PyPI (no rate limiting unlike GitHub API) response = requests.get( "https://pypi.org/pypi/ytsage/json", timeout=10, ) response.raise_for_status() pypi_data = response.json() latest_version = pypi_data["info"]["version"] # Compare versions if version.parse(latest_version) > version.parse(self.version): release_url = "https://github.com/oop7/YTSage/releases/latest" # Try to fetch changelog from GitHub (with fallback if rate-limited) changelog = "View the full changelog on the [GitHub Releases](https://github.com/oop7/YTSage/releases) page." try: gh_response = requests.get( "https://api.github.com/repos/oop7/YTSage/releases/latest", headers={"Accept": "application/vnd.github.v3+json"}, timeout=5, ) if gh_response.status_code == 200: gh_data = gh_response.json() changelog = gh_data.get("body", changelog) except Exception: # Silently fallback to static message if GitHub API fails (rate limit, etc.) pass self.show_update_dialog(latest_version, release_url, changelog) except Exception as e: logger.exception(f"Failed to check for updates: {e}") def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter msg = QDialog(self) msg.setWindowTitle(_("update_dialog.title")) msg.setMinimumWidth(600) # Increased width for better layout msg.setMinimumHeight(450) # Increased height for better spacing # Set custom icon directly try: if self.windowIcon() and not self.windowIcon().isNull(): msg.setWindowIcon(self.windowIcon()) else: # Fallback to icon file # icon_path logic moved to src\utils\ytsage_constants.py if ICON_PATH.exists(): msg.setWindowIcon(QIcon(str(ICON_PATH))) except Exception: pass layout = QVBoxLayout(msg) layout.setSpacing(15) # Increased spacing for better layout layout.setContentsMargins(20, 20, 20, 20) # Added margins # Header with icon and title header_layout = QHBoxLayout() # Add update icon icon_label = QLabel() icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload).pixmap(32, 32)) header_layout.addWidget(icon_label) # Title title_label = QLabel(f"