From 36f9996424a6b5d1021a535235d953d7470ccc88 Mon Sep 17 00:00:00 2001 From: Your Name <110548351+oop7@users.noreply.github.com> Date: Fri, 4 Jul 2025 21:35:16 +0300 Subject: [PATCH] Refactor Imports, yt-dlp/Pygame Availability, and ffmpeg/yt-dlp Checks --- ytsage_gui_main.py | 511 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 459 insertions(+), 52 deletions(-) diff --git a/ytsage_gui_main.py b/ytsage_gui_main.py index 343983b..29861a3 100644 --- a/ytsage_gui_main.py +++ b/ytsage_gui_main.py @@ -17,11 +17,24 @@ from pathlib import Path from packaging import version import subprocess import re -import yt_dlp +try: + import yt_dlp + YT_DLP_AVAILABLE = True +except ImportError: + YT_DLP_AVAILABLE = False + print("Warning: yt-dlp not available at startup, will be downloaded at runtime") import markdown +try: + import pygame + PYGAME_AVAILABLE = True +except ImportError: + PYGAME_AVAILABLE = False + print("Warning: pygame not available, audio notifications disabled") +import threading from ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes -from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions +from ytsage_utils import check_ffmpeg, load_saved_path, save_path, get_config_file_path, get_ytdlp_version, get_ffmpeg_version, should_check_for_auto_update, check_and_update_ytdlp_auto # Import utility functions +from ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp, get_ytdlp_executable_path, get_yt_dlp_path # Import the new yt-dlp functions from ytsage_gui_dialogs import (LogWindow, CustomCommandDialog, FFmpegCheckDialog, YTDLPUpdateDialog, AboutDialog, SubtitleSelectionDialog, PlaylistSelectionDialog, CookieLoginDialog, @@ -32,13 +45,25 @@ from ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins def __init__(self): super().__init__() + # 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: + print(f"Using yt-dlp from: {ytdlp_path}") - self.version = "4.5.7" + self.version = "4.6.0" self.check_for_updates() - self.config_file = Path.home() / '.ytsage_config.json' + + # Check for auto-updates if enabled + self.check_auto_update_ytdlp() + + self.config_file = get_config_file_path() load_saved_path(self) # Load custom icon icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png') @@ -247,6 +272,57 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Initialize UI state based on current mode self.handle_mode_change() + + # Initialize pygame for sound notifications + self.init_sound() + + def init_sound(self): + """Initialize pygame mixer for sound notifications""" + try: + if PYGAME_AVAILABLE: + pygame.mixer.init() + self.sound_enabled = True + + # Get the notification sound path + self.notification_sound_path = os.path.join(os.path.dirname(__file__), 'sound', 'notification.mp3') + + # Check if the notification sound file exists + if not os.path.exists(self.notification_sound_path): + print(f"Warning: Notification sound file not found at: {self.notification_sound_path}") + self.sound_enabled = False + else: + print(f"Notification sound loaded from: {self.notification_sound_path}") + else: + self.sound_enabled = False + print("Sound notifications disabled - pygame not available") + + except Exception as e: + print(f"Error initializing sound: {e}") + self.sound_enabled = False + + def play_notification_sound(self): + """Play notification sound in a separate thread to avoid blocking the UI""" + if not self.sound_enabled: + return + + def play_sound(): + try: + if PYGAME_AVAILABLE: + # Load and play the sound + pygame.mixer.music.load(self.notification_sound_path) + pygame.mixer.music.play() + + # Wait for the sound to finish playing + while pygame.mixer.music.get_busy(): + pygame.time.wait(100) + + except Exception as e: + print(f"Error playing notification sound: {e}") + + # Play sound in a separate thread to avoid blocking the UI + sound_thread = threading.Thread(target=play_sound) + sound_thread.daemon = True + sound_thread.start() def load_saved_path(self): # Using function from ytsage_utils now - no longer needed in class pass # Handled in class init now via ytsage_utils.load_saved_path(self) @@ -255,7 +331,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m save_path(self, path) # Call the utility function def init_ui(self): - self.setWindowTitle('YTSage v4.5.7') + self.setWindowTitle('YTSage v4.6.0') self.setMinimumSize(900, 750) # Main widget and layout @@ -606,6 +682,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m playlist_id = url.split('list=')[1].split('&')[0] url = f'https://www.youtube.com/playlist?list={playlist_id}' + # Check if yt-dlp Python module is available + if not YT_DLP_AVAILABLE: + # Use subprocess to call yt-dlp executable + self._analyze_url_with_subprocess(url) + return + # Initial extraction with basic options - suppress warnings here too ydl_opts = { 'quiet': False, @@ -781,20 +863,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.url_input.setText(clipboard.text()) def update_ytdlp(self): - # Check if we're running in a PyInstaller package - if getattr(sys, 'frozen', False): - # Running from PyInstaller package, show info message - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Information) - msg_box.setWindowTitle("Feature Unavailable") - msg_box.setText("This feature is available only on the Python (PyPI) version.") - msg_box.setInformativeText("To update yt-dlp in a packaged version, please download a new version of the application.") - msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) - msg_box.exec() - else: - # Running from source code, show the normal update dialog - dialog = YTDLPUpdateDialog(self) - dialog.exec() + """Show the yt-dlp update dialog with proper progress tracking""" + # Make the dialog non-modal to prevent blocking the main UI + dialog = YTDLPUpdateDialog(self) + dialog.setModal(False) # Make it non-modal + dialog.show() # Use show() instead of exec() to avoid blocking def show_download_settings_dialog(self): # Renamed method dialog = DownloadSettingsDialog( @@ -962,6 +1035,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m # Default case else: self.status_label.setText("✅ Download completed!") + + # Play notification sound when download completes + self.play_notification_sound() def download_error(self, error_message): self.toggle_download_controls(True) @@ -1010,8 +1086,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def show_update_dialog(self, latest_version, release_url, changelog): # Added changelog parameter msg = QDialog(self) msg.setWindowTitle("Update Available") - msg.setMinimumWidth(500) # Increased width for changelog - msg.setMinimumHeight(400) # Added min height + msg.setMinimumWidth(600) # Increased width for better layout + msg.setMinimumHeight(450) # Increased height for better spacing # Set custom icon directly icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png') @@ -1022,19 +1098,47 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m msg.setWindowIcon(self.windowIcon()) layout = QVBoxLayout(msg) - layout.setSpacing(10) # Added spacing + layout.setSpacing(15) # Increased spacing for better layout + layout.setContentsMargins(20, 20, 20, 20) # Added margins - # Update message + # 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("

