diff --git a/YTSage.py b/YTSage.py new file mode 100644 index 0000000..222b786 --- /dev/null +++ b/YTSage.py @@ -0,0 +1,503 @@ +import sys +import os +import threading +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QTableWidget, + QTableWidgetItem, QProgressBar, QLabel, QFileDialog, + QHeaderView, QStyle, QStyleFactory, QComboBox) +from PyQt6.QtCore import Qt, pyqtSignal, QObject +from PyQt6.QtGui import QIcon, QPalette, QColor, QPixmap +import yt_dlp +import requests +from io import BytesIO +from PIL import Image +from urllib.request import urlopen +from datetime import datetime + +class SignalManager(QObject): + update_formats = pyqtSignal(list) + update_status = pyqtSignal(str) + update_progress = pyqtSignal(float) + +class YouTubeDownloaderGUI(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + self.signals = SignalManager() + self.init_ui() + self.setStyleSheet(""" + QMainWindow { + background-color: #2b2b2b; + } + QWidget { + background-color: #2b2b2b; + color: #ffffff; + } + QLineEdit { + padding: 8px; + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + color: #ffffff; + } + QPushButton { + padding: 8px 15px; + background-color: #ff0000; /* YouTube red */ + border: none; + border-radius: 4px; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #cc0000; /* Darker red on hover */ + } + QPushButton:pressed { + background-color: #990000; /* Even darker red when pressed */ + } + QTableWidget { + border: 2px solid #3d3d3d; + border-radius: 4px; + background-color: #363636; + gridline-color: #3d3d3d; + } + QHeaderView::section { + background-color: #2b2b2b; + padding: 5px; + border: 1px solid #3d3d3d; + color: #ffffff; + } + QProgressBar { + border: 2px solid #3d3d3d; + border-radius: 4px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #ff0000; /* YouTube red */ + border-radius: 2px; + } + QLabel { + color: #ffffff; + } + /* Style for filter buttons */ + QPushButton.filter-btn { + background-color: #363636; + padding: 5px 10px; + margin: 0 5px; + } + QPushButton.filter-btn:checked { + background-color: #ff0000; + } + QPushButton.filter-btn:hover { + background-color: #444444; + } + QPushButton.filter-btn:checked:hover { + background-color: #cc0000; + } + """) + self.signals.update_progress.connect(self.update_progress_bar) + + def init_ui(self): + self.setWindowTitle('YT-GUI Downloader') + self.setMinimumSize(900, 600) + + # Main widget and layout + main_widget = QWidget() + self.setCentralWidget(main_widget) + layout = QVBoxLayout(main_widget) + layout.setSpacing(10) + layout.setContentsMargins(20, 20, 20, 20) + + # URL input section + url_layout = QHBoxLayout() + self.url_input = QLineEdit() + self.url_input.setPlaceholderText('Enter YouTube URL...') + self.analyze_btn = QPushButton('Analyze') + self.analyze_btn.clicked.connect(self.analyze_url) + url_layout.addWidget(self.url_input) + url_layout.addWidget(self.analyze_btn) + layout.addLayout(url_layout) + + # Create a horizontal layout for thumbnail and video info + media_info_layout = QHBoxLayout() + + # Thumbnail on the left + self.thumbnail_label = QLabel() + self.thumbnail_label.setFixedSize(320, 180) + self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + media_info_layout.addWidget(self.thumbnail_label) + + # Video information on the right + video_info_layout = QVBoxLayout() + self.title_label = QLabel() + self.title_label.setWordWrap(True) + self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") + + self.channel_label = QLabel() + self.views_label = QLabel() + self.date_label = QLabel() + self.duration_label = QLabel() + + # Subtitle section + subtitle_layout = QHBoxLayout() + self.subtitle_check = QPushButton("Download Subtitles") + self.subtitle_check.setCheckable(True) + self.subtitle_check.clicked.connect(self.toggle_subtitle_list) + subtitle_layout.addWidget(self.subtitle_check) + + self.subtitle_combo = QComboBox() + self.subtitle_combo.setVisible(False) + subtitle_layout.addWidget(self.subtitle_combo) + subtitle_layout.addStretch() + + # Add all info widgets to the video info layout + video_info_layout.addWidget(self.title_label) + video_info_layout.addWidget(self.channel_label) + video_info_layout.addWidget(self.views_label) + video_info_layout.addWidget(self.date_label) + video_info_layout.addWidget(self.duration_label) + video_info_layout.addLayout(subtitle_layout) + video_info_layout.addStretch() + + media_info_layout.addLayout(video_info_layout) + layout.addLayout(media_info_layout) + + # Simplified format filter buttons + filter_layout = QHBoxLayout() + self.filter_label = QLabel("Show formats:") + filter_layout.addWidget(self.filter_label) + + self.video_btn = QPushButton("Video") + self.video_btn.setCheckable(True) + self.video_btn.setChecked(True) + self.video_btn.clicked.connect(self.filter_formats) + self.video_btn.setProperty("class", "filter-btn") + + self.audio_btn = QPushButton("Audio Only") + self.audio_btn.setCheckable(True) + self.audio_btn.clicked.connect(self.filter_formats) + self.audio_btn.setProperty("class", "filter-btn") + + filter_layout.addWidget(self.video_btn) + filter_layout.addWidget(self.audio_btn) + filter_layout.addStretch() + layout.addLayout(filter_layout) + + # Format table + self.format_table = QTableWidget() + self.format_table.setColumnCount(6) + self.format_table.setHorizontalHeaderLabels(['Format ID', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio']) + self.format_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + layout.addWidget(self.format_table) + + # Store formats for filtering + self.all_formats = [] + + # Download section + download_layout = QHBoxLayout() + self.path_input = QLineEdit() + self.path_input.setPlaceholderText('Download path...') + self.browse_btn = QPushButton('Browse') + self.browse_btn.clicked.connect(self.browse_path) + self.download_btn = QPushButton('Download') + self.download_btn.clicked.connect(self.start_download) + download_layout.addWidget(self.path_input) + download_layout.addWidget(self.browse_btn) + download_layout.addWidget(self.download_btn) + layout.addLayout(download_layout) + + # Progress section + self.progress_bar = QProgressBar() + layout.addWidget(self.progress_bar) + + self.status_label = QLabel('Ready') + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.status_label) + + # 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) + + def analyze_url(self): + url = self.url_input.text() + if url: + self.signals.update_status.emit("Analyzing...") + threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() + + def _analyze_url_thread(self, url): + try: + ydl_opts = { + 'format': 'best', + 'quiet': True, + 'no_warnings': True, + 'extract_flat': False, + 'writesubtitles': True, + 'allsubtitles': True, + 'writeautomaticsub': True, + 'format_sort': ['res', 'ext', 'size', 'br', 'proto'], + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + # Store all formats and video info + self.all_formats = info['formats'] + self.video_info = info + + # Update UI with video information + self.signals.update_status.emit("Updating video information...") + self.update_video_info(info) + + # Download and display thumbnail + thumbnail_url = info.get('thumbnail') + if thumbnail_url: + self.download_thumbnail(thumbnail_url) + + # Update available subtitles (including auto-generated) + self.available_subtitles = info.get('subtitles', {}) + self.available_automatic_subtitles = info.get('automatic_captions', {}) + self.update_subtitle_list() + + # Update table with filtered formats (default to video) + self.video_btn.setChecked(True) + self.audio_btn.setChecked(False) + self.filter_formats() + + self.signals.update_status.emit("Analysis complete") + except Exception as e: + error_message = str(e) + print(f"Error in analysis: {error_message}") + self.signals.update_status.emit(f"Error: {error_message}") + + def update_video_info(self, info): + # Format view count with commas + views = int(info.get('view_count', 0)) + formatted_views = f"{views:,}" + + # Format upload date + upload_date = info.get('upload_date', '') + if upload_date: + date_obj = datetime.strptime(upload_date, '%Y%m%d') + formatted_date = date_obj.strftime('%B %d, %Y') + else: + formatted_date = 'Unknown date' + + # Format duration + duration = info.get('duration', 0) + minutes = duration // 60 + seconds = duration % 60 + duration_str = f"{minutes}:{seconds:02d}" + + # Update labels + self.title_label.setText(info.get('title', 'Unknown title')) + self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}") + self.views_label.setText(f"Views: {formatted_views}") + self.date_label.setText(f"Upload date: {formatted_date}") + self.duration_label.setText(f"Duration: {duration_str}") + + def toggle_subtitle_list(self): + self.subtitle_combo.setVisible(self.subtitle_check.isChecked()) + + def update_subtitle_list(self): + self.subtitle_combo.clear() + + if not (self.available_subtitles or self.available_automatic_subtitles): + self.subtitle_combo.addItem("No subtitles available") + return + + self.subtitle_combo.addItem("Select subtitle language") + + # Add manual subtitles + for lang_code, subtitle_info in self.available_subtitles.items(): + self.subtitle_combo.addItem(f"{lang_code} - Manual") + + # Add auto-generated subtitles + for lang_code, subtitle_info in self.available_automatic_subtitles.items(): + self.subtitle_combo.addItem(f"{lang_code} - Auto-generated") + + def download_thumbnail(self, url): + try: + response = requests.get(url) + image = Image.open(BytesIO(response.content)) + image = image.resize((320, 180), Image.Resampling.LANCZOS) + img_byte_arr = BytesIO() + image.save(img_byte_arr, format='PNG') + img_byte_arr = img_byte_arr.getvalue() + + pixmap = QPixmap() + pixmap.loadFromData(img_byte_arr) + self.thumbnail_label.setPixmap(pixmap) + except Exception as e: + print(f"Error loading thumbnail: {str(e)}") + + def filter_formats(self): + # Clear current table + self.format_table.setRowCount(0) + + # Determine which formats to show + filtered_formats = [] + + if self.video_btn.isChecked(): + # Include all video formats (both with and without audio) + filtered_formats.extend([f for f in self.all_formats + if f.get('vcodec') != 'none' + and f.get('filesize', 0) > 0]) + + if self.audio_btn.isChecked(): + # Add audio-only formats + filtered_formats.extend([f for f in self.all_formats + if (f.get('vcodec') == 'none' + or 'audio only' in f.get('format_note', '').lower()) + and f.get('acodec') != 'none' + and f.get('filesize', 0) > 0]) + + # Sort formats by quality + def get_quality(f): + if f.get('vcodec') != 'none': + # Extract height from resolution (e.g., "1920x1080" -> 1080) + res = f.get('resolution', '0x0').split('x')[-1] + try: + return int(res) + except ValueError: + return 0 + else: + return f.get('abr', 0) + + filtered_formats.sort(key=get_quality, reverse=True) + + # Update table with filtered formats + self.update_format_table(filtered_formats) + + def update_format_table(self, formats): + self.format_table.setRowCount(0) + for f in formats: + row = self.format_table.rowCount() + self.format_table.insertRow(row) + + # Format ID + self.format_table.setItem(row, 0, QTableWidgetItem(str(f.get('format_id', '')))) + + # Extension + self.format_table.setItem(row, 1, QTableWidgetItem(f.get('ext', ''))) + + # Resolution + resolution = f.get('resolution', 'N/A') + if f.get('vcodec') == 'none': + resolution = 'Audio only' + self.format_table.setItem(row, 2, QTableWidgetItem(resolution)) + + # File Size + filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB" + self.format_table.setItem(row, 3, QTableWidgetItem(filesize)) + + # Codec + if f.get('vcodec') == 'none': + codec = f.get('acodec', 'N/A') + else: + codec = f"{f.get('vcodec', 'N/A')}" + if f.get('acodec') != 'none': + codec += f" / {f.get('acodec', 'N/A')}" + self.format_table.setItem(row, 4, QTableWidgetItem(codec)) + + # Audio Status + needs_audio = f.get('acodec') == 'none' + audio_status = "Will merge audio" if needs_audio else "✓ Has Audio" + audio_item = QTableWidgetItem(audio_status) + if needs_audio: + audio_item.setForeground(QColor('#ffa500')) # Orange for merge indication + self.format_table.setItem(row, 5, audio_item) + + def browse_path(self): + path = QFileDialog.getExistingDirectory(self, "Select Download Directory") + if path: + self.path_input.setText(path) + + def start_download(self): + url = self.url_input.text() + path = self.path_input.text() + selected_items = self.format_table.selectedItems() + if not selected_items: + self.signals.update_status.emit("Please select a format") + return + + format_id = self.format_table.item(selected_items[0].row(), 0).text() + + # If it's a video format, also download the best audio + selected_format = next((f for f in self.all_formats if str(f.get('format_id')) == format_id), None) + if selected_format and selected_format.get('acodec') == 'none': + format_id = f"{format_id}+bestaudio" + + # Check if subtitles should be downloaded + download_subs = self.subtitle_check.isChecked() + selected_sub = self.subtitle_combo.currentText().split(' - ')[0] if download_subs else None + + threading.Thread(target=self._download_thread, + args=(url, path, format_id, selected_sub), + daemon=True).start() + + def _download_thread(self, url, path, format_id, subtitle_lang=None): + ydl_opts = { + 'format': format_id, + 'outtmpl': os.path.join(path, '%(title)s.%(ext)s'), + 'progress_hooks': [self._progress_hook], + 'merge_output_format': 'mp4', + } + + # Add subtitle options if requested + if subtitle_lang: + # Extract language code from the combo box text + lang_code = subtitle_lang.split(' - ')[0] + is_auto = 'Auto-generated' in subtitle_lang + + ydl_opts.update({ + 'writesubtitles': True, + 'subtitleslangs': [lang_code], + 'writeautomaticsub': True, + 'skip_manual_subs': is_auto, # Skip manual subs if auto is selected + 'skip_auto_subs': not is_auto, # Skip auto subs if manual is selected + }) + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + except Exception as e: + error_message = str(e) + self.signals.update_status.emit(f"Error: {error_message}") + + def _progress_hook(self, d): + if d['status'] == 'downloading': + try: + downloaded_bytes = int(d.get('downloaded_bytes', 0)) + total_bytes = int(d.get('total_bytes', 1)) # Use 1 as default to avoid division by zero + # Ensure we're working with integers throughout + progress = int((downloaded_bytes * 100) // total_bytes) + # Clamp the value between 0 and 100 + progress = max(0, min(100, progress)) + self.signals.update_progress.emit(progress) + self.signals.update_status.emit(f"Downloading: {progress}%") + except Exception as e: + print(f"Progress calculation error: {str(e)}") + self.signals.update_status.emit("Downloading...") + elif d['status'] == 'finished': + self.signals.update_progress.emit(100) + self.signals.update_status.emit("Download completed!") + + def update_progress_bar(self, value): + try: + # Ensure the value is an integer + int_value = int(value) + self.progress_bar.setValue(int_value) + except Exception as e: + print(f"Progress bar update error: {str(e)}") + +def main(): + app = QApplication(sys.argv) + app.setStyle(QStyleFactory.create('Fusion')) + window = YouTubeDownloaderGUI() + window.show() + sys.exit(app.exec()) + +if __name__ == '__main__': + main() \ No newline at end of file