2.0.0
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||||
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
|
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
|
||||||
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
|
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
|
||||||
QHeaderView, QStyle, QStyleFactory, QComboBox)
|
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
||||||
from PyQt6.QtGui import QIcon, QPalette, QColor, QPixmap
|
from PyQt6.QtGui import QIcon, QPalette, QColor, QPixmap
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
@@ -13,17 +14,258 @@ from io import BytesIO
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from packaging import version
|
||||||
|
import subprocess
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
class SignalManager(QObject):
|
class SignalManager(QObject):
|
||||||
update_formats = pyqtSignal(list)
|
update_formats = pyqtSignal(list)
|
||||||
update_status = pyqtSignal(str)
|
update_status = pyqtSignal(str)
|
||||||
update_progress = pyqtSignal(float)
|
update_progress = pyqtSignal(float)
|
||||||
|
|
||||||
class YouTubeDownloaderGUI(QMainWindow):
|
class LogWindow(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle('yt-dlp Log')
|
||||||
|
self.setMinimumSize(700, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
self.log_text = QTextEdit()
|
||||||
|
self.log_text.setReadOnly(True)
|
||||||
|
self.log_text.setStyleSheet("""
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 2px solid #3d3d3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout.addWidget(self.log_text)
|
||||||
|
|
||||||
|
def append_log(self, message):
|
||||||
|
self.log_text.append(message)
|
||||||
|
# Auto-scroll to bottom
|
||||||
|
scrollbar = self.log_text.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
|
||||||
|
class CustomCommandDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.parent = parent
|
||||||
|
self.setWindowTitle('Custom yt-dlp Command')
|
||||||
|
self.setMinimumSize(600, 400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Help text
|
||||||
|
help_text = QLabel(
|
||||||
|
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
|
||||||
|
"Example: --extract-audio --audio-format mp3 --audio-quality 0\n"
|
||||||
|
"Note: Download path and output template will be preserved."
|
||||||
|
)
|
||||||
|
help_text.setWordWrap(True)
|
||||||
|
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||||
|
layout.addWidget(help_text)
|
||||||
|
|
||||||
|
# Command input
|
||||||
|
self.command_input = QPlainTextEdit()
|
||||||
|
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
||||||
|
self.command_input.setStyleSheet("""
|
||||||
|
QPlainTextEdit {
|
||||||
|
background-color: #363636;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 2px solid #3d3d3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.command_input)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.run_btn = QPushButton("Run Command")
|
||||||
|
self.run_btn.clicked.connect(self.run_custom_command)
|
||||||
|
|
||||||
|
self.close_btn = QPushButton("Close")
|
||||||
|
self.close_btn.clicked.connect(self.close)
|
||||||
|
|
||||||
|
button_layout.addWidget(self.run_btn)
|
||||||
|
button_layout.addWidget(self.close_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Log output
|
||||||
|
self.log_output = QTextEdit()
|
||||||
|
self.log_output.setReadOnly(True)
|
||||||
|
self.log_output.setStyleSheet("""
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 2px solid #3d3d3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.log_output)
|
||||||
|
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def run_custom_command(self):
|
||||||
|
url = self.parent.url_input.text().strip()
|
||||||
|
if not url:
|
||||||
|
self.log_output.append("Error: No URL provided")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = self.command_input.toPlainText().strip()
|
||||||
|
path = self.parent.path_input.text().strip()
|
||||||
|
|
||||||
|
self.log_output.clear()
|
||||||
|
self.log_output.append(f"Running command with URL: {url}")
|
||||||
|
self.run_btn.setEnabled(False)
|
||||||
|
|
||||||
|
# Start command in thread
|
||||||
|
threading.Thread(target=self._run_command_thread,
|
||||||
|
args=(command, url, path),
|
||||||
|
daemon=True).start()
|
||||||
|
|
||||||
|
def _run_command_thread(self, command, url, path):
|
||||||
|
try:
|
||||||
|
class CommandLogger:
|
||||||
|
def debug(self, msg):
|
||||||
|
self.dialog.log_output.append(msg)
|
||||||
|
def warning(self, msg):
|
||||||
|
self.dialog.log_output.append(f"Warning: {msg}")
|
||||||
|
def error(self, msg):
|
||||||
|
self.dialog.log_output.append(f"Error: {msg}")
|
||||||
|
def __init__(self, dialog):
|
||||||
|
self.dialog = dialog
|
||||||
|
|
||||||
|
# Split command into arguments
|
||||||
|
args = command.split()
|
||||||
|
|
||||||
|
# Base options
|
||||||
|
ydl_opts = {
|
||||||
|
'logger': CommandLogger(self),
|
||||||
|
'paths': {'home': path},
|
||||||
|
'debug_printout': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add custom arguments
|
||||||
|
for i in range(0, len(args), 2):
|
||||||
|
if i + 1 < len(args):
|
||||||
|
key = args[i].lstrip('-').replace('-', '_')
|
||||||
|
value = args[i + 1]
|
||||||
|
try:
|
||||||
|
# Try to convert to appropriate type
|
||||||
|
if value.lower() in ('true', 'false'):
|
||||||
|
value = value.lower() == 'true'
|
||||||
|
elif value.isdigit():
|
||||||
|
value = int(value)
|
||||||
|
ydl_opts[key] = value
|
||||||
|
except:
|
||||||
|
ydl_opts[key] = value
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
self.log_output.append("Command completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log_output.append(f"Error: {str(e)}")
|
||||||
|
finally:
|
||||||
|
self.run_btn.setEnabled(True)
|
||||||
|
|
||||||
|
class FFmpegCheckDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle('FFmpeg Required')
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Message
|
||||||
|
message_label = QLabel(
|
||||||
|
"FFmpeg is not installed. Please install it.\n"
|
||||||
|
"To install FFmpeg, click the button below."
|
||||||
|
)
|
||||||
|
message_label.setWordWrap(True)
|
||||||
|
layout.addWidget(message_label)
|
||||||
|
|
||||||
|
# Install button
|
||||||
|
install_btn = QPushButton("Install FFmpeg")
|
||||||
|
install_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
|
||||||
|
layout.addWidget(install_btn)
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
close_btn = QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
# Style the dialog
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
class YTSage(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
# Check for FFmpeg before proceeding
|
||||||
|
if not self.check_ffmpeg():
|
||||||
|
self.show_ffmpeg_dialog()
|
||||||
|
|
||||||
|
self.version = "2.0.0"
|
||||||
|
self.check_for_updates() # Check for updates on startup
|
||||||
|
self.config_file = Path.home() / '.ytsage_config.json'
|
||||||
|
self.load_saved_path()
|
||||||
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
|
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
|
||||||
self.signals = SignalManager()
|
self.signals = SignalManager()
|
||||||
|
self.download_paused = False
|
||||||
|
self.current_download = None
|
||||||
|
self.log_window = None
|
||||||
|
self.show_log = False
|
||||||
|
self.download_cancelled = False
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QMainWindow {
|
QMainWindow {
|
||||||
@@ -94,11 +336,127 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
QPushButton.filter-btn:checked:hover {
|
QPushButton.filter-btn:checked:hover {
|
||||||
background-color: #cc0000;
|
background-color: #cc0000;
|
||||||
}
|
}
|
||||||
|
/* Modern Scrollbar Styling */
|
||||||
|
QScrollBar:vertical {
|
||||||
|
border: none;
|
||||||
|
background: #2b2b2b;
|
||||||
|
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: #2b2b2b;
|
||||||
|
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: #2b2b2b;
|
||||||
|
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: #2b2b2b;
|
||||||
|
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: #2b2b2b;
|
||||||
|
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: #2b2b2b;
|
||||||
|
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)
|
self.signals.update_progress.connect(self.update_progress_bar)
|
||||||
|
|
||||||
|
def load_saved_path(self):
|
||||||
|
try:
|
||||||
|
if self.config_file.exists():
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
saved_path = config.get('download_path', '')
|
||||||
|
if os.path.exists(saved_path):
|
||||||
|
self.last_path = saved_path
|
||||||
|
else:
|
||||||
|
self.last_path = str(Path.home() / 'Downloads')
|
||||||
|
else:
|
||||||
|
self.last_path = str(Path.home() / 'Downloads')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading saved path: {e}")
|
||||||
|
self.last_path = str(Path.home() / 'Downloads')
|
||||||
|
|
||||||
|
def save_path(self, path):
|
||||||
|
try:
|
||||||
|
config = {'download_path': path}
|
||||||
|
with open(self.config_file, 'w') as f:
|
||||||
|
json.dump(config, f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving path: {e}")
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
self.setWindowTitle('YT-GUI Downloader')
|
self.setWindowTitle('YTSage v2.0.0')
|
||||||
self.setMinimumSize(900, 600)
|
self.setMinimumSize(900, 600)
|
||||||
|
|
||||||
# Main widget and layout
|
# Main widget and layout
|
||||||
@@ -163,6 +521,12 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
media_info_layout.addLayout(video_info_layout)
|
media_info_layout.addLayout(video_info_layout)
|
||||||
layout.addLayout(media_info_layout)
|
layout.addLayout(media_info_layout)
|
||||||
|
|
||||||
|
# Add playlist information section
|
||||||
|
self.playlist_info_label = QLabel()
|
||||||
|
self.playlist_info_label.setVisible(False)
|
||||||
|
self.playlist_info_label.setStyleSheet("font-size: 12px; color: #ff9900;")
|
||||||
|
layout.addWidget(self.playlist_info_label)
|
||||||
|
|
||||||
# Simplified format filter buttons
|
# Simplified format filter buttons
|
||||||
filter_layout = QHBoxLayout()
|
filter_layout = QHBoxLayout()
|
||||||
self.filter_label = QLabel("Show formats:")
|
self.filter_label = QLabel("Show formats:")
|
||||||
@@ -198,22 +562,56 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
download_layout = QHBoxLayout()
|
download_layout = QHBoxLayout()
|
||||||
self.path_input = QLineEdit()
|
self.path_input = QLineEdit()
|
||||||
self.path_input.setPlaceholderText('Download path...')
|
self.path_input.setPlaceholderText('Download path...')
|
||||||
|
self.path_input.setText(self.last_path)
|
||||||
self.browse_btn = QPushButton('Browse')
|
self.browse_btn = QPushButton('Browse')
|
||||||
self.browse_btn.clicked.connect(self.browse_path)
|
self.browse_btn.clicked.connect(self.browse_path)
|
||||||
|
|
||||||
|
# Create download control buttons
|
||||||
self.download_btn = QPushButton('Download')
|
self.download_btn = QPushButton('Download')
|
||||||
self.download_btn.clicked.connect(self.start_download)
|
self.download_btn.clicked.connect(self.start_download)
|
||||||
|
|
||||||
|
self.pause_btn = QPushButton('Pause')
|
||||||
|
self.pause_btn.clicked.connect(self.toggle_pause)
|
||||||
|
self.pause_btn.setVisible(False)
|
||||||
|
|
||||||
|
# Add cancel button
|
||||||
|
self.cancel_btn = QPushButton('Cancel')
|
||||||
|
self.cancel_btn.clicked.connect(self.cancel_download)
|
||||||
|
self.cancel_btn.setVisible(False)
|
||||||
|
|
||||||
|
# Add log button
|
||||||
|
self.log_btn = QPushButton('yt-dlp Log')
|
||||||
|
self.log_btn.setCheckable(True)
|
||||||
|
self.log_btn.clicked.connect(self.toggle_log_window)
|
||||||
|
download_layout.addWidget(self.log_btn)
|
||||||
|
|
||||||
|
# Add custom command button to download_layout
|
||||||
|
self.custom_cmd_btn = QPushButton('Custom Command')
|
||||||
|
self.custom_cmd_btn.clicked.connect(self.show_custom_command)
|
||||||
|
download_layout.addWidget(self.custom_cmd_btn)
|
||||||
|
|
||||||
download_layout.addWidget(self.path_input)
|
download_layout.addWidget(self.path_input)
|
||||||
download_layout.addWidget(self.browse_btn)
|
download_layout.addWidget(self.browse_btn)
|
||||||
download_layout.addWidget(self.download_btn)
|
download_layout.addWidget(self.download_btn)
|
||||||
|
download_layout.addWidget(self.pause_btn)
|
||||||
|
download_layout.addWidget(self.cancel_btn)
|
||||||
layout.addLayout(download_layout)
|
layout.addLayout(download_layout)
|
||||||
|
|
||||||
# Progress section
|
# Progress section
|
||||||
|
progress_layout = QVBoxLayout()
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
layout.addWidget(self.progress_bar)
|
progress_layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
|
# Add download details label
|
||||||
|
self.download_details_label = QLabel()
|
||||||
|
self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
progress_layout.addWidget(self.download_details_label)
|
||||||
|
|
||||||
self.status_label = QLabel('Ready')
|
self.status_label = QLabel('Ready')
|
||||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(self.status_label)
|
progress_layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
layout.addLayout(progress_layout)
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.signals.update_formats.connect(self.update_format_table)
|
self.signals.update_formats.connect(self.update_format_table)
|
||||||
@@ -221,51 +619,115 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
self.signals.update_progress.connect(self.update_progress_bar)
|
self.signals.update_progress.connect(self.update_progress_bar)
|
||||||
|
|
||||||
def analyze_url(self):
|
def analyze_url(self):
|
||||||
url = self.url_input.text()
|
url = self.url_input.text().strip()
|
||||||
if url:
|
if not url:
|
||||||
self.signals.update_status.emit("Analyzing...")
|
self.signals.update_status.emit("Invalid URL or please enter a URL.")
|
||||||
threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start()
|
return
|
||||||
|
|
||||||
|
self.signals.update_status.emit("Analyzing...")
|
||||||
|
threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start()
|
||||||
|
|
||||||
def _analyze_url_thread(self, url):
|
def _analyze_url_thread(self, url):
|
||||||
try:
|
try:
|
||||||
|
# Clean up the URL to handle both playlist and video URLs
|
||||||
|
if 'list=' in url and 'watch?v=' in url:
|
||||||
|
# Extract playlist ID
|
||||||
|
playlist_id = url.split('list=')[1].split('&')[0]
|
||||||
|
url = f'https://www.youtube.com/playlist?list={playlist_id}'
|
||||||
|
|
||||||
|
# Initial extraction with basic options
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'format': 'best',
|
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'extract_flat': False,
|
'extract_flat': True,
|
||||||
'writesubtitles': True,
|
'force_generic_extractor': False,
|
||||||
'allsubtitles': True,
|
'ignoreerrors': True,
|
||||||
'writeautomaticsub': True,
|
'no_color': True
|
||||||
'format_sort': ['res', 'ext', 'size', 'br', 'proto'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
try:
|
||||||
|
basic_info = ydl.extract_info(url, download=False)
|
||||||
# Store all formats and video info
|
except Exception as e:
|
||||||
self.all_formats = info['formats']
|
print(f"First extraction failed: {str(e)}")
|
||||||
self.video_info = info
|
raise Exception("Could not extract video information")
|
||||||
|
|
||||||
# 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)
|
# Configure options for detailed extraction
|
||||||
self.available_subtitles = info.get('subtitles', {})
|
ydl_opts.update({
|
||||||
self.available_automatic_subtitles = info.get('automatic_captions', {})
|
'extract_flat': False,
|
||||||
self.update_subtitle_list()
|
'format': 'best', # Start with best format to ensure we get some formats
|
||||||
|
'writesubtitles': True,
|
||||||
|
'allsubtitles': True,
|
||||||
|
'writeautomaticsub': True,
|
||||||
|
'playliststart': 1,
|
||||||
|
'playlistend': 1 # Only get first video initially
|
||||||
|
})
|
||||||
|
|
||||||
# Update table with filtered formats (default to video)
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
self.video_btn.setChecked(True)
|
try:
|
||||||
self.audio_btn.setChecked(False)
|
if basic_info.get('_type') == 'playlist':
|
||||||
self.filter_formats()
|
self.is_playlist = True
|
||||||
|
self.playlist_info = basic_info
|
||||||
self.signals.update_status.emit("Analysis complete")
|
|
||||||
|
# Get the first video URL from the playlist
|
||||||
|
first_video = None
|
||||||
|
for entry in basic_info['entries']:
|
||||||
|
if entry:
|
||||||
|
first_video = entry
|
||||||
|
break
|
||||||
|
|
||||||
|
if not first_video:
|
||||||
|
raise Exception("No valid videos found in playlist")
|
||||||
|
|
||||||
|
# Get the video URL
|
||||||
|
video_url = first_video.get('url') or first_video.get('webpage_url')
|
||||||
|
if not video_url:
|
||||||
|
raise Exception("Could not extract video URL from playlist")
|
||||||
|
|
||||||
|
# Extract detailed information for the first video
|
||||||
|
self.video_info = ydl.extract_info(video_url, download=False)
|
||||||
|
|
||||||
|
# Update playlist info
|
||||||
|
playlist_text = f"Playlist: {basic_info.get('title', 'Unknown')} | {len(basic_info['entries'])} videos"
|
||||||
|
self.playlist_info_label.setText(playlist_text)
|
||||||
|
self.playlist_info_label.setVisible(True)
|
||||||
|
else:
|
||||||
|
self.is_playlist = False
|
||||||
|
self.video_info = ydl.extract_info(url, download=False)
|
||||||
|
self.playlist_info_label.setVisible(False)
|
||||||
|
|
||||||
|
# Verify we have format information
|
||||||
|
if not self.video_info or 'formats' not in self.video_info:
|
||||||
|
print(f"Debug - video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
|
||||||
|
raise Exception("No format information available")
|
||||||
|
|
||||||
|
self.all_formats = self.video_info['formats']
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.signals.update_status.emit("Updating video information...")
|
||||||
|
self.update_video_info(self.video_info)
|
||||||
|
|
||||||
|
# Update thumbnail
|
||||||
|
thumbnail_url = self.video_info.get('thumbnail')
|
||||||
|
if thumbnail_url:
|
||||||
|
self.download_thumbnail(thumbnail_url)
|
||||||
|
|
||||||
|
# Update subtitles
|
||||||
|
self.available_subtitles = self.video_info.get('subtitles', {})
|
||||||
|
self.available_automatic_subtitles = self.video_info.get('automatic_captions', {})
|
||||||
|
self.update_subtitle_list()
|
||||||
|
|
||||||
|
# Update format table
|
||||||
|
self.video_btn.setChecked(True)
|
||||||
|
self.audio_btn.setChecked(False)
|
||||||
|
self.filter_formats()
|
||||||
|
|
||||||
|
self.signals.update_status.emit("Analysis complete")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Detailed extraction failed: {str(e)}")
|
||||||
|
raise Exception(f"Failed to extract video details: {str(e)}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
print(f"Error in analysis: {error_message}")
|
print(f"Error in analysis: {error_message}")
|
||||||
@@ -410,13 +872,21 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
self.format_table.setItem(row, 5, audio_item)
|
self.format_table.setItem(row, 5, audio_item)
|
||||||
|
|
||||||
def browse_path(self):
|
def browse_path(self):
|
||||||
path = QFileDialog.getExistingDirectory(self, "Select Download Directory")
|
path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.last_path)
|
||||||
if path:
|
if path:
|
||||||
self.path_input.setText(path)
|
self.path_input.setText(path)
|
||||||
|
self.save_path(path)
|
||||||
|
self.last_path = path
|
||||||
|
|
||||||
def start_download(self):
|
def start_download(self):
|
||||||
url = self.url_input.text()
|
url = self.url_input.text()
|
||||||
path = self.path_input.text()
|
path = self.path_input.text().strip()
|
||||||
|
|
||||||
|
# Path validation
|
||||||
|
if not path:
|
||||||
|
self.signals.update_status.emit("Invalid path or please enter a path.")
|
||||||
|
return
|
||||||
|
|
||||||
selected_items = self.format_table.selectedItems()
|
selected_items = self.format_table.selectedItems()
|
||||||
if not selected_items:
|
if not selected_items:
|
||||||
self.signals.update_status.emit("Please select a format")
|
self.signals.update_status.emit("Please select a format")
|
||||||
@@ -433,56 +903,132 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
download_subs = self.subtitle_check.isChecked()
|
download_subs = self.subtitle_check.isChecked()
|
||||||
selected_sub = self.subtitle_combo.currentText().split(' - ')[0] if download_subs else None
|
selected_sub = self.subtitle_combo.currentText().split(' - ')[0] if download_subs else None
|
||||||
|
|
||||||
|
self.download_paused = False
|
||||||
|
self.download_cancelled = False
|
||||||
|
self.pause_btn.setText('Pause')
|
||||||
|
self.download_btn.setEnabled(False)
|
||||||
|
self.pause_btn.setVisible(True)
|
||||||
|
self.cancel_btn.setVisible(True)
|
||||||
|
|
||||||
threading.Thread(target=self._download_thread,
|
threading.Thread(target=self._download_thread,
|
||||||
args=(url, path, format_id, selected_sub),
|
args=(url, path, format_id, selected_sub, self.is_playlist),
|
||||||
daemon=True).start()
|
daemon=True).start()
|
||||||
|
|
||||||
def _download_thread(self, url, path, format_id, subtitle_lang=None):
|
def _download_thread(self, url, path, format_id, subtitle_lang=None, is_playlist=False):
|
||||||
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:
|
try:
|
||||||
|
class DebugLogger:
|
||||||
|
def debug(self, msg):
|
||||||
|
# Update both status and log window
|
||||||
|
if any(x in msg.lower() for x in ['downloading webpage', 'downloading api', 'extracting', 'downloading m3u8']):
|
||||||
|
self.signals.update_status.emit("Preparing for download...")
|
||||||
|
self.signals.update_progress.emit(0)
|
||||||
|
if self.parent.show_log and self.parent.log_window:
|
||||||
|
self.parent.log_window.append_log(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
if self.parent.show_log and self.parent.log_window:
|
||||||
|
self.parent.log_window.append_log(f"Warning: {msg}")
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
if self.parent.show_log and self.parent.log_window:
|
||||||
|
self.parent.log_window.append_log(f"Error: {msg}")
|
||||||
|
|
||||||
|
def __init__(self, signals, parent):
|
||||||
|
self.signals = signals
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
class CancelHandler:
|
||||||
|
def __init__(self, parent):
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
def __call__(self, signum, frame):
|
||||||
|
return self.parent.download_cancelled
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'format': format_id,
|
||||||
|
'outtmpl': os.path.join(path, '%(playlist_title)s/%(title)s.%(ext)s' if is_playlist else '%(title)s.%(ext)s'),
|
||||||
|
'progress_hooks': [self._progress_hook],
|
||||||
|
'merge_output_format': 'mp4',
|
||||||
|
'debug_printout': True,
|
||||||
|
'logger': DebugLogger(self.signals, self),
|
||||||
|
'signal_handler': CancelHandler(self),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add subtitle options if requested
|
||||||
|
if subtitle_lang:
|
||||||
|
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_auto_subs': not is_auto,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.signals.update_status.emit("Preparing for download...")
|
||||||
|
self.signals.update_progress.emit(0)
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
self.current_download = ydl
|
||||||
ydl.download([url])
|
ydl.download([url])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = str(e)
|
if self.download_cancelled:
|
||||||
self.signals.update_status.emit(f"Error: {error_message}")
|
self.signals.update_status.emit("Download cancelled")
|
||||||
|
else:
|
||||||
|
error_message = str(e)
|
||||||
|
self.signals.update_status.emit(f"Error: {error_message}")
|
||||||
|
finally:
|
||||||
|
self.current_download = None
|
||||||
|
self.download_btn.setEnabled(True)
|
||||||
|
self.pause_btn.setVisible(False)
|
||||||
|
self.cancel_btn.setVisible(False)
|
||||||
|
|
||||||
def _progress_hook(self, d):
|
def _progress_hook(self, d):
|
||||||
|
if self.download_cancelled:
|
||||||
|
raise Exception("Download cancelled by user")
|
||||||
|
|
||||||
if d['status'] == 'downloading':
|
if d['status'] == 'downloading':
|
||||||
|
while self.download_paused and not self.download_cancelled:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloaded_bytes = int(d.get('downloaded_bytes', 0))
|
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
|
total_bytes = int(d.get('total_bytes', 1))
|
||||||
# Ensure we're working with integers throughout
|
|
||||||
progress = int((downloaded_bytes * 100) // total_bytes)
|
progress = int((downloaded_bytes * 100) // total_bytes)
|
||||||
# Clamp the value between 0 and 100
|
|
||||||
progress = max(0, min(100, progress))
|
progress = max(0, min(100, progress))
|
||||||
|
|
||||||
|
# Extract additional download information
|
||||||
|
speed = d.get('speed', 0)
|
||||||
|
eta = d.get('eta', 0)
|
||||||
|
filename = os.path.basename(d.get('filename', ''))
|
||||||
|
|
||||||
|
# Format the speed dynamically
|
||||||
|
if speed:
|
||||||
|
if speed > 1024 * 1024: # If speed is greater than 1 MiB/s
|
||||||
|
speed_str = f"{speed/(1024*1024):.1f} MiB/s"
|
||||||
|
else:
|
||||||
|
speed_str = f"{speed/1024:.1f} KiB/s"
|
||||||
|
else:
|
||||||
|
speed_str = "N/A"
|
||||||
|
|
||||||
|
eta_str = f"{eta//60}:{eta%60:02d}" if eta else "N/A"
|
||||||
|
|
||||||
|
# Update the UI
|
||||||
|
details_text = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}"
|
||||||
|
self.signals.update_status.emit(details_text)
|
||||||
self.signals.update_progress.emit(progress)
|
self.signals.update_progress.emit(progress)
|
||||||
self.signals.update_status.emit(f"Downloading: {progress}%")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Progress calculation error: {str(e)}")
|
print(f"Progress calculation error: {str(e)}")
|
||||||
self.signals.update_status.emit("Downloading...")
|
self.signals.update_status.emit("Downloading...")
|
||||||
|
|
||||||
elif d['status'] == 'finished':
|
elif d['status'] == 'finished':
|
||||||
self.signals.update_progress.emit(100)
|
self.signals.update_progress.emit(100)
|
||||||
self.signals.update_status.emit("Download completed!")
|
self.signals.update_status.emit("Download completed!")
|
||||||
|
self.download_details_label.setText("") # Clear the details when finished
|
||||||
|
|
||||||
def update_progress_bar(self, value):
|
def update_progress_bar(self, value):
|
||||||
try:
|
try:
|
||||||
@@ -492,10 +1038,130 @@ class YouTubeDownloaderGUI(QMainWindow):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Progress bar update error: {str(e)}")
|
print(f"Progress bar update error: {str(e)}")
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
if self.current_download:
|
||||||
|
self.download_paused = not self.download_paused
|
||||||
|
if self.download_paused:
|
||||||
|
self.pause_btn.setText('Resume')
|
||||||
|
self.signals.update_status.emit("Download paused")
|
||||||
|
else:
|
||||||
|
self.pause_btn.setText('Pause')
|
||||||
|
self.signals.update_status.emit("Download resumed")
|
||||||
|
|
||||||
|
def toggle_log_window(self):
|
||||||
|
if self.log_btn.isChecked():
|
||||||
|
if not self.log_window:
|
||||||
|
self.log_window = LogWindow(self)
|
||||||
|
self.log_window.show()
|
||||||
|
self.show_log = True
|
||||||
|
else:
|
||||||
|
if self.log_window:
|
||||||
|
self.log_window.hide()
|
||||||
|
self.show_log = False
|
||||||
|
|
||||||
|
def check_for_updates(self):
|
||||||
|
try:
|
||||||
|
# Get the latest release info from GitHub
|
||||||
|
response = requests.get(
|
||||||
|
"https://api.github.com/repos/oop7/YTSage/releases/latest",
|
||||||
|
headers={"Accept": "application/vnd.github.v3+json"}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
latest_release = response.json()
|
||||||
|
latest_version = latest_release["tag_name"].lstrip('v')
|
||||||
|
|
||||||
|
# Compare versions
|
||||||
|
if version.parse(latest_version) > version.parse(self.version):
|
||||||
|
self.show_update_dialog(latest_version, latest_release["html_url"])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to check for updates: {str(e)}")
|
||||||
|
|
||||||
|
def show_update_dialog(self, latest_version, release_url):
|
||||||
|
msg = QDialog(self)
|
||||||
|
msg.setWindowTitle("Update Available")
|
||||||
|
msg.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(msg)
|
||||||
|
|
||||||
|
# Update message
|
||||||
|
message_label = QLabel(
|
||||||
|
f"A new version of YTSage is available!\n\n"
|
||||||
|
f"Current version: {self.version}\n"
|
||||||
|
f"Latest version: {latest_version}"
|
||||||
|
)
|
||||||
|
message_label.setWordWrap(True)
|
||||||
|
layout.addWidget(message_label)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
download_btn = QPushButton("Download Update")
|
||||||
|
download_btn.clicked.connect(lambda: self.open_release_page(release_url))
|
||||||
|
|
||||||
|
remind_btn = QPushButton("Remind Me Later")
|
||||||
|
remind_btn.clicked.connect(msg.close)
|
||||||
|
|
||||||
|
button_layout.addWidget(download_btn)
|
||||||
|
button_layout.addWidget(remind_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Style the dialog
|
||||||
|
msg.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #cc0000;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
msg.show()
|
||||||
|
|
||||||
|
def open_release_page(self, url):
|
||||||
|
import webbrowser
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
def show_custom_command(self):
|
||||||
|
dialog = CustomCommandDialog(self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
def cancel_download(self):
|
||||||
|
self.download_cancelled = True
|
||||||
|
if self.current_download:
|
||||||
|
self.signals.update_status.emit("Cancelling download...")
|
||||||
|
|
||||||
|
def check_ffmpeg(self):
|
||||||
|
try:
|
||||||
|
subprocess.run(['ffmpeg', '-version'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=True)
|
||||||
|
return True
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def show_ffmpeg_dialog(self):
|
||||||
|
dialog = FFmpegCheckDialog(self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setStyle(QStyleFactory.create('Fusion'))
|
app.setStyle(QStyleFactory.create('Fusion'))
|
||||||
window = YouTubeDownloaderGUI()
|
window = YTSage()
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user