Update Available

") + header_layout.addWidget(title_label) + header_layout.addStretch() + + layout.addLayout(header_layout) + + # Update message with better formatting message_label = QLabel( - f"A new version of YTSage is available!\n\n" - f"Current version: {self.version}\n" - f"Latest version: {latest_version}" + f"
" + f"A new version of YTSage is available!

" + f"Current version: {self.version}
" + f"Latest version: {latest_version}" + f"
" ) message_label.setWordWrap(True) + message_label.setStyleSheet(""" + QLabel { + background-color: #1d1e22; + border: 1px solid #3d3d3d; + border-radius: 6px; + padding: 15px; + margin: 5px 0; + } + """) layout.addWidget(message_label) # Changelog Section - changelog_label = QLabel("Changelog:") + changelog_label = QLabel("Changelog:") + changelog_label.setStyleSheet("padding: 5px 0; margin-top: 10px;") layout.addWidget(changelog_label) changelog_text = QTextEdit() @@ -1049,52 +1153,95 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m changelog_text.setStyleSheet(""" QTextEdit { - background-color: #1e1e1e; - border: 1px solid #3d3d3d; - border-radius: 4px; - color: #cccccc; - padding: 5px; - font-family: Consolas, monospace; - font-size: 11px; /* Slightly smaller font */ + background-color: #1d1e22; + border: 2px solid #3d3d3d; + border-radius: 6px; + color: #ffffff; + padding: 10px; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 12px; + line-height: 1.4; + } + QScrollBar:vertical { + border: none; + background: #1d1e22; + width: 12px; + border-radius: 6px; + } + QScrollBar::handle:vertical { + background: #404040; + min-height: 20px; + border-radius: 6px; + } + QScrollBar::handle:vertical:hover { + background: #505050; } """) - changelog_text.setMaximumHeight(200) # Limit height + changelog_text.setMaximumHeight(180) # Limit height layout.addWidget(changelog_text) - # Buttons + # Buttons with better styling button_layout = QHBoxLayout() + button_layout.setSpacing(10) download_btn = QPushButton("Download Update") download_btn.clicked.connect(lambda: self.open_release_page(release_url)) + download_btn.setStyleSheet(""" + QPushButton { + padding: 10px 20px; + background-color: #c90000; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + font-size: 13px; + min-width: 140px; + } + QPushButton:hover { + background-color: #a50000; + } + QPushButton:pressed { + background-color: #800000; + } + """) remind_btn = QPushButton("Remind Me Later") remind_btn.clicked.connect(msg.close) + remind_btn.setStyleSheet(""" + QPushButton { + padding: 10px 20px; + background-color: #3d3d3d; + border: 1px solid #555555; + border-radius: 6px; + color: white; + font-weight: bold; + font-size: 13px; + min-width: 140px; + } + QPushButton:hover { + background-color: #4d4d4d; + border-color: #666666; + } + QPushButton:pressed { + background-color: #2d2d2d; + } + """) + button_layout.addStretch() button_layout.addWidget(download_btn) button_layout.addWidget(remind_btn) layout.addLayout(button_layout) - # Style the dialog + # Style the dialog with improved theme matching msg.setStyleSheet(""" QDialog { - background-color: #2b2b2b; + background-color: #15181b; + border: 1px solid #3d3d3d; + border-radius: 8px; } QLabel { color: #ffffff; font-size: 12px; - padding: 5px 0; /* Adjusted padding */ - } - QPushButton { - padding: 8px 15px; - background-color: #ff0000; - border: none; - border-radius: 4px; - color: white; - font-weight: bold; - min-width: 120px; /* Adjusted min-width */ - } - QPushButton:hover { - background-color: #cc0000; } """) @@ -1103,6 +1250,75 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m def open_release_page(self, url): webbrowser.open(url) + def check_auto_update_ytdlp(self): + """Check and perform auto-update for yt-dlp if enabled and due.""" + try: + # Check if auto-update should be performed + if should_check_for_auto_update(): + print("Performing auto-update check for yt-dlp...") + # Perform the auto-update in a non-blocking way + # We don't want to block the UI startup for this + from PySide6.QtCore import QTimer + QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup + except Exception as e: + print(f"Error in auto-update check: {e}") + + def _perform_auto_update(self): + """Actually perform the auto-update check and update if needed in a background thread.""" + try: + # Create and start the auto-update thread to avoid blocking the UI + from ytsage_gui_dialogs import AutoUpdateThread + self.auto_update_thread = AutoUpdateThread() + self.auto_update_thread.update_finished.connect(self._on_auto_update_finished) + self.auto_update_thread.start() + except Exception as e: + print(f"Error starting auto-update thread: {e}") + + def _on_auto_update_finished(self, success, message): + """Handle auto-update completion.""" + if success: + print(f"Auto-update completed successfully: {message}") + else: + print(f"Auto-update completed with issues: {message}") + + # Clean up the thread reference and ensure it's properly finished + if hasattr(self, 'auto_update_thread'): + # Disconnect all signals to prevent further callbacks + self.auto_update_thread.update_finished.disconnect() + # Make sure thread is finished + if self.auto_update_thread.isRunning(): + self.auto_update_thread.quit() + self.auto_update_thread.wait(1000) # Wait up to 1 second + # Remove the reference + delattr(self, 'auto_update_thread') + + def closeEvent(self, event): + """Handle application close event to ensure proper cleanup of background threads.""" + try: + # Stop the auto-update thread if it's running + if hasattr(self, 'auto_update_thread') and self.auto_update_thread.isRunning(): + print("Stopping auto-update thread...") + self.auto_update_thread.quit() + if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown + print("Force terminating auto-update thread...") + self.auto_update_thread.terminate() + self.auto_update_thread.wait(1000) # Wait for termination + + # Cancel any running downloads + if self.current_download and self.current_download.isRunning(): + print("Canceling running download...") + self.current_download.cancel() + if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown + print("Force terminating download thread...") + self.current_download.terminate() + self.current_download.wait(1000) # Wait for termination + + print("Application closing...") + event.accept() + except Exception as e: + print(f"Error during application close: {e}") + event.accept() # Accept the close event anyway + def show_custom_options(self): dialog = CustomOptionsDialog(self) if dialog.exec(): @@ -1327,4 +1543,195 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m self.download_section = None self.force_keyframes = False self.time_range_btn.setStyleSheet("") - self.time_range_btn.setToolTip("") \ No newline at end of file + self.time_range_btn.setToolTip("") + + def show_ytdlp_setup_dialog(self): + """Show the yt-dlp setup dialog to configure yt-dlp""" + yt_dlp_path = setup_ytdlp(self) + if yt_dlp_path != "yt-dlp": + success_dialog = QMessageBox(self) + success_dialog.setIcon(QMessageBox.Information) + success_dialog.setWindowTitle("yt-dlp Setup") + success_dialog.setText(f"yt-dlp has been successfully configured at:\n{yt_dlp_path}") + success_dialog.setWindowIcon(self.windowIcon()) + success_dialog.setStyleSheet(""" + QMessageBox { + background-color: #15181b; + color: #ffffff; + } + QLabel { + color: #ffffff; + } + QPushButton { + padding: 8px 15px; + background-color: #c90000; + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #a50000; + } + """) + success_dialog.exec() + + def _analyze_url_with_subprocess(self, url): + """Analyze URL using yt-dlp executable when Python module is not available""" + import subprocess + import json + import tempfile + + try: + yt_dlp_path = get_yt_dlp_path() + if not yt_dlp_path: + raise Exception("yt-dlp executable not found. Please install yt-dlp first.") + + self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable") + + # 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}' + + # Build command for basic info extraction + cmd = [yt_dlp_path, '--dump-json', '--no-warnings', url] + + # Add cookies if available + if self.cookie_file_path: + cmd.extend(['--cookies', self.cookie_file_path]) + + # Execute command with hidden console window on Windows + import sys + if sys.platform == 'win32': + # Hide the console window on Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, startupinfo=startupinfo) + else: + # For other platforms, use normal subprocess call + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + raise Exception(f"yt-dlp failed: {result.stderr}") + + # Parse JSON output - yt-dlp outputs one JSON object per line for playlists + json_lines = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] + + if not json_lines: + raise Exception("No data returned from yt-dlp") + + # Parse first JSON object to determine if it's a playlist + first_info = json.loads(json_lines[0]) + + self.signals.update_status.emit("Analyzing (60%)... Processing data") + + if first_info.get('_type') == 'playlist' or len(json_lines) > 1: + # Handle playlist + self.is_playlist = True + self.playlist_info = first_info + self.selected_playlist_items = None + self.playlist_entries = [] + + # Parse all entries + for line in json_lines: + try: + entry = json.loads(line) + if entry.get('_type') != 'playlist': # Skip playlist metadata + self.playlist_entries.append(entry) + except json.JSONDecodeError: + continue + + if not self.playlist_entries: + raise Exception("Playlist contains no valid videos.") + + # Use first video for format information + self.video_info = self.playlist_entries[0] + + # Update playlist info label + playlist_text = (f"Playlist: {first_info.get('title', 'Unknown Playlist')} | " + f"{len(self.playlist_entries)} videos") + QMetaObject.invokeMethod( + self.playlist_info_label, "setText", Qt.ConnectionType.QueuedConnection, + Q_ARG(str, playlist_text) + ) + QMetaObject.invokeMethod( + self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, True) + ) + + # Show playlist selection button + QMetaObject.invokeMethod( + self, 'update_playlist_button_text', Qt.ConnectionType.QueuedConnection, + Q_ARG(str, "Select Videos... (All selected)") + ) + QMetaObject.invokeMethod( + self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, True) + ) + else: + # Handle single video + self.is_playlist = False + self.video_info = first_info + self.playlist_entries = [] + self.selected_playlist_items = None + + # Hide playlist UI + QMetaObject.invokeMethod( + self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, False) + ) + QMetaObject.invokeMethod( + self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, + Q_ARG(bool, False) + ) + + # Verify we have format information + if not self.video_info or 'formats' not in self.video_info: + raise Exception("No format information available") + + self.signals.update_status.emit("Analyzing (75%)... Processing formats") + self.all_formats = self.video_info['formats'] + + # Update UI + self.update_video_info(self.video_info) + + # Update thumbnail + self.signals.update_status.emit("Analyzing (85%)... Loading thumbnail") + thumbnail_url = None + if self.is_playlist: + thumbnail_url = self.playlist_info.get('thumbnail') + + if not thumbnail_url: + thumbnail_url = self.video_info.get('thumbnail') + + self.download_thumbnail(thumbnail_url) + + # Save thumbnail if enabled + if self.save_thumbnail: + self.download_thumbnail_file(self.video_url, self.path_input.text()) + + # Handle subtitles + self.signals.update_status.emit("Analyzing (90%)... Processing subtitles") + self.selected_subtitles = [] + self.available_subtitles = self.video_info.get('subtitles', {}) + self.available_automatic_subtitles = self.video_info.get('automatic_captions', {}) + + # Update subtitle UI + QMetaObject.invokeMethod(self.selected_subs_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "0 selected")) + + # Update format table + self.signals.update_status.emit("Analyzing (95%)... Updating format table") + self.video_button.setChecked(True) + self.audio_button.setChecked(False) + self.filter_formats() + + self.signals.update_status.emit("Analysis complete!") + + except subprocess.TimeoutExpired: + raise Exception("Analysis timed out. Please try again.") + except json.JSONDecodeError as e: + raise Exception(f"Failed to parse yt-dlp output: {str(e)}") + except Exception as e: + raise Exception(f"Analysis failed: {str(e)}")