Refactor/code cleanup (#37)
* fix imports remove unused imports use full import path sort import (1. Standard Library, 2. Third-Party, 3. Local) in alphabetic. * - remove: Method 3 from src.core.ytsage_downlader:cleanup_subtitle_file - it could delete the subtitle file of other movies if it present in same directory as it scane recursively. - refactor: migrate from os.path to pathlib.Path for path handling - Replaced os.path methods with pathlib.Path to improve readability, - avoid repeatation. - cross-platform compatibility, and maintain cleaner code. - improve: enhance code readability - Standardized string literals to use double quotes for consistency - Removed unnecessary spaces to maintain cleaner formatting - Applied code formatting for better readability and maintainability * - add: ytsage_constants.py file for one place to store all constants. - imporve: return type hint for function. - remove: src/gui/ytsage_gui_dialogs.py file to avoid repetation - src/gui/dialogs is renamed to src/gui/ytsage_gui_dialogs for same naming convection. (future import will remains same) - use of src/gui/ytsage_gui_dialogs/__init__.py to import the dilogs modules. - change: variable self.parent to self._parent so it does not overwrite the parent() - add type hint checking. * - refactor: QMetaObject.invokeMethod to Signal - I encounter error with incokeMethod. Could not solve it. - So, Changed it to Signal to match app code language. - implement: the ytsage_constants.py to code - remove: unnecessary logic - remove: repetitive code logic. - update: yt-dlp logic for src\gui\ytsage_gui_dialogs\ytsage_dialogs_update:_update_binary - yt-dlp update logic will use `yt-dlp -U` * refactor: remove unused imports and streamline code formatting across multiple files
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
|
|
||||||
from src.core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp, get_ytdlp_executable_path # Import the new yt-dlp setup functions
|
|
||||||
from src.core.ytsage_logging import logger
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.core.ytsage_yt_dlp import ( # Import the new yt-dlp setup functions
|
||||||
|
check_ytdlp_binary,
|
||||||
|
setup_ytdlp,
|
||||||
|
)
|
||||||
|
from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
|
||||||
|
|
||||||
|
|
||||||
def show_error_dialog(message):
|
def show_error_dialog(message):
|
||||||
error_dialog = QMessageBox()
|
error_dialog = QMessageBox()
|
||||||
@@ -12,28 +18,29 @@ def show_error_dialog(message):
|
|||||||
error_dialog.setWindowTitle("Error")
|
error_dialog.setWindowTitle("Error")
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
logger.info("Starting YTSage application")
|
logger.info("Starting YTSage application")
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
# Get the expected binary path and check if it exists
|
# Get the expected binary path and check if it exists
|
||||||
expected_path = get_ytdlp_executable_path()
|
|
||||||
if not check_ytdlp_binary():
|
if not check_ytdlp_binary():
|
||||||
# No app-specific binary found, show setup dialog regardless of Python package
|
# No app-specific binary found, show setup dialog regardless of Python package
|
||||||
logger.warning("No yt-dlp binary found, starting setup process")
|
logger.warning("No yt-dlp binary found, starting setup process")
|
||||||
yt_dlp_path = setup_ytdlp()
|
yt_dlp_path = setup_ytdlp()
|
||||||
if yt_dlp_path == "yt-dlp": # If user canceled or something went wrong
|
if yt_dlp_path == "yt-dlp": # If user canceled or something went wrong
|
||||||
logger.warning("yt-dlp not configured properly")
|
logger.warning("yt-dlp not configured properly")
|
||||||
|
|
||||||
window = YTSageApp() # Instantiate the main application class
|
window = YTSageApp() # Instantiate the main application class
|
||||||
window.show()
|
window.show()
|
||||||
logger.info("Application window shown, entering main loop")
|
logger.info("Application window shown, entering main loop")
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(f"Critical application error: {str(e)}", exc_info=True)
|
logger.critical(f"Critical application error: {e}", exc_info=True)
|
||||||
show_error_dialog(f"Critical error: {str(e)}")
|
show_error_dialog(f"Critical error: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
+255
-291
@@ -1,24 +1,34 @@
|
|||||||
from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer
|
import re
|
||||||
from .ytsage_logging import logger
|
import shlex # For safely parsing command arguments
|
||||||
|
import subprocess # For direct CLI command execution
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal
|
||||||
|
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
|
from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yt_dlp # Keep yt_dlp import here - only downloader uses it.
|
import yt_dlp # Keep yt_dlp import here - only downloader uses it.
|
||||||
|
|
||||||
YT_DLP_AVAILABLE = True
|
YT_DLP_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
YT_DLP_AVAILABLE = False
|
YT_DLP_AVAILABLE = False
|
||||||
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
|
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess # For direct CLI command execution
|
|
||||||
import shlex # For safely parsing command arguments
|
|
||||||
import sys # Added to get executable path information
|
|
||||||
from pathlib import Path
|
|
||||||
from .ytsage_yt_dlp import get_yt_dlp_path # Import the new yt-dlp path function
|
|
||||||
|
|
||||||
class SignalManager(QObject):
|
class SignalManager(QObject):
|
||||||
update_formats = Signal(list)
|
update_formats = Signal(list)
|
||||||
update_status = Signal(str)
|
update_status = Signal(str)
|
||||||
update_progress = Signal(float)
|
update_progress = Signal(float)
|
||||||
|
playlist_info_label_visible = Signal(bool)
|
||||||
|
playlist_info_label_text = Signal(str)
|
||||||
|
selected_subs_label_text = Signal(str)
|
||||||
|
playlist_select_btn_visible = Signal(bool)
|
||||||
|
playlist_select_btn_text = Signal(str)
|
||||||
|
|
||||||
|
|
||||||
class DownloadThread(QThread):
|
class DownloadThread(QThread):
|
||||||
progress_signal = Signal(float)
|
progress_signal = Signal(float)
|
||||||
@@ -26,18 +36,36 @@ class DownloadThread(QThread):
|
|||||||
finished_signal = Signal()
|
finished_signal = Signal()
|
||||||
error_signal = Signal(str)
|
error_signal = Signal(str)
|
||||||
file_exists_signal = Signal(str) # New signal for file existence
|
file_exists_signal = Signal(str) # New signal for file existence
|
||||||
update_details = Signal(str) # New signal for filename, speed, ETA
|
update_details = Signal(str) # New signal for filename, speed, ETA
|
||||||
|
|
||||||
def __init__(self, url, path, format_id, subtitle_langs=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, sponsorblock_categories=None, resolution='', playlist_items=None, save_description=False, embed_chapters=False, cookie_file=None, rate_limit=None, download_section=None, force_keyframes=False):
|
def __init__(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
path,
|
||||||
|
format_id,
|
||||||
|
subtitle_langs=None,
|
||||||
|
is_playlist=False,
|
||||||
|
merge_subs=False,
|
||||||
|
enable_sponsorblock=False,
|
||||||
|
sponsorblock_categories=None,
|
||||||
|
resolution="",
|
||||||
|
playlist_items=None,
|
||||||
|
save_description=False,
|
||||||
|
embed_chapters=False,
|
||||||
|
cookie_file=None,
|
||||||
|
rate_limit=None,
|
||||||
|
download_section=None,
|
||||||
|
force_keyframes=False,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.url = url
|
self.url = url
|
||||||
self.path = path
|
self.path = Path(path)
|
||||||
self.format_id = format_id
|
self.format_id = format_id
|
||||||
self.subtitle_langs = subtitle_langs if subtitle_langs else []
|
self.subtitle_langs = subtitle_langs if subtitle_langs else []
|
||||||
self.is_playlist = is_playlist
|
self.is_playlist = is_playlist
|
||||||
self.merge_subs = merge_subs
|
self.merge_subs = merge_subs
|
||||||
self.enable_sponsorblock = enable_sponsorblock
|
self.enable_sponsorblock = enable_sponsorblock
|
||||||
self.sponsorblock_categories = sponsorblock_categories if sponsorblock_categories else ['sponsor']
|
self.sponsorblock_categories = sponsorblock_categories if sponsorblock_categories else ["sponsor"]
|
||||||
self.resolution = resolution
|
self.resolution = resolution
|
||||||
self.playlist_items = playlist_items
|
self.playlist_items = playlist_items
|
||||||
self.save_description = save_description
|
self.save_description = save_description
|
||||||
@@ -52,205 +80,136 @@ class DownloadThread(QThread):
|
|||||||
self.use_direct_command = True # Flag to use direct CLI command instead of Python API
|
self.use_direct_command = True # Flag to use direct CLI command instead of Python API
|
||||||
self.last_output_time = time.time()
|
self.last_output_time = time.time()
|
||||||
self.timeout_timer = None
|
self.timeout_timer = None
|
||||||
self.current_filename = None # Initialize filename storage
|
self.current_filename = None # Initialize filename storage
|
||||||
self.last_file_path = None # Initialize full file path storage
|
self.last_file_path = None # Initialize full file path storage
|
||||||
self.subtitle_files = [] # Track subtitle files that are created
|
self.subtitle_files = [] # Track subtitle files that are created
|
||||||
self.initial_subtitle_files = set() # Track initial subtitle files before download
|
self.initial_subtitle_files = set() # Track initial subtitle files before download
|
||||||
|
|
||||||
def cleanup_partial_files(self):
|
def cleanup_partial_files(self) -> None:
|
||||||
"""Delete any partial files including .part and unmerged format-specific files"""
|
"""Delete any partial files including .part and unmerged format-specific files"""
|
||||||
try:
|
try:
|
||||||
pattern = re.compile(r'\.f\d+\.') # Pattern to match format codes like .f243.
|
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
||||||
for filename in os.listdir(self.path):
|
for file_path in self.path.iterdir():
|
||||||
file_path = os.path.join(self.path, filename)
|
if file_path.suffix == ".part" or pattern.search(file_path.name):
|
||||||
if filename.endswith('.part') or pattern.search(filename):
|
|
||||||
try:
|
try:
|
||||||
if os.path.isfile(file_path):
|
file_path.unlink(missing_ok=True)
|
||||||
os.remove(file_path)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting {filename}: {str(e)}")
|
logger.error(f"Error deleting {file_path.name}: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
|
self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
|
||||||
|
|
||||||
def cleanup_subtitle_files(self):
|
def cleanup_subtitle_files(self) -> None:
|
||||||
"""Delete subtitle files after they have been merged into the video file"""
|
"""Delete subtitle files after they have been merged into the video file"""
|
||||||
if not self.merge_subs:
|
deleted_count = [0, 0]
|
||||||
return # Only cleanup if merge_subs is enabled
|
|
||||||
|
def safe_delete(path: Path) -> bool:
|
||||||
try:
|
|
||||||
deleted_count = 0
|
|
||||||
|
|
||||||
# Method 1: Delete tracked subtitle files from output messages
|
|
||||||
if self.subtitle_files:
|
|
||||||
for subtitle_file in self.subtitle_files:
|
|
||||||
try:
|
|
||||||
if os.path.isfile(subtitle_file):
|
|
||||||
os.remove(subtitle_file)
|
|
||||||
deleted_count += 1
|
|
||||||
logger.debug(f"Deleted tracked subtitle file: {os.path.basename(subtitle_file)}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting subtitle file {subtitle_file}: {str(e)}")
|
|
||||||
|
|
||||||
logger.debug(f"Deleted {deleted_count} of {len(self.subtitle_files)} tracked subtitle files")
|
|
||||||
|
|
||||||
# Method 2: Find newly created subtitle files by comparing with initial set
|
|
||||||
try:
|
try:
|
||||||
new_subtitle_files = set()
|
path.unlink(missing_ok=True)
|
||||||
for root, dirs, files in os.walk(self.path):
|
logger.debug(f"Deleted subtitle file: {path.name}")
|
||||||
for file in files:
|
return True
|
||||||
if file.endswith('.vtt') or file.endswith('.srt'):
|
|
||||||
full_path = os.path.join(root, file)
|
|
||||||
if full_path not in self.initial_subtitle_files:
|
|
||||||
new_subtitle_files.add(full_path)
|
|
||||||
|
|
||||||
if new_subtitle_files:
|
|
||||||
logger.debug(f"Found {len(new_subtitle_files)} new subtitle files to delete")
|
|
||||||
for subtitle_file in new_subtitle_files:
|
|
||||||
try:
|
|
||||||
if os.path.isfile(subtitle_file):
|
|
||||||
os.remove(subtitle_file)
|
|
||||||
deleted_count += 1
|
|
||||||
logger.debug(f"Deleted new subtitle file: {os.path.basename(subtitle_file)}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting new subtitle file {subtitle_file}: {str(e)}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in finding new subtitle files: {str(e)}") # Method 3: As a last resort, use timestamp-based approach for recently created files
|
logger.error(f"Error deleting subtitle file {path}: {e}")
|
||||||
if self.last_file_path and deleted_count == 0:
|
return False
|
||||||
target_dir = os.path.dirname(self.last_file_path)
|
|
||||||
|
try:
|
||||||
# Look for subtitle files created in last 5 minutes
|
# --- Method 1: Delete tracked subtitle files ---
|
||||||
now = time.time()
|
for f in self.subtitle_files or []:
|
||||||
for filename in os.listdir(target_dir):
|
deleted_count[0] += safe_delete(path=Path(f))
|
||||||
if filename.endswith('.vtt') or filename.endswith('.srt'):
|
else:
|
||||||
file_path = os.path.join(target_dir, filename)
|
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
|
||||||
|
|
||||||
# Check if it was created in the last 5 minutes
|
# --- Method 2: Delete new subtitle files not in initial set ---
|
||||||
file_time = os.path.getctime(file_path)
|
new_subtitle_files = {
|
||||||
if now - file_time < 300: # 5 minutes
|
f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files
|
||||||
try:
|
}
|
||||||
os.remove(file_path)
|
for subtitle_file in new_subtitle_files:
|
||||||
deleted_count += 1
|
deleted_count[1] += safe_delete(path=subtitle_file)
|
||||||
logger.debug(f"Deleted subtitle file by timestamp: {filename}")
|
else:
|
||||||
except Exception as e:
|
logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files")
|
||||||
logger.error(f"Error deleting subtitle file {filename}: {str(e)}")
|
|
||||||
|
|
||||||
logger.debug(f"Total subtitle files deleted: {deleted_count}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning subtitle files: {str(e)}")
|
logger.error(f"Error cleaning subtitle files: {str(e)}")
|
||||||
|
|
||||||
def check_file_exists(self):
|
def check_file_exists(self) -> bool | None:
|
||||||
"""Check if the file already exists before downloading"""
|
"""Check if the file already exists before downloading"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Starting file existence check")
|
logger.debug("Starting file existence check")
|
||||||
# Use yt-dlp to get the filename without downloading, suppressing warnings
|
# Use yt-dlp to get the filename without downloading, suppressing warnings
|
||||||
ydl_opts_check = {
|
ydl_opts_check = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'skip_download': True,
|
"skip_download": True,
|
||||||
'no_warnings': True, # <-- Suppress warnings during check
|
"no_warnings": True, # <-- Suppress warnings during check
|
||||||
'ignoreerrors': True, # Also ignore other potential errors during this check
|
"ignoreerrors": True, # Also ignore other potential errors during this check
|
||||||
'outtmpl': {'default': os.path.join(self.path, '%(title)s.%(ext)s')},
|
"outtmpl": {"default": Path.joinpath(self.path, "%(title)s.%(ext)s")},
|
||||||
'format': self.format_id if self.format_id else 'best' # Use selected format or best
|
"format": (self.format_id if self.format_id else "best"), # Use selected format or best
|
||||||
}
|
}
|
||||||
if self.cookie_file:
|
if self.cookie_file:
|
||||||
ydl_opts_check['cookiefile'] = self.cookie_file
|
ydl_opts_check["cookiefile"] = self.cookie_file
|
||||||
|
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
with yt_dlp.YoutubeDL(ydl_opts_check) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts_check) as ydl:
|
||||||
info = ydl.extract_info(self.url, download=False)
|
info = ydl.extract_info(self.url, download=False)
|
||||||
|
|
||||||
# Handle cases where info extraction fails silently
|
# Handle cases where info extraction fails silently
|
||||||
if not info:
|
if not info:
|
||||||
logger.debug("Failed to extract info during file existence check. Skipping check.")
|
logger.debug("Failed to extract info during file existence check. Skipping check.")
|
||||||
return False # Proceed with download attempt
|
return False # Proceed with download attempt
|
||||||
|
|
||||||
# Get the title and sanitize it for filename
|
# Get the title and sanitize it for filename
|
||||||
title = info.get('title', 'video')
|
title = info.get("title", "video")
|
||||||
# Don't remove colons and other special characters yet
|
# Don't remove colons and other special characters yet
|
||||||
logger.debug(f"Original video title: {title}")
|
logger.debug(f"Original video title: {title}")
|
||||||
|
|
||||||
# Get resolution for better matching
|
# Get resolution for better matching
|
||||||
resolution = ""
|
resolution = ""
|
||||||
for format_info in info.get('formats', []):
|
for format_info in info.get("formats", []):
|
||||||
if format_info.get('format_id') == self.format_id:
|
if format_info.get("format_id") == self.format_id:
|
||||||
resolution = format_info.get('resolution', '')
|
resolution = format_info.get("resolution", "")
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.debug(f"Resolution: {resolution}")
|
logger.debug(f"Resolution: {resolution}")
|
||||||
else:
|
else:
|
||||||
logger.debug("yt-dlp not available, skipping file existence check")
|
logger.debug("yt-dlp not available, skipping file existence check")
|
||||||
return False # Proceed with download attempt
|
return False # Proceed with download attempt
|
||||||
|
|
||||||
# Create the expected filename (more specific)
|
|
||||||
if self.is_playlist and info.get('playlist_title'):
|
|
||||||
playlist_title = re.sub(r'[\\/*?"<>|]', "", info.get('playlist_title', '')).strip()
|
|
||||||
base_path = os.path.join(self.path, playlist_title)
|
|
||||||
else:
|
|
||||||
base_path = self.path
|
|
||||||
|
|
||||||
# Normalize the path to use consistent separators
|
|
||||||
base_path = os.path.normpath(base_path)
|
|
||||||
logger.debug(f"Base path: {base_path}")
|
|
||||||
|
|
||||||
# Instead of trying to predict the exact filename, scan the directory
|
|
||||||
# and look for files that contain both the title and resolution
|
|
||||||
if os.path.exists(base_path):
|
|
||||||
for filename in os.listdir(base_path):
|
|
||||||
if filename.endswith('.mp4'):
|
|
||||||
# Check if both title parts and resolution are in the filename
|
|
||||||
title_words = title.lower().split()
|
|
||||||
filename_lower = filename.lower()
|
|
||||||
|
|
||||||
# Check if most title words are in the filename
|
|
||||||
title_match = all(word in filename_lower for word in title_words[:3])
|
|
||||||
resolution_match = resolution.lower() in filename_lower
|
|
||||||
|
|
||||||
logger.debug(f"Checking file: {filename}, Title match: {title_match}, Resolution match: {resolution_match}")
|
|
||||||
|
|
||||||
if title_match and resolution_match:
|
|
||||||
logger.debug(f"Found matching file: {filename}")
|
|
||||||
return filename
|
|
||||||
|
|
||||||
logger.debug("No matching file found")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error checking file existence: {str(e)}")
|
logger.debug(f"Error checking file existence: {str(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _build_yt_dlp_command(self):
|
def _build_yt_dlp_command(self) -> list:
|
||||||
"""Build the yt-dlp command line with all options for direct execution."""
|
"""Build the yt-dlp command line with all options for direct execution."""
|
||||||
# Use the new yt-dlp path function from ytsage_yt_dlp module
|
# Use the new yt-dlp path function from ytsage_yt_dlp module
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
cmd = [yt_dlp_path]
|
cmd: list = [yt_dlp_path]
|
||||||
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
||||||
|
|
||||||
# Format selection strategy - use format ID if provided or fallback to resolution
|
# Format selection strategy - use format ID if provided or fallback to resolution
|
||||||
if self.format_id:
|
if self.format_id:
|
||||||
# Strip the -drc suffix if present to fix issues with certain audio formats
|
# Strip the -drc suffix if present to fix issues with certain audio formats
|
||||||
clean_format_id = self.format_id.split('-drc')[0] if '-drc' in self.format_id else self.format_id
|
clean_format_id = self.format_id.split("-drc")[0] if "-drc" in self.format_id else self.format_id
|
||||||
|
|
||||||
# Check if this is an audio-only format
|
# Check if this is an audio-only format
|
||||||
is_audio_format = False
|
is_audio_format = False
|
||||||
try:
|
try:
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'no_warnings': True,
|
"no_warnings": True,
|
||||||
'skip_download': True,
|
"skip_download": True,
|
||||||
}
|
}
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(self.url, download=False)
|
info = ydl.extract_info(self.url, download=False) or {}
|
||||||
for fmt in info.get('formats', []):
|
for fmt in info.get("formats", []):
|
||||||
if fmt.get('format_id') == clean_format_id:
|
if fmt.get("format_id") == clean_format_id:
|
||||||
if fmt.get('vcodec') == 'none' or 'audio only' in fmt.get('format_note', '').lower():
|
if fmt.get("vcodec") == "none" or "audio only" in fmt.get("format_note", "").lower():
|
||||||
is_audio_format = True
|
is_audio_format = True
|
||||||
logger.debug(f"Detected audio-only format for ID: {clean_format_id}")
|
logger.debug(f"Detected audio-only format for ID: {clean_format_id}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error checking if format is audio-only: {e}")
|
logger.debug(f"Error checking if format is audio-only: {e}")
|
||||||
|
|
||||||
# For audio-only formats, don't try to merge with video
|
# For audio-only formats, don't try to merge with video
|
||||||
if is_audio_format:
|
if is_audio_format:
|
||||||
cmd.extend(["-f", clean_format_id])
|
cmd.extend(["-f", clean_format_id])
|
||||||
@@ -258,7 +217,7 @@ class DownloadThread(QThread):
|
|||||||
else:
|
else:
|
||||||
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
||||||
logger.debug(f"Using video format selection with audio: {clean_format_id}+bestaudio/best")
|
logger.debug(f"Using video format selection with audio: {clean_format_id}+bestaudio/best")
|
||||||
|
|
||||||
# Determine output format based on the selected format ID - only for video formats
|
# Determine output format based on the selected format ID - only for video formats
|
||||||
if not is_audio_format:
|
if not is_audio_format:
|
||||||
try:
|
try:
|
||||||
@@ -266,24 +225,24 @@ class DownloadThread(QThread):
|
|||||||
logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
|
logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'no_warnings': True,
|
"no_warnings": True,
|
||||||
'skip_download': True,
|
"skip_download": True,
|
||||||
}
|
}
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(self.url, download=False)
|
info = ydl.extract_info(self.url, download=False) or {}
|
||||||
# Look for the clean format ID first
|
# Look for the clean format ID first
|
||||||
for fmt in info.get('formats', []):
|
for fmt in info.get("formats", []):
|
||||||
if fmt.get('format_id') == clean_format_id:
|
if fmt.get("format_id") == clean_format_id:
|
||||||
format_ext = fmt.get('ext')
|
format_ext = fmt.get("ext")
|
||||||
break
|
break
|
||||||
# If not found, try the original ID as fallback
|
# If not found, try the original ID as fallback
|
||||||
if not format_ext:
|
if not format_ext:
|
||||||
for fmt in info.get('formats', []):
|
for fmt in info.get("formats", []):
|
||||||
if fmt.get('format_id') == self.format_id:
|
if fmt.get("format_id") == self.format_id:
|
||||||
format_ext = fmt.get('ext')
|
format_ext = fmt.get("ext")
|
||||||
break
|
break
|
||||||
|
|
||||||
if format_ext:
|
if format_ext:
|
||||||
logger.debug(f"Detected format extension: {format_ext}")
|
logger.debug(f"Detected format extension: {format_ext}")
|
||||||
# Ensure output matches the selected format - only for video formats
|
# Ensure output matches the selected format - only for video formats
|
||||||
@@ -296,139 +255,136 @@ class DownloadThread(QThread):
|
|||||||
# If no specific format ID, use resolution-based sorting (-S)
|
# If no specific format ID, use resolution-based sorting (-S)
|
||||||
res_value = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified
|
res_value = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified
|
||||||
cmd.extend(["-S", f"res:{res_value}"])
|
cmd.extend(["-S", f"res:{res_value}"])
|
||||||
|
|
||||||
# Output template with resolution in filename
|
# Output template with resolution in filename
|
||||||
output_template = os.path.join(self.path, '%(title)s_%(resolution)s.%(ext)s')
|
output_template = Path.joinpath(self.path, "%(title)s_%(resolution)s.%(ext)s")
|
||||||
|
|
||||||
# Handle playlist directory creation if needed
|
# Handle playlist directory creation if needed
|
||||||
if self.is_playlist:
|
if self.is_playlist:
|
||||||
# Create output template with playlist subfolder
|
# Create output template with playlist subfolder
|
||||||
output_template = os.path.join(self.path, '%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s')
|
output_template = self.path.joinpath("%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s")
|
||||||
|
|
||||||
cmd.extend(["-o", output_template])
|
cmd.extend(["-o", output_template.as_posix()])
|
||||||
|
|
||||||
# Add common options
|
# Add common options
|
||||||
cmd.append("--force-overwrites")
|
cmd.append("--force-overwrites")
|
||||||
|
|
||||||
# Add playlist items if specified
|
# Add playlist items if specified
|
||||||
if self.is_playlist and self.playlist_items:
|
if self.is_playlist and self.playlist_items:
|
||||||
cmd.extend(["--playlist-items", self.playlist_items])
|
cmd.extend(["--playlist-items", self.playlist_items])
|
||||||
|
|
||||||
# Add subtitle options if subtitles are selected
|
# Add subtitle options if subtitles are selected
|
||||||
if self.subtitle_langs:
|
if self.subtitle_langs:
|
||||||
# Subtitles work with both audio-only and video formats
|
# Subtitles work with both audio-only and video formats
|
||||||
# For audio-only formats, subtitles will be downloaded as separate files
|
# For audio-only formats, subtitles will be downloaded as separate files
|
||||||
cmd.append("--write-subs")
|
cmd.append("--write-subs")
|
||||||
|
|
||||||
# Get language codes from subtitle selections
|
# Get language codes from subtitle selections
|
||||||
lang_codes = []
|
lang_codes = []
|
||||||
for sub_selection in self.subtitle_langs:
|
for sub_selection in self.subtitle_langs:
|
||||||
try:
|
try:
|
||||||
# Extract just the language code (e.g., 'en' from 'en - Manual')
|
# Extract just the language code (e.g., 'en' from 'en - Manual')
|
||||||
lang_code = sub_selection.split(' - ')[0]
|
lang_code = sub_selection.split(" - ")[0]
|
||||||
lang_codes.append(lang_code)
|
lang_codes.append(lang_code)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}")
|
logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}")
|
||||||
|
|
||||||
if lang_codes:
|
if lang_codes:
|
||||||
cmd.extend(["--sub-langs", ",".join(lang_codes)])
|
cmd.extend(["--sub-langs", ",".join(lang_codes)])
|
||||||
cmd.append("--write-auto-subs") # Include auto-generated subtitles
|
cmd.append("--write-auto-subs") # Include auto-generated subtitles
|
||||||
|
|
||||||
# Only embed subtitles if merge is enabled
|
# Only embed subtitles if merge is enabled
|
||||||
if self.merge_subs:
|
if self.merge_subs:
|
||||||
cmd.append("--embed-subs")
|
cmd.append("--embed-subs")
|
||||||
|
|
||||||
# Add SponsorBlock if enabled
|
# Add SponsorBlock if enabled
|
||||||
if self.enable_sponsorblock and self.sponsorblock_categories:
|
if self.enable_sponsorblock and self.sponsorblock_categories:
|
||||||
cmd.append("--sponsorblock-remove")
|
cmd.append("--sponsorblock-remove")
|
||||||
cmd.append(",".join(self.sponsorblock_categories))
|
cmd.append(",".join(self.sponsorblock_categories))
|
||||||
|
|
||||||
# Add description saving if enabled
|
# Add description saving if enabled
|
||||||
if self.save_description:
|
if self.save_description:
|
||||||
cmd.append("--write-description")
|
cmd.append("--write-description")
|
||||||
|
|
||||||
# Add chapters embedding if enabled
|
# Add chapters embedding if enabled
|
||||||
if self.embed_chapters:
|
if self.embed_chapters:
|
||||||
cmd.append("--embed-chapters")
|
cmd.append("--embed-chapters")
|
||||||
|
|
||||||
# Add cookies if specified
|
# Add cookies if specified
|
||||||
if self.cookie_file:
|
if self.cookie_file:
|
||||||
cmd.extend(["--cookies", self.cookie_file])
|
cmd.extend(["--cookies", self.cookie_file])
|
||||||
|
|
||||||
# Add rate limit if specified
|
# Add rate limit if specified
|
||||||
if self.rate_limit:
|
if self.rate_limit:
|
||||||
cmd.extend(["-r", self.rate_limit])
|
cmd.extend(["-r", self.rate_limit])
|
||||||
|
|
||||||
# Add download section if specified
|
# Add download section if specified
|
||||||
if self.download_section:
|
if self.download_section:
|
||||||
cmd.extend(["--download-sections", self.download_section])
|
cmd.extend(["--download-sections", self.download_section])
|
||||||
|
|
||||||
# Add force keyframes option if enabled
|
# Add force keyframes option if enabled
|
||||||
if self.force_keyframes:
|
if self.force_keyframes:
|
||||||
cmd.append("--force-keyframes-at-cuts")
|
cmd.append("--force-keyframes-at-cuts")
|
||||||
|
|
||||||
logger.debug(f"Added download section: {self.download_section}, Force keyframes: {self.force_keyframes}")
|
logger.debug(f"Added download section: {self.download_section}, Force keyframes: {self.force_keyframes}")
|
||||||
|
|
||||||
# Add the URL as the final argument
|
# Add the URL as the final argument
|
||||||
cmd.append(self.url)
|
cmd.append(self.url)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
logger.debug("Starting download thread")
|
logger.debug("Starting download thread")
|
||||||
|
|
||||||
# First check if file already exists using original method
|
# First check if file already exists using original method
|
||||||
existing_file = self.check_file_exists()
|
existing_file = self.check_file_exists()
|
||||||
if existing_file:
|
if existing_file:
|
||||||
logger.debug(f"File exists, emitting signal: {existing_file}")
|
logger.debug(f"File exists, emitting signal: {existing_file}")
|
||||||
self.file_exists_signal.emit(existing_file)
|
self.file_exists_signal.emit(existing_file)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("No existing file found, proceeding with download")
|
logger.debug("No existing file found, proceeding with download")
|
||||||
|
|
||||||
# Get initial list of subtitle files to compare later
|
# Get initial list of subtitle files to compare later
|
||||||
self.initial_subtitle_files = set()
|
self.initial_subtitle_files = set()
|
||||||
if self.merge_subs:
|
if self.merge_subs:
|
||||||
try:
|
try:
|
||||||
# Scan for existing subtitle files in the directory
|
# Scan for existing subtitle files in the directory
|
||||||
for root, dirs, files in os.walk(self.path):
|
for file in self.path.rglob("*"):
|
||||||
for file in files:
|
if file.suffix in {".vtt", ".srt"}:
|
||||||
if file.endswith('.vtt') or file.endswith('.srt'):
|
self.initial_subtitle_files.add(file)
|
||||||
self.initial_subtitle_files.add(os.path.join(root, file))
|
|
||||||
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
|
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error scanning for initial subtitle files: {e}")
|
logger.warning(f"Error scanning for initial subtitle files: {e}")
|
||||||
|
|
||||||
if self.use_direct_command:
|
if self.use_direct_command:
|
||||||
# Use direct CLI command instead of Python API
|
# Use direct CLI command instead of Python API
|
||||||
self._run_direct_command()
|
self._run_direct_command()
|
||||||
else:
|
else:
|
||||||
# Original method using Python API - code left for reference
|
# Original method using Python API - code left for reference
|
||||||
self._run_python_api()
|
self._run_python_api()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch errors during setup
|
# Catch errors during setup
|
||||||
self.error_signal.emit(f"Critical error in download thread: {str(e)}")
|
self.error_signal.emit(f"Critical error in download thread: {str(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def _run_direct_command(self):
|
def _run_direct_command(self) -> None:
|
||||||
"""Run yt-dlp as a direct command line process instead of using Python API."""
|
"""Run yt-dlp as a direct command line process instead of using Python API."""
|
||||||
try:
|
try:
|
||||||
cmd = self._build_yt_dlp_command()
|
cmd = self._build_yt_dlp_command()
|
||||||
cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd)
|
cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd)
|
||||||
logger.debug(f"Executing command: {cmd_str}")
|
logger.debug(f"Executing command: {cmd_str}")
|
||||||
|
|
||||||
self.status_signal.emit("🚀 Starting download...")
|
self.status_signal.emit("🚀 Starting download...")
|
||||||
self.progress_signal.emit(0)
|
self.progress_signal.emit(0)
|
||||||
|
|
||||||
# Start the process
|
# Start the process
|
||||||
# Add creationflags=subprocess.CREATE_NO_WINDOW to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
creation_flags = 0
|
|
||||||
if os.name == 'nt': # Only use flag on Windows
|
|
||||||
creation_flags = subprocess.CREATE_NO_WINDOW
|
|
||||||
|
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -436,37 +392,39 @@ class DownloadThread(QThread):
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=1, # Line buffered
|
bufsize=1, # Line buffered
|
||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
creationflags=creation_flags # Add this flag
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process output line by line to update progress
|
# Process output line by line to update progress
|
||||||
for line in iter(self.process.stdout.readline, ''):
|
for line in iter(self.process.stdout.readline, ""): # type: ignore
|
||||||
if self.cancelled:
|
if self.cancelled:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
self.cleanup_partial_files()
|
self.cleanup_partial_files()
|
||||||
self.status_signal.emit("Download cancelled")
|
self.status_signal.emit("Download cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Wait if paused
|
# Wait if paused
|
||||||
while self.paused and not self.cancelled:
|
while self.paused and not self.cancelled:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
# Parse the line for download progress and status updates
|
# Parse the line for download progress and status updates
|
||||||
self._parse_output_line(line)
|
self._parse_output_line(line)
|
||||||
|
|
||||||
# Wait for process to complete
|
# Wait for process to complete
|
||||||
return_code = self.process.wait()
|
return_code = self.process.wait()
|
||||||
|
|
||||||
# Special handling for specific errors
|
# Special handling for specific errors
|
||||||
# return code 127 typically means command not found
|
# return code 127 typically means command not found
|
||||||
if return_code == 127:
|
if return_code == 127:
|
||||||
self.error_signal.emit("Error: yt-dlp executable not found. This could be due to improper installation or a PATH issue.")
|
self.error_signal.emit(
|
||||||
|
"Error: yt-dlp executable not found. This could be due to improper installation or a PATH issue."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if return_code == 0:
|
if return_code == 0:
|
||||||
self.progress_signal.emit(100)
|
self.progress_signal.emit(100)
|
||||||
self.status_signal.emit("✅ Download completed!")
|
self.status_signal.emit("✅ Download completed!")
|
||||||
|
|
||||||
# Clean up subtitle files if they were merged, with a small delay
|
# Clean up subtitle files if they were merged, with a small delay
|
||||||
# to ensure the embedding process has completed
|
# to ensure the embedding process has completed
|
||||||
if self.merge_subs:
|
if self.merge_subs:
|
||||||
@@ -475,7 +433,7 @@ class DownloadThread(QThread):
|
|||||||
self.status_signal.emit("✅ Download completed! Cleaning up...")
|
self.status_signal.emit("✅ Download completed! Cleaning up...")
|
||||||
time.sleep(3) # Increased delay to 3 seconds
|
time.sleep(3) # Increased delay to 3 seconds
|
||||||
self.cleanup_subtitle_files()
|
self.cleanup_subtitle_files()
|
||||||
|
|
||||||
self.finished_signal.emit()
|
self.finished_signal.emit()
|
||||||
else:
|
else:
|
||||||
# Check if it was cancelled
|
# Check if it was cancelled
|
||||||
@@ -484,217 +442,223 @@ class DownloadThread(QThread):
|
|||||||
else:
|
else:
|
||||||
# Provide more descriptive error message for possible yt-dlp conflicts
|
# Provide more descriptive error message for possible yt-dlp conflicts
|
||||||
if return_code == 1:
|
if return_code == 1:
|
||||||
self.error_signal.emit(f"Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application.")
|
self.error_signal.emit(
|
||||||
|
f"Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.error_signal.emit(f"Download failed with return code {return_code}")
|
self.error_signal.emit(f"Download failed with return code {return_code}")
|
||||||
self.cleanup_partial_files()
|
self.cleanup_partial_files()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_signal.emit(f"Error in direct command: {str(e)}")
|
self.error_signal.emit(f"Error in direct command: {str(e)}")
|
||||||
self.cleanup_partial_files()
|
self.cleanup_partial_files()
|
||||||
|
|
||||||
def _parse_output_line(self, line):
|
def _parse_output_line(self, line) -> None:
|
||||||
"""Parse yt-dlp command output to update progress and status."""
|
"""Parse yt-dlp command output to update progress and status."""
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# logger.info(f"yt-dlp: {line}") # Log all output - OPTIONALLY UNCOMMENT FOR VERBOSE DEBUG
|
# logger.info(f"yt-dlp: {line}") # Log all output - OPTIONALLY UNCOMMENT FOR VERBOSE DEBUG
|
||||||
|
|
||||||
# Extract filename when the destination line appears
|
# Extract filename when the destination line appears
|
||||||
# Use a slightly more robust regex looking for the start of the line
|
# Use a slightly more robust regex looking for the start of the line
|
||||||
dest_match = re.search(r'^\[download\] Destination:\s*(.*)', line)
|
dest_match = re.search(r"^\[download\] Destination:\s*(.*)", line)
|
||||||
if dest_match:
|
if dest_match:
|
||||||
try:
|
try:
|
||||||
filepath = dest_match.group(1).strip()
|
filepath = dest_match.group(1).strip()
|
||||||
self.current_filename = os.path.basename(filepath)
|
self.current_filename = Path(filepath).name
|
||||||
self.last_file_path = filepath # Store the full path for later cleanup
|
self.last_file_path = filepath # Store the full path for later cleanup
|
||||||
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
||||||
|
|
||||||
# Check if this is an audio-only download by looking in the previous lines
|
# Check if this is an audio-only download by looking in the previous lines
|
||||||
is_audio_download = False
|
is_audio_download = False
|
||||||
|
|
||||||
# Look for audio format indicators in the current line or preceding output
|
# Look for audio format indicators in the current line or preceding output
|
||||||
# yt-dlp typically mentions format like "Downloading format 251 - audio only"
|
# yt-dlp typically mentions format like "Downloading format 251 - audio only"
|
||||||
if ' - audio only' in line:
|
if " - audio only" in line:
|
||||||
is_audio_download = True
|
is_audio_download = True
|
||||||
# Check if the format ID is mentioned earlier in the line
|
# Check if the format ID is mentioned earlier in the line
|
||||||
format_match = re.search(r'Downloading format (\d+)', line)
|
format_match = re.search(r"Downloading format (\d+)", line)
|
||||||
if format_match:
|
if format_match:
|
||||||
format_id = format_match.group(1)
|
format_id = format_match.group(1)
|
||||||
logger.debug(f"Detected format ID: {format_id}")
|
logger.debug(f"Detected format ID: {format_id}")
|
||||||
# Format IDs for audio typically have different patterns
|
# Format IDs for audio typically have different patterns
|
||||||
# (like 140, 251 for audio vs 137, 248 for video)
|
# (like 140, 251 for audio vs 137, 248 for video)
|
||||||
# This is just a heuristic since format IDs can vary
|
# This is just a heuristic since format IDs can vary
|
||||||
|
|
||||||
# Determine file type based on extension and context
|
# Determine file type based on extension and context
|
||||||
ext = os.path.splitext(self.current_filename)[1].lower()
|
ext = Path(self.current_filename).suffix.lower()
|
||||||
|
|
||||||
# Check if this is explicitly an audio stream download
|
# Check if this is explicitly an audio stream download
|
||||||
if is_audio_download or 'Downloading audio' in line:
|
if is_audio_download or "Downloading audio" in line:
|
||||||
self.status_signal.emit(f"⏬ Downloading audio...")
|
self.status_signal.emit(f"⏬ Downloading audio...")
|
||||||
# Video file extensions with likely video content
|
# Video file extensions with likely video content
|
||||||
elif ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
|
elif ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]:
|
||||||
self.status_signal.emit(f"⏬ Downloading video...")
|
self.status_signal.emit(f"⏬ Downloading video...")
|
||||||
# Audio file extensions
|
# Audio file extensions
|
||||||
elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']:
|
elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]:
|
||||||
self.status_signal.emit(f"⏬ Downloading audio...")
|
self.status_signal.emit(f"⏬ Downloading audio...")
|
||||||
# Subtitle file extensions
|
# Subtitle file extensions
|
||||||
elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
|
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
|
||||||
self.status_signal.emit(f"⏬ Downloading subtitle...")
|
self.status_signal.emit(f"⏬ Downloading subtitle...")
|
||||||
# Default case
|
# Default case
|
||||||
else:
|
else:
|
||||||
self.status_signal.emit(f"⏬ Downloading...")
|
self.status_signal.emit(f"⏬ Downloading...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error extracting filename from line '{line}': {e}")
|
logger.error(f"Error extracting filename from line '{line}': {e}")
|
||||||
self.status_signal.emit("⚡ Downloading...") # Fallback status
|
self.status_signal.emit("⚡ Downloading...") # Fallback status
|
||||||
return # Don't process this line further for speed/ETA
|
return # Don't process this line further for speed/ETA
|
||||||
|
|
||||||
# Check for specific download types in the output
|
# Check for specific download types in the output
|
||||||
if "Downloading video" in line:
|
if "Downloading video" in line:
|
||||||
self.status_signal.emit(f"⏬ Downloading video...")
|
self.status_signal.emit(f"⏬ Downloading video...")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif "Downloading audio" in line:
|
elif "Downloading audio" in line:
|
||||||
self.status_signal.emit(f"⏬ Downloading audio...")
|
self.status_signal.emit(f"⏬ Downloading audio...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Detect subtitle file creation
|
# Detect subtitle file creation
|
||||||
# Look for lines like "[info] Writing video subtitles to: filename.xx.vtt"
|
# Look for lines like "[info] Writing video subtitles to: filename.xx.vtt"
|
||||||
subtitle_match = re.search(r'(?:Writing|Downloading) (?:video )?subtitles.*?(?:to|:)\s*(.*\.(?:vtt|srt))', line, re.IGNORECASE)
|
subtitle_match = re.search(
|
||||||
|
r"(?:Writing|Downloading) (?:video )?subtitles.*?(?:to|:)\s*(.*\.(?:vtt|srt))",
|
||||||
|
line,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
if subtitle_match:
|
if subtitle_match:
|
||||||
subtitle_file = subtitle_match.group(1).strip()
|
subtitle_file = subtitle_match.group(1).strip()
|
||||||
# Show subtitle download message
|
# Show subtitle download message
|
||||||
self.status_signal.emit(f"⏬ Downloading subtitle...")
|
self.status_signal.emit(f"⏬ Downloading subtitle...")
|
||||||
# Store the subtitle file path for later deletion if merging is enabled
|
# Store the subtitle file path for later deletion if merging is enabled
|
||||||
if self.merge_subs:
|
if self.merge_subs:
|
||||||
if not os.path.isabs(subtitle_file):
|
if not Path(subtitle_file).is_absolute():
|
||||||
# If it's a relative path, make it absolute based on current path
|
# If it's a relative path, make it absolute based on current path
|
||||||
subtitle_file = os.path.join(self.path, subtitle_file)
|
subtitle_file = Path.joinpath(self.path, subtitle_file)
|
||||||
self.subtitle_files.append(subtitle_file)
|
self.subtitle_files.append(subtitle_file)
|
||||||
logger.debug(f"Tracking subtitle file for later cleanup: {subtitle_file}")
|
logger.debug(f"Tracking subtitle file for later cleanup: {subtitle_file}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send status updates based on output line content
|
# Send status updates based on output line content
|
||||||
if 'Downloading webpage' in line or 'Extracting URL' in line:
|
if "Downloading webpage" in line or "Extracting URL" in line:
|
||||||
self.status_signal.emit("🔍 Fetching video information...")
|
self.status_signal.emit("🔍 Fetching video information...")
|
||||||
self.progress_signal.emit(0)
|
self.progress_signal.emit(0)
|
||||||
elif 'Downloading API JSON' in line:
|
elif "Downloading API JSON" in line:
|
||||||
self.status_signal.emit("📋 Processing playlist data...")
|
self.status_signal.emit("📋 Processing playlist data...")
|
||||||
self.progress_signal.emit(0)
|
self.progress_signal.emit(0)
|
||||||
elif 'Downloading m3u8 information' in line:
|
elif "Downloading m3u8 information" in line:
|
||||||
self.status_signal.emit("🎯 Preparing video streams...")
|
self.status_signal.emit("🎯 Preparing video streams...")
|
||||||
self.progress_signal.emit(0)
|
self.progress_signal.emit(0)
|
||||||
elif '[download] Downloading video ' in line:
|
elif "[download] Downloading video " in line:
|
||||||
self.status_signal.emit("⏬ Downloading video...")
|
self.status_signal.emit("⏬ Downloading video...")
|
||||||
elif '[download] Downloading audio ' in line:
|
elif "[download] Downloading audio " in line:
|
||||||
self.status_signal.emit("⏬ Downloading audio...")
|
self.status_signal.emit("⏬ Downloading audio...")
|
||||||
elif 'Downloading format' in line:
|
elif "Downloading format" in line:
|
||||||
# Try to detect if it's audio or video format
|
# Try to detect if it's audio or video format
|
||||||
if ' - audio only' in line:
|
if " - audio only" in line:
|
||||||
self.status_signal.emit("⏬ Downloading audio...")
|
self.status_signal.emit("⏬ Downloading audio...")
|
||||||
elif ' - video only' in line:
|
elif " - video only" in line:
|
||||||
self.status_signal.emit("⏬ Downloading video...")
|
self.status_signal.emit("⏬ Downloading video...")
|
||||||
else:
|
else:
|
||||||
# Don't emit generic message - format is unclear
|
# Don't emit generic message - format is unclear
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Look for download percentage
|
# Look for download percentage
|
||||||
percent_match = re.search(r'(\d+\.\d+)%', line)
|
percent_match = re.search(r"(\d+\.\d+)%", line)
|
||||||
if percent_match:
|
if percent_match:
|
||||||
try:
|
try:
|
||||||
percent = float(percent_match.group(1))
|
percent = float(percent_match.group(1))
|
||||||
self.progress_signal.emit(percent)
|
self.progress_signal.emit(percent)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check for download speed and ETA
|
# Check for download speed and ETA
|
||||||
if '[download]' in line and '%' in line:
|
if "[download]" in line and "%" in line:
|
||||||
# Try to extract more detailed status info
|
# Try to extract more detailed status info
|
||||||
try:
|
try:
|
||||||
# Look for speed
|
# Look for speed
|
||||||
speed_match = re.search(r'at\s+(\d+\.\d+[KMG]iB/s)', line)
|
speed_match = re.search(r"at\s+(\d+\.\d+[KMG]iB/s)", line)
|
||||||
speed_str = speed_match.group(1) if speed_match else "N/A"
|
speed_str = speed_match.group(1) if speed_match else "N/A"
|
||||||
|
|
||||||
# Look for ETA
|
# Look for ETA
|
||||||
eta_match = re.search(r'ETA\s+(\d+:\d+)', line)
|
eta_match = re.search(r"ETA\s+(\d+:\d+)", line)
|
||||||
eta_str = eta_match.group(1) if eta_match else "N/A"
|
eta_str = eta_match.group(1) if eta_match else "N/A"
|
||||||
|
|
||||||
# Simplify status message to only show the speed and ETA
|
# Simplify status message to only show the speed and ETA
|
||||||
status = f"Speed: {speed_str} | ETA: {eta_str}"
|
status = f"Speed: {speed_str} | ETA: {eta_str}"
|
||||||
self.update_details.emit(status)
|
self.update_details.emit(status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If parsing fails, just show basic status (maybe log the error)
|
# If parsing fails, just show basic status (maybe log the error)
|
||||||
logger.error(f"Error parsing download details line: {line} -> {e}")
|
logger.error(f"Error parsing download details line: {line} -> {e}")
|
||||||
pass # Keep basic status emission below if needed, or emit generic details
|
pass # Keep basic status emission below if needed, or emit generic details
|
||||||
|
|
||||||
# Check for post-processing
|
# Check for post-processing
|
||||||
if '[Merger]' in line or 'Merging formats' in line:
|
if "[Merger]" in line or "Merging formats" in line:
|
||||||
self.status_signal.emit("✨ Post-processing: Merging formats...")
|
self.status_signal.emit("✨ Post-processing: Merging formats...")
|
||||||
self.progress_signal.emit(95)
|
self.progress_signal.emit(95)
|
||||||
elif 'SponsorBlock' in line:
|
elif "SponsorBlock" in line:
|
||||||
self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
|
self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
|
||||||
self.progress_signal.emit(97)
|
self.progress_signal.emit(97)
|
||||||
elif 'Deleting original file' in line:
|
elif "Deleting original file" in line:
|
||||||
self.progress_signal.emit(98)
|
self.progress_signal.emit(98)
|
||||||
elif 'has already been downloaded' in line:
|
elif "has already been downloaded" in line:
|
||||||
# File already exists - extract filename
|
# File already exists - extract filename
|
||||||
match = re.search(r'(.*?) has already been downloaded', line)
|
match = re.search(r"(.*?) has already been downloaded", line)
|
||||||
if match:
|
if match:
|
||||||
filename = os.path.basename(match.group(1))
|
filename = Path(match.group(1)).name
|
||||||
# Determine file type based on extension for existing file message
|
# Determine file type based on extension for existing file message
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
ext = Path(filename).suffix.lower()
|
||||||
|
|
||||||
if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
|
if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]:
|
||||||
self.status_signal.emit(f"⚠️ Video file already exists")
|
self.status_signal.emit(f"⚠️ Video file already exists")
|
||||||
elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']:
|
elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]:
|
||||||
self.status_signal.emit(f"⚠️ Audio file already exists")
|
self.status_signal.emit(f"⚠️ Audio file already exists")
|
||||||
elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
|
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
|
||||||
self.status_signal.emit(f"⚠️ Subtitle file already exists")
|
self.status_signal.emit(f"⚠️ Subtitle file already exists")
|
||||||
else:
|
else:
|
||||||
self.status_signal.emit(f"⚠️ File already exists")
|
self.status_signal.emit(f"⚠️ File already exists")
|
||||||
|
|
||||||
self.file_exists_signal.emit(filename)
|
self.file_exists_signal.emit(filename)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Could not extract filename from 'already downloaded' line: {line}")
|
logger.info(f"Could not extract filename from 'already downloaded' line: {line}")
|
||||||
self.status_signal.emit("⚠️ File already exists") # Fallback status
|
self.status_signal.emit("⚠️ File already exists") # Fallback status
|
||||||
elif 'Finished downloading' in line:
|
elif "Finished downloading" in line:
|
||||||
self.progress_signal.emit(100)
|
self.progress_signal.emit(100)
|
||||||
|
|
||||||
# Show completion message based on file type
|
# Show completion message based on file type
|
||||||
if self.current_filename:
|
if self.current_filename:
|
||||||
ext = os.path.splitext(self.current_filename)[1].lower()
|
ext = Path(self.current_filename).suffix.lower()
|
||||||
|
|
||||||
# Video file extensions
|
# Video file extensions
|
||||||
if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
|
if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]:
|
||||||
self.status_signal.emit(f"✅ Video download completed!")
|
self.status_signal.emit(f"✅ Video download completed!")
|
||||||
# Audio file extensions
|
# Audio file extensions
|
||||||
elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']:
|
elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]:
|
||||||
self.status_signal.emit(f"✅ Audio download completed!")
|
self.status_signal.emit(f"✅ Audio download completed!")
|
||||||
# Subtitle file extensions
|
# Subtitle file extensions
|
||||||
elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
|
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
|
||||||
self.status_signal.emit(f"✅ Subtitle download completed!")
|
self.status_signal.emit(f"✅ Subtitle download completed!")
|
||||||
# Default case
|
# Default case
|
||||||
else:
|
else:
|
||||||
self.status_signal.emit("✅ Download completed!")
|
self.status_signal.emit("✅ Download completed!")
|
||||||
else:
|
else:
|
||||||
self.status_signal.emit("✅ Download completed!")
|
self.status_signal.emit("✅ Download completed!")
|
||||||
|
|
||||||
self.update_details.emit("") # Clear details label on completion
|
self.update_details.emit("") # Clear details label on completion
|
||||||
|
|
||||||
def _run_python_api(self):
|
def _run_python_api(self) -> None:
|
||||||
"""Original download method using Python API - kept for reference."""
|
"""Original download method using Python API - kept for reference."""
|
||||||
# The existing run method code using yt_dlp.YoutubeDL starts here
|
# The existing run method code using yt_dlp.YoutubeDL starts here
|
||||||
# This method is no longer used by default
|
# This method is no longer used by default
|
||||||
|
|
||||||
def pause(self):
|
def pause(self) -> None:
|
||||||
self.paused = True
|
self.paused = True
|
||||||
|
|
||||||
def resume(self):
|
def resume(self) -> None:
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self) -> None:
|
||||||
self.cancelled = True
|
self.cancelled = True
|
||||||
# Terminate the subprocess if it's running
|
# Terminate the subprocess if it's running
|
||||||
if self.process:
|
if self.process:
|
||||||
try:
|
try:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+153
-124
@@ -1,33 +1,39 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import requests
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PySide6.QtGui import QIcon
|
|
||||||
from .ytsage_logging import logger
|
|
||||||
|
|
||||||
def check_7zip_installed():
|
import requests
|
||||||
|
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.utils.ytsage_constants import (
|
||||||
|
FFMPEG_7Z_DOWNLOAD_URL,
|
||||||
|
FFMPEG_7Z_SHA256_URL,
|
||||||
|
FFMPEG_ZIP_DOWNLOAD_URL,
|
||||||
|
OS_NAME,
|
||||||
|
SUBPROCESS_CREATIONFLAGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_7zip_installed() -> bool:
|
||||||
"""Check if 7-Zip is installed on Windows."""
|
"""Check if 7-Zip is installed on Windows."""
|
||||||
try:
|
try:
|
||||||
subprocess.run(['7z', '--help'],
|
subprocess.run(["7z", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=SUBPROCESS_CREATIONFLAGS)
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
|
||||||
return True
|
return True
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def download_file(url, dest_path, progress_callback=None):
|
|
||||||
|
def download_file(url, dest_path, progress_callback=None) -> bool:
|
||||||
"""Download a file from URL to destination path with progress indication."""
|
"""Download a file from URL to destination path with progress indication."""
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, stream=True, timeout=30) # Added timeout
|
response = requests.get(url, stream=True, timeout=30) # Added timeout
|
||||||
response.raise_for_status() # Check for HTTP errors
|
response.raise_for_status() # Check for HTTP errors
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
|
|
||||||
with open(dest_path, 'wb') as f:
|
with open(dest_path, "wb") as f:
|
||||||
if total_size == 0:
|
if total_size == 0:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
else:
|
else:
|
||||||
@@ -43,7 +49,8 @@ def download_file(url, dest_path, progress_callback=None):
|
|||||||
logger.info(f"Download error: {str(e)}")
|
logger.info(f"Download error: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_file_sha256(file_path):
|
|
||||||
|
def get_file_sha256(file_path) -> str:
|
||||||
"""Calculate SHA-256 hash of a file."""
|
"""Calculate SHA-256 hash of a file."""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
@@ -51,17 +58,18 @@ def get_file_sha256(file_path):
|
|||||||
sha256_hash.update(chunk)
|
sha256_hash.update(chunk)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
def verify_sha256(file_path, expected_hash_url):
|
|
||||||
|
def verify_sha256(file_path, expected_hash_url) -> bool:
|
||||||
"""Verify file SHA-256 hash against expected hash from URL."""
|
"""Verify file SHA-256 hash against expected hash from URL."""
|
||||||
try:
|
try:
|
||||||
# Download the SHA-256 hash
|
# Download the SHA-256 hash
|
||||||
response = requests.get(expected_hash_url, timeout=10)
|
response = requests.get(expected_hash_url, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
expected_hash = response.text.strip().split()[0] # Get just the hash part
|
expected_hash = response.text.strip().split()[0] # Get just the hash part
|
||||||
|
|
||||||
# Calculate actual hash
|
# Calculate actual hash
|
||||||
actual_hash = get_file_sha256(file_path)
|
actual_hash = get_file_sha256(file_path)
|
||||||
|
|
||||||
# Compare hashes
|
# Compare hashes
|
||||||
if actual_hash.lower() == expected_hash.lower():
|
if actual_hash.lower() == expected_hash.lower():
|
||||||
logger.info("SHA-256 verification successful!")
|
logger.info("SHA-256 verification successful!")
|
||||||
@@ -75,20 +83,23 @@ def verify_sha256(file_path, expected_hash_url):
|
|||||||
logger.info(f"⚠️ SHA-256 verification error: {str(e)}")
|
logger.info(f"⚠️ SHA-256 verification error: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_ffmpeg_install_path():
|
|
||||||
"""Get the FFmpeg installation path."""
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
return os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg', 'ffmpeg-7.1.1-full_build', 'bin')
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
paths = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin']
|
|
||||||
for path in paths:
|
|
||||||
if os.path.exists(os.path.join(path, 'ffmpeg')):
|
|
||||||
return path
|
|
||||||
return '/usr/local/bin' # Default Homebrew path
|
|
||||||
else:
|
|
||||||
return '/usr/bin' # Standard Linux path
|
|
||||||
|
|
||||||
def get_ffmpeg_path():
|
def get_ffmpeg_install_path() -> Path:
|
||||||
|
"""Get the FFmpeg installation path."""
|
||||||
|
if OS_NAME == "Windows":
|
||||||
|
return Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" / "ffmpeg-7.1.1-full_build" / "bin" # type: ignore
|
||||||
|
|
||||||
|
elif OS_NAME == "Darwin":
|
||||||
|
paths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]
|
||||||
|
for path in paths:
|
||||||
|
if Path(path).joinpath("ffmpeg").exists():
|
||||||
|
return Path(path)
|
||||||
|
return Path("/usr/local/bin") # Default Homebrew path
|
||||||
|
else:
|
||||||
|
return Path("/usr/bin") # Standard Linux path
|
||||||
|
|
||||||
|
|
||||||
|
def get_ffmpeg_path() -> str | Path:
|
||||||
"""
|
"""
|
||||||
Get the FFmpeg executable path, either from PATH or installation directory.
|
Get the FFmpeg executable path, either from PATH or installation directory.
|
||||||
Returns:
|
Returns:
|
||||||
@@ -96,150 +107,159 @@ def get_ffmpeg_path():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# First try to find ffmpeg in PATH using 'where' on Windows or 'which' on Unix
|
# First try to find ffmpeg in PATH using 'where' on Windows or 'which' on Unix
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
# On Windows, use 'where' command and hide console window
|
# On Windows, use 'where' command and hide console window
|
||||||
startupinfo = None
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
if hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['where', 'ffmpeg'],
|
["where", "ffmpeg"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
startupinfo=startupinfo
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
ffmpeg_path = result.stdout.strip().split('\n')[0]
|
ffmpeg_path = result.stdout.strip().split("\n")[0]
|
||||||
return ffmpeg_path
|
return ffmpeg_path
|
||||||
else:
|
else:
|
||||||
# On Unix systems, use 'which' command
|
# On Unix systems, use 'which' command
|
||||||
result = subprocess.run(['which', 'ffmpeg'], capture_output=True, text=True, check=False)
|
result = subprocess.run(["which", "ffmpeg"], capture_output=True, text=True, check=False)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
ffmpeg_path = result.stdout.strip()
|
ffmpeg_path = result.stdout.strip()
|
||||||
return ffmpeg_path
|
return ffmpeg_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error finding ffmpeg in PATH: {e}")
|
logger.error(f"Error finding ffmpeg in PATH: {e}")
|
||||||
|
|
||||||
# If not found in PATH, check the installation directory
|
# If not found in PATH, check the installation directory
|
||||||
ffmpeg_install_path = get_ffmpeg_install_path()
|
ffmpeg_install_path = get_ffmpeg_install_path()
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_install_path, 'ffmpeg.exe')
|
ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg.exe")
|
||||||
else:
|
else:
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_install_path, 'ffmpeg')
|
ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg")
|
||||||
|
|
||||||
if os.path.exists(ffmpeg_exe):
|
if ffmpeg_exe.exists():
|
||||||
return ffmpeg_exe
|
return ffmpeg_exe
|
||||||
|
|
||||||
# Return command name as fallback
|
# Return command name as fallback
|
||||||
return "ffmpeg"
|
return "ffmpeg"
|
||||||
|
|
||||||
def check_ffmpeg_installed():
|
|
||||||
|
def check_ffmpeg_installed() -> bool:
|
||||||
"""Check if FFmpeg is installed and accessible."""
|
"""Check if FFmpeg is installed and accessible."""
|
||||||
try:
|
try:
|
||||||
# First try the PATH
|
# First try the PATH
|
||||||
result = subprocess.run(['ffmpeg', '-version'],
|
result = subprocess.run(
|
||||||
stdout=subprocess.PIPE,
|
["ffmpeg", "-version"],
|
||||||
stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
check=True,
|
stderr=subprocess.PIPE,
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0,
|
check=True,
|
||||||
timeout=5) # Added timeout
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
|
timeout=5,
|
||||||
|
) # Added timeout
|
||||||
return True
|
return True
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
# If not in PATH, check the installation directory
|
# If not in PATH, check the installation directory
|
||||||
ffmpeg_path = get_ffmpeg_install_path()
|
ffmpeg_path = get_ffmpeg_install_path()
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe')
|
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
|
||||||
else:
|
else:
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg')
|
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg")
|
||||||
|
|
||||||
if os.path.exists(ffmpeg_exe):
|
if ffmpeg_exe.exists():
|
||||||
# Add to PATH if found
|
# Add to PATH if found
|
||||||
os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"FFmpeg check error: {str(e)}")
|
logger.info(f"FFmpeg check error: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def install_ffmpeg_windows():
|
|
||||||
|
def install_ffmpeg_windows() -> bool:
|
||||||
"""Install FFmpeg on Windows using 7z method primarily, with zip as fallback."""
|
"""Install FFmpeg on Windows using 7z method primarily, with zip as fallback."""
|
||||||
ffmpeg_path = get_ffmpeg_install_path()
|
ffmpeg_path = get_ffmpeg_install_path()
|
||||||
|
|
||||||
# Check if already installed
|
# Check if already installed
|
||||||
if check_ffmpeg_installed():
|
if check_ffmpeg_installed():
|
||||||
logger.info("FFmpeg is already installed!")
|
logger.info("FFmpeg is already installed!")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Define variables - prioritize 7z version
|
# Define variables - prioritize 7z version
|
||||||
ffmpeg_7z_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z"
|
# ffmpeg variables moved to src\utils\ytsage_constants.py
|
||||||
ffmpeg_zip_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip"
|
extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
|
||||||
sha256_url = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build.7z.sha256"
|
full_build_dir = extract_dir / "ffmpeg-7.1.1-full_build"
|
||||||
extract_dir = os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg')
|
bin_dir = full_build_dir / "bin"
|
||||||
full_build_dir = os.path.join(extract_dir, 'ffmpeg-7.1.1-full_build')
|
|
||||||
bin_dir = os.path.join(full_build_dir, 'bin')
|
|
||||||
|
|
||||||
# Create extraction directory if it doesn't exist
|
# Create extraction directory if it doesn't exist
|
||||||
os.makedirs(extract_dir, exist_ok=True)
|
extract_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Try 7z method first (smaller size)
|
# Try 7z method first (smaller size)
|
||||||
use_7zip = check_7zip_installed()
|
use_7zip = check_7zip_installed()
|
||||||
if use_7zip:
|
if use_7zip:
|
||||||
logger.info("Using 7-Zip method (smaller download size)...")
|
logger.info("Using 7-Zip method (smaller download size)...")
|
||||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.7z').name
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".7z").name
|
||||||
|
|
||||||
# Download 7z file
|
# Download 7z file
|
||||||
if not download_file(ffmpeg_7z_url, temp_file,
|
if not download_file(
|
||||||
progress_callback=lambda msg: logger.debug(msg)):
|
FFMPEG_7Z_DOWNLOAD_URL,
|
||||||
|
temp_file,
|
||||||
|
progress_callback=lambda msg: logger.debug(msg),
|
||||||
|
):
|
||||||
logger.error("Failed to download 7z file, trying zip fallback...")
|
logger.error("Failed to download 7z file, trying zip fallback...")
|
||||||
use_7zip = False
|
use_7zip = False
|
||||||
else:
|
else:
|
||||||
# Verify SHA-256 hash for 7z file
|
# Verify SHA-256 hash for 7z file
|
||||||
if verify_sha256(temp_file, sha256_url):
|
if verify_sha256(temp_file, FFMPEG_7Z_SHA256_URL):
|
||||||
logger.info("Extracting FFmpeg components from 7z archive...")
|
logger.info("Extracting FFmpeg components from 7z archive...")
|
||||||
try:
|
try:
|
||||||
subprocess.run(['7z', 'x', temp_file, f'-o{extract_dir}', '-y'],
|
subprocess.run(
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0,
|
["7z", "x", temp_file, f"-o{extract_dir}", "-y"],
|
||||||
timeout=300) # 5-minute timeout
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
|
timeout=300,
|
||||||
|
) # 5-minute timeout
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...")
|
logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...")
|
||||||
use_7zip = False
|
use_7zip = False
|
||||||
else:
|
else:
|
||||||
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
|
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
|
||||||
use_7zip = False
|
use_7zip = False
|
||||||
|
|
||||||
# Fallback to zip method if 7z failed or not available
|
# Fallback to zip method if 7z failed or not available
|
||||||
if not use_7zip:
|
if not use_7zip:
|
||||||
logger.info("Using ZIP method as fallback...")
|
logger.info("Using ZIP method as fallback...")
|
||||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip').name
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip").name
|
||||||
|
|
||||||
# Download zip file
|
# Download zip file
|
||||||
if not download_file(ffmpeg_zip_url, temp_file,
|
if not download_file(
|
||||||
progress_callback=lambda msg: logger.debug(msg)):
|
FFMPEG_ZIP_DOWNLOAD_URL,
|
||||||
|
temp_file,
|
||||||
|
progress_callback=lambda msg: logger.debug(msg),
|
||||||
|
):
|
||||||
raise Exception("Failed to download FFmpeg (both 7z and zip methods failed)")
|
raise Exception("Failed to download FFmpeg (both 7z and zip methods failed)")
|
||||||
|
|
||||||
logger.info("Extracting FFmpeg components from zip archive...")
|
logger.info("Extracting FFmpeg components from zip archive...")
|
||||||
try:
|
try:
|
||||||
import zipfile
|
import zipfile
|
||||||
with zipfile.ZipFile(temp_file, 'r') as zip_ref:
|
|
||||||
|
with zipfile.ZipFile(temp_file, "r") as zip_ref:
|
||||||
zip_ref.extractall(extract_dir)
|
zip_ref.extractall(extract_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Extraction failed: {str(e)}")
|
raise Exception(f"Extraction failed: {str(e)}")
|
||||||
|
|
||||||
logger.info("Configuring system paths...")
|
logger.info("Configuring system paths...")
|
||||||
# Add to System Path
|
# Add to System Path
|
||||||
user_path = os.environ.get('PATH', '')
|
user_path = os.environ.get("PATH", "")
|
||||||
if bin_dir not in user_path:
|
if str(bin_dir) not in user_path.split(os.pathsep):
|
||||||
subprocess.run(['setx', 'PATH', f"{user_path};{bin_dir}"],
|
subprocess.run(
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
["setx", "PATH", f"{user_path};{bin_dir}"],
|
||||||
os.environ['PATH'] = f"{user_path};{bin_dir}"
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
|
)
|
||||||
|
os.environ["PATH"] = f"{user_path};{bin_dir}"
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
try:
|
try:
|
||||||
os.unlink(temp_file)
|
Path(temp_file).unlink(missing_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Ignore cleanup errors
|
pass # Ignore cleanup errors
|
||||||
|
|
||||||
@@ -254,16 +274,19 @@ def install_ffmpeg_windows():
|
|||||||
logger.error(f"Error installing FFmpeg: {str(e)}")
|
logger.error(f"Error installing FFmpeg: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def install_ffmpeg_macos():
|
|
||||||
|
def install_ffmpeg_macos() -> bool:
|
||||||
"""Install FFmpeg on macOS using Homebrew."""
|
"""Install FFmpeg on macOS using Homebrew."""
|
||||||
try:
|
try:
|
||||||
# Check if Homebrew is installed
|
# Check if Homebrew is installed
|
||||||
try:
|
try:
|
||||||
subprocess.run(['brew', '--version'],
|
subprocess.run(
|
||||||
stdout=subprocess.PIPE,
|
["brew", "--version"],
|
||||||
stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
check=True,
|
stderr=subprocess.PIPE,
|
||||||
timeout=5)
|
check=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
logger.info("Installing Homebrew...")
|
logger.info("Installing Homebrew...")
|
||||||
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||||
@@ -271,56 +294,62 @@ def install_ffmpeg_macos():
|
|||||||
|
|
||||||
# Install FFmpeg
|
# Install FFmpeg
|
||||||
logger.info("Installing FFmpeg...")
|
logger.info("Installing FFmpeg...")
|
||||||
subprocess.run(['brew', 'install', 'ffmpeg'], check=True, timeout=300)
|
subprocess.run(["brew", "install", "ffmpeg"], check=True, timeout=300)
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
if not check_ffmpeg_installed():
|
if not check_ffmpeg_installed():
|
||||||
raise Exception("FFmpeg installation verification failed")
|
raise Exception("FFmpeg installation verification failed")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error installing FFmpeg: {str(e)}")
|
logger.error(f"Error installing FFmpeg: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def install_ffmpeg_linux():
|
|
||||||
|
def install_ffmpeg_linux() -> bool:
|
||||||
"""Install FFmpeg on Linux using appropriate package manager."""
|
"""Install FFmpeg on Linux using appropriate package manager."""
|
||||||
try:
|
try:
|
||||||
# Detect the package manager
|
# Detect the package manager
|
||||||
if shutil.which('apt'):
|
if shutil.which("apt"):
|
||||||
# Debian/Ubuntu
|
# Debian/Ubuntu
|
||||||
subprocess.run(['sudo', 'apt', 'update'], check=True, timeout=60)
|
subprocess.run(["sudo", "apt", "update"], check=True, timeout=60)
|
||||||
subprocess.run(['sudo', 'apt', 'install', '-y', 'ffmpeg'], check=True, timeout=300)
|
subprocess.run(["sudo", "apt", "install", "-y", "ffmpeg"], check=True, timeout=300)
|
||||||
elif shutil.which('dnf'):
|
elif shutil.which("dnf"):
|
||||||
# Fedora
|
# Fedora
|
||||||
subprocess.run(['sudo', 'dnf', 'install', '-y', 'ffmpeg'], check=True, timeout=300)
|
subprocess.run(["sudo", "dnf", "install", "-y", "ffmpeg"], check=True, timeout=300)
|
||||||
elif shutil.which('pacman'):
|
elif shutil.which("pacman"):
|
||||||
# Arch Linux
|
# Arch Linux
|
||||||
subprocess.run(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg'], check=True, timeout=300)
|
subprocess.run(
|
||||||
elif shutil.which('snap'):
|
["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"],
|
||||||
|
check=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
elif shutil.which("snap"):
|
||||||
# Universal snap package
|
# Universal snap package
|
||||||
subprocess.run(['sudo', 'snap', 'install', 'ffmpeg'], check=True, timeout=300)
|
subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300)
|
||||||
else:
|
else:
|
||||||
raise Exception("No supported package manager found")
|
raise Exception("No supported package manager found")
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
if not check_ffmpeg_installed():
|
if not check_ffmpeg_installed():
|
||||||
raise Exception("FFmpeg installation verification failed")
|
raise Exception("FFmpeg installation verification failed")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error installing FFmpeg: {str(e)}")
|
logger.error(f"Error installing FFmpeg: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def auto_install_ffmpeg():
|
|
||||||
|
def auto_install_ffmpeg() -> bool:
|
||||||
"""Automatically install FFmpeg based on the operating system."""
|
"""Automatically install FFmpeg based on the operating system."""
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
return install_ffmpeg_windows()
|
return install_ffmpeg_windows()
|
||||||
elif sys.platform == 'darwin':
|
elif OS_NAME == "Darwin":
|
||||||
return install_ffmpeg_macos()
|
return install_ffmpeg_macos()
|
||||||
elif sys.platform.startswith('linux'):
|
elif OS_NAME == "Linux":
|
||||||
return install_ffmpeg_linux()
|
return install_ffmpeg_linux()
|
||||||
else:
|
else:
|
||||||
logger.info(f"Unsupported operating system: {sys.platform}")
|
logger.info(f"Unsupported operating system: {OS_NAME}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
+63
-49
@@ -5,71 +5,82 @@ This module provides centralized logging configuration for the entire YTSage app
|
|||||||
It replaces the inefficient print statements with structured logging using loguru.
|
It replaces the inefficient print statements with structured logging using loguru.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.utils.ytsage_constants import APP_LOG_DIR
|
||||||
|
|
||||||
# Try to import loguru, but handle case where it might not be available
|
# Try to import loguru, but handle case where it might not be available
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
LOGURU_AVAILABLE = True
|
LOGURU_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
LOGURU_AVAILABLE = False
|
LOGURU_AVAILABLE = False
|
||||||
|
|
||||||
# Create a dummy logger class that does nothing
|
# Create a dummy logger class that does nothing
|
||||||
class DummyLogger:
|
class DummyLogger:
|
||||||
def info(self, *args, **kwargs): pass
|
def info(self, *args, **kwargs):
|
||||||
def debug(self, *args, **kwargs): pass
|
pass
|
||||||
def warning(self, *args, **kwargs): pass
|
|
||||||
def error(self, *args, **kwargs): pass
|
def debug(self, *args, **kwargs):
|
||||||
def critical(self, *args, **kwargs): pass
|
pass
|
||||||
def remove(self, *args, **kwargs): pass
|
|
||||||
def add(self, *args, **kwargs): pass
|
def warning(self, *args, **kwargs):
|
||||||
def bind(self, *args, **kwargs): return self
|
pass
|
||||||
|
|
||||||
|
def error(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def critical(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def remove(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def bind(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _core(self):
|
def _core(self):
|
||||||
class Core:
|
class Core:
|
||||||
handlers = []
|
handlers = []
|
||||||
|
|
||||||
return Core()
|
return Core()
|
||||||
|
|
||||||
logger = DummyLogger()
|
logger = DummyLogger()
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
"""
|
"""
|
||||||
Configure loguru logging for YTSage application.
|
Configure loguru logging for YTSage application.
|
||||||
|
|
||||||
Sets up multiple log levels and outputs:
|
Sets up multiple log levels and outputs:
|
||||||
- Console output for INFO and above
|
- Console output for INFO and above
|
||||||
- File output for DEBUG and above
|
- File output for DEBUG and above
|
||||||
- Separate error log file for ERROR and above
|
- Separate error log file for ERROR and above
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not LOGURU_AVAILABLE:
|
if not LOGURU_AVAILABLE:
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
# Remove default logger to avoid duplicate output
|
# Remove default logger to avoid duplicate output
|
||||||
try:
|
try:
|
||||||
logger.remove()
|
logger.remove()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get the application data directory with fallbacks
|
# Get the application data directory with fallbacks
|
||||||
try:
|
try:
|
||||||
if sys.platform == 'win32':
|
# logic moved to src\utils\ytsage_constants.py
|
||||||
localappdata = os.environ.get('LOCALAPPDATA')
|
log_dir = APP_LOG_DIR
|
||||||
if localappdata:
|
|
||||||
log_dir = Path(localappdata) / 'YTSage' / 'logs'
|
|
||||||
else:
|
|
||||||
# Fallback for PyInstaller or when LOCALAPPDATA is not set
|
|
||||||
log_dir = Path.home() / 'AppData' / 'Local' / 'YTSage' / 'logs'
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
log_dir = Path.home() / 'Library' / 'Application Support' / 'YTSage' / 'logs'
|
|
||||||
else:
|
|
||||||
log_dir = Path.home() / '.local' / 'share' / 'YTSage' / 'logs'
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Ultimate fallback - use current directory
|
# Ultimate fallback - use current directory
|
||||||
log_dir = Path.cwd() / 'logs'
|
log_dir = Path.cwd() / "logs"
|
||||||
|
|
||||||
# Create log directory if it doesn't exist
|
# Create log directory if it doesn't exist
|
||||||
try:
|
try:
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -80,11 +91,11 @@ def setup_logging():
|
|||||||
log_dir.mkdir(exist_ok=True)
|
log_dir.mkdir(exist_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # If we still can't create it, we'll just log to console
|
pass # If we still can't create it, we'll just log to console
|
||||||
|
|
||||||
# Console handler - INFO and above, with colors
|
# Console handler - INFO and above, with colors
|
||||||
# Check if stdout is available (it might be None in PyInstaller windowed apps)
|
# Check if stdout is available (it might be None in PyInstaller windowed apps)
|
||||||
stdout_available = sys.stdout is not None
|
stdout_available = sys.stdout is not None
|
||||||
|
|
||||||
if stdout_available:
|
if stdout_available:
|
||||||
try:
|
try:
|
||||||
logger.add(
|
logger.add(
|
||||||
@@ -92,7 +103,7 @@ def setup_logging():
|
|||||||
level="INFO",
|
level="INFO",
|
||||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||||
colorize=True,
|
colorize=True,
|
||||||
catch=True
|
catch=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to basic console logging without colors
|
# Fallback to basic console logging without colors
|
||||||
@@ -101,11 +112,11 @@ def setup_logging():
|
|||||||
sys.stdout,
|
sys.stdout,
|
||||||
level="INFO",
|
level="INFO",
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
|
||||||
catch=True
|
catch=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
stdout_available = False
|
stdout_available = False
|
||||||
|
|
||||||
# If stdout is not available, try stderr or skip console logging entirely
|
# If stdout is not available, try stderr or skip console logging entirely
|
||||||
if not stdout_available:
|
if not stdout_available:
|
||||||
try:
|
try:
|
||||||
@@ -114,26 +125,26 @@ def setup_logging():
|
|||||||
sys.stderr,
|
sys.stderr,
|
||||||
level="WARNING", # Only warnings and errors to stderr
|
level="WARNING", # Only warnings and errors to stderr
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
|
||||||
catch=True
|
catch=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If even stderr fails, we'll rely only on file logging
|
# If even stderr fails, we'll rely only on file logging
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Only add file handlers if we successfully created a log directory
|
# Only add file handlers if we successfully created a log directory
|
||||||
if log_dir and log_dir.exists():
|
if log_dir and log_dir.exists():
|
||||||
try:
|
try:
|
||||||
# Main log file - DEBUG and above, with rotation
|
# Main log file - DEBUG and above, with rotation
|
||||||
logger.add(
|
logger.add(
|
||||||
log_dir / "ytsage.log",
|
log_dir / "ytsage.log",
|
||||||
level="DEBUG",
|
level="DEBUG",
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||||
rotation="10 MB", # Rotate when file reaches 10MB
|
rotation="10 MB", # Rotate when file reaches 10MB
|
||||||
retention="7 days", # Keep logs for 7 days
|
retention="7 days", # Keep logs for 7 days
|
||||||
compression="zip", # Compress old logs
|
compression="zip", # Compress old logs
|
||||||
catch=True
|
catch=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Error log file - ERROR and above only
|
# Error log file - ERROR and above only
|
||||||
logger.add(
|
logger.add(
|
||||||
log_dir / "ytsage_errors.log",
|
log_dir / "ytsage_errors.log",
|
||||||
@@ -142,12 +153,12 @@ def setup_logging():
|
|||||||
rotation="5 MB",
|
rotation="5 MB",
|
||||||
retention="30 days", # Keep error logs longer
|
retention="30 days", # Keep error logs longer
|
||||||
compression="zip",
|
compression="zip",
|
||||||
catch=True
|
catch=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If file logging fails, just log to console
|
# If file logging fails, just log to console
|
||||||
logger.warning(f"Could not set up file logging: {e}")
|
logger.warning(f"Could not set up file logging: {e}")
|
||||||
|
|
||||||
# Log startup message if we have any handlers
|
# Log startup message if we have any handlers
|
||||||
if logger._core.handlers:
|
if logger._core.handlers:
|
||||||
logger.info("YTSage logging system initialized")
|
logger.info("YTSage logging system initialized")
|
||||||
@@ -155,12 +166,13 @@ def setup_logging():
|
|||||||
logger.debug(f"Log directory: {log_dir}")
|
logger.debug(f"Log directory: {log_dir}")
|
||||||
else:
|
else:
|
||||||
logger.warning("File logging disabled - could not create log directory")
|
logger.warning("File logging disabled - could not create log directory")
|
||||||
|
|
||||||
# If no handlers were successfully added, add a null handler to prevent errors
|
# If no handlers were successfully added, add a null handler to prevent errors
|
||||||
if not logger._core.handlers:
|
if not logger._core.handlers:
|
||||||
# Add a minimal handler that just discards messages
|
# Add a minimal handler that just discards messages
|
||||||
# This prevents loguru from complaining about no handlers
|
# This prevents loguru from complaining about no handlers
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to add a temporary file handler as last resort
|
# Try to add a temporary file handler as last resort
|
||||||
temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log"
|
temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log"
|
||||||
@@ -169,17 +181,17 @@ def setup_logging():
|
|||||||
# If even that fails, we're in a very restricted environment
|
# If even that fails, we're in a very restricted environment
|
||||||
# loguru should handle this gracefully with its internal fallbacks
|
# loguru should handle this gracefully with its internal fallbacks
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str = None):
|
def get_logger(name: str | None = None):
|
||||||
"""
|
"""
|
||||||
Get a logger instance for a specific module.
|
Get a logger instance for a specific module.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Name of the module/component requesting the logger
|
name: Name of the module/component requesting the logger
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured logger instance
|
Configured logger instance
|
||||||
"""
|
"""
|
||||||
@@ -191,12 +203,13 @@ def get_logger(name: str = None):
|
|||||||
# Initialize logging when module is imported - with maximum safety
|
# Initialize logging when module is imported - with maximum safety
|
||||||
_setup_complete = False
|
_setup_complete = False
|
||||||
|
|
||||||
|
|
||||||
def safe_setup():
|
def safe_setup():
|
||||||
"""Safely initialize logging with multiple fallback strategies."""
|
"""Safely initialize logging with multiple fallback strategies."""
|
||||||
global _setup_complete
|
global _setup_complete
|
||||||
if _setup_complete:
|
if _setup_complete:
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
setup_logging()
|
setup_logging()
|
||||||
_setup_complete = True
|
_setup_complete = True
|
||||||
@@ -207,12 +220,13 @@ def safe_setup():
|
|||||||
logger.remove()
|
logger.remove()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# At this point, just ensure we have something that won't crash
|
# At this point, just ensure we have something that won't crash
|
||||||
_setup_complete = True
|
_setup_complete = True
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
# Try to set up logging, but don't let it crash the module import
|
# Try to set up logging, but don't let it crash the module import
|
||||||
try:
|
try:
|
||||||
safe_setup()
|
safe_setup()
|
||||||
@@ -221,4 +235,4 @@ except Exception:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Export the main logger for convenience
|
# Export the main logger for convenience
|
||||||
__all__ = ['logger', 'get_logger', 'setup_logging']
|
__all__ = ["logger", "get_logger", "setup_logging"]
|
||||||
|
|||||||
@@ -149,4 +149,4 @@ QMessageBox QLabel {
|
|||||||
QMessageBox QPushButton {
|
QMessageBox QPushButton {
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
+228
-258
@@ -1,191 +1,201 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
from packaging import version
|
|
||||||
import requests
|
import requests
|
||||||
from .ytsage_logging import logger
|
from packaging import version
|
||||||
from .ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path, get_ffmpeg_path
|
|
||||||
from .ytsage_yt_dlp import get_yt_dlp_path # Import the new function to avoid import errors
|
from src.core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
|
from src.utils.ytsage_constants import (
|
||||||
|
APP_CONFIG_FILE,
|
||||||
|
OS_NAME,
|
||||||
|
SUBPROCESS_CREATIONFLAGS,
|
||||||
|
USER_HOME_DIR,
|
||||||
|
YTDLP_APP_BIN_PATH,
|
||||||
|
YTDLP_DOWNLOAD_URL,
|
||||||
|
)
|
||||||
|
|
||||||
# Cache for version information to avoid delays
|
# Cache for version information to avoid delays
|
||||||
_version_cache = {
|
_version_cache = {
|
||||||
'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0},
|
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
'ffmpeg': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0}
|
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache expiry time in seconds (5 minutes)
|
# Cache expiry time in seconds (5 minutes)
|
||||||
CACHE_EXPIRY = 300
|
CACHE_EXPIRY = 300
|
||||||
|
|
||||||
def get_file_mtime(filepath):
|
|
||||||
|
def get_file_mtime(filepath) -> float:
|
||||||
"""Get file modification time safely."""
|
"""Get file modification time safely."""
|
||||||
try:
|
try:
|
||||||
if filepath and os.path.exists(filepath):
|
if filepath and Path(filepath).exists():
|
||||||
return os.path.getmtime(filepath)
|
return Path(filepath).stat().st_mtime
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def should_refresh_cache(tool_name, current_path):
|
|
||||||
|
def should_refresh_cache(tool_name, current_path) -> bool:
|
||||||
"""Determine if cache should be refreshed for a tool."""
|
"""Determine if cache should be refreshed for a tool."""
|
||||||
cache = _version_cache.get(tool_name, {})
|
cache = _version_cache.get(tool_name, {})
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Always refresh if no cached data
|
# Always refresh if no cached data
|
||||||
if not cache.get('version'):
|
if not cache.get("version"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Refresh if path changed
|
# Refresh if path changed
|
||||||
if cache.get('path') != current_path:
|
if cache.get("path") != current_path:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Refresh if file was modified
|
# Refresh if file was modified
|
||||||
current_mtime = get_file_mtime(current_path)
|
current_mtime = get_file_mtime(current_path)
|
||||||
if current_mtime > cache.get('path_mtime', 0):
|
if current_mtime > cache.get("path_mtime", 0):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Refresh if cache expired
|
# Refresh if cache expired
|
||||||
if current_time - cache.get('last_check', 0) > CACHE_EXPIRY:
|
if current_time - cache.get("last_check", 0) > CACHE_EXPIRY:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_version_cache(tool_name, version_info, path, force_save=False):
|
|
||||||
|
def update_version_cache(tool_name, version_info, path, force_save=False) -> None:
|
||||||
"""Update the version cache and optionally save to config."""
|
"""Update the version cache and optionally save to config."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
current_mtime = get_file_mtime(path)
|
current_mtime = get_file_mtime(path)
|
||||||
|
|
||||||
_version_cache[tool_name] = {
|
_version_cache[tool_name] = {
|
||||||
'version': version_info,
|
"version": version_info,
|
||||||
'path': path,
|
"path": path,
|
||||||
'last_check': current_time,
|
"last_check": current_time,
|
||||||
'path_mtime': current_mtime
|
"path_mtime": current_mtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save to persistent config
|
# Save to persistent config
|
||||||
if force_save:
|
if force_save:
|
||||||
save_version_cache_to_config()
|
save_version_cache_to_config()
|
||||||
|
|
||||||
def load_version_cache_from_config():
|
|
||||||
|
def load_version_cache_from_config() -> None:
|
||||||
"""Load cached version info from config file."""
|
"""Load cached version info from config file."""
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
cached_versions = config.get('cached_versions', {})
|
cached_versions = config.get("cached_versions", {})
|
||||||
|
|
||||||
for tool_name, cache_data in cached_versions.items():
|
for tool_name, cache_data in cached_versions.items():
|
||||||
if tool_name in _version_cache:
|
if tool_name in _version_cache:
|
||||||
_version_cache[tool_name].update(cache_data)
|
_version_cache[tool_name].update(cache_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading version cache: {e}")
|
logger.error(f"Error loading version cache: {e}")
|
||||||
|
|
||||||
def save_version_cache_to_config():
|
|
||||||
|
def save_version_cache_to_config() -> None:
|
||||||
"""Save version cache to config file."""
|
"""Save version cache to config file."""
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['cached_versions'] = _version_cache.copy()
|
config["cached_versions"] = _version_cache.copy()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving version cache: {e}")
|
logger.error(f"Error saving version cache: {e}")
|
||||||
|
|
||||||
def get_ytdlp_version_cached():
|
|
||||||
|
def get_ytdlp_version_cached() -> str:
|
||||||
"""Get yt-dlp version with caching support."""
|
"""Get yt-dlp version with caching support."""
|
||||||
try:
|
try:
|
||||||
current_path = get_yt_dlp_path()
|
current_path = get_yt_dlp_path()
|
||||||
|
|
||||||
# Check if we need to refresh cache
|
# Check if we need to refresh cache
|
||||||
if not should_refresh_cache('ytdlp', current_path):
|
if not should_refresh_cache("ytdlp", current_path):
|
||||||
cached_version = _version_cache['ytdlp'].get('version')
|
cached_version = _version_cache["ytdlp"].get("version")
|
||||||
if cached_version:
|
if cached_version:
|
||||||
return cached_version
|
return cached_version
|
||||||
|
|
||||||
# Get fresh version info
|
# Get fresh version info
|
||||||
version_info = get_ytdlp_version_direct(current_path)
|
version_info = get_ytdlp_version_direct(current_path)
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
update_version_cache('ytdlp', version_info, current_path)
|
update_version_cache("ytdlp", version_info, current_path)
|
||||||
|
|
||||||
return version_info
|
return version_info
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting cached yt-dlp version: {e}")
|
logger.error(f"Error getting cached yt-dlp version: {e}")
|
||||||
return "Error getting version"
|
return "Error getting version"
|
||||||
|
|
||||||
def get_ffmpeg_version_cached():
|
|
||||||
|
def get_ffmpeg_version_cached() -> str:
|
||||||
"""Get FFmpeg version with caching support."""
|
"""Get FFmpeg version with caching support."""
|
||||||
try:
|
try:
|
||||||
# Try to find ffmpeg path
|
# Try to find ffmpeg path
|
||||||
current_path = "ffmpeg" # Default to system PATH
|
current_path = "ffmpeg" # Default to system PATH
|
||||||
|
|
||||||
# Check if we need to refresh cache
|
# Check if we need to refresh cache
|
||||||
if not should_refresh_cache('ffmpeg', current_path):
|
if not should_refresh_cache("ffmpeg", current_path):
|
||||||
cached_version = _version_cache['ffmpeg'].get('version')
|
cached_version = _version_cache["ffmpeg"].get("version")
|
||||||
if cached_version:
|
if cached_version:
|
||||||
return cached_version
|
return cached_version
|
||||||
|
|
||||||
# Get fresh version info
|
# Get fresh version info
|
||||||
version_info = get_ffmpeg_version_direct()
|
version_info = get_ffmpeg_version_direct()
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
update_version_cache('ffmpeg', version_info, current_path)
|
update_version_cache("ffmpeg", version_info, current_path)
|
||||||
|
|
||||||
return version_info
|
return version_info
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting cached FFmpeg version: {e}")
|
logger.error(f"Error getting cached FFmpeg version: {e}")
|
||||||
return "Error getting version"
|
return "Error getting version"
|
||||||
|
|
||||||
def refresh_version_cache(force=False):
|
|
||||||
|
def refresh_version_cache(force=False) -> bool:
|
||||||
"""Manually refresh version cache for both tools."""
|
"""Manually refresh version cache for both tools."""
|
||||||
try:
|
try:
|
||||||
# Refresh yt-dlp
|
# Refresh yt-dlp
|
||||||
current_path = get_yt_dlp_path()
|
current_path = get_yt_dlp_path()
|
||||||
version_info = get_ytdlp_version_direct(current_path)
|
version_info = get_ytdlp_version_direct(current_path)
|
||||||
update_version_cache('ytdlp', version_info, current_path, force_save=True)
|
update_version_cache("ytdlp", version_info, current_path, force_save=True)
|
||||||
|
|
||||||
# Refresh FFmpeg
|
# Refresh FFmpeg
|
||||||
version_info = get_ffmpeg_version_direct()
|
version_info = get_ffmpeg_version_direct()
|
||||||
update_version_cache('ffmpeg', version_info, "ffmpeg", force_save=True)
|
update_version_cache("ffmpeg", version_info, "ffmpeg", force_save=True)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error refreshing version cache: {e}")
|
logger.error(f"Error refreshing version cache: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_ytdlp_version():
|
|
||||||
|
def get_ytdlp_version() -> str:
|
||||||
"""Get the version of yt-dlp (uses cached version for performance)."""
|
"""Get the version of yt-dlp (uses cached version for performance)."""
|
||||||
return get_ytdlp_version_cached()
|
return get_ytdlp_version_cached()
|
||||||
|
|
||||||
def get_ffmpeg_version():
|
|
||||||
|
def get_ffmpeg_version() -> str:
|
||||||
"""Get the version of FFmpeg (uses cached version for performance)."""
|
"""Get the version of FFmpeg (uses cached version for performance)."""
|
||||||
return get_ffmpeg_version_cached()
|
return get_ffmpeg_version_cached()
|
||||||
|
|
||||||
def get_ytdlp_version_direct(yt_dlp_path=None):
|
|
||||||
|
def get_ytdlp_version_direct(yt_dlp_path=None) -> str:
|
||||||
"""Get yt-dlp version directly without caching."""
|
"""Get yt-dlp version directly without caching."""
|
||||||
try:
|
try:
|
||||||
if yt_dlp_path is None:
|
if yt_dlp_path is None:
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
|
|
||||||
if not yt_dlp_path or yt_dlp_path == "yt-dlp":
|
if not yt_dlp_path or yt_dlp_path == "yt-dlp":
|
||||||
return "Not found"
|
return "Not found"
|
||||||
|
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[yt_dlp_path, '--version'],
|
[yt_dlp_path, "--version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
else:
|
else:
|
||||||
@@ -194,34 +204,25 @@ def get_ytdlp_version_direct(yt_dlp_path=None):
|
|||||||
logger.error(f"Error getting yt-dlp version: {e}")
|
logger.error(f"Error getting yt-dlp version: {e}")
|
||||||
return "Error getting version"
|
return "Error getting version"
|
||||||
|
|
||||||
def get_ffmpeg_version_direct():
|
|
||||||
|
def get_ffmpeg_version_direct() -> str:
|
||||||
"""Get FFmpeg version directly without caching."""
|
"""Get FFmpeg version directly without caching."""
|
||||||
try:
|
try:
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ffmpeg', '-version'],
|
["ffmpeg", "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Parse the first line to get version info
|
# Parse the first line to get version info
|
||||||
lines = result.stdout.split('\n')
|
lines = result.stdout.split("\n")
|
||||||
if lines:
|
if lines:
|
||||||
first_line = lines[0]
|
first_line = lines[0]
|
||||||
# Extract version from something like "ffmpeg version 4.4.2 Copyright..."
|
# Extract version from something like "ffmpeg version 4.4.2 Copyright..."
|
||||||
if 'version' in first_line:
|
if "version" in first_line:
|
||||||
parts = first_line.split()
|
parts = first_line.split()
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
if part == 'version' and i + 1 < len(parts):
|
if part == "version" and i + 1 < len(parts):
|
||||||
return parts[i + 1]
|
return parts[i + 1]
|
||||||
return first_line.strip()
|
return first_line.strip()
|
||||||
return "Unknown version"
|
return "Unknown version"
|
||||||
@@ -231,28 +232,24 @@ def get_ffmpeg_version_direct():
|
|||||||
# If ffmpeg is not in PATH, try the installation directory
|
# If ffmpeg is not in PATH, try the installation directory
|
||||||
try:
|
try:
|
||||||
ffmpeg_path = get_ffmpeg_install_path()
|
ffmpeg_path = get_ffmpeg_install_path()
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe')
|
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
|
||||||
else:
|
else:
|
||||||
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg')
|
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg")
|
||||||
|
|
||||||
if os.path.exists(ffmpeg_exe):
|
if ffmpeg_exe.exists():
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[ffmpeg_exe, '-version'],
|
[ffmpeg_exe, "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
lines = result.stdout.split('\n')
|
lines = result.stdout.split("\n")
|
||||||
if lines:
|
if lines:
|
||||||
first_line = lines[0]
|
first_line = lines[0]
|
||||||
if 'version' in first_line:
|
if "version" in first_line:
|
||||||
parts = first_line.split()
|
parts = first_line.split()
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
if part == 'version' and i + 1 < len(parts):
|
if part == "version" and i + 1 < len(parts):
|
||||||
return parts[i + 1]
|
return parts[i + 1]
|
||||||
return first_line.strip()
|
return first_line.strip()
|
||||||
return "Unknown version"
|
return "Unknown version"
|
||||||
@@ -264,49 +261,32 @@ def get_ffmpeg_version_direct():
|
|||||||
logger.error(f"Error getting FFmpeg version: {e}")
|
logger.error(f"Error getting FFmpeg version: {e}")
|
||||||
return "Error getting version"
|
return "Error getting version"
|
||||||
|
|
||||||
def get_app_data_dir():
|
|
||||||
"""Get the OS-specific application data directory."""
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
# Windows: %LOCALAPPDATA%\YTSage\data\
|
|
||||||
return Path(os.environ.get('LOCALAPPDATA', '')) / 'YTSage' / 'data'
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
# macOS: ~/Library/Application Support/YTSage/data/
|
|
||||||
return Path.home() / 'Library' / 'Application Support' / 'YTSage' / 'data'
|
|
||||||
else:
|
|
||||||
# Linux: ~/.local/share/YTSage/data/
|
|
||||||
return Path.home() / '.local' / 'share' / 'YTSage' / 'data'
|
|
||||||
|
|
||||||
def get_config_file_path():
|
# get_app_data_dir() moved to src\utils\ytsage_constants.py
|
||||||
"""Get the path to the main configuration file."""
|
# get_config_file_path() moved to src\utils\ytsage_constants.py
|
||||||
return get_app_data_dir() / 'ytsage_config.json'
|
# ensure_app_data_dir() moved to src\utils\ytsage_constants.py
|
||||||
|
|
||||||
def ensure_app_data_dir():
|
|
||||||
"""Ensure the application data directory exists."""
|
|
||||||
data_dir = get_app_data_dir()
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return data_dir
|
|
||||||
|
|
||||||
def load_config():
|
def load_config() -> dict:
|
||||||
"""Load the application configuration from file."""
|
"""Load the application configuration from file."""
|
||||||
config_file = get_config_file_path()
|
|
||||||
default_config = {
|
default_config = {
|
||||||
'download_path': str(Path.home() / 'Downloads'),
|
"download_path": str(USER_HOME_DIR / "Downloads"),
|
||||||
'speed_limit_value': None,
|
"speed_limit_value": None,
|
||||||
'speed_limit_unit_index': 0,
|
"speed_limit_unit_index": 0,
|
||||||
'cookie_file_path': None,
|
"cookie_file_path": None,
|
||||||
'last_used_cookie_file': None,
|
"last_used_cookie_file": None,
|
||||||
'auto_update_ytdlp': True, # Enable auto-update by default
|
"auto_update_ytdlp": True, # Enable auto-update by default
|
||||||
'auto_update_frequency': 'daily', # daily, weekly, or startup
|
"auto_update_frequency": "daily", # daily, weekly, or startup
|
||||||
'last_update_check': 0, # timestamp of last check
|
"last_update_check": 0, # timestamp of last check
|
||||||
'cached_versions': {
|
"cached_versions": {
|
||||||
'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0},
|
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
'ffmpeg': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0}
|
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if config_file.exists():
|
if APP_CONFIG_FILE.exists():
|
||||||
with open(config_file, 'r', encoding='utf-8') as f:
|
with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
# Merge with defaults to ensure all keys exist
|
# Merge with defaults to ensure all keys exist
|
||||||
for key, value in default_config.items():
|
for key, value in default_config.items():
|
||||||
@@ -317,178 +297,160 @@ def load_config():
|
|||||||
logger.error(f"Error reading config file: {e}")
|
logger.error(f"Error reading config file: {e}")
|
||||||
# If config file is corrupted, create a new one with defaults
|
# If config file is corrupted, create a new one with defaults
|
||||||
save_config(default_config)
|
save_config(default_config)
|
||||||
|
|
||||||
return default_config
|
return default_config
|
||||||
|
|
||||||
def save_config(config):
|
|
||||||
|
def save_config(config) -> bool:
|
||||||
"""Save the application configuration to file."""
|
"""Save the application configuration to file."""
|
||||||
config_file = get_config_file_path()
|
|
||||||
try:
|
try:
|
||||||
# Ensure the config directory exists
|
with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
ensure_app_data_dir()
|
|
||||||
|
|
||||||
with open(config_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving config: {e}")
|
logger.error(f"Error saving config: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def check_ffmpeg():
|
|
||||||
|
def check_ffmpeg() -> bool:
|
||||||
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
|
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
|
||||||
try:
|
try:
|
||||||
# Use the enhanced FFmpeg check from ytsage_ffmpeg
|
# Use the enhanced FFmpeg check from ytsage_ffmpeg
|
||||||
if check_ffmpeg_installed():
|
if check_ffmpeg_installed():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# For Windows, try to add the FFmpeg path to environment
|
# For Windows, try to add the FFmpeg path to environment
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
ffmpeg_path = get_ffmpeg_install_path()
|
ffmpeg_path = get_ffmpeg_install_path()
|
||||||
if os.path.exists(os.path.join(ffmpeg_path, 'ffmpeg.exe')):
|
if ffmpeg_path.joinpath("ffmpeg.exe").exists():
|
||||||
try:
|
try:
|
||||||
# Add to current session PATH
|
# Add to current session PATH
|
||||||
os.environ['PATH'] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating PATH: {e}")
|
logger.error(f"Error updating PATH: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# For macOS, check common paths
|
# For macOS, check common paths
|
||||||
elif sys.platform == 'darwin':
|
elif OS_NAME == "Darwin":
|
||||||
common_paths = [
|
common_paths = [
|
||||||
'/usr/local/bin/ffmpeg',
|
"/usr/local/bin/ffmpeg",
|
||||||
'/opt/homebrew/bin/ffmpeg',
|
"/opt/homebrew/bin/ffmpeg",
|
||||||
'/usr/bin/ffmpeg'
|
"/usr/bin/ffmpeg",
|
||||||
]
|
]
|
||||||
for path in common_paths:
|
for path in common_paths:
|
||||||
if os.path.exists(path):
|
if Path(path).exists():
|
||||||
try:
|
try:
|
||||||
ffmpeg_dir = os.path.dirname(path)
|
ffmpeg_dir = Path(path).parent
|
||||||
os.environ['PATH'] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
|
os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating PATH: {e}")
|
logger.error(f"Error updating PATH: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking FFmpeg: {e}")
|
logger.error(f"Error checking FFmpeg: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_saved_path(main_window_instance):
|
|
||||||
|
def load_saved_path(main_window_instance) -> None:
|
||||||
"""Load saved download path with enhanced error handling."""
|
"""Load saved download path with enhanced error handling."""
|
||||||
config_file = get_config_file_path()
|
|
||||||
try:
|
try:
|
||||||
if config_file.exists():
|
if APP_CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r', encoding='utf-8') as f:
|
with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
saved_path = config.get('download_path', '')
|
saved_path = config.get("download_path", "")
|
||||||
if os.path.exists(saved_path) and os.access(saved_path, os.W_OK):
|
if Path(saved_path).exists() and os.access(saved_path, os.W_OK):
|
||||||
main_window_instance.last_path = saved_path
|
main_window_instance.last_path = saved_path
|
||||||
return
|
return
|
||||||
except (json.JSONDecodeError, UnicodeError) as e:
|
except (json.JSONDecodeError, UnicodeError) as e:
|
||||||
logger.error(f"Error reading config file: {e}")
|
logger.error(f"Error reading config file: {e}")
|
||||||
# If config file is corrupted, try to remove it
|
# If config file is corrupted, try to remove it
|
||||||
try:
|
try:
|
||||||
os.remove(config_file)
|
APP_CONFIG_FILE.unlink(missing_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback to Downloads folder
|
# Fallback to Downloads folder
|
||||||
downloads_path = str(Path.home() / 'Downloads')
|
downloads_path = USER_HOME_DIR / "Downloads"
|
||||||
if os.path.exists(downloads_path) and os.access(downloads_path, os.W_OK):
|
if downloads_path.exists() and os.access(downloads_path, os.W_OK):
|
||||||
main_window_instance.last_path = downloads_path
|
main_window_instance.last_path = downloads_path
|
||||||
else:
|
else:
|
||||||
# Final fallback to temp directory if Downloads is not accessible
|
# Final fallback to temp directory if Downloads is not accessible
|
||||||
main_window_instance.last_path = tempfile.gettempdir()
|
main_window_instance.last_path = tempfile.gettempdir()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading saved settings: {e}")
|
logger.error(f"Error loading saved settings: {e}")
|
||||||
main_window_instance.last_path = tempfile.gettempdir()
|
main_window_instance.last_path = tempfile.gettempdir()
|
||||||
|
|
||||||
def save_path(main_window_instance, path):
|
|
||||||
|
def save_path(main_window_instance, path) -> bool:
|
||||||
"""Save download path with enhanced error handling."""
|
"""Save download path with enhanced error handling."""
|
||||||
config_file = get_config_file_path()
|
|
||||||
try:
|
try:
|
||||||
# Verify the path is valid and writable
|
# Verify the path is valid and writable
|
||||||
if not os.path.exists(path):
|
if not Path(path).exists():
|
||||||
try:
|
try:
|
||||||
os.makedirs(path, exist_ok=True)
|
Path(path).mkdir(exist_ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating directory: {e}")
|
logger.error(f"Error creating directory: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not os.access(path, os.W_OK):
|
if not os.access(path, os.W_OK):
|
||||||
logger.info("Path is not writable")
|
logger.info("Path is not writable")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Ensure the config directory exists
|
|
||||||
ensure_app_data_dir()
|
|
||||||
|
|
||||||
# Save the config
|
# Save the config
|
||||||
config = {'download_path': path}
|
config = {"download_path": path}
|
||||||
with open(config_file, 'w', encoding='utf-8') as f:
|
with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(config, f, ensure_ascii=False)
|
json.dump(config, f, ensure_ascii=False)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving settings: {e}")
|
logger.error(f"Error saving settings: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_yt_dlp():
|
|
||||||
|
def update_yt_dlp() -> bool:
|
||||||
"""Check for yt-dlp updates and update if a newer version is available."""
|
"""Check for yt-dlp updates and update if a newer version is available."""
|
||||||
try:
|
try:
|
||||||
# Get the yt-dlp path
|
# Get the yt-dlp path
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
|
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
# For binaries downloaded with our app, use direct binary update approach
|
# For binaries downloaded with our app, use direct binary update approach
|
||||||
if os.path.dirname(yt_dlp_path) in [
|
if yt_dlp_path.samefile(YTDLP_APP_BIN_PATH):
|
||||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
|
|
||||||
os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
|
|
||||||
os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
|
||||||
]:
|
|
||||||
# We're using a binary installed by our app, update directly
|
# We're using a binary installed by our app, update directly
|
||||||
logger.info(f"Updating yt-dlp binary at {yt_dlp_path}")
|
logger.info(f"Updating yt-dlp binary at {yt_dlp_path}")
|
||||||
|
|
||||||
# Determine the URL based on OS
|
# Determine the URL based on OS
|
||||||
if sys.platform == 'win32':
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
|
||||||
else:
|
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
|
||||||
|
|
||||||
# Download the latest version
|
# Download the latest version
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, stream=True)
|
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
temp_file = f"{yt_dlp_path}.new"
|
temp_file = f"{yt_dlp_path}.new"
|
||||||
|
|
||||||
with open(temp_file, 'wb') as f:
|
with open(temp_file, "wb") as f:
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
# Make executable on Unix systems
|
# Make executable on Unix systems
|
||||||
if sys.platform != 'win32':
|
if OS_NAME != "Windows":
|
||||||
os.chmod(temp_file, 0o755)
|
os.chmod(temp_file, 0o755)
|
||||||
|
|
||||||
# Replace the old file with the new one
|
# Replace the old file with the new one
|
||||||
try:
|
try:
|
||||||
# On Windows, we need to remove the old file first
|
# On Windows, we need to remove the old file first
|
||||||
if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
|
if OS_NAME == "Windows" and yt_dlp_path.exists():
|
||||||
os.remove(yt_dlp_path)
|
yt_dlp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
os.rename(temp_file, yt_dlp_path)
|
Path(temp_file).rename(yt_dlp_path)
|
||||||
logger.info("yt-dlp binary successfully updated")
|
logger.info("yt-dlp binary successfully updated")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -503,7 +465,7 @@ def update_yt_dlp():
|
|||||||
else:
|
else:
|
||||||
# We're using a system-installed yt-dlp, use pip to update
|
# We're using a system-installed yt-dlp, use pip to update
|
||||||
logger.info("Using pip to update yt-dlp")
|
logger.info("Using pip to update yt-dlp")
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
try:
|
try:
|
||||||
current_version = pkg_resources.get_distribution("yt-dlp").version
|
current_version = pkg_resources.get_distribution("yt-dlp").version
|
||||||
@@ -511,7 +473,7 @@ def update_yt_dlp():
|
|||||||
except pkg_resources.DistributionNotFound:
|
except pkg_resources.DistributionNotFound:
|
||||||
logger.info("yt-dlp not installed via pip, attempting update anyway")
|
logger.info("yt-dlp not installed via pip, attempting update anyway")
|
||||||
current_version = "0.0.0" # Assume very old version to force update
|
current_version = "0.0.0" # Assume very old version to force update
|
||||||
|
|
||||||
# Get the latest version from PyPI JSON API
|
# Get the latest version from PyPI JSON API
|
||||||
try:
|
try:
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
@@ -519,16 +481,23 @@ def update_yt_dlp():
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
latest_version = data["info"]["version"]
|
latest_version = data["info"]["version"]
|
||||||
logger.info(f"Latest available yt-dlp version: {latest_version}")
|
logger.info(f"Latest available yt-dlp version: {latest_version}")
|
||||||
|
|
||||||
# Compare versions and update if needed
|
# Compare versions and update if needed
|
||||||
if version.parse(latest_version) > version.parse(current_version):
|
if version.parse(latest_version) > version.parse(current_version):
|
||||||
logger.info(f"Updating yt-dlp from {current_version} to {latest_version}...")
|
logger.info(f"Updating yt-dlp from {current_version} to {latest_version}...")
|
||||||
update_result = subprocess.run(
|
update_result = subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--upgrade",
|
||||||
|
"yt-dlp",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
startupinfo=startupinfo
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
if update_result.returncode == 0:
|
if update_result.returncode == 0:
|
||||||
logger.info("yt-dlp successfully updated")
|
logger.info("yt-dlp successfully updated")
|
||||||
@@ -544,75 +513,76 @@ def update_yt_dlp():
|
|||||||
logger.error(f"Error checking for yt-dlp updates: {e}")
|
logger.error(f"Error checking for yt-dlp updates: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"Unexpected error during yt-dlp update: {e}")
|
logger.info(f"Unexpected error during yt-dlp update: {e}")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def should_check_for_auto_update():
|
def should_check_for_auto_update() -> bool:
|
||||||
"""Check if auto-update should be performed based on user settings."""
|
"""Check if auto-update should be performed based on user settings."""
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
# Check if auto-update is enabled
|
# Check if auto-update is enabled
|
||||||
if not config.get('auto_update_ytdlp', False):
|
if not config.get("auto_update_ytdlp", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
frequency = config.get('auto_update_frequency', 'daily')
|
frequency = config.get("auto_update_frequency", "daily")
|
||||||
last_check = config.get('last_update_check', 0)
|
last_check = config.get("last_update_check", 0)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Calculate time since last check
|
# Calculate time since last check
|
||||||
time_diff = current_time - last_check
|
time_diff = current_time - last_check
|
||||||
|
|
||||||
if frequency == 'startup':
|
if frequency == "startup":
|
||||||
# Always check on startup if we haven't checked in the last hour
|
# Always check on startup if we haven't checked in the last hour
|
||||||
return time_diff > 3600 # 1 hour
|
return time_diff > 3600 # 1 hour
|
||||||
elif frequency == 'daily':
|
elif frequency == "daily":
|
||||||
return time_diff > 86400 # 24 hours
|
return time_diff > 86400 # 24 hours
|
||||||
elif frequency == 'weekly':
|
elif frequency == "weekly":
|
||||||
return time_diff > 604800 # 7 days
|
return time_diff > 604800 # 7 days
|
||||||
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking auto-update schedule: {e}")
|
logger.error(f"Error checking auto-update schedule: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_and_update_ytdlp_auto():
|
def check_and_update_ytdlp_auto() -> bool:
|
||||||
"""Perform automatic yt-dlp update check and update if needed."""
|
"""Perform automatic yt-dlp update check and update if needed."""
|
||||||
try:
|
try:
|
||||||
logger.info("Performing automatic yt-dlp update check...")
|
logger.info("Performing automatic yt-dlp update check...")
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
current_version = get_ytdlp_version()
|
current_version = get_ytdlp_version()
|
||||||
if "Error" in current_version:
|
if "Error" in current_version:
|
||||||
logger.info("Could not determine current yt-dlp version, skipping auto-update")
|
logger.info("Could not determine current yt-dlp version, skipping auto-update")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get latest version from PyPI
|
# Get latest version from PyPI
|
||||||
try:
|
try:
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
latest_version = response.json()["info"]["version"]
|
latest_version = response.json()["info"]["version"]
|
||||||
|
|
||||||
# Clean up version strings
|
# Clean up version strings
|
||||||
current_version = current_version.replace('_', '.')
|
current_version = current_version.replace("_", ".")
|
||||||
latest_version = latest_version.replace('_', '.')
|
latest_version = latest_version.replace("_", ".")
|
||||||
|
|
||||||
logger.info(f"Current yt-dlp version: {current_version}")
|
logger.info(f"Current yt-dlp version: {current_version}")
|
||||||
logger.info(f"Latest yt-dlp version: {latest_version}")
|
logger.info(f"Latest yt-dlp version: {latest_version}")
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
from packaging import version as version_parser
|
from packaging import version as version_parser
|
||||||
|
|
||||||
if version_parser.parse(latest_version) > version_parser.parse(current_version):
|
if version_parser.parse(latest_version) > version_parser.parse(current_version):
|
||||||
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
||||||
|
|
||||||
# Perform the update
|
# Perform the update
|
||||||
if update_yt_dlp():
|
if update_yt_dlp():
|
||||||
logger.info("Auto-update completed successfully!")
|
logger.info("Auto-update completed successfully!")
|
||||||
# Update the last check timestamp
|
# Update the last check timestamp
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['last_update_check'] = time.time()
|
config["last_update_check"] = time.time()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -622,40 +592,40 @@ def check_and_update_ytdlp_auto():
|
|||||||
logger.info("yt-dlp is already up to date")
|
logger.info("yt-dlp is already up to date")
|
||||||
# Still update the timestamp even if no update was needed
|
# Still update the timestamp even if no update was needed
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['last_update_check'] = time.time()
|
config["last_update_check"] = time.time()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.info(f"Network error during auto-update check: {e}")
|
logger.info(f"Network error during auto-update check: {e}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during auto-update check: {e}")
|
logger.error(f"Error during auto-update check: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"Critical error in auto-update: {e}")
|
logger.info(f"Critical error in auto-update: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_auto_update_settings():
|
def get_auto_update_settings() -> dict:
|
||||||
"""Get current auto-update settings from config."""
|
"""Get current auto-update settings from config."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
return {
|
return {
|
||||||
'enabled': config.get('auto_update_ytdlp', True),
|
"enabled": config.get("auto_update_ytdlp", True),
|
||||||
'frequency': config.get('auto_update_frequency', 'daily'),
|
"frequency": config.get("auto_update_frequency", "daily"),
|
||||||
'last_check': config.get('last_update_check', 0)
|
"last_check": config.get("last_update_check", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def update_auto_update_settings(enabled, frequency):
|
def update_auto_update_settings(enabled, frequency) -> bool:
|
||||||
"""Update auto-update settings in config."""
|
"""Update auto-update settings in config."""
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['auto_update_ytdlp'] = enabled
|
config["auto_update_ytdlp"] = enabled
|
||||||
config['auto_update_frequency'] = frequency
|
config["auto_update_frequency"] = frequency
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating auto-update settings: {e}")
|
logger.error(f"Error updating auto-update settings: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
+189
-234
@@ -1,87 +1,64 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import platform
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import requests
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QPushButton,
|
from typing import Optional
|
||||||
QProgressBar, QRadioButton, QHBoxLayout,
|
|
||||||
QMessageBox, QFileDialog, QWidget)
|
import requests
|
||||||
from PySide6.QtCore import QThread, Signal, Qt
|
from PySide6.QtCore import Qt, QThread, Signal
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
from .ytsage_logging import logger
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QFileDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
# Define binary URLs
|
from src.core.ytsage_logging import logger
|
||||||
YTDLP_URLS = {
|
from src.utils.ytsage_constants import (
|
||||||
"windows": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe",
|
APP_BIN_DIR,
|
||||||
"macos": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos",
|
ICON_PATH,
|
||||||
"linux": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
OS_FULL_NAME,
|
||||||
}
|
OS_NAME,
|
||||||
|
SUBPROCESS_CREATIONFLAGS,
|
||||||
|
YTDLP_APP_BIN_PATH,
|
||||||
|
YTDLP_DOWNLOAD_URL,
|
||||||
|
)
|
||||||
|
|
||||||
# Define installation paths
|
# YTDLP_URLS moved to src\utils\ytsage_constants.py
|
||||||
def get_ytdlp_install_dir():
|
# get_ytdlp_install_dir() moved to src\utils\ytsage_constants.py
|
||||||
"""Get the OS-specific yt-dlp installation directory"""
|
# get_ytdlp_executable_path() moved to src\utils\ytsage_constants.py
|
||||||
if sys.platform == 'win32':
|
# get_os_type() moved to src\utils\ytsage_constants.py
|
||||||
# Windows: %LOCALAPPDATA%\YTSage\bin\
|
# ensure_install_dir_exists() moved to src\utils\ytsage_constants.py
|
||||||
return os.path.join(os.environ.get('LOCALAPPDATA'), 'YTSage', 'bin')
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
# macOS: ~/Library/Application Support/YTSage/bin/
|
|
||||||
return os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin'))
|
|
||||||
else:
|
|
||||||
# Linux: ~/.local/share/YTSage/bin/
|
|
||||||
return os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
|
||||||
|
|
||||||
def get_ytdlp_executable_path():
|
|
||||||
"""Get the full path to the yt-dlp executable based on OS"""
|
|
||||||
install_dir = get_ytdlp_install_dir()
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
return os.path.join(install_dir, 'yt-dlp.exe')
|
|
||||||
else:
|
|
||||||
return os.path.join(install_dir, 'yt-dlp')
|
|
||||||
|
|
||||||
def get_os_type():
|
|
||||||
"""Detect the operating system"""
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
return "windows"
|
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
return "macos"
|
|
||||||
else:
|
|
||||||
return "linux"
|
|
||||||
|
|
||||||
def ensure_install_dir_exists():
|
|
||||||
"""Make sure the installation directory exists"""
|
|
||||||
install_dir = get_ytdlp_install_dir()
|
|
||||||
os.makedirs(install_dir, exist_ok=True)
|
|
||||||
return install_dir
|
|
||||||
|
|
||||||
class DownloadYtdlpThread(QThread):
|
class DownloadYtdlpThread(QThread):
|
||||||
progress_signal = Signal(int)
|
progress_signal = Signal(int)
|
||||||
finished_signal = Signal(bool, str)
|
finished_signal = Signal(bool, str)
|
||||||
|
|
||||||
def __init__(self, os_type):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.os_type = os_type
|
|
||||||
|
def run(self) -> None:
|
||||||
def run(self):
|
|
||||||
try:
|
try:
|
||||||
url = YTDLP_URLS[self.os_type]
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
install_dir = ensure_install_dir_exists()
|
exe_path = YTDLP_APP_BIN_PATH
|
||||||
|
|
||||||
if self.os_type == "windows":
|
|
||||||
exe_path = os.path.join(install_dir, "yt-dlp.exe")
|
|
||||||
else:
|
|
||||||
exe_path = os.path.join(install_dir, "yt-dlp")
|
|
||||||
|
|
||||||
# Download with progress reporting
|
# Download with progress reporting
|
||||||
response = requests.get(url, stream=True)
|
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
block_size = 1024 # 1 Kibibyte
|
block_size = 1024 # 1 Kibibyte
|
||||||
|
|
||||||
if total_size == 0:
|
if total_size == 0:
|
||||||
self.progress_signal.emit(100)
|
self.progress_signal.emit(100)
|
||||||
|
|
||||||
with open(exe_path, 'wb') as f:
|
with open(exe_path, "wb") as f:
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
for data in response.iter_content(block_size):
|
for data in response.iter_content(block_size):
|
||||||
f.write(data)
|
f.write(data)
|
||||||
@@ -89,44 +66,41 @@ class DownloadYtdlpThread(QThread):
|
|||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
progress = int(downloaded / total_size * 100)
|
progress = int(downloaded / total_size * 100)
|
||||||
self.progress_signal.emit(progress)
|
self.progress_signal.emit(progress)
|
||||||
|
|
||||||
# Make executable on macOS and Linux
|
# Make executable on macOS and Linux
|
||||||
if self.os_type != "windows":
|
if OS_NAME != "Windows":
|
||||||
os.chmod(exe_path, 0o755)
|
os.chmod(exe_path, 0o755)
|
||||||
|
|
||||||
self.finished_signal.emit(True, exe_path)
|
self.finished_signal.emit(True, exe_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.finished_signal.emit(False, str(e))
|
self.finished_signal.emit(False, str(e))
|
||||||
|
|
||||||
|
|
||||||
class YtdlpSetupDialog(QDialog):
|
class YtdlpSetupDialog(QDialog):
|
||||||
setup_complete = Signal(str) # Signal emitting the path to yt-dlp
|
setup_complete = Signal(str) # Signal emitting the path to yt-dlp
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.os_type = get_os_type()
|
|
||||||
self.setWindowTitle("yt-dlp Setup Required")
|
self.setWindowTitle("yt-dlp Setup Required")
|
||||||
self.setMinimumWidth(520)
|
self.setMinimumWidth(520)
|
||||||
self.setMinimumHeight(350)
|
self.setMinimumHeight(350)
|
||||||
self.resize(520, 380)
|
self.resize(520, 380)
|
||||||
|
|
||||||
# Set the window icon to match the main app
|
# Set the window icon to match the main app
|
||||||
if parent and parent.windowIcon():
|
if parent and parent.windowIcon():
|
||||||
self.setWindowIcon(parent.windowIcon())
|
self.setWindowIcon(parent.windowIcon())
|
||||||
else:
|
else:
|
||||||
# Try to load the icon directly if parent not available
|
# icon_path logic moved to src\utils\ytsage_constants.py
|
||||||
# Navigate from src/core/ to project root, then to assets/Icon/
|
icon_path = ICON_PATH
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__)) # core/
|
if Path.exists(icon_path):
|
||||||
src_dir = os.path.dirname(current_dir) # src/
|
self.setWindowIcon(QIcon(icon_path.as_posix()))
|
||||||
project_root = os.path.dirname(src_dir) # project root
|
|
||||||
icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png')
|
|
||||||
if os.path.exists(icon_path):
|
|
||||||
self.setWindowIcon(QIcon(icon_path))
|
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
||||||
# Apply dark theme styling to match app
|
# Apply dark theme styling to match app
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -190,55 +164,54 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
border: 2px solid #c90000;
|
border: 2px solid #c90000;
|
||||||
background: #c90000;
|
background: #c90000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
def init_ui(self):
|
|
||||||
|
def init_ui(self) -> None:
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(15)
|
layout.setSpacing(15)
|
||||||
layout.setContentsMargins(25, 25, 25, 25)
|
layout.setContentsMargins(25, 25, 25, 25)
|
||||||
|
|
||||||
# Header title
|
# Header title
|
||||||
title_label = QLabel("yt-dlp Setup Required")
|
title_label = QLabel("yt-dlp Setup Required")
|
||||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
||||||
title_label.setAlignment(Qt.AlignCenter)
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
# Information label with improved styling
|
# Information label with improved styling
|
||||||
if self.os_type == "windows":
|
# os_name logic moved to src\utils\ytsage_constants.py
|
||||||
os_name = "Windows"
|
|
||||||
elif self.os_type == "macos":
|
info_label = QLabel(
|
||||||
os_name = "macOS"
|
f"YTSage requires yt-dlp to download videos.<br><br>"
|
||||||
else:
|
f"yt-dlp was not found in the app's local directory. "
|
||||||
os_name = "Linux"
|
f"YTSage needs to set up yt-dlp for your {OS_FULL_NAME} system.<br><br>"
|
||||||
|
f"Please choose an option below:"
|
||||||
info_label = QLabel(f"YTSage requires yt-dlp to download videos.<br><br>"
|
)
|
||||||
f"yt-dlp was not found in the app's local directory. "
|
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
f"YTSage needs to set up yt-dlp for your {os_name} system.<br><br>"
|
|
||||||
f"Please choose an option below:")
|
|
||||||
info_label.setAlignment(Qt.AlignCenter)
|
|
||||||
info_label.setWordWrap(True)
|
info_label.setWordWrap(True)
|
||||||
info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;")
|
info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;")
|
||||||
layout.addWidget(info_label)
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
# Radio buttons with minimal spacing
|
# Radio buttons with minimal spacing
|
||||||
option_widget = QWidget()
|
option_widget = QWidget()
|
||||||
option_layout = QVBoxLayout(option_widget)
|
option_layout = QVBoxLayout(option_widget)
|
||||||
option_layout.setSpacing(8)
|
option_layout.setSpacing(8)
|
||||||
option_layout.setContentsMargins(0, 0, 0, 0)
|
option_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
self.auto_radio = QRadioButton("Download automatically (Recommended)")
|
self.auto_radio = QRadioButton("Download automatically (Recommended)")
|
||||||
self.auto_radio.setChecked(True)
|
self.auto_radio.setChecked(True)
|
||||||
self.manual_radio = QRadioButton("Select path manually")
|
self.manual_radio = QRadioButton("Select path manually")
|
||||||
|
|
||||||
option_layout.addWidget(self.auto_radio)
|
option_layout.addWidget(self.auto_radio)
|
||||||
option_layout.addWidget(self.manual_radio)
|
option_layout.addWidget(self.manual_radio)
|
||||||
layout.addWidget(option_widget)
|
layout.addWidget(option_widget)
|
||||||
|
|
||||||
# Progress bar with proper sizing
|
# Progress bar with proper sizing
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
self.progress_bar.setFixedHeight(20) # Fixed height for consistency
|
self.progress_bar.setFixedHeight(20) # Fixed height for consistency
|
||||||
self.progress_bar.setStyleSheet("""
|
self.progress_bar.setStyleSheet(
|
||||||
|
"""
|
||||||
QProgressBar {
|
QProgressBar {
|
||||||
border: 1px solid #3d3d3d;
|
border: 1px solid #3d3d3d;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -254,61 +227,62 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.addWidget(self.progress_bar)
|
layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
# Status label with better spacing
|
# Status label with better spacing
|
||||||
self.status_label = QLabel("")
|
self.status_label = QLabel("")
|
||||||
self.status_label.setAlignment(Qt.AlignCenter)
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.status_label.setStyleSheet("font-size: 12px; color: #cccccc; padding: 8px 0;")
|
self.status_label.setStyleSheet("font-size: 12px; color: #cccccc; padding: 8px 0;")
|
||||||
self.status_label.setWordWrap(True)
|
self.status_label.setWordWrap(True)
|
||||||
layout.addWidget(self.status_label)
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
# Add stretch to push buttons to bottom
|
# Add stretch to push buttons to bottom
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
# Button layout with improved spacing
|
# Button layout with improved spacing
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(15)
|
button_layout.setSpacing(15)
|
||||||
button_layout.setContentsMargins(0, 10, 0, 0) # Add top margin for buttons
|
button_layout.setContentsMargins(0, 10, 0, 0) # Add top margin for buttons
|
||||||
|
|
||||||
self.setup_button = QPushButton("Setup yt-dlp")
|
self.setup_button = QPushButton("Setup yt-dlp")
|
||||||
self.setup_button.clicked.connect(self.setup_ytdlp)
|
self.setup_button.clicked.connect(self.setup_ytdlp)
|
||||||
|
|
||||||
self.cancel_button = QPushButton("Cancel")
|
self.cancel_button = QPushButton("Cancel")
|
||||||
self.cancel_button.clicked.connect(self.reject)
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
button_layout.addWidget(self.setup_button)
|
button_layout.addWidget(self.setup_button)
|
||||||
button_layout.addWidget(self.cancel_button)
|
button_layout.addWidget(self.cancel_button)
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def setup_ytdlp(self):
|
def setup_ytdlp(self) -> None:
|
||||||
if self.auto_radio.isChecked():
|
if self.auto_radio.isChecked():
|
||||||
self.download_ytdlp()
|
self.download_ytdlp()
|
||||||
else:
|
else:
|
||||||
self.select_ytdlp_path()
|
self.select_ytdlp_path()
|
||||||
|
|
||||||
def download_ytdlp(self):
|
def download_ytdlp(self) -> None:
|
||||||
self.progress_bar.setVisible(True)
|
self.progress_bar.setVisible(True)
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.status_label.setText("Downloading yt-dlp...")
|
self.status_label.setText("Downloading yt-dlp...")
|
||||||
self.setup_button.setEnabled(False)
|
self.setup_button.setEnabled(False)
|
||||||
self.cancel_button.setEnabled(False)
|
self.cancel_button.setEnabled(False)
|
||||||
|
|
||||||
self.download_thread = DownloadYtdlpThread(self.os_type)
|
self.download_thread = DownloadYtdlpThread()
|
||||||
self.download_thread.progress_signal.connect(self.update_progress)
|
self.download_thread.progress_signal.connect(self.update_progress)
|
||||||
self.download_thread.finished_signal.connect(self.download_finished)
|
self.download_thread.finished_signal.connect(self.download_finished)
|
||||||
self.download_thread.start()
|
self.download_thread.start()
|
||||||
|
|
||||||
def update_progress(self, value):
|
def update_progress(self, value) -> None:
|
||||||
self.progress_bar.setValue(value)
|
self.progress_bar.setValue(value)
|
||||||
|
|
||||||
def download_finished(self, success, result):
|
def download_finished(self, success, result) -> None:
|
||||||
self.setup_button.setEnabled(True)
|
self.setup_button.setEnabled(True)
|
||||||
self.cancel_button.setEnabled(True)
|
self.cancel_button.setEnabled(True)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.status_label.setText("yt-dlp was successfully installed!")
|
self.status_label.setText("yt-dlp was successfully installed!")
|
||||||
self.setup_complete.emit(result)
|
self.setup_complete.emit(result)
|
||||||
@@ -316,12 +290,13 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
else:
|
else:
|
||||||
self.status_label.setText(f"Error: {result}")
|
self.status_label.setText(f"Error: {result}")
|
||||||
error_dialog = QMessageBox(self)
|
error_dialog = QMessageBox(self)
|
||||||
error_dialog.setIcon(QMessageBox.Critical)
|
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||||
error_dialog.setWindowTitle("Download Failed")
|
error_dialog.setWindowTitle("Download Failed")
|
||||||
error_dialog.setText(f"Failed to download yt-dlp: {result}")
|
error_dialog.setText(f"Failed to download yt-dlp: {result}")
|
||||||
# Set the window icon to match the main dialog
|
# Set the window icon to match the main dialog
|
||||||
error_dialog.setWindowIcon(self.windowIcon())
|
error_dialog.setWindowIcon(self.windowIcon())
|
||||||
error_dialog.setStyleSheet("""
|
error_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -340,18 +315,20 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
|
|
||||||
def select_ytdlp_path(self):
|
def select_ytdlp_path(self) -> None:
|
||||||
if self.os_type == "windows":
|
if OS_NAME == "Windows":
|
||||||
file_filter = "Executable Files (*.exe)"
|
file_filter = "Executable Files (*.exe)"
|
||||||
else:
|
else:
|
||||||
file_filter = "All Files (*)"
|
file_filter = "All Files (*)"
|
||||||
|
|
||||||
# Apply style to QFileDialog
|
# Apply style to QFileDialog
|
||||||
file_dialog = QFileDialog(self)
|
file_dialog = QFileDialog(self)
|
||||||
file_dialog.setStyleSheet("""
|
file_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QFileDialog {
|
QFileDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -371,57 +348,42 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
|
||||||
file_path, _ = file_dialog.getOpenFileName(
|
|
||||||
self, "Select yt-dlp executable", "", file_filter
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
file_path, _ = file_dialog.getOpenFileName(self, "Select yt-dlp executable", "", file_filter)
|
||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
logger.debug(f"User selected file: {file_path}")
|
logger.debug(f"User selected file: {file_path}")
|
||||||
# Verify the selected file
|
# Verify the selected file
|
||||||
try:
|
try:
|
||||||
# Set up startupinfo to hide console window on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
# Try to run yt-dlp --version
|
# Try to run yt-dlp --version
|
||||||
logger.debug(f"Verifying file with --version command")
|
logger.debug(f"Verifying file with --version command")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[file_path, "--version"],
|
[file_path, "--version"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
logger.debug(f"Version check result: {result.returncode}, Output: {result.stdout.strip()}")
|
logger.debug(f"Version check result: {result.returncode}, Output: {result.stdout.strip()}")
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# File is valid, copy it to our app's bin directory
|
# File is valid, copy it to our app's bin directory
|
||||||
try:
|
try:
|
||||||
# Ensure the bin directory exists
|
# Ensure the bin directory exists
|
||||||
install_dir = ensure_install_dir_exists()
|
logger.debug(f"Install directory: {APP_BIN_DIR}")
|
||||||
logger.debug(f"Install directory: {install_dir}")
|
|
||||||
|
|
||||||
# Determine the target filename based on OS
|
# Determine the target filename based on OS
|
||||||
if self.os_type == "windows":
|
target_path = YTDLP_APP_BIN_PATH
|
||||||
target_path = os.path.join(install_dir, "yt-dlp.exe")
|
|
||||||
else:
|
|
||||||
target_path = os.path.join(install_dir, "yt-dlp")
|
|
||||||
logger.debug(f"Target path: {target_path}")
|
logger.debug(f"Target path: {target_path}")
|
||||||
|
|
||||||
# Copy the file
|
# Copy the file
|
||||||
shutil.copy2(file_path, target_path)
|
shutil.copy2(file_path, target_path)
|
||||||
logger.debug(f"File copied successfully")
|
logger.debug(f"File copied successfully")
|
||||||
|
|
||||||
# Set executable permissions on Unix systems
|
# Set executable permissions on Unix systems
|
||||||
if self.os_type != "windows":
|
if OS_NAME != "Windows":
|
||||||
os.chmod(target_path, 0o755)
|
os.chmod(target_path, 0o755)
|
||||||
logger.debug(f"Permissions set on Unix system")
|
logger.debug(f"Permissions set on Unix system")
|
||||||
|
|
||||||
# Return the path of the copied file
|
# Return the path of the copied file
|
||||||
self.status_label.setText(f"yt-dlp successfully copied to {target_path}")
|
self.status_label.setText(f"yt-dlp successfully copied to {target_path}")
|
||||||
logger.debug(f"Emitting setup_complete signal with path: {target_path}")
|
logger.debug(f"Emitting setup_complete signal with path: {target_path}")
|
||||||
@@ -430,10 +392,11 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
except Exception as copy_error:
|
except Exception as copy_error:
|
||||||
logger.debug(f"Error copying file: {str(copy_error)}")
|
logger.debug(f"Error copying file: {str(copy_error)}")
|
||||||
error_dialog = QMessageBox(self)
|
error_dialog = QMessageBox(self)
|
||||||
error_dialog.setIcon(QMessageBox.Critical)
|
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||||
error_dialog.setWindowTitle("Setup Error")
|
error_dialog.setWindowTitle("Setup Error")
|
||||||
error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}")
|
error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}")
|
||||||
error_dialog.setStyleSheet("""
|
error_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -452,15 +415,17 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
else:
|
else:
|
||||||
logger.debug(f"File verification failed with return code: {result.returncode}")
|
logger.debug(f"File verification failed with return code: {result.returncode}")
|
||||||
error_dialog = QMessageBox(self)
|
error_dialog = QMessageBox(self)
|
||||||
error_dialog.setIcon(QMessageBox.Warning)
|
error_dialog.setIcon(QMessageBox.Icon.Warning)
|
||||||
error_dialog.setWindowTitle("Invalid Executable")
|
error_dialog.setWindowTitle("Invalid Executable")
|
||||||
error_dialog.setText("The selected file does not appear to be a valid yt-dlp executable.")
|
error_dialog.setText("The selected file does not appear to be a valid yt-dlp executable.")
|
||||||
error_dialog.setStyleSheet("""
|
error_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -479,15 +444,17 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Exception during verification: {str(e)}")
|
logger.debug(f"Exception during verification: {str(e)}")
|
||||||
error_dialog = QMessageBox(self)
|
error_dialog = QMessageBox(self)
|
||||||
error_dialog.setIcon(QMessageBox.Critical)
|
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||||
error_dialog.setWindowTitle("Error")
|
error_dialog.setWindowTitle("Error")
|
||||||
error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}")
|
error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}")
|
||||||
error_dialog.setStyleSheet("""
|
error_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -506,63 +473,57 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
|
|
||||||
def check_ytdlp_binary():
|
|
||||||
|
def check_ytdlp_binary() -> Optional[Path]:
|
||||||
"""
|
"""
|
||||||
Check if yt-dlp binary exists in the expected location.
|
Check if yt-dlp binary exists in the expected location.
|
||||||
Returns:
|
Returns:
|
||||||
str or None: Path to yt-dlp binary if found, None otherwise
|
Path or None: Path to yt-dlp binary if found, None otherwise
|
||||||
"""
|
"""
|
||||||
exe_path = get_ytdlp_executable_path()
|
exe_path = YTDLP_APP_BIN_PATH
|
||||||
if os.path.exists(exe_path):
|
if exe_path.exists():
|
||||||
# Make sure it's executable on Unix systems
|
# Make sure it's executable on Unix systems
|
||||||
if sys.platform != 'win32' and not os.access(exe_path, os.X_OK):
|
if OS_NAME != "Windows" and not os.access(exe_path, os.X_OK):
|
||||||
try:
|
try:
|
||||||
os.chmod(exe_path, 0o755)
|
os.chmod(exe_path, 0o755)
|
||||||
logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
|
logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not set executable permissions on {exe_path}: {e}")
|
logger.warning(f"Could not set executable permissions on {exe_path}: {e}")
|
||||||
return None
|
|
||||||
return exe_path
|
return exe_path
|
||||||
|
|
||||||
# If not found in app directory, check if yt-dlp is available in PATH
|
# If not found in app directory, check if yt-dlp is available in PATH
|
||||||
try:
|
try:
|
||||||
# Use subprocess to check if yt-dlp is available
|
# Use subprocess to check if yt-dlp is available
|
||||||
if sys.platform == 'win32':
|
if OS_NAME == "Windows":
|
||||||
# On Windows, use 'where' command and hide console window
|
# On Windows, use 'where' command and hide console window
|
||||||
startupinfo = None
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
if hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['where', 'yt-dlp'],
|
["where", "yt-dlp"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
yt_dlp_path = result.stdout.strip().split('\n')[0]
|
yt_dlp_path = result.stdout.strip().split("\n")[0]
|
||||||
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
|
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
|
||||||
return yt_dlp_path
|
return Path(yt_dlp_path)
|
||||||
else:
|
else:
|
||||||
# On Unix systems, use 'which' command
|
# On Unix systems, use 'which' command
|
||||||
result = subprocess.run(['which', 'yt-dlp'], capture_output=True, text=True, check=False)
|
result = subprocess.run(["which", "yt-dlp"], capture_output=True, text=True, check=False)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
yt_dlp_path = result.stdout.strip()
|
yt_dlp_path = result.stdout.strip()
|
||||||
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
|
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
|
||||||
return yt_dlp_path
|
return Path(yt_dlp_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking for yt-dlp in PATH: {e}")
|
logger.error(f"Error checking for yt-dlp in PATH: {e}")
|
||||||
|
# We're only interested in our app-specific installation or system PATH
|
||||||
# We're only interested in our app-specific installation or system PATH
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def check_ytdlp_installed():
|
|
||||||
|
def check_ytdlp_installed() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if yt-dlp is installed and accessible.
|
Check if yt-dlp is installed and accessible.
|
||||||
Returns:
|
Returns:
|
||||||
@@ -573,19 +534,9 @@ def check_ytdlp_installed():
|
|||||||
if ytdlp_path:
|
if ytdlp_path:
|
||||||
# Try to run yt-dlp --version to verify it's working
|
# Try to run yt-dlp --version to verify it's working
|
||||||
try:
|
try:
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[ytdlp_path, '--version'],
|
[ytdlp_path, "--version"], capture_output=True, text=True, timeout=5, creationflags=SUBPROCESS_CREATIONFLAGS
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
startupinfo=startupinfo
|
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -594,7 +545,8 @@ def check_ytdlp_installed():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_yt_dlp_path():
|
|
||||||
|
def get_yt_dlp_path() -> Path:
|
||||||
"""
|
"""
|
||||||
Get the yt-dlp path, either from the app's bin directory or system PATH.
|
Get the yt-dlp path, either from the app's bin directory or system PATH.
|
||||||
This replaces the function in ytsage_utils.py.
|
This replaces the function in ytsage_utils.py.
|
||||||
@@ -606,10 +558,11 @@ def get_yt_dlp_path():
|
|||||||
if ytdlp_path:
|
if ytdlp_path:
|
||||||
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
||||||
return ytdlp_path
|
return ytdlp_path
|
||||||
|
|
||||||
# If not found anywhere, fall back to the command name as a last resort
|
# If not found anywhere, fall back to the command name as a last resort
|
||||||
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
|
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
|
||||||
return "yt-dlp"
|
return "yt-dlp" # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
def setup_ytdlp(parent_widget=None):
|
def setup_ytdlp(parent_widget=None):
|
||||||
"""
|
"""
|
||||||
@@ -619,33 +572,33 @@ def setup_ytdlp(parent_widget=None):
|
|||||||
"""
|
"""
|
||||||
logger.debug("Starting yt-dlp setup dialog")
|
logger.debug("Starting yt-dlp setup dialog")
|
||||||
dialog = YtdlpSetupDialog(parent_widget)
|
dialog = YtdlpSetupDialog(parent_widget)
|
||||||
|
|
||||||
# Store the setup result from the signal
|
# Store the setup result from the signal
|
||||||
setup_result = {"path": None}
|
setup_result = {"path": None}
|
||||||
|
|
||||||
def on_setup_complete(path):
|
def on_setup_complete(path) -> None:
|
||||||
logger.debug(f"Received setup_complete signal with path: {path}")
|
logger.debug(f"Received setup_complete signal with path: {path}")
|
||||||
setup_result["path"] = path
|
setup_result["path"] = path
|
||||||
|
|
||||||
# Connect to the setup_complete signal
|
# Connect to the setup_complete signal
|
||||||
dialog.setup_complete.connect(on_setup_complete)
|
dialog.setup_complete.connect(on_setup_complete)
|
||||||
|
|
||||||
# Show the dialog
|
# Show the dialog
|
||||||
result = dialog.exec()
|
result = dialog.exec()
|
||||||
logger.debug(f"Dialog result: {result} (Accepted={QDialog.Accepted})")
|
logger.debug(f"Dialog result: {result} (Accepted={QDialog.DialogCode.Accepted})")
|
||||||
|
|
||||||
if result == QDialog.Accepted:
|
if result == QDialog.DialogCode.Accepted:
|
||||||
# First check if we received a path from the signal
|
# First check if we received a path from the signal
|
||||||
if setup_result["path"] and os.path.exists(setup_result["path"]):
|
if setup_result["path"] and Path.exists(setup_result["path"]):
|
||||||
logger.debug(f"Using path from signal: {setup_result['path']}")
|
logger.debug(f"Using path from signal: {setup_result['path']}")
|
||||||
return setup_result["path"]
|
return setup_result["path"]
|
||||||
|
|
||||||
# Get the expected path for verification as fallback
|
# Get the expected path for verification as fallback
|
||||||
expected_path = get_ytdlp_executable_path()
|
expected_path = YTDLP_APP_BIN_PATH
|
||||||
logger.debug(f"Expected yt-dlp path: {expected_path}")
|
logger.debug(f"Expected yt-dlp path: {expected_path}")
|
||||||
|
|
||||||
# Verify the path exists after dialog is accepted
|
# Verify the path exists after dialog is accepted
|
||||||
if os.path.exists(expected_path):
|
if Path.exists(expected_path):
|
||||||
logger.debug(f"yt-dlp successfully found at expected path: {expected_path}")
|
logger.debug(f"yt-dlp successfully found at expected path: {expected_path}")
|
||||||
return expected_path
|
return expected_path
|
||||||
else:
|
else:
|
||||||
@@ -653,20 +606,21 @@ def setup_ytdlp(parent_widget=None):
|
|||||||
# Try to use the get_yt_dlp_path function to find yt-dlp elsewhere
|
# Try to use the get_yt_dlp_path function to find yt-dlp elsewhere
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
logger.debug(f"Alternate detection result: {yt_dlp_path}")
|
logger.debug(f"Alternate detection result: {yt_dlp_path}")
|
||||||
if yt_dlp_path != "yt-dlp" and os.path.exists(yt_dlp_path):
|
if yt_dlp_path != "yt-dlp" and Path.exists(yt_dlp_path):
|
||||||
logger.debug(f"yt-dlp found at alternate location: {yt_dlp_path}")
|
logger.debug(f"yt-dlp found at alternate location: {yt_dlp_path}")
|
||||||
return yt_dlp_path
|
return yt_dlp_path
|
||||||
|
|
||||||
# Something went wrong, show an error message
|
# Something went wrong, show an error message
|
||||||
logger.debug(f"Setup failed, showing error dialog")
|
logger.debug(f"Setup failed, showing error dialog")
|
||||||
if parent_widget:
|
if parent_widget:
|
||||||
error_dialog = QMessageBox(parent_widget)
|
error_dialog = QMessageBox(parent_widget)
|
||||||
error_dialog.setIcon(QMessageBox.Warning)
|
error_dialog.setIcon(QMessageBox.Icon.Warning)
|
||||||
error_dialog.setWindowTitle("Setup Failed")
|
error_dialog.setWindowTitle("Setup Failed")
|
||||||
error_dialog.setText("Failed to set up yt-dlp. Some features may not work correctly.")
|
error_dialog.setText("Failed to set up yt-dlp. Some features may not work correctly.")
|
||||||
# Set the window icon to match the parent
|
# Set the window icon to match the parent
|
||||||
error_dialog.setWindowIcon(parent_widget.windowIcon())
|
error_dialog.setWindowIcon(parent_widget.windowIcon())
|
||||||
error_dialog.setStyleSheet("""
|
error_dialog.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -685,12 +639,13 @@ def setup_ytdlp(parent_widget=None):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
error_dialog.exec()
|
error_dialog.exec()
|
||||||
logger.warning(f"yt-dlp setup failed, path does not exist: {expected_path}")
|
logger.warning(f"yt-dlp setup failed, path does not exist: {expected_path}")
|
||||||
else:
|
else:
|
||||||
logger.debug("User cancelled the setup dialog")
|
logger.debug("User cancelled the setup dialog")
|
||||||
|
|
||||||
# User cancelled or setup failed, return the fallback command
|
# User cancelled or setup failed, return the fallback command
|
||||||
logger.debug("Returning fallback command 'yt-dlp'")
|
logger.debug("Returning fallback command 'yt-dlp'")
|
||||||
return "yt-dlp"
|
return "yt-dlp"
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""
|
|
||||||
Dialog modules for YTSage GUI.
|
|
||||||
|
|
||||||
This package contains all dialog classes organized by functionality:
|
|
||||||
- Base dialogs (LogWindow, AboutDialog)
|
|
||||||
- Settings dialogs (DownloadSettingsDialog, AutoUpdateSettingsDialog)
|
|
||||||
- Update dialogs (YTDLPUpdateDialog, update threads)
|
|
||||||
- FFmpeg dialogs (FFmpegCheckDialog, installation)
|
|
||||||
- Selection dialogs (SubtitleSelectionDialog, PlaylistSelectionDialog)
|
|
||||||
- Custom dialogs (CustomCommandDialog, CookieLoginDialog, etc.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Re-export all dialog classes for backward compatibility
|
|
||||||
from .ytsage_dialogs_base import LogWindow, AboutDialog
|
|
||||||
from .ytsage_dialogs_settings import DownloadSettingsDialog, AutoUpdateSettingsDialog
|
|
||||||
from .ytsage_dialogs_update import (VersionCheckThread, UpdateThread, YTDLPUpdateDialog,
|
|
||||||
AutoUpdateThread)
|
|
||||||
from .ytsage_dialogs_ffmpeg import FFmpegInstallThread, FFmpegCheckDialog
|
|
||||||
from .ytsage_dialogs_selection import SubtitleSelectionDialog, PlaylistSelectionDialog, SponsorBlockCategoryDialog
|
|
||||||
from .ytsage_dialogs_custom import (CustomCommandDialog, CookieLoginDialog,
|
|
||||||
CustomOptionsDialog, TimeRangeDialog)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Base dialogs
|
|
||||||
'LogWindow', 'AboutDialog',
|
|
||||||
|
|
||||||
# Settings dialogs
|
|
||||||
'DownloadSettingsDialog', 'AutoUpdateSettingsDialog',
|
|
||||||
|
|
||||||
# Update dialogs and threads
|
|
||||||
'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread',
|
|
||||||
|
|
||||||
# FFmpeg dialogs
|
|
||||||
'FFmpegInstallThread', 'FFmpegCheckDialog',
|
|
||||||
|
|
||||||
# Selection dialogs
|
|
||||||
'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog',
|
|
||||||
|
|
||||||
# Custom functionality dialogs
|
|
||||||
'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog'
|
|
||||||
]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""
|
|
||||||
YTSage GUI Dialogs Module
|
|
||||||
|
|
||||||
This module serves as a centralized import point for all dialog classes in YTSage.
|
|
||||||
The dialogs have been split into logical modules for better maintainability:
|
|
||||||
|
|
||||||
- dialogs.ytsage_dialogs_base: Base utility dialogs (LogWindow, AboutDialog)
|
|
||||||
- dialogs.ytsage_dialogs_settings: Settings configuration dialogs
|
|
||||||
- dialogs.ytsage_dialogs_update: Update-related dialogs and threads
|
|
||||||
- dialogs.ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
|
|
||||||
- dialogs.ytsage_dialogs_selection: Subtitle and playlist selection dialogs
|
|
||||||
- dialogs.ytsage_dialogs_custom: Custom functionality dialogs
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Import all dialog classes from the dialogs package
|
|
||||||
from .dialogs import *
|
|
||||||
|
|
||||||
# For backward compatibility, re-export all dialog classes
|
|
||||||
__all__ = [
|
|
||||||
# Base dialogs
|
|
||||||
'LogWindow', 'AboutDialog',
|
|
||||||
|
|
||||||
# Settings dialogs
|
|
||||||
'DownloadSettingsDialog', 'AutoUpdateSettingsDialog',
|
|
||||||
|
|
||||||
# Update dialogs and threads
|
|
||||||
'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread',
|
|
||||||
|
|
||||||
# FFmpeg dialogs
|
|
||||||
'FFmpegInstallThread', 'FFmpegCheckDialog',
|
|
||||||
|
|
||||||
# Selection dialogs
|
|
||||||
'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog',
|
|
||||||
|
|
||||||
# Custom functionality dialogs
|
|
||||||
'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog'
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Dialog modules for YTSage GUI.
|
||||||
|
|
||||||
|
This package contains all dialog classes organized by functionality:
|
||||||
|
|
||||||
|
- ytsage_dialogs_base: Base utility dialogs
|
||||||
|
- ytsage_dialogs_settings: Settings configuration dialogs
|
||||||
|
- ytsage_dialogs_update: Update-related dialogs and threads
|
||||||
|
- ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
|
||||||
|
- ytsage_dialogs_selection: Subtitle and playlist selection dialogs
|
||||||
|
- ytsage_dialogs_custom: Custom functionality dialogs
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Re-export all dialog classes for backward compatibility
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import (
|
||||||
|
CookieLoginDialog,
|
||||||
|
CustomCommandDialog,
|
||||||
|
CustomOptionsDialog,
|
||||||
|
TimeRangeDialog,
|
||||||
|
)
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
|
||||||
|
PlaylistSelectionDialog,
|
||||||
|
SponsorBlockCategoryDialog,
|
||||||
|
SubtitleSelectionDialog,
|
||||||
|
)
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog
|
||||||
|
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Base dialogs
|
||||||
|
"LogWindow",
|
||||||
|
"AboutDialog",
|
||||||
|
|
||||||
|
# Settings dialogs
|
||||||
|
"DownloadSettingsDialog",
|
||||||
|
"AutoUpdateSettingsDialog",
|
||||||
|
|
||||||
|
# Update dialogs and threads
|
||||||
|
"VersionCheckThread",
|
||||||
|
"UpdateThread",
|
||||||
|
"YTDLPUpdateDialog",
|
||||||
|
"AutoUpdateThread",
|
||||||
|
|
||||||
|
# FFmpeg dialogs
|
||||||
|
"FFmpegInstallThread",
|
||||||
|
"FFmpegCheckDialog",
|
||||||
|
|
||||||
|
# Selection dialogs
|
||||||
|
"SubtitleSelectionDialog",
|
||||||
|
"PlaylistSelectionDialog",
|
||||||
|
"SponsorBlockCategoryDialog",
|
||||||
|
|
||||||
|
# Custom functionality dialogs
|
||||||
|
"CustomCommandDialog",
|
||||||
|
"CookieLoginDialog",
|
||||||
|
"CustomOptionsDialog",
|
||||||
|
"TimeRangeDialog",
|
||||||
|
]
|
||||||
+141
-99
@@ -3,32 +3,37 @@ Base dialogs for YTSage application.
|
|||||||
Contains basic utility dialogs like LogWindow and AboutDialog.
|
Contains basic utility dialogs like LogWindow and AboutDialog.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||||
import os
|
from PySide6.QtWidgets import (
|
||||||
import webbrowser
|
QDialog,
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
QDialogButtonBox,
|
||||||
QTextEdit, QWidget, QDialogButtonBox, QSizePolicy,
|
QHBoxLayout,
|
||||||
QPushButton, QMessageBox, QScrollArea)
|
QLabel,
|
||||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
QMessageBox,
|
||||||
from PySide6.QtGui import QIcon
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
from ...core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_path
|
from src.core.ytsage_ffmpeg import get_ffmpeg_path
|
||||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path, check_ytdlp_installed
|
from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache
|
||||||
from ...core.ytsage_utils import (check_ffmpeg, get_ytdlp_version, get_ffmpeg_version,
|
from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path
|
||||||
refresh_version_cache, _version_cache)
|
|
||||||
|
|
||||||
|
|
||||||
class LogWindow(QDialog):
|
class LogWindow(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle('yt-dlp Log')
|
self.setWindowTitle("yt-dlp Log")
|
||||||
self.setMinimumSize(700, 500)
|
self.setMinimumSize(700, 500)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
self.log_text = QTextEdit()
|
self.log_text = QTextEdit()
|
||||||
self.log_text.setReadOnly(True)
|
self.log_text.setReadOnly(True)
|
||||||
self.log_text.setStyleSheet("""
|
self.log_text.setStyleSheet(
|
||||||
|
"""
|
||||||
QTextEdit {
|
QTextEdit {
|
||||||
background-color: #2b2b2b;
|
background-color: #2b2b2b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -37,11 +42,12 @@ class LogWindow(QDialog):
|
|||||||
border: 2px solid #3d3d3d;
|
border: 2px solid #3d3d3d;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
layout.addWidget(self.log_text)
|
layout.addWidget(self.log_text)
|
||||||
|
|
||||||
def append_log(self, message):
|
def append_log(self, message) -> None:
|
||||||
self.log_text.append(message)
|
self.log_text.append(message)
|
||||||
# Auto-scroll to bottom
|
# Auto-scroll to bottom
|
||||||
scrollbar = self.log_text.verticalScrollBar()
|
scrollbar = self.log_text.verticalScrollBar()
|
||||||
@@ -49,17 +55,17 @@ class LogWindow(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
class AboutDialog(QDialog):
|
class AboutDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent = parent # Store parent to access version etc.
|
self._parent = parent # Store parent to access version etc.
|
||||||
self.setWindowTitle("About YTSage")
|
self.setWindowTitle("About YTSage")
|
||||||
self.setMinimumSize(460, 420) # Slightly increased to accommodate paths
|
self.setMinimumSize(460, 420) # Slightly increased to accommodate paths
|
||||||
self.resize(460, 440) # Slightly increased initial size
|
self.resize(460, 440) # Slightly increased initial size
|
||||||
self.setMaximumSize(500, 480) # Reasonable maximum size
|
self.setMaximumSize(500, 480) # Reasonable maximum size
|
||||||
|
|
||||||
# Set window flags to make dialog independent of parent movement
|
# Set window flags to make dialog independent of parent movement
|
||||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setSpacing(15) # Reduced spacing
|
layout.setSpacing(15) # Reduced spacing
|
||||||
layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
|
layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
|
||||||
@@ -84,7 +90,7 @@ class AboutDialog(QDialog):
|
|||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
|
|
||||||
# Center the button
|
# Center the button
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
button_layout.addWidget(button_box)
|
button_layout.addWidget(button_box)
|
||||||
@@ -92,7 +98,8 @@ class AboutDialog(QDialog):
|
|||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
# Apply overall styling - improved consistency
|
# Apply overall styling - improved consistency
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -131,20 +138,25 @@ class AboutDialog(QDialog):
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _create_app_info_section(self):
|
def _create_app_info_section(self) -> QWidget:
|
||||||
"""Create the application information section - compact version"""
|
"""Create the application information section - compact version"""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout(widget)
|
layout = QVBoxLayout(widget)
|
||||||
layout.setSpacing(6) # Reduced spacing
|
layout.setSpacing(6) # Reduced spacing
|
||||||
|
|
||||||
# Title and Version - more compact
|
# Title and Version - more compact
|
||||||
title_label = QLabel("<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>")
|
title_label = QLabel(
|
||||||
|
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>"
|
||||||
|
)
|
||||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
version_label = QLabel(f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self.parent, 'version', '4.7.0')}</span>")
|
version_label = QLabel(
|
||||||
|
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.7.0')}</span>"
|
||||||
|
)
|
||||||
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(version_label)
|
layout.addWidget(version_label)
|
||||||
|
|
||||||
@@ -158,49 +170,56 @@ class AboutDialog(QDialog):
|
|||||||
# Author and Links - compact single line
|
# Author and Links - compact single line
|
||||||
info_layout = QHBoxLayout()
|
info_layout = QHBoxLayout()
|
||||||
info_layout.setSpacing(15)
|
info_layout.setSpacing(15)
|
||||||
|
|
||||||
author_label = QLabel("By: <a href='https://github.com/oop7/' style='color: #c90000; text-decoration: none; font-size: 10px;'>oop7</a>")
|
author_label = QLabel(
|
||||||
|
"By: <a href='https://github.com/oop7/' style='color: #c90000; text-decoration: none; font-size: 10px;'>oop7</a>"
|
||||||
|
)
|
||||||
author_label.setOpenExternalLinks(True)
|
author_label.setOpenExternalLinks(True)
|
||||||
info_layout.addWidget(author_label)
|
info_layout.addWidget(author_label)
|
||||||
|
|
||||||
repo_label = QLabel("GitHub: <a href='https://github.com/oop7/YTSage/' style='color: #c90000; text-decoration: none; font-size: 10px;'>YTSage</a>")
|
repo_label = QLabel(
|
||||||
|
"GitHub: <a href='https://github.com/oop7/YTSage/' style='color: #c90000; text-decoration: none; font-size: 10px;'>YTSage</a>"
|
||||||
|
)
|
||||||
repo_label.setOpenExternalLinks(True)
|
repo_label.setOpenExternalLinks(True)
|
||||||
info_layout.addWidget(repo_label)
|
info_layout.addWidget(repo_label)
|
||||||
|
|
||||||
# Center the info layout
|
# Center the info layout
|
||||||
info_container = QHBoxLayout()
|
info_container = QHBoxLayout()
|
||||||
info_container.addStretch()
|
info_container.addStretch()
|
||||||
info_container.addLayout(info_layout)
|
info_container.addLayout(info_layout)
|
||||||
info_container.addStretch()
|
info_container.addStretch()
|
||||||
|
|
||||||
layout.addLayout(info_container)
|
layout.addLayout(info_container)
|
||||||
|
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _create_system_info_section(self):
|
def _create_system_info_section(self) -> QWidget:
|
||||||
"""Create the system information section with compact design"""
|
"""Create the system information section with compact design"""
|
||||||
# Create main container with compact styling
|
# Create main container with compact styling
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
container.setStyleSheet("""
|
container.setStyleSheet(
|
||||||
|
"""
|
||||||
QWidget {
|
QWidget {
|
||||||
border: 1px solid #333333;
|
border: 1px solid #333333;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
main_layout = QVBoxLayout(container)
|
main_layout = QVBoxLayout(container)
|
||||||
main_layout.setSpacing(8) # Compact spacing
|
main_layout.setSpacing(8) # Compact spacing
|
||||||
main_layout.setContentsMargins(15, 10, 15, 10)
|
main_layout.setContentsMargins(15, 10, 15, 10)
|
||||||
|
|
||||||
# Create header with title and refresh button on same line
|
# Create header with title and refresh button on same line
|
||||||
header_layout = QHBoxLayout()
|
header_layout = QHBoxLayout()
|
||||||
header_layout.setContentsMargins(0, 0, 0, 5)
|
header_layout.setContentsMargins(0, 0, 0, 5)
|
||||||
|
|
||||||
# System Information title
|
# System Information title
|
||||||
title_label = QLabel("System Information")
|
title_label = QLabel("System Information")
|
||||||
title_label.setStyleSheet("""
|
title_label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -208,16 +227,18 @@ class AboutDialog(QDialog):
|
|||||||
padding: 0px;
|
padding: 0px;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
header_layout.addWidget(title_label)
|
header_layout.addWidget(title_label)
|
||||||
|
|
||||||
# Add stretch to push refresh button to the right
|
# Add stretch to push refresh button to the right
|
||||||
header_layout.addStretch()
|
header_layout.addStretch()
|
||||||
|
|
||||||
# Create refresh button
|
# Create refresh button
|
||||||
self.refresh_btn = QPushButton("🔄")
|
self.refresh_btn = QPushButton("🔄")
|
||||||
self.refresh_btn.setFixedSize(16, 16)
|
self.refresh_btn.setFixedSize(16, 16)
|
||||||
self.refresh_btn.setStyleSheet("""
|
self.refresh_btn.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -236,99 +257,103 @@ class AboutDialog(QDialog):
|
|||||||
color: #c90000;
|
color: #c90000;
|
||||||
background-color: rgba(201, 0, 0, 0.1);
|
background-color: rgba(201, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.refresh_btn.clicked.connect(self.refresh_version_info)
|
self.refresh_btn.clicked.connect(self.refresh_version_info)
|
||||||
header_layout.addWidget(self.refresh_btn)
|
header_layout.addWidget(self.refresh_btn)
|
||||||
|
|
||||||
main_layout.addLayout(header_layout)
|
main_layout.addLayout(header_layout)
|
||||||
|
|
||||||
# Compact status grid layout
|
# Compact status grid layout
|
||||||
self.status_container = QVBoxLayout()
|
self.status_container = QVBoxLayout()
|
||||||
self.status_container.setSpacing(6) # Tight spacing
|
self.status_container.setSpacing(6) # Tight spacing
|
||||||
self.status_container.setContentsMargins(0, 0, 0, 0)
|
self.status_container.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
main_layout.addLayout(self.status_container)
|
main_layout.addLayout(self.status_container)
|
||||||
|
|
||||||
# Show loading message initially
|
# Show loading message initially
|
||||||
self._show_loading_message()
|
self._show_loading_message()
|
||||||
|
|
||||||
# Populate system information asynchronously
|
# Populate system information asynchronously
|
||||||
QTimer.singleShot(100, self.update_system_info)
|
QTimer.singleShot(100, self.update_system_info)
|
||||||
|
|
||||||
return container
|
return container
|
||||||
|
|
||||||
def _show_loading_message(self):
|
def _show_loading_message(self) -> None:
|
||||||
"""Show a compact loading message while system information is being gathered."""
|
"""Show a compact loading message while system information is being gathered."""
|
||||||
loading_label = QLabel("🔄 Loading system information...")
|
loading_label = QLabel("🔄 Loading system information...")
|
||||||
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
loading_label.setStyleSheet("""
|
loading_label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
self.status_container.addWidget(loading_label)
|
self.status_container.addWidget(loading_label)
|
||||||
|
|
||||||
def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status=""):
|
def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status="") -> QWidget:
|
||||||
"""Create a compact status item widget"""
|
"""Create a compact status item widget"""
|
||||||
item_widget = QWidget()
|
item_widget = QWidget()
|
||||||
# Adjust height based on whether we have path info
|
# Adjust height based on whether we have path info
|
||||||
item_height = 50 if path_text else 35
|
item_height = 50 if path_text else 35
|
||||||
item_widget.setMaximumHeight(item_height)
|
item_widget.setMaximumHeight(item_height)
|
||||||
item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
|
||||||
# Main layout
|
# Main layout
|
||||||
item_layout = QVBoxLayout(item_widget)
|
item_layout = QVBoxLayout(item_widget)
|
||||||
item_layout.setContentsMargins(8, 4, 8, 4)
|
item_layout.setContentsMargins(8, 4, 8, 4)
|
||||||
item_layout.setSpacing(2)
|
item_layout.setSpacing(2)
|
||||||
|
|
||||||
# First row: Icon, name, status, version
|
# First row: Icon, name, status, version
|
||||||
first_row = QHBoxLayout()
|
first_row = QHBoxLayout()
|
||||||
first_row.setSpacing(8)
|
first_row.setSpacing(8)
|
||||||
|
|
||||||
# Icon and name - compact
|
# Icon and name - compact
|
||||||
name_label = QLabel(f"{icon} <b>{name}</b>")
|
name_label = QLabel(f"{icon} <b>{name}</b>")
|
||||||
name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
|
name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
|
||||||
name_label.setMinimumWidth(80)
|
name_label.setMinimumWidth(80)
|
||||||
first_row.addWidget(name_label)
|
first_row.addWidget(name_label)
|
||||||
|
|
||||||
# Status - compact
|
# Status - compact
|
||||||
status_label = QLabel(status_text)
|
status_label = QLabel(status_text)
|
||||||
status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
|
status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
|
||||||
status_label.setMinimumWidth(70)
|
status_label.setMinimumWidth(70)
|
||||||
first_row.addWidget(status_label)
|
first_row.addWidget(status_label)
|
||||||
|
|
||||||
# Version info - improved readability
|
# Version info - improved readability
|
||||||
version_info = version_text
|
version_info = version_text
|
||||||
if cache_status:
|
if cache_status:
|
||||||
version_info += cache_status
|
version_info += cache_status
|
||||||
|
|
||||||
version_label = QLabel(version_info)
|
version_label = QLabel(version_info)
|
||||||
version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
|
version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
|
||||||
version_label.setWordWrap(False)
|
version_label.setWordWrap(False)
|
||||||
first_row.addWidget(version_label)
|
first_row.addWidget(version_label)
|
||||||
|
|
||||||
# Add stretch to push everything left
|
# Add stretch to push everything left
|
||||||
first_row.addStretch()
|
first_row.addStretch()
|
||||||
|
|
||||||
item_layout.addLayout(first_row)
|
item_layout.addLayout(first_row)
|
||||||
|
|
||||||
# Second row: Path (if provided)
|
# Second row: Path (if provided)
|
||||||
if path_text:
|
if path_text:
|
||||||
path_label = QLabel(f"📁 {path_text}")
|
path_label = QLabel(f"📁 {path_text}")
|
||||||
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
|
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
|
||||||
path_label.setWordWrap(False)
|
path_label.setWordWrap(False)
|
||||||
# Truncate very long paths
|
# Truncate very long paths
|
||||||
if len(path_text) > 60:
|
if len(str(path_text)) > 60:
|
||||||
truncated_path = "..." + path_text[-57:]
|
truncated_path = "..." + str(path_text)[-57:]
|
||||||
path_label.setText(f"📁 {truncated_path}")
|
path_label.setText(f"📁 {truncated_path}")
|
||||||
item_layout.addWidget(path_label)
|
item_layout.addWidget(path_label)
|
||||||
|
|
||||||
# Subtle background with minimal border
|
# Subtle background with minimal border
|
||||||
item_widget.setStyleSheet("""
|
item_widget.setStyleSheet(
|
||||||
|
"""
|
||||||
QWidget {
|
QWidget {
|
||||||
background-color: rgba(45, 45, 45, 0.3);
|
background-color: rgba(45, 45, 45, 0.3);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid #2a2a2a;
|
||||||
@@ -338,11 +363,12 @@ class AboutDialog(QDialog):
|
|||||||
QWidget:hover {
|
QWidget:hover {
|
||||||
background-color: rgba(60, 60, 60, 0.4);
|
background-color: rgba(60, 60, 60, 0.4);
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
return item_widget
|
return item_widget
|
||||||
|
|
||||||
def update_system_info(self):
|
def update_system_info(self) -> None:
|
||||||
"""Update the system information display with compact layout."""
|
"""Update the system information display with compact layout."""
|
||||||
# Clear existing items
|
# Clear existing items
|
||||||
for i in reversed(range(self.status_container.count())):
|
for i in reversed(range(self.status_container.count())):
|
||||||
@@ -352,86 +378,101 @@ class AboutDialog(QDialog):
|
|||||||
|
|
||||||
# yt-dlp Status - compact version with path
|
# yt-dlp Status - compact version with path
|
||||||
ytdlp_found = check_ytdlp_installed()
|
ytdlp_found = check_ytdlp_installed()
|
||||||
ytdlp_status_text = "<span style='color: #4CAF50;'>✓ Detected</span>" if ytdlp_found else "<span style='color: #F44336;'>✗ Missing</span>"
|
ytdlp_status_text = (
|
||||||
|
"<span style='color: #4CAF50;'>✓ Detected</span>" if ytdlp_found else "<span style='color: #F44336;'>✗ Missing</span>"
|
||||||
|
)
|
||||||
ytdlp_version = get_ytdlp_version()
|
ytdlp_version = get_ytdlp_version()
|
||||||
|
|
||||||
# Get yt-dlp path
|
# Get yt-dlp path
|
||||||
ytdlp_path = get_yt_dlp_path() if ytdlp_found else None
|
ytdlp_path = get_yt_dlp_path() if ytdlp_found else None
|
||||||
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
|
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
|
||||||
|
|
||||||
# Simplified cache status
|
# Simplified cache status
|
||||||
ytdlp_cache = _version_cache.get('ytdlp', {})
|
ytdlp_cache = _version_cache.get("ytdlp", {})
|
||||||
last_check = ytdlp_cache.get('last_check', 0)
|
last_check = ytdlp_cache.get("last_check", 0)
|
||||||
cache_status = ""
|
cache_status = ""
|
||||||
if last_check > 0:
|
if last_check > 0:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||||
|
|
||||||
ytdlp_item = self._create_status_item(
|
ytdlp_item = self._create_status_item(
|
||||||
"🎥", "yt-dlp", ytdlp_status_text, ytdlp_version + cache_status, ytdlp_path_text
|
"🎥",
|
||||||
|
"yt-dlp",
|
||||||
|
ytdlp_status_text,
|
||||||
|
ytdlp_version + cache_status,
|
||||||
|
ytdlp_path_text,
|
||||||
)
|
)
|
||||||
self.status_container.addWidget(ytdlp_item)
|
self.status_container.addWidget(ytdlp_item)
|
||||||
|
|
||||||
# FFmpeg Status - compact version with path
|
# FFmpeg Status - compact version with path
|
||||||
ffmpeg_found = check_ffmpeg()
|
ffmpeg_found = check_ffmpeg()
|
||||||
ffmpeg_status_text = "<span style='color: #4CAF50;'>✓ Detected</span>" if ffmpeg_found else "<span style='color: #F44336;'>✗ Missing</span>"
|
ffmpeg_status_text = (
|
||||||
|
"<span style='color: #4CAF50;'>✓ Detected</span>"
|
||||||
|
if ffmpeg_found
|
||||||
|
else "<span style='color: #F44336;'>✗ Missing</span>"
|
||||||
|
)
|
||||||
ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
|
ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
|
||||||
|
|
||||||
# Get FFmpeg path
|
# Get FFmpeg path
|
||||||
ffmpeg_path_text = None
|
ffmpeg_path_text = None
|
||||||
if ffmpeg_found:
|
if ffmpeg_found:
|
||||||
ffmpeg_path = get_ffmpeg_path()
|
ffmpeg_path = get_ffmpeg_path()
|
||||||
ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None
|
ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None
|
||||||
|
|
||||||
# Simplified cache status for FFmpeg
|
# Simplified cache status for FFmpeg
|
||||||
ffmpeg_cache = _version_cache.get('ffmpeg', {})
|
ffmpeg_cache = _version_cache.get("ffmpeg", {})
|
||||||
last_check = ffmpeg_cache.get('last_check', 0)
|
last_check = ffmpeg_cache.get("last_check", 0)
|
||||||
cache_status = ""
|
cache_status = ""
|
||||||
if last_check > 0 and ffmpeg_found:
|
if last_check > 0 and ffmpeg_found:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||||
|
|
||||||
ffmpeg_item = self._create_status_item(
|
ffmpeg_item = self._create_status_item(
|
||||||
"🎬", "FFmpeg", ffmpeg_status_text, ffmpeg_version + cache_status, ffmpeg_path_text
|
"🎬",
|
||||||
|
"FFmpeg",
|
||||||
|
ffmpeg_status_text,
|
||||||
|
ffmpeg_version + cache_status,
|
||||||
|
ffmpeg_path_text,
|
||||||
)
|
)
|
||||||
self.status_container.addWidget(ffmpeg_item)
|
self.status_container.addWidget(ffmpeg_item)
|
||||||
|
|
||||||
def refresh_version_info(self):
|
def refresh_version_info(self) -> None:
|
||||||
"""Refresh version information manually."""
|
"""Refresh version information manually."""
|
||||||
self.refresh_btn.setText("🔄 Refreshing...")
|
self.refresh_btn.setText("🔄 Refreshing...")
|
||||||
self.refresh_btn.setEnabled(False)
|
self.refresh_btn.setEnabled(False)
|
||||||
|
|
||||||
# Perform refresh in a separate thread to avoid blocking UI
|
# Perform refresh in a separate thread to avoid blocking UI
|
||||||
from PySide6.QtCore import QThread, Signal
|
|
||||||
|
|
||||||
class RefreshThread(QThread):
|
class RefreshThread(QThread):
|
||||||
finished = Signal(bool)
|
finished = Signal(bool)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
success = refresh_version_cache(force=True)
|
success = refresh_version_cache(force=True)
|
||||||
self.finished.emit(success)
|
self.finished.emit(success)
|
||||||
|
|
||||||
self.refresh_thread = RefreshThread()
|
self.refresh_thread = RefreshThread()
|
||||||
self.refresh_thread.finished.connect(self.on_refresh_finished)
|
self.refresh_thread.finished.connect(self.on_refresh_finished)
|
||||||
self.refresh_thread.start()
|
self.refresh_thread.start()
|
||||||
|
|
||||||
def on_refresh_finished(self, success):
|
def on_refresh_finished(self, success) -> None:
|
||||||
"""Handle refresh completion."""
|
"""Handle refresh completion."""
|
||||||
self.refresh_btn.setText("🔄 Refresh")
|
self.refresh_btn.setText("🔄 Refresh")
|
||||||
self.refresh_btn.setEnabled(True)
|
self.refresh_btn.setEnabled(True)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.update_system_info()
|
self.update_system_info()
|
||||||
else:
|
else:
|
||||||
# Show error message with proper styling
|
# Show error message with proper styling
|
||||||
msg_box = QMessageBox(self)
|
msg_box = QMessageBox(self)
|
||||||
msg_box.setIcon(QMessageBox.Warning)
|
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||||
msg_box.setWindowTitle("Refresh Failed")
|
msg_box.setWindowTitle("Refresh Failed")
|
||||||
msg_box.setText("Failed to refresh version information.")
|
msg_box.setText("Failed to refresh version information.")
|
||||||
msg_box.setWindowIcon(self.windowIcon())
|
msg_box.setWindowIcon(self.windowIcon())
|
||||||
msg_box.setStyleSheet("""
|
msg_box.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -451,5 +492,6 @@ class AboutDialog(QDialog):
|
|||||||
QMessageBox QPushButton:hover {
|
QMessageBox QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
+221
-160
@@ -3,30 +3,47 @@ Custom functionality dialogs for YTSage application.
|
|||||||
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
import threading
|
||||||
QLineEdit, QPushButton, QTextEdit, QPlainTextEdit,
|
from pathlib import Path
|
||||||
QCheckBox, QTabWidget, QWidget, QDialogButtonBox,
|
from typing import TYPE_CHECKING, cast
|
||||||
QFileDialog, QGroupBox)
|
|
||||||
from PySide6.QtCore import Qt, QMetaObject, Q_ARG
|
|
||||||
|
|
||||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
from PySide6.QtCore import Q_ARG, QMetaObject, Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
YT_DLP_AVAILABLE = True
|
YT_DLP_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
YT_DLP_AVAILABLE = False
|
YT_DLP_AVAILABLE = False
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
|
||||||
|
|
||||||
|
|
||||||
class CustomCommandDialog(QDialog):
|
class CustomCommandDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent = parent
|
self._parent = self.parent()
|
||||||
self.setWindowTitle('Custom yt-dlp Command')
|
self.setWindowTitle("Custom yt-dlp Command")
|
||||||
self.setMinimumSize(600, 400)
|
self.setMinimumSize(600, 400)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -44,7 +61,8 @@ class CustomCommandDialog(QDialog):
|
|||||||
# Command input
|
# Command input
|
||||||
self.command_input = QPlainTextEdit()
|
self.command_input = QPlainTextEdit()
|
||||||
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
||||||
self.command_input.setStyleSheet("""
|
self.command_input.setStyleSheet(
|
||||||
|
"""
|
||||||
QPlainTextEdit {
|
QPlainTextEdit {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -53,12 +71,14 @@ class CustomCommandDialog(QDialog):
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.addWidget(self.command_input)
|
layout.addWidget(self.command_input)
|
||||||
|
|
||||||
# Add SponsorBlock checkbox
|
# Add SponsorBlock checkbox
|
||||||
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
||||||
self.sponsorblock_checkbox.setStyleSheet("""
|
self.sponsorblock_checkbox.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -79,7 +99,8 @@ class CustomCommandDialog(QDialog):
|
|||||||
background: #c90000;
|
background: #c90000;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
|
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
@@ -98,7 +119,8 @@ class CustomCommandDialog(QDialog):
|
|||||||
# Log output
|
# Log output
|
||||||
self.log_output = QTextEdit()
|
self.log_output = QTextEdit()
|
||||||
self.log_output.setReadOnly(True)
|
self.log_output.setReadOnly(True)
|
||||||
self.log_output.setStyleSheet("""
|
self.log_output.setStyleSheet(
|
||||||
|
"""
|
||||||
QTextEdit {
|
QTextEdit {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -108,10 +130,12 @@ class CustomCommandDialog(QDialog):
|
|||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.addWidget(self.log_output)
|
layout.addWidget(self.log_output)
|
||||||
|
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
}
|
}
|
||||||
@@ -126,35 +150,38 @@ class CustomCommandDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def run_custom_command(self):
|
def run_custom_command(self) -> None:
|
||||||
url = self.parent.url_input.text().strip()
|
url = self._parent.url_input.text().strip() # type: ignore[reportAttributeAccessIssue]
|
||||||
if not url:
|
if not url:
|
||||||
self.log_output.append("Error: No URL provided")
|
self.log_output.append("Error: No URL provided")
|
||||||
return
|
return
|
||||||
|
|
||||||
command = self.command_input.toPlainText().strip()
|
command = self.command_input.toPlainText().strip()
|
||||||
path = self.parent.path_input.text().strip()
|
path = self._parent.path_input.text().strip() # type: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
self.log_output.clear()
|
self.log_output.clear()
|
||||||
self.log_output.append(f"Running command with URL: {url}")
|
self.log_output.append(f"Running command with URL: {url}")
|
||||||
self.run_btn.setEnabled(False)
|
self.run_btn.setEnabled(False)
|
||||||
|
|
||||||
# Start command in thread
|
# Start command in thread
|
||||||
threading.Thread(target=self._run_command_thread,
|
threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start()
|
||||||
args=(command, url, path),
|
|
||||||
daemon=True).start()
|
|
||||||
|
|
||||||
def _run_command_thread(self, command, url, path):
|
def _run_command_thread(self, command, url, path) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
||||||
class CommandLogger:
|
class CommandLogger:
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
self.dialog.log_output.append(msg)
|
self.dialog.log_output.append(msg)
|
||||||
|
|
||||||
def warning(self, msg):
|
def warning(self, msg):
|
||||||
self.dialog.log_output.append(f"Warning: {msg}")
|
self.dialog.log_output.append(f"Warning: {msg}")
|
||||||
|
|
||||||
def error(self, msg):
|
def error(self, msg):
|
||||||
self.dialog.log_output.append(f"Error: {msg}")
|
self.dialog.log_output.append(f"Error: {msg}")
|
||||||
|
|
||||||
def __init__(self, dialog):
|
def __init__(self, dialog):
|
||||||
self.dialog = dialog
|
self.dialog = dialog
|
||||||
|
|
||||||
@@ -163,34 +190,43 @@ class CustomCommandDialog(QDialog):
|
|||||||
|
|
||||||
# Base options
|
# Base options
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'logger': CommandLogger(self),
|
"logger": CommandLogger(self),
|
||||||
'paths': {'home': path},
|
"paths": {"home": path},
|
||||||
'debug_printout': True,
|
"debug_printout": True,
|
||||||
'postprocessors': []
|
"postprocessors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add SponsorBlock options if enabled
|
# Add SponsorBlock options if enabled
|
||||||
if self.sponsorblock_checkbox.isChecked():
|
if self.sponsorblock_checkbox.isChecked():
|
||||||
ydl_opts['postprocessors'].extend([{
|
ydl_opts["postprocessors"].extend(
|
||||||
'key': 'SponsorBlock',
|
[
|
||||||
'categories': ['sponsor', 'selfpromo', 'interaction'],
|
{
|
||||||
'api': 'https://sponsor.ajay.app'
|
"key": "SponsorBlock",
|
||||||
}, {
|
"categories": ["sponsor", "selfpromo", "interaction"],
|
||||||
'key': 'ModifyChapters',
|
"api": "https://sponsor.ajay.app",
|
||||||
'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'],
|
},
|
||||||
'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l',
|
{
|
||||||
'force_keyframes': True
|
"key": "ModifyChapters",
|
||||||
}])
|
"remove_sponsor_segments": [
|
||||||
|
"sponsor",
|
||||||
|
"selfpromo",
|
||||||
|
"interaction",
|
||||||
|
],
|
||||||
|
"sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l",
|
||||||
|
"force_keyframes": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Add custom arguments
|
# Add custom arguments
|
||||||
for i in range(0, len(args), 2):
|
for i in range(0, len(args), 2):
|
||||||
if i + 1 < len(args):
|
if i + 1 < len(args):
|
||||||
key = args[i].lstrip('-').replace('-', '_')
|
key = args[i].lstrip("-").replace("-", "_")
|
||||||
value = args[i + 1]
|
value = args[i + 1]
|
||||||
try:
|
try:
|
||||||
# Try to convert to appropriate type
|
# Try to convert to appropriate type
|
||||||
if value.lower() in ('true', 'false'):
|
if value.lower() in ("true", "false"):
|
||||||
value = value.lower() == 'true'
|
value = value.lower() == "true"
|
||||||
elif value.isdigit():
|
elif value.isdigit():
|
||||||
value = int(value)
|
value = int(value)
|
||||||
ydl_opts[key] = value
|
ydl_opts[key] = value
|
||||||
@@ -209,9 +245,9 @@ class CustomCommandDialog(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
class CookieLoginDialog(QDialog):
|
class CookieLoginDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle('Login with Cookies')
|
self.setWindowTitle("Login with Cookies")
|
||||||
self.setMinimumSize(400, 150)
|
self.setMinimumSize(400, 150)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -237,43 +273,38 @@ class CookieLoginDialog(QDialog):
|
|||||||
layout.addLayout(path_layout)
|
layout.addLayout(path_layout)
|
||||||
|
|
||||||
# Dialog buttons
|
# Dialog buttons
|
||||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
def browse_cookie_file(self):
|
def browse_cookie_file(self) -> None:
|
||||||
# Open file dialog to select cookie file
|
# Open file dialog to select cookie file
|
||||||
file_dialog = QFileDialog(self)
|
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
|
||||||
file_dialog.setFileMode(QFileDialog.ExistingFile)
|
if selected_files:
|
||||||
file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
|
self.cookie_path_input.setText(selected_files[0])
|
||||||
if file_dialog.exec():
|
|
||||||
selected_files = file_dialog.selectedFiles()
|
|
||||||
if selected_files:
|
|
||||||
self.cookie_path_input.setText(selected_files[0])
|
|
||||||
|
|
||||||
def get_cookie_file_path(self):
|
def get_cookie_file_path(self) -> str:
|
||||||
# Return the selected cookie file path
|
# Return the selected cookie file path
|
||||||
return self.cookie_path_input.text()
|
return self.cookie_path_input.text()
|
||||||
|
|
||||||
|
|
||||||
class CustomOptionsDialog(QDialog):
|
class CustomOptionsDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent = parent
|
self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking.
|
||||||
self.setWindowTitle('Custom Options')
|
self.setWindowTitle("Custom Options")
|
||||||
self.setMinimumSize(600, 500)
|
self.setMinimumSize(600, 500)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Create tab widget to organize content
|
# Create tab widget to organize content
|
||||||
self.tab_widget = QTabWidget()
|
self.tab_widget = QTabWidget()
|
||||||
layout.addWidget(self.tab_widget)
|
layout.addWidget(self.tab_widget)
|
||||||
|
|
||||||
# === Cookies Tab ===
|
# === Cookies Tab ===
|
||||||
cookies_tab = QWidget()
|
cookies_tab = QWidget()
|
||||||
cookies_layout = QVBoxLayout(cookies_tab)
|
cookies_layout = QVBoxLayout(cookies_tab)
|
||||||
|
|
||||||
# Help text
|
# Help text
|
||||||
help_text = QLabel(
|
help_text = QLabel(
|
||||||
"Select the Netscape-format cookies file for logging in.\n"
|
"Select the Netscape-format cookies file for logging in.\n"
|
||||||
@@ -287,26 +318,26 @@ class CustomOptionsDialog(QDialog):
|
|||||||
path_layout = QHBoxLayout()
|
path_layout = QHBoxLayout()
|
||||||
self.cookie_path_input = QLineEdit()
|
self.cookie_path_input = QLineEdit()
|
||||||
self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
|
self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
|
||||||
if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path:
|
if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
|
||||||
self.cookie_path_input.setText(parent.cookie_file_path)
|
self.cookie_path_input.setText(self._parent.cookie_file_path.as_posix())
|
||||||
path_layout.addWidget(self.cookie_path_input)
|
path_layout.addWidget(self.cookie_path_input)
|
||||||
|
|
||||||
self.browse_button = QPushButton("Browse")
|
self.browse_button = QPushButton("Browse")
|
||||||
self.browse_button.clicked.connect(self.browse_cookie_file)
|
self.browse_button.clicked.connect(self.browse_cookie_file)
|
||||||
path_layout.addWidget(self.browse_button)
|
path_layout.addWidget(self.browse_button)
|
||||||
cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
|
cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
|
||||||
|
|
||||||
# Status indicator for cookies
|
# Status indicator for cookies
|
||||||
self.cookie_status = QLabel("")
|
self.cookie_status = QLabel("")
|
||||||
self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
|
self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
|
||||||
cookies_layout.addWidget(self.cookie_status)
|
cookies_layout.addWidget(self.cookie_status)
|
||||||
|
|
||||||
cookies_layout.addStretch()
|
cookies_layout.addStretch()
|
||||||
|
|
||||||
# === Custom Command Tab ===
|
# === Custom Command Tab ===
|
||||||
command_tab = QWidget()
|
command_tab = QWidget()
|
||||||
command_layout = QVBoxLayout(command_tab)
|
command_layout = QVBoxLayout(command_tab)
|
||||||
|
|
||||||
# Help text
|
# Help text
|
||||||
cmd_help_text = QLabel(
|
cmd_help_text = QLabel(
|
||||||
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
|
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
|
||||||
@@ -319,7 +350,8 @@ class CustomOptionsDialog(QDialog):
|
|||||||
|
|
||||||
# Add SponsorBlock checkbox
|
# Add SponsorBlock checkbox
|
||||||
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
||||||
self.sponsorblock_checkbox.setStyleSheet("""
|
self.sponsorblock_checkbox.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -340,13 +372,15 @@ class CustomOptionsDialog(QDialog):
|
|||||||
background: #c90000;
|
background: #c90000;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
command_layout.addWidget(self.sponsorblock_checkbox)
|
command_layout.addWidget(self.sponsorblock_checkbox)
|
||||||
|
|
||||||
# Command input
|
# Command input
|
||||||
self.command_input = QPlainTextEdit()
|
self.command_input = QPlainTextEdit()
|
||||||
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
||||||
self.command_input.setStyleSheet("""
|
self.command_input.setStyleSheet(
|
||||||
|
"""
|
||||||
QPlainTextEdit {
|
QPlainTextEdit {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -355,7 +389,8 @@ class CustomOptionsDialog(QDialog):
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
command_layout.addWidget(self.command_input)
|
command_layout.addWidget(self.command_input)
|
||||||
|
|
||||||
# Run command button
|
# Run command button
|
||||||
@@ -366,7 +401,8 @@ class CustomOptionsDialog(QDialog):
|
|||||||
# Log output
|
# Log output
|
||||||
self.log_output = QTextEdit()
|
self.log_output = QTextEdit()
|
||||||
self.log_output.setReadOnly(True)
|
self.log_output.setReadOnly(True)
|
||||||
self.log_output.setStyleSheet("""
|
self.log_output.setStyleSheet(
|
||||||
|
"""
|
||||||
QTextEdit {
|
QTextEdit {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -376,21 +412,23 @@ class CustomOptionsDialog(QDialog):
|
|||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
command_layout.addWidget(self.log_output)
|
command_layout.addWidget(self.log_output)
|
||||||
|
|
||||||
# Add tabs to the tab widget
|
# Add tabs to the tab widget
|
||||||
self.tab_widget.addTab(cookies_tab, "Login with Cookies")
|
self.tab_widget.addTab(cookies_tab, "Login with Cookies")
|
||||||
self.tab_widget.addTab(command_tab, "Custom Command")
|
self.tab_widget.addTab(command_tab, "Custom Command")
|
||||||
|
|
||||||
# Dialog buttons
|
# Dialog buttons
|
||||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
# Apply global styles
|
# Apply global styles
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
}
|
}
|
||||||
@@ -434,65 +472,71 @@ class CustomOptionsDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def browse_cookie_file(self):
|
def browse_cookie_file(self) -> None:
|
||||||
# Open file dialog to select cookie file
|
# Open file dialog to select cookie file
|
||||||
file_dialog = QFileDialog(self)
|
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
|
||||||
file_dialog.setFileMode(QFileDialog.ExistingFile)
|
|
||||||
file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
|
|
||||||
if file_dialog.exec():
|
|
||||||
selected_files = file_dialog.selectedFiles()
|
|
||||||
if selected_files:
|
|
||||||
self.cookie_path_input.setText(selected_files[0])
|
|
||||||
self.cookie_status.setText("Cookie file selected - Click OK to apply")
|
|
||||||
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
|
|
||||||
|
|
||||||
def get_cookie_file_path(self):
|
if selected_files:
|
||||||
|
self.cookie_path_input.setText(selected_files[0])
|
||||||
|
self.cookie_status.setText("Cookie file selected - Click OK to apply")
|
||||||
|
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
|
||||||
|
|
||||||
|
def get_cookie_file_path(self) -> Path | None:
|
||||||
# Return the selected cookie file path if it's not empty
|
# Return the selected cookie file path if it's not empty
|
||||||
path = self.cookie_path_input.text().strip()
|
path = Path(self.cookie_path_input.text().strip())
|
||||||
if path and os.path.exists(path):
|
if path and path.exists():
|
||||||
return path
|
return path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run_custom_command(self):
|
def run_custom_command(self) -> None:
|
||||||
url = self.parent.url_input.text().strip()
|
url = self._parent.url_input.text().strip()
|
||||||
if not url:
|
if not url:
|
||||||
self.log_output.append("Error: No URL provided")
|
self.log_output.append("Error: No URL provided")
|
||||||
return
|
return
|
||||||
|
|
||||||
command = self.command_input.toPlainText().strip()
|
command = self.command_input.toPlainText().strip()
|
||||||
|
|
||||||
# Get download path from parent
|
# Get download path from parent
|
||||||
path = self.parent.last_path
|
path = self._parent.last_path
|
||||||
|
|
||||||
self.log_output.clear()
|
self.log_output.clear()
|
||||||
self.log_output.append(f"Running command with URL: {url}")
|
self.log_output.append(f"Running command with URL: {url}")
|
||||||
self.run_btn.setEnabled(False)
|
self.run_btn.setEnabled(False)
|
||||||
|
|
||||||
# Start command in thread
|
# Start command in thread
|
||||||
threading.Thread(target=self._run_command_thread,
|
threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start()
|
||||||
args=(command, url, path),
|
|
||||||
daemon=True).start()
|
|
||||||
|
|
||||||
def _run_command_thread(self, command, url, path):
|
def _run_command_thread(self, command, url, path) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
||||||
class CommandLogger:
|
class CommandLogger:
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.dialog.log_output,
|
||||||
Q_ARG(str, msg)
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, msg),
|
||||||
)
|
)
|
||||||
|
|
||||||
def warning(self, msg):
|
def warning(self, msg):
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.dialog.log_output,
|
||||||
Q_ARG(str, f"Warning: {msg}")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, f"Warning: {msg}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def error(self, msg):
|
def error(self, msg):
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.dialog.log_output,
|
||||||
Q_ARG(str, f"Error: {msg}")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, f"Error: {msg}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, dialog):
|
def __init__(self, dialog):
|
||||||
self.dialog = dialog
|
self.dialog = dialog
|
||||||
|
|
||||||
@@ -504,12 +548,14 @@ class CustomOptionsDialog(QDialog):
|
|||||||
base_cmd = [yt_dlp_path] + args + [url]
|
base_cmd = [yt_dlp_path] + args + [url]
|
||||||
|
|
||||||
if self.sponsorblock_checkbox.isChecked():
|
if self.sponsorblock_checkbox.isChecked():
|
||||||
base_cmd.extend(['--sponsorblock-remove', 'sponsor,selfpromo,interaction'])
|
base_cmd.extend(["--sponsorblock-remove", "sponsor,selfpromo,interaction"])
|
||||||
|
|
||||||
# Show the full command
|
# Show the full command
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.log_output,
|
||||||
Q_ARG(str, f"Full command: {' '.join(base_cmd)}")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, f"Full command: {' '.join(base_cmd)}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run the command
|
# Run the command
|
||||||
@@ -518,50 +564,59 @@ class CustomOptionsDialog(QDialog):
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
encoding='utf-8',
|
encoding="utf-8",
|
||||||
errors='replace'
|
errors="replace",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream output
|
# Stream output
|
||||||
for line in proc.stdout:
|
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.log_output,
|
||||||
Q_ARG(str, line.rstrip())
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, line.rstrip()),
|
||||||
)
|
)
|
||||||
|
|
||||||
ret = proc.wait()
|
ret = proc.wait()
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.log_output,
|
||||||
Q_ARG(str, f"Command exited with code {ret}")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, f"Command exited with code {ret}"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.log_output,
|
||||||
Q_ARG(str, "Command completed successfully")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, "Command completed successfully"),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
self.log_output,
|
||||||
Q_ARG(str, f"Error: {str(e)}")
|
b"append",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(str, f"Error: {str(e)}"),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Re-enable the run button
|
# Re-enable the run button
|
||||||
QMetaObject.invokeMethod(
|
QMetaObject.invokeMethod(
|
||||||
self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection,
|
self.run_btn,
|
||||||
Q_ARG(bool, True)
|
b"setEnabled",
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
Q_ARG(bool, True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TimeRangeDialog(QDialog):
|
class TimeRangeDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent = parent
|
self.setWindowTitle("Download Video Section")
|
||||||
self.setWindowTitle('Download Video Section')
|
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Help text explaining the feature
|
# Help text explaining the feature
|
||||||
help_text = QLabel(
|
help_text = QLabel(
|
||||||
"Download only specific parts of a video by specifying time ranges.\n"
|
"Download only specific parts of a video by specifying time ranges.\n"
|
||||||
@@ -570,11 +625,11 @@ class TimeRangeDialog(QDialog):
|
|||||||
help_text.setWordWrap(True)
|
help_text.setWordWrap(True)
|
||||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||||
layout.addWidget(help_text)
|
layout.addWidget(help_text)
|
||||||
|
|
||||||
# Time range section
|
# Time range section
|
||||||
time_group = QGroupBox("Time Range")
|
time_group = QGroupBox("Time Range")
|
||||||
time_layout = QVBoxLayout()
|
time_layout = QVBoxLayout()
|
||||||
|
|
||||||
# Start time row
|
# Start time row
|
||||||
start_layout = QHBoxLayout()
|
start_layout = QHBoxLayout()
|
||||||
start_layout.addWidget(QLabel("Start Time:"))
|
start_layout.addWidget(QLabel("Start Time:"))
|
||||||
@@ -582,7 +637,7 @@ class TimeRangeDialog(QDialog):
|
|||||||
self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
|
self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
|
||||||
start_layout.addWidget(self.start_time_input)
|
start_layout.addWidget(self.start_time_input)
|
||||||
time_layout.addLayout(start_layout)
|
time_layout.addLayout(start_layout)
|
||||||
|
|
||||||
# End time row
|
# End time row
|
||||||
end_layout = QHBoxLayout()
|
end_layout = QHBoxLayout()
|
||||||
end_layout.addWidget(QLabel("End Time:"))
|
end_layout.addWidget(QLabel("End Time:"))
|
||||||
@@ -590,14 +645,15 @@ class TimeRangeDialog(QDialog):
|
|||||||
self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
|
self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
|
||||||
end_layout.addWidget(self.end_time_input)
|
end_layout.addWidget(self.end_time_input)
|
||||||
time_layout.addLayout(end_layout)
|
time_layout.addLayout(end_layout)
|
||||||
|
|
||||||
time_group.setLayout(time_layout)
|
time_group.setLayout(time_layout)
|
||||||
layout.addWidget(time_group)
|
layout.addWidget(time_group)
|
||||||
|
|
||||||
# Force keyframes option
|
# Force keyframes option
|
||||||
self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
|
self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
|
||||||
self.force_keyframes.setChecked(True)
|
self.force_keyframes.setChecked(True)
|
||||||
self.force_keyframes.setStyleSheet("""
|
self.force_keyframes.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -617,14 +673,16 @@ class TimeRangeDialog(QDialog):
|
|||||||
background: #c90000;
|
background: #c90000;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.addWidget(self.force_keyframes)
|
layout.addWidget(self.force_keyframes)
|
||||||
|
|
||||||
# Format preview
|
# Format preview
|
||||||
preview_group = QGroupBox("Command Preview")
|
preview_group = QGroupBox("Command Preview")
|
||||||
preview_layout = QVBoxLayout()
|
preview_layout = QVBoxLayout()
|
||||||
self.preview_label = QLabel("--download-sections \"*-\"")
|
self.preview_label = QLabel('--download-sections "*-"')
|
||||||
self.preview_label.setStyleSheet("""
|
self.preview_label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -633,24 +691,26 @@ class TimeRangeDialog(QDialog):
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
preview_layout.addWidget(self.preview_label)
|
preview_layout.addWidget(self.preview_label)
|
||||||
preview_group.setLayout(preview_layout)
|
preview_group.setLayout(preview_layout)
|
||||||
layout.addWidget(preview_group)
|
layout.addWidget(preview_group)
|
||||||
|
|
||||||
# Connect signals for live preview updates
|
# Connect signals for live preview updates
|
||||||
self.start_time_input.textChanged.connect(self.update_preview)
|
self.start_time_input.textChanged.connect(self.update_preview)
|
||||||
self.end_time_input.textChanged.connect(self.update_preview)
|
self.end_time_input.textChanged.connect(self.update_preview)
|
||||||
self.force_keyframes.stateChanged.connect(self.update_preview)
|
self.force_keyframes.stateChanged.connect(self.update_preview)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
# Apply styling
|
# Apply styling
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
}
|
}
|
||||||
@@ -687,15 +747,16 @@ class TimeRangeDialog(QDialog):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #a50000;
|
background-color: #a50000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize preview
|
# Initialize preview
|
||||||
self.update_preview()
|
self.update_preview()
|
||||||
|
|
||||||
def update_preview(self):
|
def update_preview(self) -> None:
|
||||||
start = self.start_time_input.text().strip()
|
start = self.start_time_input.text().strip()
|
||||||
end = self.end_time_input.text().strip()
|
end = self.end_time_input.text().strip()
|
||||||
|
|
||||||
if start and end:
|
if start and end:
|
||||||
time_range = f"*{start}-{end}"
|
time_range = f"*{start}-{end}"
|
||||||
elif start:
|
elif start:
|
||||||
@@ -704,21 +765,21 @@ class TimeRangeDialog(QDialog):
|
|||||||
time_range = f"*-{end}"
|
time_range = f"*-{end}"
|
||||||
else:
|
else:
|
||||||
time_range = "*-" # Full video
|
time_range = "*-" # Full video
|
||||||
|
|
||||||
preview = f"--download-sections \"{time_range}\""
|
preview = f'--download-sections "{time_range}"'
|
||||||
if self.force_keyframes.isChecked():
|
if self.force_keyframes.isChecked():
|
||||||
preview += " --force-keyframes-at-cuts"
|
preview += " --force-keyframes-at-cuts"
|
||||||
|
|
||||||
self.preview_label.setText(preview)
|
self.preview_label.setText(preview)
|
||||||
|
|
||||||
def get_download_sections(self):
|
def get_download_sections(self) -> str | None:
|
||||||
"""Returns the download sections command arguments or None if no selection made"""
|
"""Returns the download sections command arguments or None if no selection made"""
|
||||||
start = self.start_time_input.text().strip()
|
start = self.start_time_input.text().strip()
|
||||||
end = self.end_time_input.text().strip()
|
end = self.end_time_input.text().strip()
|
||||||
|
|
||||||
if not start and not end:
|
if not start and not end:
|
||||||
return None # No selection made
|
return None # No selection made
|
||||||
|
|
||||||
if start and end:
|
if start and end:
|
||||||
time_range = f"*{start}-{end}"
|
time_range = f"*{start}-{end}"
|
||||||
elif start:
|
elif start:
|
||||||
@@ -727,9 +788,9 @@ class TimeRangeDialog(QDialog):
|
|||||||
time_range = f"*-{end}"
|
time_range = f"*-{end}"
|
||||||
else:
|
else:
|
||||||
return None # Shouldn't happen but just in case
|
return None # Shouldn't happen but just in case
|
||||||
|
|
||||||
return time_range
|
return time_range
|
||||||
|
|
||||||
def get_force_keyframes(self):
|
def get_force_keyframes(self) -> bool:
|
||||||
"""Returns whether to force keyframes at cuts"""
|
"""Returns whether to force keyframes at cuts"""
|
||||||
return self.force_keyframes.isChecked()
|
return self.force_keyframes.isChecked()
|
||||||
+35
-39
@@ -3,57 +3,52 @@ FFmpeg installation dialogs for YTSage application.
|
|||||||
Contains dialogs and threads for checking and installing FFmpeg.
|
Contains dialogs and threads for checking and installing FFmpeg.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import webbrowser
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import webbrowser
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QPushButton, QStyle, QSizePolicy, QDialogButtonBox)
|
|
||||||
from PySide6.QtCore import QThread, Signal, Qt
|
|
||||||
from PySide6.QtGui import QIcon
|
|
||||||
|
|
||||||
from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
|
from PySide6.QtCore import Qt, QThread, Signal
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
|
from src.core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
|
||||||
|
from src.utils.ytsage_constants import ICON_PATH
|
||||||
|
|
||||||
|
|
||||||
class FFmpegInstallThread(QThread):
|
class FFmpegInstallThread(QThread):
|
||||||
finished = Signal(bool)
|
finished = Signal(bool)
|
||||||
progress = Signal(str)
|
progress = Signal(str)
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
# Redirect stdout to capture progress messages
|
# Redirect stdout to capture progress messages
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
with contextlib.redirect_stdout(output):
|
with contextlib.redirect_stdout(output):
|
||||||
success = auto_install_ffmpeg()
|
success = auto_install_ffmpeg()
|
||||||
|
|
||||||
# Process captured output and emit progress signals
|
# Process captured output and emit progress signals
|
||||||
for line in output.getvalue().splitlines():
|
for line in output.getvalue().splitlines():
|
||||||
self.progress.emit(line)
|
self.progress.emit(line)
|
||||||
|
|
||||||
self.finished.emit(success)
|
self.finished.emit(success)
|
||||||
|
|
||||||
|
|
||||||
class FFmpegCheckDialog(QDialog):
|
class FFmpegCheckDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle('FFmpeg Installation')
|
self.setWindowTitle("FFmpeg Installation")
|
||||||
self.setMinimumWidth(450)
|
self.setMinimumWidth(450)
|
||||||
self.setMinimumHeight(200)
|
self.setMinimumHeight(200)
|
||||||
self.resize(450, 220)
|
self.resize(450, 220)
|
||||||
|
|
||||||
# Set the window icon to match the main app
|
# Set the window icon to match the main app
|
||||||
if parent and parent.windowIcon():
|
if parent and parent.windowIcon():
|
||||||
self.setWindowIcon(parent.windowIcon())
|
self.setWindowIcon(parent.windowIcon())
|
||||||
else:
|
else:
|
||||||
# Try to load the icon directly if parent not available
|
# Try to load the icon directly if parent not available
|
||||||
# Navigate from src/gui/dialogs/ to project root, then to assets/Icon/
|
# icon_path logic moved to src\utils\ytsage_constants.py
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__)) # dialogs/
|
|
||||||
gui_dir = os.path.dirname(current_dir) # gui/
|
if ICON_PATH.exists():
|
||||||
src_dir = os.path.dirname(gui_dir) # src/
|
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
|
||||||
project_root = os.path.dirname(src_dir) # project root
|
|
||||||
icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png')
|
|
||||||
if os.path.exists(icon_path):
|
|
||||||
self.setWindowIcon(QIcon(icon_path))
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setSpacing(15)
|
layout.setSpacing(15)
|
||||||
@@ -66,10 +61,7 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
layout.addWidget(header_text)
|
layout.addWidget(header_text)
|
||||||
|
|
||||||
# Message
|
# Message
|
||||||
self.message_label = QLabel(
|
self.message_label = QLabel("YTSage needs FFmpeg to process videos.\n\n" "Choose an installation option below:")
|
||||||
"YTSage needs FFmpeg to process videos.\n\n"
|
|
||||||
"Choose an installation option below:"
|
|
||||||
)
|
|
||||||
self.message_label.setWordWrap(True)
|
self.message_label.setWordWrap(True)
|
||||||
self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
|
self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
|
||||||
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
@@ -80,7 +72,8 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
self.progress_label.setWordWrap(True)
|
self.progress_label.setWordWrap(True)
|
||||||
self.progress_label.setMinimumHeight(60) # Smaller but visible area
|
self.progress_label.setMinimumHeight(60) # Smaller but visible area
|
||||||
self.progress_label.setMaximumHeight(80) # Limit maximum height
|
self.progress_label.setMaximumHeight(80) # Limit maximum height
|
||||||
self.progress_label.setStyleSheet("""
|
self.progress_label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
@@ -91,17 +84,18 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.progress_label.hide()
|
self.progress_label.hide()
|
||||||
layout.addWidget(self.progress_label)
|
layout.addWidget(self.progress_label)
|
||||||
|
|
||||||
# Add minimal stretch - just enough to push buttons down slightly
|
# Add minimal stretch - just enough to push buttons down slightly
|
||||||
layout.addSpacing(10)
|
layout.addSpacing(10)
|
||||||
|
|
||||||
# Buttons container - simple approach that should work
|
# Buttons container - simple approach that should work
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(15) # Simple spacing
|
button_layout.setSpacing(15) # Simple spacing
|
||||||
|
|
||||||
# Install button
|
# Install button
|
||||||
self.install_btn = QPushButton("Install FFmpeg")
|
self.install_btn = QPushButton("Install FFmpeg")
|
||||||
self.install_btn.clicked.connect(self.start_installation)
|
self.install_btn.clicked.connect(self.start_installation)
|
||||||
@@ -109,7 +103,7 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
|
|
||||||
# Manual install button
|
# Manual install button
|
||||||
self.manual_btn = QPushButton("Manual Guide")
|
self.manual_btn = QPushButton("Manual Guide")
|
||||||
self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
|
self.manual_btn.clicked.connect(lambda: webbrowser.open("https://github.com/oop7/ffmpeg-install-guide"))
|
||||||
button_layout.addWidget(self.manual_btn)
|
button_layout.addWidget(self.manual_btn)
|
||||||
|
|
||||||
# Close button
|
# Close button
|
||||||
@@ -120,7 +114,8 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
# Style the dialog to match app theme
|
# Style the dialog to match app theme
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -148,16 +143,17 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
background-color: #666666;
|
background-color: #666666;
|
||||||
color: #999999;
|
color: #999999;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize installation thread
|
# Initialize installation thread
|
||||||
self.install_thread = None
|
self.install_thread = None
|
||||||
|
|
||||||
def start_installation(self):
|
def start_installation(self) -> None:
|
||||||
self.install_btn.setEnabled(False)
|
self.install_btn.setEnabled(False)
|
||||||
self.manual_btn.setEnabled(False)
|
self.manual_btn.setEnabled(False)
|
||||||
self.close_btn.setEnabled(False)
|
self.close_btn.setEnabled(False)
|
||||||
|
|
||||||
# Check if FFmpeg is already installed
|
# Check if FFmpeg is already installed
|
||||||
if check_ffmpeg_installed():
|
if check_ffmpeg_installed():
|
||||||
self.message_label.setText("FFmpeg is already installed!")
|
self.message_label.setText("FFmpeg is already installed!")
|
||||||
@@ -167,7 +163,7 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
self.manual_btn.hide()
|
self.manual_btn.hide()
|
||||||
self.close_btn.setEnabled(True)
|
self.close_btn.setEnabled(True)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.message_label.setText("Installing FFmpeg... Please wait")
|
self.message_label.setText("Installing FFmpeg... Please wait")
|
||||||
self.progress_label.show()
|
self.progress_label.show()
|
||||||
|
|
||||||
@@ -176,10 +172,10 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
self.install_thread.progress.connect(self.update_progress)
|
self.install_thread.progress.connect(self.update_progress)
|
||||||
self.install_thread.start()
|
self.install_thread.start()
|
||||||
|
|
||||||
def update_progress(self, message):
|
def update_progress(self, message) -> None:
|
||||||
self.progress_label.setText(message)
|
self.progress_label.setText(message)
|
||||||
|
|
||||||
def installation_finished(self, success):
|
def installation_finished(self, success) -> None:
|
||||||
if success:
|
if success:
|
||||||
self.message_label.setText("FFmpeg has been installed successfully!")
|
self.message_label.setText("FFmpeg has been installed successfully!")
|
||||||
self.progress_label.setText("Installation complete. You can now close this dialog and continue using YTSage.")
|
self.progress_label.setText("Installation complete. You can now close this dialog and continue using YTSage.")
|
||||||
@@ -190,5 +186,5 @@ class FFmpegCheckDialog(QDialog):
|
|||||||
self.progress_label.setText("Please try using the manual installation guide instead.")
|
self.progress_label.setText("Please try using the manual installation guide instead.")
|
||||||
self.install_btn.setEnabled(True)
|
self.install_btn.setEnabled(True)
|
||||||
self.manual_btn.setEnabled(True)
|
self.manual_btn.setEnabled(True)
|
||||||
|
|
||||||
self.close_btn.setEnabled(True)
|
self.close_btn.setEnabled(True)
|
||||||
+191
-163
@@ -3,14 +3,23 @@ Selection dialogs for YTSage application.
|
|||||||
Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
|
Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QLineEdit, QPushButton, QScrollArea, QWidget,
|
|
||||||
QCheckBox, QDialogButtonBox, QGroupBox)
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubtitleSelectionDialog(QDialog):
|
class SubtitleSelectionDialog(QDialog):
|
||||||
def __init__(self, available_manual, available_auto, previously_selected, parent=None):
|
def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Select Subtitles")
|
self.setWindowTitle("Select Subtitles")
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(400)
|
||||||
@@ -28,7 +37,8 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
self.filter_input = QLineEdit()
|
self.filter_input = QLineEdit()
|
||||||
self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
|
self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
|
||||||
self.filter_input.textChanged.connect(self.filter_list)
|
self.filter_input.textChanged.connect(self.filter_list)
|
||||||
self.filter_input.setStyleSheet("""
|
self.filter_input.setStyleSheet(
|
||||||
|
"""
|
||||||
QLineEdit {
|
QLineEdit {
|
||||||
background-color: #363636;
|
background-color: #363636;
|
||||||
border: 2px solid #3d3d3d;
|
border: 2px solid #3d3d3d;
|
||||||
@@ -40,7 +50,8 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
QLineEdit:focus {
|
QLineEdit:focus {
|
||||||
border-color: #ff0000;
|
border-color: #ff0000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout.addWidget(self.filter_input)
|
layout.addWidget(self.filter_input)
|
||||||
|
|
||||||
# Scroll Area for the list
|
# Scroll Area for the list
|
||||||
@@ -67,7 +78,8 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
|
|
||||||
# Style the buttons
|
# Style the buttons
|
||||||
for button in button_box.buttons():
|
for button in button_box.buttons():
|
||||||
button.setStyleSheet("""
|
button.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #363636;
|
background-color: #363636;
|
||||||
border: 2px solid #3d3d3d;
|
border: 2px solid #3d3d3d;
|
||||||
@@ -82,14 +94,18 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background-color: #555555;
|
background-color: #555555;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
# Style the OK button specifically if needed
|
)
|
||||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
# Style the OK button specifically if needed
|
||||||
button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
|
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||||
|
button.setStyleSheet(
|
||||||
|
button.styleSheet()
|
||||||
|
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }"
|
||||||
|
)
|
||||||
|
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
def populate_list(self, filter_text=""):
|
def populate_list(self, filter_text="") -> None:
|
||||||
# Clear existing checkboxes from layout
|
# Clear existing checkboxes from layout
|
||||||
while self.list_layout.count():
|
while self.list_layout.count():
|
||||||
item = self.list_layout.takeAt(0)
|
item = self.list_layout.takeAt(0)
|
||||||
@@ -102,14 +118,14 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
|
|
||||||
# Add manual subs
|
# Add manual subs
|
||||||
for lang_code, sub_info in self.available_manual.items():
|
for lang_code, sub_info in self.available_manual.items():
|
||||||
if not filter_text or filter_text in lang_code.lower():
|
if not filter_text or filter_text in lang_code.lower():
|
||||||
combined_subs[lang_code] = f"{lang_code} - Manual"
|
combined_subs[lang_code] = f"{lang_code} - Manual"
|
||||||
|
|
||||||
# Add auto subs (only if no manual exists and matches filter)
|
# Add auto subs (only if no manual exists and matches filter)
|
||||||
for lang_code, sub_info in self.available_auto.items():
|
for lang_code, sub_info in self.available_auto.items():
|
||||||
if lang_code not in combined_subs: # Don't overwrite manual
|
if lang_code not in combined_subs: # Don't overwrite manual
|
||||||
if not filter_text or filter_text in lang_code.lower():
|
if not filter_text or filter_text in lang_code.lower():
|
||||||
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
|
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
|
||||||
|
|
||||||
if not combined_subs:
|
if not combined_subs:
|
||||||
no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
|
no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
|
||||||
@@ -126,7 +142,8 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
checkbox.setProperty("subtitle_id", item_text) # Store the identifier
|
checkbox.setProperty("subtitle_id", item_text) # Store the identifier
|
||||||
checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
|
checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
|
||||||
checkbox.stateChanged.connect(self.update_selection)
|
checkbox.stateChanged.connect(self.update_selection)
|
||||||
checkbox.setStyleSheet("""
|
checkbox.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -144,15 +161,16 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
border: 2px solid #ff0000;
|
border: 2px solid #ff0000;
|
||||||
background: #ff0000;
|
background: #ff0000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.list_layout.addWidget(checkbox)
|
self.list_layout.addWidget(checkbox)
|
||||||
|
|
||||||
self.list_layout.addStretch() # Pushes items up if list is short
|
self.list_layout.addStretch() # Pushes items up if list is short
|
||||||
|
|
||||||
def filter_list(self):
|
def filter_list(self) -> None:
|
||||||
self.populate_list(self.filter_input.text())
|
self.populate_list(self.filter_input.text())
|
||||||
|
|
||||||
def update_selection(self, state):
|
def update_selection(self, state) -> None:
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
subtitle_id = sender.property("subtitle_id")
|
subtitle_id = sender.property("subtitle_id")
|
||||||
if state == Qt.CheckState.Checked.value:
|
if state == Qt.CheckState.Checked.value:
|
||||||
@@ -162,18 +180,18 @@ class SubtitleSelectionDialog(QDialog):
|
|||||||
if subtitle_id in self.previously_selected:
|
if subtitle_id in self.previously_selected:
|
||||||
self.previously_selected.remove(subtitle_id)
|
self.previously_selected.remove(subtitle_id)
|
||||||
|
|
||||||
def get_selected_subtitles(self):
|
def get_selected_subtitles(self) -> list:
|
||||||
# Return the final set as a list
|
# Return the final set as a list
|
||||||
return list(self.previously_selected)
|
return list(self.previously_selected)
|
||||||
|
|
||||||
def accept(self):
|
def accept(self) -> None:
|
||||||
# Update the final list before closing
|
# Update the final list before closing
|
||||||
self.selected_subtitles = self.get_selected_subtitles()
|
self.selected_subtitles = self.get_selected_subtitles()
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
|
||||||
class PlaylistSelectionDialog(QDialog):
|
class PlaylistSelectionDialog(QDialog):
|
||||||
def __init__(self, playlist_entries, previously_selected_string, parent=None):
|
def __init__(self, playlist_entries, previously_selected_string, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Select Playlist Videos")
|
self.setWindowTitle("Select Playlist Videos")
|
||||||
self.setMinimumWidth(500)
|
self.setMinimumWidth(500)
|
||||||
@@ -192,7 +210,8 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
select_all_btn.clicked.connect(self._select_all)
|
select_all_btn.clicked.connect(self._select_all)
|
||||||
deselect_all_btn.clicked.connect(self._deselect_all)
|
deselect_all_btn.clicked.connect(self._deselect_all)
|
||||||
# Style the buttons to match the subtitle dialog
|
# Style the buttons to match the subtitle dialog
|
||||||
select_all_btn.setStyleSheet("""
|
select_all_btn.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #363636;
|
background-color: #363636;
|
||||||
border: 2px solid #3d3d3d;
|
border: 2px solid #3d3d3d;
|
||||||
@@ -207,7 +226,8 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background-color: #555555;
|
background-color: #555555;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
|
deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
|
||||||
button_layout.addWidget(select_all_btn)
|
button_layout.addWidget(select_all_btn)
|
||||||
button_layout.addWidget(deselect_all_btn)
|
button_layout.addWidget(deselect_all_btn)
|
||||||
@@ -233,10 +253,11 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
|
|
||||||
# Style the buttons to match subtitle dialog
|
# Style the buttons to match subtitle dialog
|
||||||
for button in button_box.buttons():
|
for button in button_box.buttons():
|
||||||
button.setStyleSheet("""
|
button.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #363636;
|
background-color: #363636;
|
||||||
border: 2px solid #3d3d3d;
|
border: 2px solid #3d3d3d;
|
||||||
@@ -251,15 +272,20 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background-color: #555555;
|
background-color: #555555;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
# Style the OK button specifically if needed
|
# Style the OK button specifically if needed
|
||||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||||
button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
|
button.setStyleSheet(
|
||||||
|
button.styleSheet()
|
||||||
|
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }"
|
||||||
|
)
|
||||||
|
|
||||||
main_layout.addWidget(button_box)
|
main_layout.addWidget(button_box)
|
||||||
|
|
||||||
# Apply styling to match subtitle dialog
|
# Apply styling to match subtitle dialog
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog { background-color: #15181b; }
|
QDialog { background-color: #15181b; }
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -279,21 +305,22 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
background: #ff0000;
|
background: #ff0000;
|
||||||
}
|
}
|
||||||
QWidget { background-color: #15181b; }
|
QWidget { background-color: #15181b; }
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_selection_string(self, selection_string):
|
def _parse_selection_string(self, selection_string) -> set:
|
||||||
"""Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
|
"""Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
|
||||||
selected_indices = set()
|
selected_indices = set()
|
||||||
if not selection_string:
|
if not selection_string:
|
||||||
# If no previous selection, assume all are selected initially
|
# If no previous selection, assume all are selected initially
|
||||||
return set(range(1, len(self.playlist_entries) + 1))
|
return set(range(1, len(self.playlist_entries) + 1))
|
||||||
|
|
||||||
parts = selection_string.split(',')
|
parts = selection_string.split(",")
|
||||||
for part in parts:
|
for part in parts:
|
||||||
part = part.strip()
|
part = part.strip()
|
||||||
if '-' in part:
|
if "-" in part:
|
||||||
try:
|
try:
|
||||||
start, end = map(int, part.split('-'))
|
start, end = map(int, part.split("-"))
|
||||||
if start <= end:
|
if start <= end:
|
||||||
selected_indices.update(range(start, end + 1))
|
selected_indices.update(range(start, end + 1))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -305,10 +332,10 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
pass # Ignore invalid numbers
|
pass # Ignore invalid numbers
|
||||||
return selected_indices
|
return selected_indices
|
||||||
|
|
||||||
def _populate_list(self, previously_selected_string):
|
def _populate_list(self, previously_selected_string) -> None:
|
||||||
"""Populates the scroll area with checkboxes for each video."""
|
"""Populates the scroll area with checkboxes for each video."""
|
||||||
selected_indices = self._parse_selection_string(previously_selected_string)
|
selected_indices = self._parse_selection_string(previously_selected_string)
|
||||||
|
|
||||||
# Clear existing checkboxes if any (e.g., if repopulating)
|
# Clear existing checkboxes if any (e.g., if repopulating)
|
||||||
while self.list_layout.count():
|
while self.list_layout.count():
|
||||||
child = self.list_layout.takeAt(0)
|
child = self.list_layout.takeAt(0)
|
||||||
@@ -317,18 +344,19 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
self.checkboxes.clear()
|
self.checkboxes.clear()
|
||||||
|
|
||||||
for index, entry in enumerate(self.playlist_entries):
|
for index, entry in enumerate(self.playlist_entries):
|
||||||
if not entry:
|
if not entry:
|
||||||
continue # Skip None entries if yt-dlp returns them
|
continue # Skip None entries if yt-dlp returns them
|
||||||
|
|
||||||
video_index = index + 1 # yt-dlp uses 1-based indexing
|
video_index = index + 1 # yt-dlp uses 1-based indexing
|
||||||
title = entry.get('title', f'Video {video_index}')
|
title = entry.get("title", f"Video {video_index}")
|
||||||
# Shorten title if too long
|
# Shorten title if too long
|
||||||
display_title = (title[:70] + '...') if len(title) > 73 else title
|
display_title = (title[:70] + "...") if len(title) > 73 else title
|
||||||
|
|
||||||
checkbox = QCheckBox(f"{video_index}. {display_title}")
|
checkbox = QCheckBox(f"{video_index}. {display_title}")
|
||||||
checkbox.setChecked(video_index in selected_indices)
|
checkbox.setChecked(video_index in selected_indices)
|
||||||
checkbox.setProperty("video_index", video_index) # Store index
|
checkbox.setProperty("video_index", video_index) # Store index
|
||||||
checkbox.setStyleSheet("""
|
checkbox.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -346,132 +374,126 @@ class PlaylistSelectionDialog(QDialog):
|
|||||||
border: 2px solid #ff0000;
|
border: 2px solid #ff0000;
|
||||||
background: #ff0000;
|
background: #ff0000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.list_layout.addWidget(checkbox)
|
self.list_layout.addWidget(checkbox)
|
||||||
self.checkboxes.append(checkbox)
|
self.checkboxes.append(checkbox)
|
||||||
self.list_layout.addStretch() # Push checkboxes to the top
|
self.list_layout.addStretch() # Push checkboxes to the top
|
||||||
|
|
||||||
def _select_all(self):
|
def _select_all(self) -> None:
|
||||||
for checkbox in self.checkboxes:
|
for checkbox in self.checkboxes:
|
||||||
checkbox.setChecked(True)
|
checkbox.setChecked(True)
|
||||||
|
|
||||||
def _deselect_all(self):
|
def _deselect_all(self) -> None:
|
||||||
for checkbox in self.checkboxes:
|
for checkbox in self.checkboxes:
|
||||||
checkbox.setChecked(False)
|
checkbox.setChecked(False)
|
||||||
|
|
||||||
def _condense_indices(self, indices):
|
def _condense_indices(self, indices: list[int]) -> str:
|
||||||
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
|
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
|
||||||
if not indices:
|
if not indices:
|
||||||
return ""
|
return ""
|
||||||
indices = sorted(list(set(indices)))
|
|
||||||
if not indices: # Check again after sorting/set conversion
|
# Remove duplicates and sort in one step
|
||||||
return ""
|
indices = sorted(set(indices))
|
||||||
|
|
||||||
ranges = []
|
ranges = []
|
||||||
start = indices[0]
|
start = end = indices[0]
|
||||||
end = indices[0]
|
|
||||||
for i in range(1, len(indices)):
|
for num in indices[1:]:
|
||||||
if indices[i] == end + 1:
|
if num == end + 1:
|
||||||
end = indices[i]
|
end = num
|
||||||
else:
|
else:
|
||||||
if start == end:
|
ranges.append(f"{start}-{end}" if start != end else str(start))
|
||||||
ranges.append(str(start))
|
start = end = num
|
||||||
else:
|
|
||||||
ranges.append(f"{start}-{end}")
|
# Append the last range
|
||||||
start = indices[i]
|
ranges.append(f"{start}-{end}" if start != end else str(start))
|
||||||
end = indices[i]
|
|
||||||
# Add the last range
|
|
||||||
if start == end:
|
|
||||||
ranges.append(str(start))
|
|
||||||
else:
|
|
||||||
ranges.append(f"{start}-{end}")
|
|
||||||
return ",".join(ranges)
|
return ",".join(ranges)
|
||||||
|
|
||||||
def get_selected_items_string(self):
|
def get_selected_items_string(self) -> str | None:
|
||||||
"""Returns the selection string based on checked boxes."""
|
"""Returns the selection string based on checked boxes."""
|
||||||
selected_indices = [
|
selected_indices = [cb.property("video_index") for cb in self.checkboxes if cb.isChecked()]
|
||||||
cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
|
|
||||||
]
|
|
||||||
|
|
||||||
# Check if all items are selected
|
# Check if all items are selected
|
||||||
if len(selected_indices) == len(self.playlist_entries):
|
if len(selected_indices) == len(self.playlist_entries):
|
||||||
return None # yt-dlp default is all items, so return None or empty string
|
return None # yt-dlp default is all items, so return None or empty string
|
||||||
|
|
||||||
return self._condense_indices(selected_indices)
|
return self._condense_indices(selected_indices)
|
||||||
|
|
||||||
|
|
||||||
class SponsorBlockCategoryDialog(QDialog):
|
class SponsorBlockCategoryDialog(QDialog):
|
||||||
"""Dialog for selecting SponsorBlock categories to remove from videos."""
|
"""Dialog for selecting SponsorBlock categories to remove from videos."""
|
||||||
|
|
||||||
# Default SponsorBlock categories with descriptions
|
# Default SponsorBlock categories with descriptions
|
||||||
SPONSORBLOCK_CATEGORIES = {
|
SPONSORBLOCK_CATEGORIES = {
|
||||||
'sponsor': {
|
"sponsor": {
|
||||||
'name': 'Sponsor',
|
"name": "Sponsor",
|
||||||
'description': 'Paid promotion, paid referrals and direct advertisements',
|
"description": "Paid promotion, paid referrals and direct advertisements",
|
||||||
'default': True
|
"default": True,
|
||||||
},
|
},
|
||||||
'selfpromo': {
|
"selfpromo": {
|
||||||
'name': 'Unpaid/Self Promotion',
|
"name": "Unpaid/Self Promotion",
|
||||||
'description': 'Unpaid promotion of creators\' own content',
|
"description": "Unpaid promotion of creators' own content",
|
||||||
'default': True
|
"default": True,
|
||||||
},
|
},
|
||||||
'interaction': {
|
"interaction": {
|
||||||
'name': 'Interaction Reminder',
|
"name": "Interaction Reminder",
|
||||||
'description': 'Asking viewers to like, subscribe, or follow social media',
|
"description": "Asking viewers to like, subscribe, or follow social media",
|
||||||
'default': True
|
"default": True,
|
||||||
},
|
},
|
||||||
'intro': {
|
"intro": {
|
||||||
'name': 'Intro',
|
"name": "Intro",
|
||||||
'description': 'Video introduction that can be skipped',
|
"description": "Video introduction that can be skipped",
|
||||||
'default': False
|
"default": False,
|
||||||
},
|
},
|
||||||
'outro': {
|
"outro": {
|
||||||
'name': 'Outro/End Cards',
|
"name": "Outro/End Cards",
|
||||||
'description': 'Credits or when the video ends',
|
"description": "Credits or when the video ends",
|
||||||
'default': False
|
"default": False,
|
||||||
},
|
},
|
||||||
'preview': {
|
"preview": {
|
||||||
'name': 'Preview/Recap',
|
"name": "Preview/Recap",
|
||||||
'description': 'Quick recap of previous videos or preview of what\'s coming up',
|
"description": "Quick recap of previous videos or preview of what's coming up",
|
||||||
'default': False
|
"default": False,
|
||||||
},
|
},
|
||||||
'music_offtopic': {
|
"music_offtopic": {
|
||||||
'name': 'Non-Music Section',
|
"name": "Non-Music Section",
|
||||||
'description': 'Only for music videos. Marks non-music sections',
|
"description": "Only for music videos. Marks non-music sections",
|
||||||
'default': False
|
"default": False,
|
||||||
|
},
|
||||||
|
"filler": {
|
||||||
|
"name": "Filler Tangent",
|
||||||
|
"description": "Tangential scenes added only for filler or humor",
|
||||||
|
"default": False,
|
||||||
},
|
},
|
||||||
'filler': {
|
|
||||||
'name': 'Filler Tangent',
|
|
||||||
'description': 'Tangential scenes added only for filler or humor',
|
|
||||||
'default': False
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, previously_selected=None, parent=None):
|
def __init__(self, previously_selected=None, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("SponsorBlock Categories")
|
self.setWindowTitle("SponsorBlock Categories")
|
||||||
self.setMinimumWidth(500)
|
self.setMinimumWidth(500)
|
||||||
self.setMinimumHeight(400)
|
self.setMinimumHeight(400)
|
||||||
|
|
||||||
# Set the window icon to match the main app
|
# Set the window icon to match the main app
|
||||||
if parent:
|
if parent:
|
||||||
self.setWindowIcon(parent.windowIcon())
|
self.setWindowIcon(parent.windowIcon())
|
||||||
|
|
||||||
self.previously_selected = set(previously_selected) if previously_selected else set()
|
self.previously_selected = set(previously_selected) if previously_selected else set()
|
||||||
self.checkboxes = {}
|
self.checkboxes = {}
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.apply_styling()
|
self.apply_styling()
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self) -> None:
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Title and description
|
# Title and description
|
||||||
title_label = QLabel("SponsorBlock Categories")
|
title_label = QLabel("SponsorBlock Categories")
|
||||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
|
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
desc_label = QLabel(
|
desc_label = QLabel(
|
||||||
"Select which types of video segments to automatically remove during download.\n"
|
"Select which types of video segments to automatically remove during download.\n"
|
||||||
"SponsorBlock uses community-submitted data to identify these segments."
|
"SponsorBlock uses community-submitted data to identify these segments."
|
||||||
@@ -480,17 +502,17 @@ class SponsorBlockCategoryDialog(QDialog):
|
|||||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||||
layout.addWidget(desc_label)
|
layout.addWidget(desc_label)
|
||||||
|
|
||||||
# Scroll area for categories
|
# Scroll area for categories
|
||||||
scroll_area = QScrollArea()
|
scroll_area = QScrollArea()
|
||||||
scroll_area.setWidgetResizable(True)
|
scroll_area.setWidgetResizable(True)
|
||||||
scroll_area.setStyleSheet("QScrollArea { border: none; }")
|
scroll_area.setStyleSheet("QScrollArea { border: none; }")
|
||||||
|
|
||||||
scroll_widget = QWidget()
|
scroll_widget = QWidget()
|
||||||
scroll_layout = QVBoxLayout(scroll_widget)
|
scroll_layout = QVBoxLayout(scroll_widget)
|
||||||
scroll_layout.setContentsMargins(10, 0, 10, 0)
|
scroll_layout.setContentsMargins(10, 0, 10, 0)
|
||||||
scroll_layout.setSpacing(8)
|
scroll_layout.setSpacing(8)
|
||||||
|
|
||||||
# Add category checkboxes
|
# Add category checkboxes
|
||||||
for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
|
for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
|
||||||
# Create a container widget for each category
|
# Create a container widget for each category
|
||||||
@@ -498,22 +520,23 @@ class SponsorBlockCategoryDialog(QDialog):
|
|||||||
category_layout = QVBoxLayout(category_widget)
|
category_layout = QVBoxLayout(category_widget)
|
||||||
category_layout.setContentsMargins(0, 0, 0, 0)
|
category_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
category_layout.setSpacing(2)
|
category_layout.setSpacing(2)
|
||||||
|
|
||||||
# Create checkbox with just the name
|
# Create checkbox with just the name
|
||||||
checkbox = QCheckBox(category_info['name'])
|
checkbox = QCheckBox(category_info["name"])
|
||||||
checkbox.setProperty("category_id", category_id)
|
checkbox.setProperty("category_id", category_id)
|
||||||
|
|
||||||
# Determine if this category should be checked
|
# Determine if this category should be checked
|
||||||
if self.previously_selected:
|
if self.previously_selected:
|
||||||
# Use previously selected categories
|
# Use previously selected categories
|
||||||
is_checked = category_id in self.previously_selected
|
is_checked = category_id in self.previously_selected
|
||||||
else:
|
else:
|
||||||
# Use default values for first time
|
# Use default values for first time
|
||||||
is_checked = category_info['default']
|
is_checked = category_info["default"]
|
||||||
|
|
||||||
checkbox.setChecked(is_checked)
|
checkbox.setChecked(is_checked)
|
||||||
|
|
||||||
checkbox.setStyleSheet("""
|
checkbox.setStyleSheet(
|
||||||
|
"""
|
||||||
QCheckBox {
|
QCheckBox {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@@ -533,61 +556,64 @@ class SponsorBlockCategoryDialog(QDialog):
|
|||||||
border: 2px solid #ff0000;
|
border: 2px solid #ff0000;
|
||||||
background: #ff0000;
|
background: #ff0000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Create description label
|
# Create description label
|
||||||
desc_label = QLabel(category_info['description'])
|
desc_label = QLabel(category_info["description"])
|
||||||
desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;")
|
desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;")
|
||||||
desc_label.setWordWrap(True)
|
desc_label.setWordWrap(True)
|
||||||
|
|
||||||
category_layout.addWidget(checkbox)
|
category_layout.addWidget(checkbox)
|
||||||
category_layout.addWidget(desc_label)
|
category_layout.addWidget(desc_label)
|
||||||
|
|
||||||
self.checkboxes[category_id] = checkbox
|
self.checkboxes[category_id] = checkbox
|
||||||
scroll_layout.addWidget(category_widget)
|
scroll_layout.addWidget(category_widget)
|
||||||
|
|
||||||
scroll_layout.addStretch()
|
scroll_layout.addStretch()
|
||||||
scroll_area.setWidget(scroll_widget)
|
scroll_area.setWidget(scroll_widget)
|
||||||
layout.addWidget(scroll_area)
|
layout.addWidget(scroll_area)
|
||||||
|
|
||||||
# Quick selection buttons
|
# Quick selection buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
select_defaults_btn = QPushButton("Select Defaults")
|
select_defaults_btn = QPushButton("Select Defaults")
|
||||||
select_defaults_btn.clicked.connect(self.select_defaults)
|
select_defaults_btn.clicked.connect(self.select_defaults)
|
||||||
select_defaults_btn.setStyleSheet(self._get_button_style())
|
select_defaults_btn.setStyleSheet(self._get_button_style())
|
||||||
|
|
||||||
select_all_btn = QPushButton("Select All")
|
select_all_btn = QPushButton("Select All")
|
||||||
select_all_btn.clicked.connect(self.select_all)
|
select_all_btn.clicked.connect(self.select_all)
|
||||||
select_all_btn.setStyleSheet(self._get_button_style())
|
select_all_btn.setStyleSheet(self._get_button_style())
|
||||||
|
|
||||||
deselect_all_btn = QPushButton("Deselect All")
|
deselect_all_btn = QPushButton("Deselect All")
|
||||||
deselect_all_btn.clicked.connect(self.deselect_all)
|
deselect_all_btn.clicked.connect(self.deselect_all)
|
||||||
deselect_all_btn.setStyleSheet(self._get_button_style())
|
deselect_all_btn.setStyleSheet(self._get_button_style())
|
||||||
|
|
||||||
button_layout.addWidget(select_defaults_btn)
|
button_layout.addWidget(select_defaults_btn)
|
||||||
button_layout.addWidget(select_all_btn)
|
button_layout.addWidget(select_all_btn)
|
||||||
button_layout.addWidget(deselect_all_btn)
|
button_layout.addWidget(deselect_all_btn)
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
# Dialog buttons
|
# Dialog buttons
|
||||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
button_box.accepted.connect(self.accept)
|
button_box.accepted.connect(self.accept)
|
||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
|
|
||||||
# Style the dialog buttons
|
# Style the dialog buttons
|
||||||
for button in button_box.buttons():
|
for button in button_box.buttons():
|
||||||
button.setStyleSheet(self._get_button_style())
|
button.setStyleSheet(self._get_button_style())
|
||||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||||
button.setStyleSheet(button.styleSheet() +
|
button.setStyleSheet(
|
||||||
"QPushButton { background-color: #ff0000; border-color: #cc0000; } " +
|
button.styleSheet()
|
||||||
"QPushButton:hover { background-color: #cc0000; }")
|
+ "QPushButton { background-color: #ff0000; border-color: #cc0000; } "
|
||||||
|
+ "QPushButton:hover { background-color: #cc0000; }"
|
||||||
|
)
|
||||||
|
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
def _get_button_style(self):
|
def _get_button_style(self) -> str:
|
||||||
"""Returns the standard button style for this dialog."""
|
"""Returns the standard button style for this dialog."""
|
||||||
return """
|
return """
|
||||||
QPushButton {
|
QPushButton {
|
||||||
@@ -605,10 +631,11 @@ class SponsorBlockCategoryDialog(QDialog):
|
|||||||
background-color: #555555;
|
background-color: #555555;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def apply_styling(self):
|
def apply_styling(self) -> None:
|
||||||
"""Apply the dialog styling to match the rest of the application."""
|
"""Apply the dialog styling to match the rest of the application."""
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -619,33 +646,34 @@ class SponsorBlockCategoryDialog(QDialog):
|
|||||||
QWidget {
|
QWidget {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
def select_defaults(self):
|
|
||||||
|
def select_defaults(self) -> None:
|
||||||
"""Select only the default categories."""
|
"""Select only the default categories."""
|
||||||
for category_id, checkbox in self.checkboxes.items():
|
for category_id, checkbox in self.checkboxes.items():
|
||||||
default_value = self.SPONSORBLOCK_CATEGORIES[category_id]['default']
|
default_value = self.SPONSORBLOCK_CATEGORIES[category_id]["default"]
|
||||||
checkbox.setChecked(default_value)
|
checkbox.setChecked(default_value)
|
||||||
|
|
||||||
def select_all(self):
|
def select_all(self) -> None:
|
||||||
"""Select all categories."""
|
"""Select all categories."""
|
||||||
for checkbox in self.checkboxes.values():
|
for checkbox in self.checkboxes.values():
|
||||||
checkbox.setChecked(True)
|
checkbox.setChecked(True)
|
||||||
|
|
||||||
def deselect_all(self):
|
def deselect_all(self) -> None:
|
||||||
"""Deselect all categories."""
|
"""Deselect all categories."""
|
||||||
for checkbox in self.checkboxes.values():
|
for checkbox in self.checkboxes.values():
|
||||||
checkbox.setChecked(False)
|
checkbox.setChecked(False)
|
||||||
|
|
||||||
def get_selected_categories(self):
|
def get_selected_categories(self) -> list:
|
||||||
"""Returns a list of selected category IDs."""
|
"""Returns a list of selected category IDs."""
|
||||||
selected = []
|
selected = []
|
||||||
for category_id, checkbox in self.checkboxes.items():
|
for category_id, checkbox in self.checkboxes.items():
|
||||||
if checkbox.isChecked():
|
if checkbox.isChecked():
|
||||||
selected.append(category_id)
|
selected.append(category_id)
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
def get_selected_categories_string(self):
|
def get_selected_categories_string(self) -> str:
|
||||||
"""Returns a comma-separated string of selected categories for yt-dlp."""
|
"""Returns a comma-separated string of selected categories for yt-dlp."""
|
||||||
selected = self.get_selected_categories()
|
selected = self.get_selected_categories()
|
||||||
return ','.join(selected) if selected else ''
|
return ",".join(selected) if selected else ""
|
||||||
+176
-155
@@ -3,22 +3,39 @@ Settings-related dialogs for YTSage application.
|
|||||||
Contains dialogs for configuring download settings and auto-update preferences.
|
Contains dialogs for configuring download settings and auto-update preferences.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import time
|
||||||
import requests
|
from datetime import datetime
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QLineEdit, QPushButton, QGroupBox, QCheckBox,
|
|
||||||
QRadioButton, QComboBox, QDialogButtonBox,
|
|
||||||
QButtonGroup, QMessageBox, QFileDialog)
|
|
||||||
from PySide6.QtCore import Qt, QThread, Signal
|
|
||||||
from PySide6.QtGui import QIcon
|
|
||||||
from ...core.ytsage_logging import logger
|
|
||||||
|
|
||||||
from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings,
|
import requests
|
||||||
check_and_update_ytdlp_auto, get_ytdlp_version)
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.core.ytsage_utils import (
|
||||||
|
check_and_update_ytdlp_auto,
|
||||||
|
get_auto_update_settings,
|
||||||
|
get_ytdlp_version,
|
||||||
|
update_auto_update_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DownloadSettingsDialog(QDialog):
|
class DownloadSettingsDialog(QDialog):
|
||||||
def __init__(self, current_path, current_limit, current_unit_index, parent=None):
|
def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Download Settings")
|
self.setWindowTitle("Download Settings")
|
||||||
self.setMinimumWidth(450)
|
self.setMinimumWidth(450)
|
||||||
@@ -28,7 +45,8 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
self.current_unit_index = current_unit_index
|
self.current_unit_index = current_unit_index
|
||||||
|
|
||||||
# Apply main app styling
|
# Apply main app styling
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -137,7 +155,8 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
selection-background-color: #c90000;
|
selection-background-color: #c90000;
|
||||||
selection-color: #ffffff;
|
selection-color: #ffffff;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
@@ -147,7 +166,9 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
|
|
||||||
self.path_display = QLabel(self.current_path)
|
self.path_display = QLabel(self.current_path)
|
||||||
self.path_display.setWordWrap(True)
|
self.path_display.setWordWrap(True)
|
||||||
self.path_display.setStyleSheet("QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }")
|
self.path_display.setStyleSheet(
|
||||||
|
"QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }"
|
||||||
|
)
|
||||||
path_layout.addWidget(self.path_display)
|
path_layout.addWidget(self.path_display)
|
||||||
|
|
||||||
browse_button = QPushButton("Browse...")
|
browse_button = QPushButton("Browse...")
|
||||||
@@ -182,7 +203,7 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
|
|
||||||
# Enable/Disable auto-update checkbox
|
# Enable/Disable auto-update checkbox
|
||||||
self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
|
self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
|
||||||
self.auto_update_enabled.setChecked(auto_settings['enabled'])
|
self.auto_update_enabled.setChecked(auto_settings["enabled"])
|
||||||
auto_update_layout.addWidget(self.auto_update_enabled)
|
auto_update_layout.addWidget(self.auto_update_enabled)
|
||||||
|
|
||||||
# Frequency options
|
# Frequency options
|
||||||
@@ -195,10 +216,10 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
self.weekly_radio = QRadioButton("Check weekly")
|
self.weekly_radio = QRadioButton("Check weekly")
|
||||||
|
|
||||||
# Set current selection based on saved settings
|
# Set current selection based on saved settings
|
||||||
current_frequency = auto_settings['frequency']
|
current_frequency = auto_settings["frequency"]
|
||||||
if current_frequency == 'startup':
|
if current_frequency == "startup":
|
||||||
self.startup_radio.setChecked(True)
|
self.startup_radio.setChecked(True)
|
||||||
elif current_frequency == 'daily':
|
elif current_frequency == "daily":
|
||||||
self.daily_radio.setChecked(True)
|
self.daily_radio.setChecked(True)
|
||||||
else: # weekly
|
else: # weekly
|
||||||
self.weekly_radio.setChecked(True)
|
self.weekly_radio.setChecked(True)
|
||||||
@@ -224,17 +245,17 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
layout.addWidget(button_box)
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
def browse_new_path(self):
|
def browse_new_path(self) -> None:
|
||||||
new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
|
new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
|
||||||
if new_path:
|
if new_path:
|
||||||
self.current_path = new_path
|
self.current_path = new_path
|
||||||
self.path_display.setText(self.current_path)
|
self.path_display.setText(self.current_path)
|
||||||
|
|
||||||
def get_selected_path(self):
|
def get_selected_path(self) -> str:
|
||||||
"""Returns the confirmed path after the dialog is accepted."""
|
"""Returns the confirmed path after the dialog is accepted."""
|
||||||
return self.current_path
|
return self.current_path
|
||||||
|
|
||||||
def get_selected_speed_limit(self):
|
def get_selected_speed_limit(self) -> str | None:
|
||||||
"""Returns the entered speed limit value (as string or None)."""
|
"""Returns the entered speed limit value (as string or None)."""
|
||||||
limit_str = self.speed_limit_input.text().strip()
|
limit_str = self.speed_limit_input.text().strip()
|
||||||
if not limit_str:
|
if not limit_str:
|
||||||
@@ -246,18 +267,19 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
logger.info("Invalid speed limit input in dialog")
|
logger.info("Invalid speed limit input in dialog")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_selected_unit_index(self):
|
def get_selected_unit_index(self) -> int:
|
||||||
"""Returns the index of the selected speed limit unit."""
|
"""Returns the index of the selected speed limit unit."""
|
||||||
return self.speed_limit_unit.currentIndex()
|
return self.speed_limit_unit.currentIndex()
|
||||||
|
|
||||||
def _create_styled_message_box(self, icon, title, text):
|
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
|
||||||
"""Create a styled QMessageBox that matches the app theme."""
|
"""Create a styled QMessageBox that matches the app theme."""
|
||||||
msg_box = QMessageBox(self)
|
msg_box = QMessageBox(self)
|
||||||
msg_box.setIcon(icon)
|
msg_box.setIcon(icon)
|
||||||
msg_box.setWindowTitle(title)
|
msg_box.setWindowTitle(title)
|
||||||
msg_box.setText(text)
|
msg_box.setText(text)
|
||||||
msg_box.setWindowIcon(self.windowIcon())
|
msg_box.setWindowIcon(self.windowIcon())
|
||||||
msg_box.setStyleSheet("""
|
msg_box.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -280,183 +302,187 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
QMessageBox QPushButton:pressed {
|
QMessageBox QPushButton:pressed {
|
||||||
background-color: #800000;
|
background-color: #800000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
return msg_box
|
return msg_box
|
||||||
|
|
||||||
def test_update_check(self):
|
def test_update_check(self) -> None:
|
||||||
"""Test the update check functionality."""
|
"""Test the update check functionality."""
|
||||||
try:
|
try:
|
||||||
# Get current version
|
# Get current version
|
||||||
current_version = get_ytdlp_version()
|
current_version = get_ytdlp_version()
|
||||||
if "Error" in current_version:
|
if "Error" in current_version:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Warning,
|
QMessageBox.Icon.Warning,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
"Could not determine current yt-dlp version."
|
"Could not determine current yt-dlp version.",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get latest version from PyPI
|
# Get latest version from PyPI
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
latest_version = response.json()["info"]["version"]
|
latest_version = response.json()["info"]["version"]
|
||||||
|
|
||||||
# Clean up version strings
|
# Clean up version strings
|
||||||
current_version = current_version.replace('_', '.')
|
current_version = current_version.replace("_", ".")
|
||||||
latest_version = latest_version.replace('_', '.')
|
latest_version = latest_version.replace("_", ".")
|
||||||
|
|
||||||
from packaging import version as version_parser
|
from packaging import version as version_parser
|
||||||
|
|
||||||
if version_parser.parse(latest_version) > version_parser.parse(current_version):
|
if version_parser.parse(latest_version) > version_parser.parse(current_version):
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Information,
|
QMessageBox.Icon.Information,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update."
|
f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update.",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
else:
|
else:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Information,
|
QMessageBox.Icon.Information,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
f"yt-dlp is up to date!\n\nCurrent version: {current_version}"
|
f"yt-dlp is up to date!\n\nCurrent version: {current_version}",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Warning,
|
QMessageBox.Icon.Warning,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
f"Error checking for updates: {str(e)}"
|
f"Error checking for updates: {str(e)}",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
|
|
||||||
def get_auto_update_settings(self):
|
def get_auto_update_settings(self) -> tuple[bool, str]:
|
||||||
"""Returns the auto-update settings from the dialog."""
|
"""Returns the auto-update settings from the dialog."""
|
||||||
enabled = self.auto_update_enabled.isChecked()
|
enabled = self.auto_update_enabled.isChecked()
|
||||||
|
|
||||||
if self.startup_radio.isChecked():
|
if self.startup_radio.isChecked():
|
||||||
frequency = 'startup'
|
frequency = "startup"
|
||||||
elif self.daily_radio.isChecked():
|
elif self.daily_radio.isChecked():
|
||||||
frequency = 'daily'
|
frequency = "daily"
|
||||||
else: # weekly_radio is checked
|
else: # weekly_radio is checked
|
||||||
frequency = 'weekly'
|
frequency = "weekly"
|
||||||
|
|
||||||
return enabled, frequency
|
return enabled, frequency
|
||||||
|
|
||||||
def accept(self):
|
def accept(self) -> None:
|
||||||
"""Override accept to save auto-update settings."""
|
"""Override accept to save auto-update settings."""
|
||||||
try:
|
try:
|
||||||
# Save auto-update settings
|
# Save auto-update settings
|
||||||
enabled, frequency = self.get_auto_update_settings()
|
enabled, frequency = self.get_auto_update_settings()
|
||||||
|
|
||||||
if update_auto_update_settings(enabled, frequency):
|
if update_auto_update_settings(enabled, frequency):
|
||||||
QMessageBox.information(self, "Settings Saved",
|
QMessageBox.information(
|
||||||
"Auto-update settings have been saved successfully!")
|
self,
|
||||||
|
"Settings Saved",
|
||||||
|
"Auto-update settings have been saved successfully!",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, "Error",
|
QMessageBox.warning(self, "Error", "Failed to save auto-update settings.")
|
||||||
"Failed to save auto-update settings.")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error",
|
QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {str(e)}")
|
||||||
f"Error saving auto-update settings: {str(e)}")
|
|
||||||
|
|
||||||
# Call the parent accept method to close the dialog
|
# Call the parent accept method to close the dialog
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
|
||||||
class AutoUpdateSettingsDialog(QDialog):
|
class AutoUpdateSettingsDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Auto-Update Settings")
|
self.setWindowTitle("Auto-Update Settings")
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(400)
|
||||||
self.setMinimumHeight(300)
|
self.setMinimumHeight(300)
|
||||||
|
|
||||||
# Set the window icon to match the main app
|
# Set the window icon to match the main app
|
||||||
if parent:
|
if parent:
|
||||||
self.setWindowIcon(parent.windowIcon())
|
self.setWindowIcon(parent.windowIcon())
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.load_current_settings()
|
self.load_current_settings()
|
||||||
self.apply_styling()
|
self.apply_styling()
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self) -> None:
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title_label = QLabel("<h2>🔄 Auto-Update Settings</h2>")
|
title_label = QLabel("<h2>🔄 Auto-Update Settings</h2>")
|
||||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(title_label)
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.")
|
desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.")
|
||||||
desc_label.setWordWrap(True)
|
desc_label.setWordWrap(True)
|
||||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||||
layout.addWidget(desc_label)
|
layout.addWidget(desc_label)
|
||||||
|
|
||||||
# Enable/Disable auto-update
|
# Enable/Disable auto-update
|
||||||
self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates")
|
self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates")
|
||||||
self.enable_checkbox.setChecked(True) # Default enabled
|
self.enable_checkbox.setChecked(True) # Default enabled
|
||||||
self.enable_checkbox.toggled.connect(self.on_enable_toggled)
|
self.enable_checkbox.toggled.connect(self.on_enable_toggled)
|
||||||
layout.addWidget(self.enable_checkbox)
|
layout.addWidget(self.enable_checkbox)
|
||||||
|
|
||||||
# Frequency options
|
# Frequency options
|
||||||
frequency_group = QGroupBox("Update Frequency")
|
frequency_group = QGroupBox("Update Frequency")
|
||||||
frequency_layout = QVBoxLayout()
|
frequency_layout = QVBoxLayout()
|
||||||
|
|
||||||
self.frequency_group = QButtonGroup(self)
|
self.frequency_group = QButtonGroup(self)
|
||||||
|
|
||||||
self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
|
self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
|
||||||
self.daily_radio = QRadioButton("Check daily")
|
self.daily_radio = QRadioButton("Check daily")
|
||||||
self.weekly_radio = QRadioButton("Check weekly")
|
self.weekly_radio = QRadioButton("Check weekly")
|
||||||
|
|
||||||
self.daily_radio.setChecked(True) # Default to daily
|
self.daily_radio.setChecked(True) # Default to daily
|
||||||
|
|
||||||
self.frequency_group.addButton(self.startup_radio, 0)
|
self.frequency_group.addButton(self.startup_radio, 0)
|
||||||
self.frequency_group.addButton(self.daily_radio, 1)
|
self.frequency_group.addButton(self.daily_radio, 1)
|
||||||
self.frequency_group.addButton(self.weekly_radio, 2)
|
self.frequency_group.addButton(self.weekly_radio, 2)
|
||||||
|
|
||||||
frequency_layout.addWidget(self.startup_radio)
|
frequency_layout.addWidget(self.startup_radio)
|
||||||
frequency_layout.addWidget(self.daily_radio)
|
frequency_layout.addWidget(self.daily_radio)
|
||||||
frequency_layout.addWidget(self.weekly_radio)
|
frequency_layout.addWidget(self.weekly_radio)
|
||||||
frequency_group.setLayout(frequency_layout)
|
frequency_group.setLayout(frequency_layout)
|
||||||
|
|
||||||
layout.addWidget(frequency_group)
|
layout.addWidget(frequency_group)
|
||||||
|
|
||||||
# Current status
|
# Current status
|
||||||
status_group = QGroupBox("Current Status")
|
status_group = QGroupBox("Current Status")
|
||||||
status_layout = QVBoxLayout()
|
status_layout = QVBoxLayout()
|
||||||
|
|
||||||
self.current_version_label = QLabel("Current yt-dlp version: Checking...")
|
self.current_version_label = QLabel("Current yt-dlp version: Checking...")
|
||||||
self.last_check_label = QLabel("Last update check: Never")
|
self.last_check_label = QLabel("Last update check: Never")
|
||||||
self.next_check_label = QLabel("Next check: Based on settings")
|
self.next_check_label = QLabel("Next check: Based on settings")
|
||||||
|
|
||||||
status_layout.addWidget(self.current_version_label)
|
status_layout.addWidget(self.current_version_label)
|
||||||
status_layout.addWidget(self.last_check_label)
|
status_layout.addWidget(self.last_check_label)
|
||||||
status_layout.addWidget(self.next_check_label)
|
status_layout.addWidget(self.next_check_label)
|
||||||
status_group.setLayout(status_layout)
|
status_group.setLayout(status_layout)
|
||||||
|
|
||||||
layout.addWidget(status_group)
|
layout.addWidget(status_group)
|
||||||
|
|
||||||
# Manual check button
|
# Manual check button
|
||||||
self.manual_check_btn = QPushButton("🔍 Check for Updates Now")
|
self.manual_check_btn = QPushButton("🔍 Check for Updates Now")
|
||||||
self.manual_check_btn.clicked.connect(self.manual_check)
|
self.manual_check_btn.clicked.connect(self.manual_check)
|
||||||
layout.addWidget(self.manual_check_btn)
|
layout.addWidget(self.manual_check_btn)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.save_btn = QPushButton("Save Settings")
|
self.save_btn = QPushButton("Save Settings")
|
||||||
self.save_btn.clicked.connect(self.save_settings)
|
self.save_btn.clicked.connect(self.save_settings)
|
||||||
|
|
||||||
self.cancel_btn = QPushButton("Cancel")
|
self.cancel_btn = QPushButton("Cancel")
|
||||||
self.cancel_btn.clicked.connect(self.reject)
|
self.cancel_btn.clicked.connect(self.reject)
|
||||||
|
|
||||||
button_layout.addWidget(self.save_btn)
|
button_layout.addWidget(self.save_btn)
|
||||||
button_layout.addWidget(self.cancel_btn)
|
button_layout.addWidget(self.cancel_btn)
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
def apply_styling(self):
|
def apply_styling(self) -> None:
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -518,24 +544,22 @@ class AutoUpdateSettingsDialog(QDialog):
|
|||||||
background-color: #666666;
|
background-color: #666666;
|
||||||
color: #999999;
|
color: #999999;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
def load_current_settings(self):
|
|
||||||
|
def load_current_settings(self) -> None:
|
||||||
"""Load current auto-update settings from config."""
|
"""Load current auto-update settings from config."""
|
||||||
try:
|
try:
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
settings = get_auto_update_settings()
|
settings = get_auto_update_settings()
|
||||||
|
|
||||||
# Set checkbox
|
# Set checkbox
|
||||||
self.enable_checkbox.setChecked(settings['enabled'])
|
self.enable_checkbox.setChecked(settings["enabled"])
|
||||||
|
|
||||||
# Set frequency
|
# Set frequency
|
||||||
frequency = settings['frequency']
|
frequency = settings["frequency"]
|
||||||
if frequency == 'startup':
|
if frequency == "startup":
|
||||||
self.startup_radio.setChecked(True)
|
self.startup_radio.setChecked(True)
|
||||||
elif frequency == 'weekly':
|
elif frequency == "weekly":
|
||||||
self.weekly_radio.setChecked(True)
|
self.weekly_radio.setChecked(True)
|
||||||
else: # daily
|
else: # daily
|
||||||
self.daily_radio.setChecked(True)
|
self.daily_radio.setChecked(True)
|
||||||
@@ -543,106 +567,106 @@ class AutoUpdateSettingsDialog(QDialog):
|
|||||||
# Update status labels
|
# Update status labels
|
||||||
current_version = get_ytdlp_version()
|
current_version = get_ytdlp_version()
|
||||||
self.current_version_label.setText(f"Current yt-dlp version: {current_version}")
|
self.current_version_label.setText(f"Current yt-dlp version: {current_version}")
|
||||||
|
|
||||||
last_check = settings['last_check']
|
last_check = settings["last_check"]
|
||||||
if last_check > 0:
|
if last_check > 0:
|
||||||
last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
|
last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
self.last_check_label.setText(f"Last update check: {last_check_time}")
|
self.last_check_label.setText(f"Last update check: {last_check_time}")
|
||||||
else:
|
else:
|
||||||
self.last_check_label.setText("Last update check: Never")
|
self.last_check_label.setText("Last update check: Never")
|
||||||
|
|
||||||
# Calculate next check time
|
# Calculate next check time
|
||||||
self.update_next_check_label()
|
self.update_next_check_label()
|
||||||
|
|
||||||
# Update UI state
|
# Update UI state
|
||||||
self.on_enable_toggled(settings['enabled'])
|
self.on_enable_toggled(settings["enabled"])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading auto-update settings: {e}")
|
logger.error(f"Error loading auto-update settings: {e}")
|
||||||
|
|
||||||
def update_next_check_label(self):
|
def update_next_check_label(self) -> None:
|
||||||
"""Update the next check label based on current settings."""
|
"""Update the next check label based on current settings."""
|
||||||
try:
|
try:
|
||||||
if not self.enable_checkbox.isChecked():
|
if not self.enable_checkbox.isChecked():
|
||||||
self.next_check_label.setText("Next check: Disabled")
|
self.next_check_label.setText("Next check: Disabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
settings = get_auto_update_settings()
|
settings = get_auto_update_settings()
|
||||||
last_check = settings['last_check']
|
last_check = settings["last_check"]
|
||||||
frequency = self.get_selected_frequency()
|
frequency = self.get_selected_frequency()
|
||||||
|
|
||||||
if last_check == 0:
|
if last_check == 0:
|
||||||
self.next_check_label.setText("Next check: On next startup")
|
self.next_check_label.setText("Next check: On next startup")
|
||||||
return
|
return
|
||||||
|
|
||||||
next_check_time = last_check
|
next_check_time = last_check
|
||||||
if frequency == 'startup':
|
if frequency == "startup":
|
||||||
next_check_time += 3600 # 1 hour
|
next_check_time += 3600 # 1 hour
|
||||||
elif frequency == 'daily':
|
elif frequency == "daily":
|
||||||
next_check_time += 86400 # 24 hours
|
next_check_time += 86400 # 24 hours
|
||||||
elif frequency == 'weekly':
|
elif frequency == "weekly":
|
||||||
next_check_time += 604800 # 7 days
|
next_check_time += 604800 # 7 days
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if next_check_time <= current_time:
|
if next_check_time <= current_time:
|
||||||
self.next_check_label.setText("Next check: Now (overdue)")
|
self.next_check_label.setText("Next check: Now (overdue)")
|
||||||
else:
|
else:
|
||||||
next_check_datetime = datetime.fromtimestamp(next_check_time)
|
next_check_datetime = datetime.fromtimestamp(next_check_time)
|
||||||
self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
|
self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.next_check_label.setText("Next check: Error calculating")
|
self.next_check_label.setText("Next check: Error calculating")
|
||||||
logger.error(f"Error calculating next check time: {e}")
|
logger.error(f"Error calculating next check time: {e}")
|
||||||
|
|
||||||
def on_enable_toggled(self, enabled):
|
def on_enable_toggled(self, enabled) -> None:
|
||||||
"""Handle enable/disable checkbox toggle."""
|
"""Handle enable/disable checkbox toggle."""
|
||||||
# Enable/disable frequency options
|
# Enable/disable frequency options
|
||||||
for i in range(self.frequency_group.buttons().__len__()):
|
for i in range(self.frequency_group.buttons().__len__()):
|
||||||
self.frequency_group.button(i).setEnabled(enabled)
|
self.frequency_group.button(i).setEnabled(enabled)
|
||||||
|
|
||||||
self.update_next_check_label()
|
self.update_next_check_label()
|
||||||
|
|
||||||
def get_selected_frequency(self):
|
def get_selected_frequency(self) -> str:
|
||||||
"""Get the selected frequency setting."""
|
"""Get the selected frequency setting."""
|
||||||
if self.startup_radio.isChecked():
|
if self.startup_radio.isChecked():
|
||||||
return 'startup'
|
return "startup"
|
||||||
elif self.weekly_radio.isChecked():
|
elif self.weekly_radio.isChecked():
|
||||||
return 'weekly'
|
return "weekly"
|
||||||
else:
|
else:
|
||||||
return 'daily'
|
return "daily"
|
||||||
|
|
||||||
def manual_check(self):
|
def manual_check(self) -> None:
|
||||||
"""Perform a manual update check."""
|
"""Perform a manual update check."""
|
||||||
self.manual_check_btn.setEnabled(False)
|
self.manual_check_btn.setEnabled(False)
|
||||||
self.manual_check_btn.setText("🔄 Checking...")
|
self.manual_check_btn.setText("🔄 Checking...")
|
||||||
|
|
||||||
# Force an immediate update check
|
# Force an immediate update check
|
||||||
def check_in_thread():
|
def check_in_thread() -> None:
|
||||||
try:
|
try:
|
||||||
result = check_and_update_ytdlp_auto()
|
result = check_and_update_ytdlp_auto()
|
||||||
|
|
||||||
# Update UI in main thread
|
# Update UI in main thread
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
|
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during manual check: {e}")
|
logger.error(f"Error during manual check: {e}")
|
||||||
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
|
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
|
||||||
|
|
||||||
# Run in separate thread to avoid blocking UI
|
# Run in separate thread to avoid blocking UI
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
threading.Thread(target=check_in_thread, daemon=True).start()
|
threading.Thread(target=check_in_thread, daemon=True).start()
|
||||||
|
|
||||||
def _create_styled_message_box(self, icon, title, text):
|
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
|
||||||
"""Create a styled QMessageBox that matches the app theme."""
|
"""Create a styled QMessageBox that matches the app theme."""
|
||||||
msg_box = QMessageBox(self)
|
msg_box = QMessageBox(self)
|
||||||
msg_box.setIcon(icon)
|
msg_box.setIcon(icon)
|
||||||
msg_box.setWindowTitle(title)
|
msg_box.setWindowTitle(title)
|
||||||
msg_box.setText(text)
|
msg_box.setText(text)
|
||||||
msg_box.setWindowIcon(self.windowIcon())
|
msg_box.setWindowIcon(self.windowIcon())
|
||||||
msg_box.setStyleSheet("""
|
msg_box.setStyleSheet(
|
||||||
|
"""
|
||||||
QMessageBox {
|
QMessageBox {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -665,58 +689,55 @@ class AutoUpdateSettingsDialog(QDialog):
|
|||||||
QMessageBox QPushButton:pressed {
|
QMessageBox QPushButton:pressed {
|
||||||
background-color: #800000;
|
background-color: #800000;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
return msg_box
|
return msg_box
|
||||||
|
|
||||||
def manual_check_finished(self, success):
|
def manual_check_finished(self, success) -> None:
|
||||||
"""Handle completion of manual update check."""
|
"""Handle completion of manual update check."""
|
||||||
self.manual_check_btn.setEnabled(True)
|
self.manual_check_btn.setEnabled(True)
|
||||||
self.manual_check_btn.setText("🔍 Check for Updates Now")
|
self.manual_check_btn.setText("🔍 Check for Updates Now")
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Information,
|
QMessageBox.Icon.Information,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
"✅ Update check completed successfully!\nCheck the console for details."
|
"✅ Update check completed successfully!\nCheck the console for details.",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
else:
|
else:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Warning,
|
QMessageBox.Icon.Warning,
|
||||||
"Update Check",
|
"Update Check",
|
||||||
"❌ Update check failed.\nCheck the console for error details."
|
"❌ Update check failed.\nCheck the console for error details.",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
|
|
||||||
# Refresh the current settings display
|
# Refresh the current settings display
|
||||||
self.load_current_settings()
|
self.load_current_settings()
|
||||||
|
|
||||||
def save_settings(self):
|
def save_settings(self) -> None:
|
||||||
"""Save the auto-update settings."""
|
"""Save the auto-update settings."""
|
||||||
try:
|
try:
|
||||||
enabled = self.enable_checkbox.isChecked()
|
enabled = self.enable_checkbox.isChecked()
|
||||||
frequency = self.get_selected_frequency()
|
frequency = self.get_selected_frequency()
|
||||||
|
|
||||||
if update_auto_update_settings(enabled, frequency):
|
if update_auto_update_settings(enabled, frequency):
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Information,
|
QMessageBox.Icon.Information,
|
||||||
"Settings Saved",
|
"Settings Saved",
|
||||||
"✅ Auto-update settings have been saved successfully!"
|
"✅ Auto-update settings have been saved successfully!",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
self.accept()
|
self.accept()
|
||||||
else:
|
else:
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(
|
||||||
QMessageBox.Warning,
|
QMessageBox.Icon.Warning,
|
||||||
"Error",
|
"Error",
|
||||||
"❌ Failed to save auto-update settings.\nPlease try again."
|
"❌ Failed to save auto-update settings.\nPlease try again.",
|
||||||
)
|
)
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving auto-update settings: {e}")
|
logger.error(f"Error saving auto-update settings: {e}")
|
||||||
msg_box = self._create_styled_message_box(
|
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
|
||||||
QMessageBox.Critical,
|
|
||||||
"Error",
|
|
||||||
f"❌ Error saving settings: {str(e)}"
|
|
||||||
)
|
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
+228
-284
@@ -3,22 +3,25 @@ Update-related dialogs and threads for YTSage application.
|
|||||||
Contains dialogs and background threads for checking and performing yt-dlp updates.
|
Contains dialogs and background threads for checking and performing yt-dlp updates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from packaging import version
|
from pathlib import Path
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QPushButton, QProgressBar, QMessageBox)
|
|
||||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
|
||||||
|
|
||||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
import requests
|
||||||
from ...core.ytsage_utils import get_ytdlp_version, load_config, save_config
|
from packaging import version
|
||||||
from ...core.ytsage_logging import logger
|
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config
|
||||||
|
from src.core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
|
from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
YT_DLP_AVAILABLE = True
|
YT_DLP_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
YT_DLP_AVAILABLE = False
|
YT_DLP_AVAILABLE = False
|
||||||
@@ -26,29 +29,30 @@ except ImportError:
|
|||||||
|
|
||||||
class VersionCheckThread(QThread):
|
class VersionCheckThread(QThread):
|
||||||
finished = Signal(str, str, str) # current_version, latest_version, error_message
|
finished = Signal(str, str, str) # current_version, latest_version, error_message
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
current_version = ""
|
current_version = ""
|
||||||
latest_version = ""
|
latest_version = ""
|
||||||
error_message = ""
|
error_message = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the yt-dlp executable path
|
# Get the yt-dlp executable path
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
|
|
||||||
# Get current version with timeout
|
# Get current version with timeout
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([yt_dlp_path, '--version'],
|
result = subprocess.run(
|
||||||
capture_output=True,
|
[yt_dlp_path, "--version"],
|
||||||
text=True,
|
capture_output=True,
|
||||||
timeout=30, # 30 second timeout
|
text=True,
|
||||||
startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE),
|
timeout=30, # 30 second timeout
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
current_version = result.stdout.strip()
|
current_version = result.stdout.strip()
|
||||||
else: # Try fallback if command failed
|
else: # Try fallback if command failed
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
current_version = yt_dlp.version.__version__
|
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
|
||||||
else:
|
else:
|
||||||
error_message = "yt-dlp not available."
|
error_message = "yt-dlp not available."
|
||||||
self.finished.emit(current_version, latest_version, error_message)
|
self.finished.emit(current_version, latest_version, error_message)
|
||||||
@@ -56,34 +60,34 @@ class VersionCheckThread(QThread):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# Try fallback if timeout
|
# Try fallback if timeout
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
current_version = yt_dlp.version.__version__
|
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
|
||||||
else:
|
else:
|
||||||
error_message = "yt-dlp version check timed out and package not found."
|
error_message = "yt-dlp version check timed out and package not found."
|
||||||
self.finished.emit(current_version, latest_version, error_message)
|
self.finished.emit(current_version, latest_version, error_message)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to importing yt_dlp package directly if subprocess fails
|
# Fallback to importing yt_dlp package directly if subprocess fails
|
||||||
if YT_DLP_AVAILABLE:
|
if YT_DLP_AVAILABLE:
|
||||||
current_version = yt_dlp.version.__version__
|
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
|
||||||
else:
|
else:
|
||||||
error_message = "yt-dlp not found or accessible."
|
error_message = "yt-dlp not found or accessible."
|
||||||
self.finished.emit(current_version, latest_version, error_message)
|
self.finished.emit(current_version, latest_version, error_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get latest version from PyPI
|
# Get latest version from PyPI
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
latest_version = response.json()["info"]["version"]
|
latest_version = response.json()["info"]["version"]
|
||||||
|
|
||||||
# Clean up version strings
|
# Clean up version strings
|
||||||
current_version = current_version.replace('_', '.')
|
current_version = current_version.replace("_", ".")
|
||||||
latest_version = latest_version.replace('_', '.')
|
latest_version = latest_version.replace("_", ".")
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
error_message = f"Network error checking PyPI: {e}"
|
error_message = f"Network error checking PyPI: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"Error checking version: {e}"
|
error_message = f"Error checking version: {e}"
|
||||||
|
|
||||||
self.finished.emit(current_version, latest_version, error_message)
|
self.finished.emit(current_version, latest_version, error_message)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,54 +95,43 @@ class UpdateThread(QThread):
|
|||||||
update_status = Signal(str) # For status messages
|
update_status = Signal(str) # For status messages
|
||||||
update_progress = Signal(int) # For progress percentage (0-100)
|
update_progress = Signal(int) # For progress percentage (0-100)
|
||||||
update_finished = Signal(bool, str) # success (bool), message/error (str)
|
update_finished = Signal(bool, str) # success (bool), message/error (str)
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
error_message = ""
|
error_message = ""
|
||||||
success = False
|
success = False
|
||||||
try:
|
try:
|
||||||
self.update_status.emit("🔍 Checking current installation...")
|
self.update_status.emit("🔍 Checking current installation...")
|
||||||
self.update_progress.emit(10)
|
self.update_progress.emit(10)
|
||||||
|
|
||||||
# Get the yt-dlp path
|
# Get the yt-dlp path
|
||||||
try:
|
try:
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
self.update_status.emit(f"📍 Found yt-dlp at: {os.path.basename(yt_dlp_path)}")
|
self.update_status.emit(f"📍 Found yt-dlp at: {yt_dlp_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
|
self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
|
||||||
self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
|
self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
self.update_progress.emit(20)
|
self.update_progress.emit(20)
|
||||||
|
|
||||||
# Check if we're using an app-managed binary or system installation
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
app_managed_dirs = [
|
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
|
||||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
|
|
||||||
os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
|
|
||||||
os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
|
||||||
]
|
|
||||||
|
|
||||||
is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
|
|
||||||
|
|
||||||
if is_app_managed:
|
if is_app_managed:
|
||||||
self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
|
self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
|
||||||
success = self._update_binary(yt_dlp_path)
|
success = self._update_binary(yt_dlp_path)
|
||||||
else:
|
else:
|
||||||
self.update_status.emit("🐍 Updating system yt-dlp via pip...")
|
self.update_status.emit("🐍 Updating system yt-dlp via pip...")
|
||||||
success = self._update_via_pip(startupinfo)
|
success = self._update_via_pip()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.update_progress.emit(100)
|
self.update_progress.emit(100)
|
||||||
error_message = "✅ yt-dlp has been successfully updated!"
|
error_message = "✅ yt-dlp has been successfully updated!"
|
||||||
else:
|
else:
|
||||||
error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
|
error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
error_message = f"❌ Network error during update: {str(e)}"
|
error_message = f"❌ Network error during update: {str(e)}"
|
||||||
self.update_status.emit(error_message)
|
self.update_status.emit(error_message)
|
||||||
@@ -147,92 +140,56 @@ class UpdateThread(QThread):
|
|||||||
error_message = f"❌ Update failed: {str(e)}"
|
error_message = f"❌ Update failed: {str(e)}"
|
||||||
self.update_status.emit(error_message)
|
self.update_status.emit(error_message)
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
self.update_finished.emit(success, error_message)
|
self.update_finished.emit(success, error_message)
|
||||||
|
|
||||||
def _update_binary(self, yt_dlp_path):
|
def _update_binary(self, yt_dlp_path: Path) -> bool:
|
||||||
"""Update yt-dlp binary directly from GitHub releases."""
|
"""Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread)."""
|
||||||
try:
|
try:
|
||||||
self.update_status.emit("🌐 Determining download URL...")
|
logger.info("UpdateThread: Checking for yt-dlp updates...")
|
||||||
self.update_progress.emit(30)
|
|
||||||
|
result = subprocess.run(
|
||||||
# Determine the URL based on OS
|
[yt_dlp_path, "-U"],
|
||||||
if sys.platform == 'win32':
|
capture_output=True,
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
text=True,
|
||||||
elif sys.platform == 'darwin':
|
timeout=60,
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
else:
|
)
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
|
||||||
|
if result.returncode == 0:
|
||||||
self.update_status.emit("⬇️ Downloading latest yt-dlp binary...")
|
# Make executable on Unix systems
|
||||||
self.update_progress.emit(40)
|
if OS_NAME != "Windows":
|
||||||
|
os.chmod(yt_dlp_path, 0o755)
|
||||||
# Download with progress tracking and timeout
|
|
||||||
response = requests.get(url, stream=True, timeout=30)
|
logger.info("UpdateThread: yt-dlp update completed successfully.")
|
||||||
if response.status_code != 200:
|
if result.stdout:
|
||||||
self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}")
|
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
|
||||||
return False
|
|
||||||
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
temp_file = f"{yt_dlp_path}.new"
|
|
||||||
downloaded = 0
|
|
||||||
|
|
||||||
self.update_status.emit("💾 Downloading and saving binary...")
|
|
||||||
|
|
||||||
with open(temp_file, 'wb') as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded += len(chunk)
|
|
||||||
|
|
||||||
# Update progress (40-80% for download)
|
|
||||||
if total_size > 0:
|
|
||||||
progress = 40 + int((downloaded / total_size) * 40)
|
|
||||||
self.update_progress.emit(progress)
|
|
||||||
|
|
||||||
self.update_status.emit("🔧 Installing updated binary...")
|
|
||||||
self.update_progress.emit(85)
|
|
||||||
|
|
||||||
# Make executable on Unix systems
|
|
||||||
if sys.platform != 'win32':
|
|
||||||
os.chmod(temp_file, 0o755)
|
|
||||||
|
|
||||||
# Replace the old file with the new one
|
|
||||||
try:
|
|
||||||
# On Windows, we need to remove the old file first
|
|
||||||
if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
|
|
||||||
os.remove(yt_dlp_path)
|
|
||||||
|
|
||||||
os.rename(temp_file, yt_dlp_path)
|
|
||||||
self.update_status.emit("✅ Binary successfully updated!")
|
self.update_status.emit("✅ Binary successfully updated!")
|
||||||
self.update_progress.emit(95)
|
self.update_progress.emit(95)
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
except Exception as e:
|
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
|
||||||
self.update_status.emit(f"❌ Error installing binary: {e}")
|
self.update_status.emit(f"❌ yt-dlp update failed: {result.stderr.strip()}")
|
||||||
# Clean up temp file if it exists
|
|
||||||
if os.path.exists(temp_file):
|
|
||||||
try:
|
|
||||||
os.remove(temp_file)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except subprocess.TimeoutExpired:
|
||||||
self.update_status.emit(f"❌ Network error: {e}")
|
logger.error("UpdateThread: yt-dlp update timed out.")
|
||||||
|
self.update_status.emit("❌ yt-dlp update timed out.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status.emit(f"❌ Binary update failed: {e}")
|
logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True)
|
||||||
|
self.update_status.emit(f"❌ Unexpected error during update: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _update_via_pip(self, startupinfo):
|
def _update_via_pip(self) -> bool:
|
||||||
"""Update yt-dlp via pip."""
|
"""Update yt-dlp via pip."""
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
|
||||||
self.update_status.emit("🔍 Checking current pip installation...")
|
self.update_status.emit("🔍 Checking current pip installation...")
|
||||||
self.update_progress.emit(30)
|
self.update_progress.emit(30)
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
try:
|
try:
|
||||||
current_version = pkg_resources.get_distribution("yt-dlp").version
|
current_version = pkg_resources.get_distribution("yt-dlp").version
|
||||||
@@ -240,27 +197,27 @@ class UpdateThread(QThread):
|
|||||||
except pkg_resources.DistributionNotFound:
|
except pkg_resources.DistributionNotFound:
|
||||||
self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...")
|
self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...")
|
||||||
current_version = "0.0.0"
|
current_version = "0.0.0"
|
||||||
|
|
||||||
self.update_progress.emit(40)
|
self.update_progress.emit(40)
|
||||||
|
|
||||||
# Get the latest version from PyPI
|
# Get the latest version from PyPI
|
||||||
self.update_status.emit("🌐 Checking for latest version...")
|
self.update_status.emit("🌐 Checking for latest version...")
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
self.update_status.emit("❌ Failed to check for updates")
|
self.update_status.emit("❌ Failed to check for updates")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
latest_version = data["info"]["version"]
|
latest_version = data["info"]["version"]
|
||||||
self.update_status.emit(f"🆕 Latest version: {latest_version}")
|
self.update_status.emit(f"🆕 Latest version: {latest_version}")
|
||||||
self.update_progress.emit(50)
|
self.update_progress.emit(50)
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
if version.parse(latest_version) > version.parse(current_version):
|
if version.parse(latest_version) > version.parse(current_version):
|
||||||
self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...")
|
self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...")
|
||||||
self.update_progress.emit(60)
|
self.update_progress.emit(60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run pip update with timeout
|
# Run pip update with timeout
|
||||||
self.update_status.emit("📦 Running pip install --upgrade...")
|
self.update_status.emit("📦 Running pip install --upgrade...")
|
||||||
@@ -270,11 +227,11 @@ class UpdateThread(QThread):
|
|||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
timeout=300, # 5 minute timeout for pip install
|
timeout=300, # 5 minute timeout for pip install
|
||||||
startupinfo=startupinfo
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.update_progress.emit(85)
|
self.update_progress.emit(85)
|
||||||
|
|
||||||
if update_result.returncode == 0:
|
if update_result.returncode == 0:
|
||||||
self.update_status.emit("✅ Pip update completed successfully!")
|
self.update_status.emit("✅ Pip update completed successfully!")
|
||||||
self.update_progress.emit(95)
|
self.update_progress.emit(95)
|
||||||
@@ -282,7 +239,7 @@ class UpdateThread(QThread):
|
|||||||
else:
|
else:
|
||||||
self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}")
|
self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.update_status.emit("❌ Pip update timed out after 5 minutes")
|
self.update_status.emit("❌ Pip update timed out after 5 minutes")
|
||||||
return False
|
return False
|
||||||
@@ -293,49 +250,50 @@ class UpdateThread(QThread):
|
|||||||
self.update_status.emit("✅ yt-dlp is already up to date!")
|
self.update_status.emit("✅ yt-dlp is already up to date!")
|
||||||
self.update_progress.emit(95)
|
self.update_progress.emit(95)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status.emit(f"❌ Pip update failed: {e}")
|
self.update_status.emit(f"❌ Pip update failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class YTDLPUpdateDialog(QDialog):
|
class YTDLPUpdateDialog(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Update yt-dlp")
|
self.setWindowTitle("Update yt-dlp")
|
||||||
self.setMinimumWidth(450)
|
self.setMinimumWidth(450)
|
||||||
self.setMinimumHeight(200)
|
self.setMinimumHeight(200)
|
||||||
self._closing = False # Flag to track if dialog is closing
|
self._closing = False # Flag to track if dialog is closing
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Status label
|
# Status label
|
||||||
self.status_label = QLabel("Checking for updates...")
|
self.status_label = QLabel("Checking for updates...")
|
||||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.status_label.setWordWrap(True)
|
self.status_label.setWordWrap(True)
|
||||||
self.status_label.setMinimumHeight(60)
|
self.status_label.setMinimumHeight(60)
|
||||||
layout.addWidget(self.status_label)
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
# Progress bar
|
# Progress bar
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
self.progress_bar.hide() # Hide initially
|
self.progress_bar.hide() # Hide initially
|
||||||
layout.addWidget(self.progress_bar)
|
layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
self.update_btn = QPushButton("Update")
|
self.update_btn = QPushButton("Update")
|
||||||
self.update_btn.clicked.connect(self.perform_update)
|
self.update_btn.clicked.connect(self.perform_update)
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
|
|
||||||
self.close_btn = QPushButton("Close")
|
self.close_btn = QPushButton("Close")
|
||||||
self.close_btn.clicked.connect(self.close)
|
self.close_btn.clicked.connect(self.close)
|
||||||
|
|
||||||
button_layout.addWidget(self.update_btn)
|
button_layout.addWidget(self.update_btn)
|
||||||
button_layout.addWidget(self.close_btn)
|
button_layout.addWidget(self.close_btn)
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
# Style
|
# Style
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QDialog {
|
QDialog {
|
||||||
background-color: #15181b;
|
background-color: #15181b;
|
||||||
}
|
}
|
||||||
@@ -375,40 +333,43 @@ class YTDLPUpdateDialog(QDialog):
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Start version check in background
|
# Start version check in background
|
||||||
self.check_version()
|
self.check_version()
|
||||||
|
|
||||||
def check_version(self):
|
def check_version(self) -> None:
|
||||||
self.status_label.setText("Checking for updates...")
|
self.status_label.setText("Checking for updates...")
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
self.version_check_thread = VersionCheckThread()
|
self.version_check_thread = VersionCheckThread()
|
||||||
self.version_check_thread.finished.connect(self.on_version_check_finished)
|
self.version_check_thread.finished.connect(self.on_version_check_finished)
|
||||||
self.version_check_thread.start()
|
self.version_check_thread.start()
|
||||||
|
|
||||||
def on_version_check_finished(self, current_version, latest_version, error_message):
|
def on_version_check_finished(self, current_version, latest_version, error_message) -> None:
|
||||||
# Check if dialog is closing to avoid unnecessary updates
|
# Check if dialog is closing to avoid unnecessary updates
|
||||||
if hasattr(self, '_closing') and self._closing:
|
if hasattr(self, "_closing") and self._closing:
|
||||||
return
|
return
|
||||||
|
|
||||||
if error_message:
|
if error_message:
|
||||||
self.status_label.setText(error_message)
|
self.status_label.setText(error_message)
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not current_version or not latest_version:
|
if not current_version or not latest_version:
|
||||||
self.status_label.setText("Could not determine versions.")
|
self.status_label.setText("Could not determine versions.")
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Compare versions
|
# Compare versions
|
||||||
current_ver = version.parse(current_version)
|
current_ver = version.parse(current_version)
|
||||||
latest_ver = version.parse(latest_version)
|
latest_ver = version.parse(latest_version)
|
||||||
|
|
||||||
if current_ver < latest_ver:
|
if current_ver < latest_ver:
|
||||||
self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
|
self.status_label.setText(
|
||||||
|
f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}"
|
||||||
|
)
|
||||||
self.update_btn.setEnabled(True)
|
self.update_btn.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
||||||
@@ -416,31 +377,33 @@ class YTDLPUpdateDialog(QDialog):
|
|||||||
except version.InvalidVersion:
|
except version.InvalidVersion:
|
||||||
# If version parsing fails, do a simple string comparison
|
# If version parsing fails, do a simple string comparison
|
||||||
if current_version != latest_version:
|
if current_version != latest_version:
|
||||||
self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}")
|
self.status_label.setText(
|
||||||
|
f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}"
|
||||||
|
)
|
||||||
self.update_btn.setEnabled(True)
|
self.update_btn.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_label.setText(f"Error comparing versions: {e}")
|
self.status_label.setText(f"Error comparing versions: {e}")
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
|
|
||||||
def perform_update(self):
|
def perform_update(self) -> None:
|
||||||
# Immediate visual feedback
|
# Immediate visual feedback
|
||||||
self.update_btn.setEnabled(False)
|
self.update_btn.setEnabled(False)
|
||||||
self.close_btn.setEnabled(False)
|
self.close_btn.setEnabled(False)
|
||||||
self.update_btn.setText("Updating...")
|
self.update_btn.setText("Updating...")
|
||||||
self.status_label.setText("🚀 Initializing update process...")
|
self.status_label.setText("🚀 Initializing update process...")
|
||||||
|
|
||||||
# Show progress bar immediately
|
# Show progress bar immediately
|
||||||
self.progress_bar.setRange(0, 100)
|
self.progress_bar.setRange(0, 100)
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.progress_bar.show()
|
self.progress_bar.show()
|
||||||
|
|
||||||
# Start the update thread
|
# Start the update thread
|
||||||
self._start_update_thread()
|
self._start_update_thread()
|
||||||
|
|
||||||
def _start_update_thread(self):
|
def _start_update_thread(self) -> None:
|
||||||
"""Start the actual update thread."""
|
"""Start the actual update thread."""
|
||||||
# Create and start the update thread
|
# Create and start the update thread
|
||||||
self.update_thread = UpdateThread()
|
self.update_thread = UpdateThread()
|
||||||
@@ -449,95 +412,102 @@ class YTDLPUpdateDialog(QDialog):
|
|||||||
self.update_thread.update_finished.connect(self.on_update_finished)
|
self.update_thread.update_finished.connect(self.on_update_finished)
|
||||||
self.update_thread.start()
|
self.update_thread.start()
|
||||||
|
|
||||||
def on_update_status(self, message):
|
def on_update_status(self, message) -> None:
|
||||||
"""Slot to receive status messages from UpdateThread."""
|
"""Slot to receive status messages from UpdateThread."""
|
||||||
if not (hasattr(self, '_closing') and self._closing):
|
if not (hasattr(self, "_closing") and self._closing):
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
|
|
||||||
def on_update_progress(self, progress):
|
def on_update_progress(self, progress) -> None:
|
||||||
"""Slot to receive progress updates from UpdateThread."""
|
"""Slot to receive progress updates from UpdateThread."""
|
||||||
if not (hasattr(self, '_closing') and self._closing):
|
if not (hasattr(self, "_closing") and self._closing):
|
||||||
self.progress_bar.setValue(progress)
|
self.progress_bar.setValue(progress)
|
||||||
|
|
||||||
def on_update_finished(self, success, message):
|
def on_update_finished(self, success, message) -> None:
|
||||||
"""Slot called when the UpdateThread finishes."""
|
"""Slot called when the UpdateThread finishes."""
|
||||||
# Check if dialog is closing to avoid unnecessary updates
|
# Check if dialog is closing to avoid unnecessary updates
|
||||||
if hasattr(self, '_closing') and self._closing:
|
if hasattr(self, "_closing") and self._closing:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.progress_bar.setValue(100)
|
self.progress_bar.setValue(100)
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.close_btn.setEnabled(True)
|
self.close_btn.setEnabled(True)
|
||||||
self.update_btn.setText("Update") # Reset button text
|
self.update_btn.setText("Update") # Reset button text
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Show success briefly then auto-check version
|
# Show success briefly then auto-check version
|
||||||
QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
|
QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
|
||||||
else:
|
else:
|
||||||
# Re-enable update button on failure after a short delay
|
# Re-enable update button on failure after a short delay
|
||||||
QTimer.singleShot(3000, lambda: self.update_btn.setEnabled(True) if not (hasattr(self, '_closing') and self._closing) else None)
|
QTimer.singleShot(
|
||||||
|
3000,
|
||||||
|
lambda: (self.update_btn.setEnabled(True) if not (hasattr(self, "_closing") and self._closing) else None),
|
||||||
|
)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event) -> None:
|
||||||
"""Ensure threads are terminated if the dialog is closed prematurely."""
|
"""Ensure threads are terminated if the dialog is closed prematurely."""
|
||||||
# Set a flag to indicate dialog is closing
|
# Set a flag to indicate dialog is closing
|
||||||
self._closing = True
|
self._closing = True
|
||||||
|
|
||||||
if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning():
|
if hasattr(self, "version_check_thread") and self.version_check_thread.isRunning():
|
||||||
self.version_check_thread.quit()
|
self.version_check_thread.quit()
|
||||||
if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
|
if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
|
||||||
self.version_check_thread.terminate()
|
self.version_check_thread.terminate()
|
||||||
|
|
||||||
if hasattr(self, 'update_thread') and self.update_thread.isRunning():
|
if hasattr(self, "update_thread") and self.update_thread.isRunning():
|
||||||
self.update_thread.quit()
|
self.update_thread.quit()
|
||||||
if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
|
if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
|
||||||
self.update_thread.terminate()
|
self.update_thread.terminate()
|
||||||
|
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
class AutoUpdateThread(QThread):
|
class AutoUpdateThread(QThread):
|
||||||
"""Thread for performing automatic background updates without UI feedback."""
|
"""Thread for performing automatic background updates without UI feedback."""
|
||||||
|
|
||||||
update_finished = Signal(bool, str) # success (bool), message (str)
|
update_finished = Signal(bool, str) # success (bool), message (str)
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
"""Perform automatic yt-dlp update check and update if needed."""
|
"""Perform automatic yt-dlp update check and update if needed."""
|
||||||
try:
|
try:
|
||||||
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
|
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
current_version = get_ytdlp_version()
|
current_version = get_ytdlp_version()
|
||||||
if "Error" in current_version:
|
if "Error" in current_version:
|
||||||
logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
|
logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
|
||||||
self.update_finished.emit(False, "Could not determine current yt-dlp version")
|
self.update_finished.emit(False, "Could not determine current yt-dlp version")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get latest version from PyPI
|
# Get latest version from PyPI
|
||||||
try:
|
try:
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
latest_version = response.json()["info"]["version"]
|
latest_version = response.json()["info"]["version"]
|
||||||
|
|
||||||
# Clean up version strings
|
# Clean up version strings
|
||||||
current_version = current_version.replace('_', '.')
|
current_version = current_version.replace("_", ".")
|
||||||
latest_version = latest_version.replace('_', '.')
|
latest_version = latest_version.replace("_", ".")
|
||||||
|
|
||||||
logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
|
logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
|
||||||
logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
|
logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
if version.parse(latest_version) > version.parse(current_version):
|
if version.parse(latest_version) > version.parse(current_version):
|
||||||
logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
||||||
|
|
||||||
# Perform the update
|
# Perform the update
|
||||||
success = self._perform_update()
|
success = self._perform_update()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info("AutoUpdateThread: Auto-update completed successfully!")
|
logger.info("AutoUpdateThread: Auto-update completed successfully!")
|
||||||
# Update the last check timestamp
|
# Update the last check timestamp
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['last_update_check'] = time.time()
|
config["last_update_check"] = time.time()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
self.update_finished.emit(True, f"Successfully updated yt-dlp from {current_version} to {latest_version}")
|
self.update_finished.emit(
|
||||||
|
True,
|
||||||
|
f"Successfully updated yt-dlp from {current_version} to {latest_version}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("AutoUpdateThread: Auto-update failed")
|
logger.warning("AutoUpdateThread: Auto-update failed")
|
||||||
self.update_finished.emit(False, "Auto-update failed")
|
self.update_finished.emit(False, "Auto-update failed")
|
||||||
@@ -545,110 +515,89 @@ class AutoUpdateThread(QThread):
|
|||||||
logger.info("AutoUpdateThread: yt-dlp is already up to date")
|
logger.info("AutoUpdateThread: yt-dlp is already up to date")
|
||||||
# Still update the timestamp even if no update was needed
|
# Still update the timestamp even if no update was needed
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config['last_update_check'] = time.time()
|
config["last_update_check"] = time.time()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
self.update_finished.emit(True, f"yt-dlp is already up to date (version {current_version})")
|
self.update_finished.emit(
|
||||||
|
True,
|
||||||
|
f"yt-dlp is already up to date (version {current_version})",
|
||||||
|
)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
|
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
|
||||||
self.update_finished.emit(False, f"Network error: {e}")
|
self.update_finished.emit(False, f"Network error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"AutoUpdateThread: Error during auto-update check: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
self.update_finished.emit(False, f"Update check error: {e}")
|
self.update_finished.emit(False, f"Update check error: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
|
logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
|
||||||
self.update_finished.emit(False, f"Critical error: {e}")
|
self.update_finished.emit(False, f"Critical error: {e}")
|
||||||
|
|
||||||
def _perform_update(self):
|
def _perform_update(self) -> bool:
|
||||||
"""Perform the actual update using similar logic to UpdateThread but without UI feedback."""
|
"""Perform the actual update using similar logic to UpdateThread but without UI feedback."""
|
||||||
try:
|
try:
|
||||||
# Get the yt-dlp path
|
# Get the yt-dlp path
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
|
|
||||||
# Check if we're using an app-managed binary or system installation
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
app_managed_dirs = [
|
|
||||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
|
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
|
||||||
os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
|
|
||||||
os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
|
||||||
]
|
|
||||||
|
|
||||||
is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
|
|
||||||
|
|
||||||
if is_app_managed:
|
if is_app_managed:
|
||||||
logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...")
|
logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...")
|
||||||
return self._update_binary(yt_dlp_path)
|
return self._update_binary(yt_dlp_path)
|
||||||
else:
|
else:
|
||||||
logger.info("AutoUpdateThread: Updating system yt-dlp via pip...")
|
logger.info("AutoUpdateThread: Updating system yt-dlp via pip...")
|
||||||
return self._update_via_pip()
|
return self._update_via_pip()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
|
logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _update_binary(self, yt_dlp_path):
|
def _update_binary(self, yt_dlp_path: Path) -> bool:
|
||||||
"""Update yt-dlp binary directly from GitHub releases (silent version)."""
|
"""Update yt-dlp binary using its built-in updater."""
|
||||||
try:
|
try:
|
||||||
# Determine the URL based on OS
|
logger.info("AutoUpdateThread: Checking for yt-dlp updates...")
|
||||||
if sys.platform == 'win32':
|
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
result = subprocess.run(
|
||||||
elif sys.platform == 'darwin':
|
[yt_dlp_path, "-U"],
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
capture_output=True,
|
||||||
else:
|
text=True,
|
||||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
timeout=60,
|
||||||
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
logger.info("AutoUpdateThread: Downloading latest yt-dlp binary...")
|
)
|
||||||
|
|
||||||
# Download without progress tracking (silent)
|
if result.returncode == 0:
|
||||||
response = requests.get(url, stream=True)
|
# Make executable on Unix systems
|
||||||
if response.status_code != 200:
|
if OS_NAME != "Windows":
|
||||||
logger.error(f"AutoUpdateThread: Download failed: HTTP {response.status_code}")
|
os.chmod(yt_dlp_path, 0o755)
|
||||||
return False
|
|
||||||
|
logger.info("AutoUpdateThread: yt-dlp update completed successfully.")
|
||||||
temp_file = f"{yt_dlp_path}.new"
|
if result.stdout:
|
||||||
|
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
|
||||||
with open(temp_file, 'wb') as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
logger.info("AutoUpdateThread: Installing updated binary...")
|
|
||||||
|
|
||||||
# Make executable on Unix systems
|
|
||||||
if sys.platform != 'win32':
|
|
||||||
os.chmod(temp_file, 0o755)
|
|
||||||
|
|
||||||
# Replace the old file with the new one
|
|
||||||
try:
|
|
||||||
# On Windows, we need to remove the old file first
|
|
||||||
if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
|
|
||||||
os.remove(yt_dlp_path)
|
|
||||||
|
|
||||||
os.rename(temp_file, yt_dlp_path)
|
|
||||||
logger.info("AutoUpdateThread: Binary successfully updated!")
|
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
except Exception as e:
|
logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}")
|
||||||
logger.error(f"AutoUpdateThread: Error installing binary: {e}")
|
|
||||||
# Clean up temp file if it exists
|
|
||||||
if os.path.exists(temp_file):
|
|
||||||
try:
|
|
||||||
os.remove(temp_file)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except subprocess.TimeoutExpired:
|
||||||
logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True)
|
logger.error("AutoUpdateThread: yt-dlp update timed out.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _update_via_pip(self):
|
except Exception as e:
|
||||||
|
logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _update_via_pip(self) -> bool:
|
||||||
"""Update yt-dlp via pip (silent version)."""
|
"""Update yt-dlp via pip (silent version)."""
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
|
||||||
logger.info("AutoUpdateThread: Checking current pip installation...")
|
logger.info("AutoUpdateThread: Checking current pip installation...")
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
try:
|
try:
|
||||||
current_version = pkg_resources.get_distribution("yt-dlp").version
|
current_version = pkg_resources.get_distribution("yt-dlp").version
|
||||||
@@ -656,30 +605,25 @@ class AutoUpdateThread(QThread):
|
|||||||
except pkg_resources.DistributionNotFound:
|
except pkg_resources.DistributionNotFound:
|
||||||
logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...")
|
logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...")
|
||||||
current_version = "0.0.0"
|
current_version = "0.0.0"
|
||||||
|
|
||||||
# Get the latest version from PyPI
|
# Get the latest version from PyPI
|
||||||
logger.info("AutoUpdateThread: Checking for latest version...")
|
logger.info("AutoUpdateThread: Checking for latest version...")
|
||||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error("AutoUpdateThread: Failed to check for updates")
|
logger.error("AutoUpdateThread: Failed to check for updates")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
latest_version = data["info"]["version"]
|
latest_version = data["info"]["version"]
|
||||||
logger.info(f"AutoUpdateThread: Latest version: {latest_version}")
|
logger.info(f"AutoUpdateThread: Latest version: {latest_version}")
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
if version.parse(latest_version) > version.parse(current_version):
|
if version.parse(latest_version) > version.parse(current_version):
|
||||||
logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
|
logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
|
||||||
|
|
||||||
# Create startupinfo to hide console on Windows
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
startupinfo = None
|
|
||||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
|
||||||
|
|
||||||
# Run pip update
|
# Run pip update
|
||||||
logger.info("AutoUpdateThread: Running pip install --upgrade...")
|
logger.info("AutoUpdateThread: Running pip install --upgrade...")
|
||||||
update_result = subprocess.run(
|
update_result = subprocess.run(
|
||||||
@@ -687,9 +631,9 @@ class AutoUpdateThread(QThread):
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
startupinfo=startupinfo
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
if update_result.returncode == 0:
|
if update_result.returncode == 0:
|
||||||
logger.info("AutoUpdateThread: Pip update completed successfully!")
|
logger.info("AutoUpdateThread: Pip update completed successfully!")
|
||||||
return True
|
return True
|
||||||
@@ -699,7 +643,7 @@ class AutoUpdateThread(QThread):
|
|||||||
else:
|
else:
|
||||||
logger.info("AutoUpdateThread: yt-dlp is already up to date!")
|
logger.info("AutoUpdateThread: yt-dlp is already up to date!")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
|
logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
+136
-112
@@ -1,58 +1,67 @@
|
|||||||
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
from PySide6.QtCore import QObject, Qt, Signal
|
||||||
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
|
from PySide6.QtGui import QColor
|
||||||
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
|
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
|
||||||
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit,
|
|
||||||
QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QScrollArea,
|
|
||||||
QSizePolicy)
|
|
||||||
from PySide6.QtCore import Qt, Signal, QObject, QThread
|
|
||||||
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
|
|
||||||
|
|
||||||
class FormatSignals(QObject):
|
class FormatSignals(QObject):
|
||||||
format_update = Signal(list)
|
format_update = Signal(list)
|
||||||
|
|
||||||
|
|
||||||
class FormatTableMixin:
|
class FormatTableMixin:
|
||||||
def setup_format_table(self):
|
def setup_format_table(self) -> QTableWidget:
|
||||||
self.format_signals = FormatSignals()
|
self.format_signals = FormatSignals()
|
||||||
|
|
||||||
# Format table with improved styling
|
# Format table with improved styling
|
||||||
self.format_table = QTableWidget()
|
self.format_table = QTableWidget()
|
||||||
self.format_table.setColumnCount(8)
|
self.format_table.setColumnCount(8)
|
||||||
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
|
self.format_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
"Select",
|
||||||
|
"Quality",
|
||||||
|
"Extension",
|
||||||
|
"Resolution",
|
||||||
|
"File Size",
|
||||||
|
"Codec",
|
||||||
|
"Audio",
|
||||||
|
"Notes",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Enable alternating row colors
|
# Enable alternating row colors
|
||||||
self.format_table.setAlternatingRowColors(True)
|
self.format_table.setAlternatingRowColors(True)
|
||||||
|
|
||||||
# Set specific column widths and resize modes
|
# Set specific column widths and resize modes
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select
|
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select
|
||||||
self.format_table.setColumnWidth(0, 50) # Select column width
|
self.format_table.setColumnWidth(0, 50) # Select column width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) # Quality
|
self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) # Quality
|
||||||
self.format_table.setColumnWidth(1, 100) # Quality width
|
self.format_table.setColumnWidth(1, 100) # Quality width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Extension
|
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Extension
|
||||||
self.format_table.setColumnWidth(2, 80) # Extension width
|
self.format_table.setColumnWidth(2, 80) # Extension width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Resolution
|
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Resolution
|
||||||
self.format_table.setColumnWidth(3, 100) # Resolution width
|
self.format_table.setColumnWidth(3, 100) # Resolution width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # File Size
|
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # File Size
|
||||||
self.format_table.setColumnWidth(4, 100) # File Size width
|
self.format_table.setColumnWidth(4, 100) # File Size width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Codec
|
self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Codec
|
||||||
self.format_table.setColumnWidth(5, 150) # Codec width
|
self.format_table.setColumnWidth(5, 150) # Codec width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Audio
|
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Audio
|
||||||
self.format_table.setColumnWidth(6, 120) # Audio width
|
self.format_table.setColumnWidth(6, 120) # Audio width
|
||||||
|
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) # Notes (will stretch)
|
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) # Notes (will stretch)
|
||||||
|
|
||||||
# Set vertical header (row numbers) visible to false
|
# Set vertical header (row numbers) visible to false
|
||||||
self.format_table.verticalHeader().setVisible(False)
|
self.format_table.verticalHeader().setVisible(False)
|
||||||
|
|
||||||
# Set selection mode to no selection (since we're using checkboxes)
|
# Set selection mode to no selection (since we're using checkboxes)
|
||||||
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||||
|
|
||||||
self.format_table.setStyleSheet("""
|
self.format_table.setStyleSheet(
|
||||||
|
"""
|
||||||
QTableWidget {
|
QTableWidget {
|
||||||
background-color: #1b2021;
|
background-color: #1b2021;
|
||||||
border: 2px solid #1b2021;
|
border: 2px solid #1b2021;
|
||||||
@@ -96,27 +105,28 @@ class FormatTableMixin:
|
|||||||
QWidget {
|
QWidget {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Store format checkboxes and formats
|
# Store format checkboxes and formats
|
||||||
self.format_checkboxes = []
|
self.format_checkboxes = []
|
||||||
self.all_formats = []
|
self.all_formats = []
|
||||||
|
|
||||||
# Set table size policies
|
# Set table size policies
|
||||||
self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
# Set minimum and maximum heights
|
# Set minimum and maximum heights
|
||||||
self.format_table.setMinimumHeight(200)
|
self.format_table.setMinimumHeight(200)
|
||||||
|
|
||||||
# Connect the signal
|
# Connect the signal
|
||||||
self.format_signals.format_update.connect(self._update_format_table)
|
self.format_signals.format_update.connect(self._update_format_table)
|
||||||
|
|
||||||
return self.format_table
|
return self.format_table
|
||||||
|
|
||||||
def filter_formats(self):
|
def filter_formats(self) -> None:
|
||||||
if not hasattr(self, 'all_formats'):
|
if not hasattr(self, "all_formats"):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Clear current table
|
# Clear current table
|
||||||
self.format_table.setRowCount(0)
|
self.format_table.setRowCount(0)
|
||||||
self.format_checkboxes.clear()
|
self.format_checkboxes.clear()
|
||||||
@@ -124,44 +134,46 @@ class FormatTableMixin:
|
|||||||
# Determine which formats to show
|
# Determine which formats to show
|
||||||
filtered_formats = []
|
filtered_formats = []
|
||||||
|
|
||||||
if hasattr(self, 'video_button') and self.video_button.isChecked():
|
if hasattr(self, "video_button") and self.video_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
|
||||||
filtered_formats.extend([f for f in self.all_formats
|
filtered_formats.extend([f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None])
|
||||||
if f.get('vcodec') != 'none'
|
|
||||||
and f.get('filesize') is not None])
|
|
||||||
|
|
||||||
if hasattr(self, 'audio_button') and self.audio_button.isChecked():
|
if hasattr(self, "audio_button") and self.audio_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
|
||||||
filtered_formats.extend([f for f in self.all_formats
|
filtered_formats.extend(
|
||||||
if (f.get('vcodec') == 'none'
|
[
|
||||||
or 'audio only' in f.get('format_note', '').lower())
|
f
|
||||||
and f.get('acodec') != 'none'
|
for f in self.all_formats
|
||||||
and f.get('filesize') is not None])
|
if (f.get("vcodec") == "none" or "audio only" in f.get("format_note", "").lower())
|
||||||
|
and f.get("acodec") != "none"
|
||||||
|
and f.get("filesize") is not None
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Sort formats by quality
|
# Sort formats by quality
|
||||||
def get_quality(f):
|
def get_quality(f):
|
||||||
if f.get('vcodec') != 'none':
|
if f.get("vcodec") != "none":
|
||||||
res = f.get('resolution', '0x0').split('x')[-1]
|
res = f.get("resolution", "0x0").split("x")[-1]
|
||||||
try:
|
try:
|
||||||
return int(res)
|
return int(res)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 0
|
return 0
|
||||||
else:
|
else:
|
||||||
return f.get('abr', 0)
|
return f.get("abr", 0)
|
||||||
|
|
||||||
filtered_formats.sort(key=get_quality, reverse=True)
|
filtered_formats.sort(key=get_quality, reverse=True)
|
||||||
|
|
||||||
# Update table with filtered formats
|
# Update table with filtered formats
|
||||||
self.format_signals.format_update.emit(filtered_formats)
|
self.format_signals.format_update.emit(filtered_formats)
|
||||||
|
|
||||||
def _update_format_table(self, formats):
|
def _update_format_table(self, formats) -> None:
|
||||||
self.format_table.setRowCount(0)
|
self.format_table.setRowCount(0)
|
||||||
self.format_checkboxes.clear()
|
self.format_checkboxes.clear()
|
||||||
|
|
||||||
is_playlist_mode = hasattr(self, 'is_playlist') and self.is_playlist
|
is_playlist_mode = hasattr(self, "is_playlist") and self.is_playlist # type: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
# Configure columns based on mode
|
# Configure columns based on mode
|
||||||
if is_playlist_mode:
|
if is_playlist_mode:
|
||||||
self.format_table.setColumnCount(5)
|
self.format_table.setColumnCount(5)
|
||||||
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Resolution', 'Notes', 'Audio'])
|
self.format_table.setHorizontalHeaderLabels(["Select", "Quality", "Resolution", "Notes", "Audio"])
|
||||||
|
|
||||||
# Configure column visibility and resizing for playlist mode
|
# Configure column visibility and resizing for playlist mode
|
||||||
self.format_table.setColumnHidden(5, True)
|
self.format_table.setColumnHidden(5, True)
|
||||||
@@ -175,14 +187,25 @@ class FormatTableMixin:
|
|||||||
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.format_table.setColumnCount(8)
|
self.format_table.setColumnCount(8)
|
||||||
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
|
self.format_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
"Select",
|
||||||
|
"Quality",
|
||||||
|
"Extension",
|
||||||
|
"Resolution",
|
||||||
|
"File Size",
|
||||||
|
"Codec",
|
||||||
|
"Audio",
|
||||||
|
"Notes",
|
||||||
|
]
|
||||||
|
)
|
||||||
# Ensure all columns are visible
|
# Ensure all columns are visible
|
||||||
for i in range(2, 8):
|
for i in range(2, 8):
|
||||||
self.format_table.setColumnHidden(i, False)
|
self.format_table.setColumnHidden(i, False)
|
||||||
|
|
||||||
# Reapply resize modes for non-playlist mode if needed (optional, might be okay without)
|
# Reapply resize modes for non-playlist mode if needed (optional, might be okay without)
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||||
self.format_table.setColumnWidth(0, 50)
|
self.format_table.setColumnWidth(0, 50)
|
||||||
@@ -199,20 +222,22 @@ class FormatTableMixin:
|
|||||||
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
|
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
|
||||||
self.format_table.setColumnWidth(6, 120)
|
self.format_table.setColumnWidth(6, 120)
|
||||||
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
|
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
|
||||||
|
|
||||||
|
|
||||||
# Find best quality format for recommendations (only needed for non-playlist mode notes)
|
# Find best quality format for recommendations (only needed for non-playlist mode notes)
|
||||||
best_video_size = 0
|
best_video_size = 0
|
||||||
if not is_playlist_mode:
|
if not is_playlist_mode:
|
||||||
best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0)
|
best_video_size = max(
|
||||||
|
(f.get("filesize", 0) for f in formats if f.get("vcodec") != "none"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
for f in formats:
|
for f in formats:
|
||||||
row = self.format_table.rowCount()
|
row = self.format_table.rowCount()
|
||||||
self.format_table.insertRow(row)
|
self.format_table.insertRow(row)
|
||||||
|
|
||||||
# Column 0: Select Checkbox (Always shown)
|
# Column 0: Select Checkbox (Always shown)
|
||||||
checkbox = QCheckBox()
|
checkbox = QCheckBox()
|
||||||
checkbox.format_id = str(f.get('format_id', ''))
|
checkbox.format_id = str(f.get("format_id", "")) # type: ignore[reportAttributeAccessIssue]
|
||||||
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
||||||
self.format_checkboxes.append(checkbox)
|
self.format_checkboxes.append(checkbox)
|
||||||
checkbox_widget = QWidget()
|
checkbox_widget = QWidget()
|
||||||
@@ -229,84 +254,83 @@ class FormatTableMixin:
|
|||||||
quality_item = QTableWidgetItem(quality_text)
|
quality_item = QTableWidgetItem(quality_text)
|
||||||
# Set color based on quality
|
# Set color based on quality
|
||||||
if "Best" in quality_text:
|
if "Best" in quality_text:
|
||||||
quality_item.setForeground(QColor('#00ff00')) # Green for best quality
|
quality_item.setForeground(QColor("#00ff00")) # Green for best quality
|
||||||
elif "High" in quality_text:
|
elif "High" in quality_text:
|
||||||
quality_item.setForeground(QColor('#00cc00')) # Light green for high quality
|
quality_item.setForeground(QColor("#00cc00")) # Light green for high quality
|
||||||
elif "Medium" in quality_text:
|
elif "Medium" in quality_text:
|
||||||
quality_item.setForeground(QColor('#ffaa00')) # Orange for medium quality
|
quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality
|
||||||
elif "Low" in quality_text:
|
elif "Low" in quality_text:
|
||||||
quality_item.setForeground(QColor('#ff5555')) # Red for low quality
|
quality_item.setForeground(QColor("#ff5555")) # Red for low quality
|
||||||
self.format_table.setItem(row, 1, quality_item)
|
self.format_table.setItem(row, 1, quality_item)
|
||||||
|
|
||||||
# --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) ---
|
# --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) ---
|
||||||
|
|
||||||
# Column 2: Resolution (Always shown)
|
# Column 2: Resolution (Always shown)
|
||||||
resolution = f.get('resolution', 'N/A')
|
resolution = f.get("resolution", "N/A")
|
||||||
if f.get('vcodec') == 'none':
|
if f.get("vcodec") == "none":
|
||||||
resolution = 'Audio only'
|
resolution = "Audio only"
|
||||||
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
|
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
|
||||||
|
|
||||||
# Column 3: Notes for playlist mode, Extension for normal mode
|
# Column 3: Notes for playlist mode, Extension for normal mode
|
||||||
if is_playlist_mode:
|
if is_playlist_mode:
|
||||||
# Get notes for playlist mode
|
# Get notes for playlist mode
|
||||||
notes = self._get_format_notes(f)
|
notes = self._get_format_notes(f)
|
||||||
notes_item = QTableWidgetItem(notes)
|
notes_item = QTableWidgetItem(notes)
|
||||||
if "✨ Recommended" in notes:
|
if "✨ Recommended" in notes:
|
||||||
notes_item.setForeground(QColor('#00ff00')) # Green for recommended
|
notes_item.setForeground(QColor("#00ff00")) # Green for recommended
|
||||||
elif "💾 Storage friendly" in notes:
|
elif "💾 Storage friendly" in notes:
|
||||||
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
|
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
|
||||||
elif "📱 Mobile friendly" in notes:
|
elif "📱 Mobile friendly" in notes:
|
||||||
notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
|
notes_item.setForeground(QColor("#ff9900")) # Orange for mobile
|
||||||
self.format_table.setItem(row, 3, notes_item)
|
self.format_table.setItem(row, 3, notes_item)
|
||||||
else:
|
else:
|
||||||
# Extension for normal mode (column 2)
|
# Extension for normal mode (column 2)
|
||||||
self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper()))
|
self.format_table.setItem(row, 2, QTableWidgetItem(f.get("ext", "").upper()))
|
||||||
|
|
||||||
# Column 4 in playlist mode, Column 6 in normal mode: Audio Status
|
# Column 4 in playlist mode, Column 6 in normal mode: Audio Status
|
||||||
needs_audio = f.get('acodec') == 'none' and f.get('vcodec') != 'none' # Only mark video-only as needing merge
|
needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none" # Only mark video-only as needing merge
|
||||||
audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get('vcodec') != 'none' else "Audio Only")
|
audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get("vcodec") != "none" else "Audio Only")
|
||||||
audio_item = QTableWidgetItem(audio_status)
|
audio_item = QTableWidgetItem(audio_status)
|
||||||
if needs_audio:
|
if needs_audio:
|
||||||
audio_item.setForeground(QColor('#ffa500'))
|
audio_item.setForeground(QColor("#ffa500"))
|
||||||
elif audio_status == "Audio Only":
|
elif audio_status == "Audio Only":
|
||||||
audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only
|
audio_item.setForeground(QColor("#cccccc")) # Neutral color for audio only
|
||||||
else: # Has Audio (Video+Audio)
|
else: # Has Audio (Video+Audio)
|
||||||
audio_item.setForeground(QColor('#00cc00')) # Green for included audio
|
audio_item.setForeground(QColor("#00cc00")) # Green for included audio
|
||||||
# Set item for correct column based on mode
|
# Set item for correct column based on mode
|
||||||
audio_column_index = 4 if is_playlist_mode else 6
|
audio_column_index = 4 if is_playlist_mode else 6
|
||||||
self.format_table.setItem(row, audio_column_index, audio_item)
|
self.format_table.setItem(row, audio_column_index, audio_item)
|
||||||
|
|
||||||
|
|
||||||
# --- Populate columns only shown in non-playlist mode ---
|
# --- Populate columns only shown in non-playlist mode ---
|
||||||
if not is_playlist_mode:
|
if not is_playlist_mode:
|
||||||
# Column 3: Resolution
|
# Column 3: Resolution
|
||||||
self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
|
self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
|
||||||
|
|
||||||
# Column 4: File Size
|
# Column 4: File Size
|
||||||
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
|
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
|
||||||
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
|
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
|
||||||
|
|
||||||
# Column 5: Codec
|
# Column 5: Codec
|
||||||
if f.get('vcodec') == 'none':
|
if f.get("vcodec") == "none":
|
||||||
codec = f.get('acodec', 'N/A')
|
codec = f.get("acodec", "N/A")
|
||||||
else:
|
else:
|
||||||
codec = f"{f.get('vcodec', 'N/A')}"
|
codec = f"{f.get('vcodec', 'N/A')}"
|
||||||
if f.get('acodec') != 'none':
|
if f.get("acodec") != "none":
|
||||||
codec += f" / {f.get('acodec', 'N/A')}"
|
codec += f" / {f.get('acodec', 'N/A')}"
|
||||||
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
|
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
|
||||||
|
|
||||||
# Column 7: Notes
|
# Column 7: Notes
|
||||||
notes = self._get_format_notes(f)
|
notes = self._get_format_notes(f)
|
||||||
notes_item = QTableWidgetItem(notes)
|
notes_item = QTableWidgetItem(notes)
|
||||||
if "✨ Recommended" in notes:
|
if "✨ Recommended" in notes:
|
||||||
notes_item.setForeground(QColor('#00ff00')) # Green for recommended
|
notes_item.setForeground(QColor("#00ff00")) # Green for recommended
|
||||||
elif "💾 Storage friendly" in notes:
|
elif "💾 Storage friendly" in notes:
|
||||||
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
|
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
|
||||||
elif "📱 Mobile friendly" in notes:
|
elif "📱 Mobile friendly" in notes:
|
||||||
notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
|
notes_item.setForeground(QColor("#ff9900")) # Orange for mobile
|
||||||
self.format_table.setItem(row, 7, notes_item)
|
self.format_table.setItem(row, 7, notes_item)
|
||||||
|
|
||||||
def handle_checkbox_click(self, clicked_checkbox):
|
def handle_checkbox_click(self, clicked_checkbox) -> None:
|
||||||
for checkbox in self.format_checkboxes:
|
for checkbox in self.format_checkboxes:
|
||||||
if checkbox != clicked_checkbox:
|
if checkbox != clicked_checkbox:
|
||||||
checkbox.setChecked(False)
|
checkbox.setChecked(False)
|
||||||
@@ -317,15 +341,15 @@ class FormatTableMixin:
|
|||||||
return checkbox.format_id
|
return checkbox.format_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_format_table(self, formats):
|
def update_format_table(self, formats) -> None:
|
||||||
self.all_formats = formats
|
self.all_formats = formats
|
||||||
self.format_signals.format_update.emit(formats)
|
self.format_signals.format_update.emit(formats)
|
||||||
|
|
||||||
def get_quality_label(self, format_info):
|
def get_quality_label(self, format_info) -> str:
|
||||||
"""Determine quality label based on format information"""
|
"""Determine quality label based on format information"""
|
||||||
if format_info.get('vcodec') == 'none':
|
if format_info.get("vcodec") == "none":
|
||||||
# Audio quality
|
# Audio quality
|
||||||
abr = format_info.get('abr', 0)
|
abr = format_info.get("abr", 0)
|
||||||
if abr >= 256:
|
if abr >= 256:
|
||||||
return "Best Audio"
|
return "Best Audio"
|
||||||
elif abr >= 192:
|
elif abr >= 192:
|
||||||
@@ -337,13 +361,13 @@ class FormatTableMixin:
|
|||||||
else:
|
else:
|
||||||
# Video quality
|
# Video quality
|
||||||
height = 0
|
height = 0
|
||||||
resolution = format_info.get('resolution', '')
|
resolution = format_info.get("resolution", "")
|
||||||
if resolution:
|
if resolution:
|
||||||
try:
|
try:
|
||||||
height = int(resolution.split('x')[1])
|
height = int(resolution.split("x")[1])
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if height >= 2160:
|
if height >= 2160:
|
||||||
return "Best (4K)"
|
return "Best (4K)"
|
||||||
elif height >= 1440:
|
elif height >= 1440:
|
||||||
@@ -357,20 +381,20 @@ class FormatTableMixin:
|
|||||||
else:
|
else:
|
||||||
return "Low Quality"
|
return "Low Quality"
|
||||||
|
|
||||||
def _get_format_notes(self, format_info):
|
def _get_format_notes(self, format_info) -> str:
|
||||||
"""Generate helpful format notes based on format info."""
|
"""Generate helpful format notes based on format info."""
|
||||||
notes = []
|
notes = []
|
||||||
|
|
||||||
# Add storage indicator with more granular categories
|
# Add storage indicator with more granular categories
|
||||||
file_size = format_info.get('filesize') or format_info.get('filesize_approx', 0)
|
file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0)
|
||||||
resolution = format_info.get('resolution', '')
|
resolution = format_info.get("resolution", "")
|
||||||
height = 0
|
height = 0
|
||||||
if resolution:
|
if resolution:
|
||||||
try:
|
try:
|
||||||
height = int(resolution.split('x')[1])
|
height = int(resolution.split("x")[1])
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Better file size categories
|
# Better file size categories
|
||||||
if file_size > 50 * 1024 * 1024: # Over 50MB
|
if file_size > 50 * 1024 * 1024: # Over 50MB
|
||||||
notes.append("Large size")
|
notes.append("Large size")
|
||||||
@@ -380,20 +404,20 @@ class FormatTableMixin:
|
|||||||
notes.append("Standard size")
|
notes.append("Standard size")
|
||||||
else: # Under 5MB
|
else: # Under 5MB
|
||||||
notes.append("Small size")
|
notes.append("Small size")
|
||||||
|
|
||||||
# Add codec quality indicator
|
# Add codec quality indicator
|
||||||
vcodec = format_info.get('vcodec', '')
|
vcodec = format_info.get("vcodec", "")
|
||||||
if vcodec != 'none':
|
if vcodec != "none":
|
||||||
if 'avc1' in vcodec: # H.264
|
if "avc1" in vcodec: # H.264
|
||||||
notes.append("Compatible")
|
notes.append("Compatible")
|
||||||
elif 'av01' in vcodec: # AV1
|
elif "av01" in vcodec: # AV1
|
||||||
notes.append("Efficient")
|
notes.append("Efficient")
|
||||||
elif 'vp9' in vcodec: # VP9
|
elif "vp9" in vcodec: # VP9
|
||||||
notes.append("High quality")
|
notes.append("High quality")
|
||||||
|
|
||||||
# Add quick mobile compatibility check
|
# Add quick mobile compatibility check
|
||||||
if 'avc1' in vcodec and file_size < 8 * 1024 * 1024:
|
if "avc1" in vcodec and file_size < 8 * 1024 * 1024:
|
||||||
notes.append("Mobile")
|
notes.append("Mobile")
|
||||||
|
|
||||||
# Return simple string
|
# Return simple string
|
||||||
return " • ".join(notes)
|
return " • ".join(notes)
|
||||||
|
|||||||
+495
-459
File diff suppressed because it is too large
Load Diff
+114
-112
@@ -1,32 +1,24 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import webbrowser
|
|
||||||
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
|
||||||
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
|
|
||||||
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
|
|
||||||
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup)
|
|
||||||
from PySide6.QtCore import Qt, Signal, QObject, QThread
|
|
||||||
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
|
|
||||||
import requests
|
|
||||||
from io import BytesIO
|
|
||||||
from PIL import Image
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from packaging import version
|
|
||||||
import subprocess
|
|
||||||
import re
|
import re
|
||||||
from ..core.ytsage_logging import logger
|
from datetime import datetime
|
||||||
try:
|
from io import BytesIO
|
||||||
import yt_dlp
|
from pathlib import Path
|
||||||
YT_DLP_AVAILABLE = True
|
|
||||||
except ImportError:
|
import requests
|
||||||
YT_DLP_AVAILABLE = False
|
from PIL import Image
|
||||||
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
|
from PySide6.QtCore import Qt
|
||||||
from .ytsage_gui_dialogs import SubtitleSelectionDialog, SponsorBlockCategoryDialog
|
from PySide6.QtGui import QPixmap
|
||||||
|
from PySide6.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget
|
||||||
|
from yt_dlp import YoutubeDL
|
||||||
|
|
||||||
|
from src.core.ytsage_logging import logger
|
||||||
|
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
|
||||||
|
SponsorBlockCategoryDialog,
|
||||||
|
SubtitleSelectionDialog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VideoInfoMixin:
|
class VideoInfoMixin:
|
||||||
def setup_video_info_section(self):
|
def setup_video_info_section(self) -> QHBoxLayout:
|
||||||
# Create a horizontal layout for thumbnail and video info
|
# Create a horizontal layout for thumbnail and video info
|
||||||
media_info_layout = QHBoxLayout()
|
media_info_layout = QHBoxLayout()
|
||||||
media_info_layout.setSpacing(15)
|
media_info_layout.setSpacing(15)
|
||||||
@@ -36,7 +28,7 @@ class VideoInfoMixin:
|
|||||||
thumbnail_container.setFixedWidth(320)
|
thumbnail_container.setFixedWidth(320)
|
||||||
thumbnail_layout = QVBoxLayout(thumbnail_container)
|
thumbnail_layout = QVBoxLayout(thumbnail_container)
|
||||||
thumbnail_layout.setContentsMargins(0, 0, 0, 0)
|
thumbnail_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
# Thumbnail on the left
|
# Thumbnail on the left
|
||||||
self.thumbnail_label = QLabel()
|
self.thumbnail_label = QLabel()
|
||||||
self.thumbnail_label.setFixedSize(320, 180)
|
self.thumbnail_label.setFixedSize(320, 180)
|
||||||
@@ -44,14 +36,14 @@ class VideoInfoMixin:
|
|||||||
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
thumbnail_layout.addWidget(self.thumbnail_label)
|
thumbnail_layout.addWidget(self.thumbnail_label)
|
||||||
thumbnail_layout.addStretch()
|
thumbnail_layout.addStretch()
|
||||||
|
|
||||||
media_info_layout.addWidget(thumbnail_container)
|
media_info_layout.addWidget(thumbnail_container)
|
||||||
|
|
||||||
# Video information on the right
|
# Video information on the right
|
||||||
video_info_layout = QVBoxLayout()
|
video_info_layout = QVBoxLayout()
|
||||||
video_info_layout.setSpacing(2) # Reduce spacing between elements
|
video_info_layout.setSpacing(2) # Reduce spacing between elements
|
||||||
video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
# Title and info labels
|
# Title and info labels
|
||||||
self.title_label = QLabel()
|
self.title_label = QLabel()
|
||||||
self.title_label.setWordWrap(True)
|
self.title_label.setWordWrap(True)
|
||||||
@@ -65,14 +57,22 @@ class VideoInfoMixin:
|
|||||||
self.like_count_label = QLabel()
|
self.like_count_label = QLabel()
|
||||||
|
|
||||||
# Style the info labels
|
# Style the info labels
|
||||||
for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]:
|
for label in [
|
||||||
label.setStyleSheet("""
|
self.channel_label,
|
||||||
|
self.views_label,
|
||||||
|
self.date_label,
|
||||||
|
self.duration_label,
|
||||||
|
self.like_count_label,
|
||||||
|
]:
|
||||||
|
label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Add labels to video info layout
|
# Add labels to video info layout
|
||||||
video_info_layout.addWidget(self.title_label)
|
video_info_layout.addWidget(self.title_label)
|
||||||
@@ -90,11 +90,12 @@ class VideoInfoMixin:
|
|||||||
subtitle_layout.setSpacing(10)
|
subtitle_layout.setSpacing(10)
|
||||||
|
|
||||||
# Subtitle selection button
|
# Subtitle selection button
|
||||||
self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text
|
self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text
|
||||||
self.subtitle_select_btn.setFixedHeight(30)
|
self.subtitle_select_btn.setFixedHeight(30)
|
||||||
# self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
|
# self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
|
||||||
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
|
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
|
||||||
self.subtitle_select_btn.setStyleSheet("""
|
self.subtitle_select_btn.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
border: 2px solid #1d1e22;
|
border: 2px solid #1d1e22;
|
||||||
@@ -112,8 +113,9 @@ class VideoInfoMixin:
|
|||||||
color: #888888;
|
color: #888888;
|
||||||
border-color: #3d3d3d;
|
border-color: #3d3d3d;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
|
)
|
||||||
|
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
|
||||||
subtitle_layout.addWidget(self.subtitle_select_btn)
|
subtitle_layout.addWidget(self.subtitle_select_btn)
|
||||||
|
|
||||||
# Label to show number of selected subtitles
|
# Label to show number of selected subtitles
|
||||||
@@ -130,11 +132,12 @@ class VideoInfoMixin:
|
|||||||
|
|
||||||
# --- SponsorBlock Section ---
|
# --- SponsorBlock Section ---
|
||||||
sponsorblock_layout = QHBoxLayout()
|
sponsorblock_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...")
|
self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...")
|
||||||
self.sponsorblock_select_btn.setFixedHeight(30)
|
self.sponsorblock_select_btn.setFixedHeight(30)
|
||||||
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
|
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
|
||||||
self.sponsorblock_select_btn.setStyleSheet("""
|
self.sponsorblock_select_btn.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #1d1e22;
|
background-color: #1d1e22;
|
||||||
border: 2px solid #1d1e22;
|
border: 2px solid #1d1e22;
|
||||||
@@ -152,18 +155,19 @@ class VideoInfoMixin:
|
|||||||
color: #888888;
|
color: #888888;
|
||||||
border-color: #3d3d3d;
|
border-color: #3d3d3d;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
|
||||||
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
|
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
|
||||||
|
|
||||||
# Label to show selection count
|
# Label to show selection count
|
||||||
self.selected_sponsorblock_label = QLabel("0 selected")
|
self.selected_sponsorblock_label = QLabel("0 selected")
|
||||||
self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
|
||||||
sponsorblock_layout.addWidget(self.selected_sponsorblock_label)
|
sponsorblock_layout.addWidget(self.selected_sponsorblock_label)
|
||||||
|
|
||||||
sponsorblock_layout.addStretch()
|
sponsorblock_layout.addStretch()
|
||||||
|
|
||||||
# Add the sponsorblock layout to the main video info layout
|
# Add the sponsorblock layout to the main video info layout
|
||||||
video_info_layout.addLayout(sponsorblock_layout)
|
video_info_layout.addLayout(sponsorblock_layout)
|
||||||
# --- End SponsorBlock Section ---
|
# --- End SponsorBlock Section ---
|
||||||
@@ -180,10 +184,11 @@ class VideoInfoMixin:
|
|||||||
|
|
||||||
return media_info_layout
|
return media_info_layout
|
||||||
|
|
||||||
def setup_playlist_info_section(self):
|
def setup_playlist_info_section(self) -> QLabel:
|
||||||
self.playlist_info_label = QLabel()
|
self.playlist_info_label = QLabel()
|
||||||
self.playlist_info_label.setVisible(False)
|
self.playlist_info_label.setVisible(False)
|
||||||
self.playlist_info_label.setStyleSheet("""
|
self.playlist_info_label.setStyleSheet(
|
||||||
|
"""
|
||||||
QLabel {
|
QLabel {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -195,18 +200,19 @@ class VideoInfoMixin:
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
return self.playlist_info_label
|
return self.playlist_info_label
|
||||||
|
|
||||||
def update_video_info(self, info):
|
def update_video_info(self, info) -> None:
|
||||||
if hasattr(self, 'is_playlist') and self.is_playlist:
|
if hasattr(self, "is_playlist") and self.is_playlist:
|
||||||
# Playlist Mode: Show playlist title and video count
|
# Playlist Mode: Show playlist title and video count
|
||||||
self.title_label.setText(self.playlist_info.get('title', 'Unknown Playlist'))
|
self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist"))
|
||||||
|
|
||||||
num_videos = len(getattr(self, 'playlist_entries', []))
|
num_videos = len(getattr(self, "playlist_entries", []))
|
||||||
self.duration_label.setText(f"Total Videos: {num_videos}")
|
self.duration_label.setText(f"Total Videos: {num_videos}")
|
||||||
|
|
||||||
# Hide video-specific info
|
# Hide video-specific info
|
||||||
self.channel_label.setText("")
|
self.channel_label.setText("")
|
||||||
self.views_label.setText("")
|
self.views_label.setText("")
|
||||||
@@ -225,63 +231,62 @@ class VideoInfoMixin:
|
|||||||
self.like_count_label.setVisible(True)
|
self.like_count_label.setVisible(True)
|
||||||
|
|
||||||
# Format view count with commas
|
# Format view count with commas
|
||||||
views = info.get('view_count')
|
views = info.get("view_count")
|
||||||
formatted_views = f"{views:,}" if views is not None else 'N/A'
|
formatted_views = f"{views:,}" if views is not None else "N/A"
|
||||||
|
|
||||||
# Format like count with commas
|
# Format like count with commas
|
||||||
likes = info.get('like_count')
|
likes = info.get("like_count")
|
||||||
formatted_likes = f"{likes:,}" if likes is not None else 'N/A'
|
formatted_likes = f"{likes:,}" if likes is not None else "N/A"
|
||||||
|
|
||||||
# Format upload date
|
# Format upload date
|
||||||
upload_date = info.get('upload_date', '')
|
upload_date = info.get("upload_date", "")
|
||||||
if upload_date:
|
if upload_date:
|
||||||
date_obj = datetime.strptime(upload_date, '%Y%m%d')
|
date_obj = datetime.strptime(upload_date, "%Y%m%d")
|
||||||
formatted_date = date_obj.strftime('%B %d, %Y')
|
formatted_date = date_obj.strftime("%B %d, %Y")
|
||||||
else:
|
else:
|
||||||
formatted_date = 'Unknown date'
|
formatted_date = "Unknown date"
|
||||||
|
|
||||||
# Format duration
|
# Format duration
|
||||||
duration = info.get('duration', 0)
|
duration = info.get("duration", 0)
|
||||||
minutes = duration // 60
|
minutes = duration // 60
|
||||||
seconds = duration % 60
|
seconds = duration % 60
|
||||||
duration_str = f"{minutes}:{seconds:02d}"
|
duration_str = f"{minutes}:{seconds:02d}"
|
||||||
|
|
||||||
# Update labels
|
# Update labels
|
||||||
self.title_label.setText(info.get('title', 'Unknown title'))
|
self.title_label.setText(info.get("title", "Unknown title"))
|
||||||
self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
|
self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
|
||||||
self.views_label.setText(f"Views: {formatted_views}")
|
self.views_label.setText(f"Views: {formatted_views}")
|
||||||
self.like_count_label.setText(f"Likes: {formatted_likes}")
|
self.like_count_label.setText(f"Likes: {formatted_likes}")
|
||||||
self.date_label.setText(f"Upload date: {formatted_date}")
|
self.date_label.setText(f"Upload date: {formatted_date}")
|
||||||
self.duration_label.setText(f"Duration: {duration_str}")
|
self.duration_label.setText(f"Duration: {duration_str}")
|
||||||
|
|
||||||
def open_subtitle_dialog(self):
|
def open_subtitle_dialog(self) -> None:
|
||||||
if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'):
|
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
|
||||||
logger.warning("Subtitle info not loaded yet.")
|
logger.warning("Subtitle info not loaded yet.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not hasattr(self, 'selected_subtitles'):
|
if not hasattr(self, "selected_subtitles"):
|
||||||
self.selected_subtitles = []
|
self.selected_subtitles = []
|
||||||
|
|
||||||
dialog = SubtitleSelectionDialog(
|
dialog = SubtitleSelectionDialog(
|
||||||
self.available_subtitles,
|
self.available_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||||
self.available_automatic_subtitles,
|
self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue]
|
||||||
self.selected_subtitles,
|
self.selected_subtitles,
|
||||||
self # Parent for the dialog
|
self, # Parent for the dialog
|
||||||
)
|
)
|
||||||
|
|
||||||
# Access the main application window (parent of the mixin's widget)
|
# Access the main application window (parent of the mixin's widget)
|
||||||
# to find the merge checkbox
|
# to find the merge checkbox
|
||||||
main_window = self # In this context, self should be the YTSageApp instance
|
main_window = self # In this context, self should be the YTSageApp instance
|
||||||
if not isinstance(main_window, QMainWindow):
|
if not isinstance(main_window, QMainWindow):
|
||||||
# If the structure is different, this might need adjustment
|
# If the structure is different, this might need adjustment
|
||||||
# Maybe self.parentWidget() or similar depending on how Mixin is used
|
# Maybe self.parentWidget() or similar depending on how Mixin is used
|
||||||
logger.warning("Cannot find main window to access merge checkbox.")
|
logger.warning("Cannot find main window to access merge checkbox.")
|
||||||
merge_checkbox = None
|
merge_checkbox = None
|
||||||
else:
|
else:
|
||||||
merge_checkbox = getattr(main_window, 'merge_subs_checkbox', None)
|
merge_checkbox = getattr(main_window, "merge_subs_checkbox", None)
|
||||||
|
|
||||||
|
if dialog.exec(): # If user clicks OK
|
||||||
if dialog.exec(): # If user clicks OK
|
|
||||||
self.selected_subtitles = dialog.get_selected_subtitles()
|
self.selected_subtitles = dialog.get_selected_subtitles()
|
||||||
logger.info(f"Selected subtitles: {self.selected_subtitles}")
|
logger.info(f"Selected subtitles: {self.selected_subtitles}")
|
||||||
# Update UI to reflect selection
|
# Update UI to reflect selection
|
||||||
@@ -292,7 +297,7 @@ class VideoInfoMixin:
|
|||||||
# Enable/disable the merge checkbox in the parent window
|
# Enable/disable the merge checkbox in the parent window
|
||||||
if merge_checkbox:
|
if merge_checkbox:
|
||||||
# Only enable merge checkbox if we're not in Audio Only mode
|
# Only enable merge checkbox if we're not in Audio Only mode
|
||||||
is_audio_only = hasattr(main_window, 'audio_button') and main_window.audio_button.isChecked()
|
is_audio_only = hasattr(main_window, "audio_button") and main_window.audio_button.isChecked()
|
||||||
# In audio-only mode, we still allow subtitle selection but not merging
|
# In audio-only mode, we still allow subtitle selection but not merging
|
||||||
should_enable = count > 0 and not is_audio_only
|
should_enable = count > 0 and not is_audio_only
|
||||||
merge_checkbox.setEnabled(should_enable)
|
merge_checkbox.setEnabled(should_enable)
|
||||||
@@ -304,29 +309,29 @@ class VideoInfoMixin:
|
|||||||
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
|
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
|
||||||
# No else needed for cancel, state remains unchanged
|
# No else needed for cancel, state remains unchanged
|
||||||
|
|
||||||
def open_sponsorblock_dialog(self):
|
def open_sponsorblock_dialog(self) -> None:
|
||||||
"""Open the SponsorBlock category selection dialog."""
|
"""Open the SponsorBlock category selection dialog."""
|
||||||
# Initialize selected categories if not exists or empty (first time opening)
|
# Initialize selected categories if not exists or empty (first time opening)
|
||||||
if not hasattr(self, 'selected_sponsorblock_categories') or not self.selected_sponsorblock_categories:
|
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
|
||||||
# Use None to let the dialog set its own defaults
|
# Use None to let the dialog set its own defaults
|
||||||
dialog_categories = None
|
dialog_categories = None
|
||||||
else:
|
else:
|
||||||
dialog_categories = self.selected_sponsorblock_categories
|
dialog_categories = self.selected_sponsorblock_categories
|
||||||
|
|
||||||
dialog = SponsorBlockCategoryDialog(dialog_categories, self)
|
dialog = SponsorBlockCategoryDialog(dialog_categories, self)
|
||||||
|
|
||||||
if dialog.exec():
|
if dialog.exec():
|
||||||
self.selected_sponsorblock_categories = dialog.get_selected_categories()
|
self.selected_sponsorblock_categories = dialog.get_selected_categories()
|
||||||
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
|
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
|
||||||
self._update_sponsorblock_display()
|
self._update_sponsorblock_display()
|
||||||
|
|
||||||
def _update_sponsorblock_display(self):
|
def _update_sponsorblock_display(self) -> None:
|
||||||
"""Update the SponsorBlock button and label to reflect current selection."""
|
"""Update the SponsorBlock button and label to reflect current selection."""
|
||||||
if not hasattr(self, 'selected_sponsorblock_categories'):
|
if not hasattr(self, "selected_sponsorblock_categories"):
|
||||||
self.selected_sponsorblock_categories = []
|
self.selected_sponsorblock_categories = []
|
||||||
|
|
||||||
count = len(self.selected_sponsorblock_categories)
|
count = len(self.selected_sponsorblock_categories)
|
||||||
|
|
||||||
# Update label text
|
# Update label text
|
||||||
if count == 0:
|
if count == 0:
|
||||||
self.selected_sponsorblock_label.setText("0 selected")
|
self.selected_sponsorblock_label.setText("0 selected")
|
||||||
@@ -334,15 +339,15 @@ class VideoInfoMixin:
|
|||||||
self.selected_sponsorblock_label.setText("1 category selected")
|
self.selected_sponsorblock_label.setText("1 category selected")
|
||||||
else:
|
else:
|
||||||
self.selected_sponsorblock_label.setText(f"{count} categories selected")
|
self.selected_sponsorblock_label.setText(f"{count} categories selected")
|
||||||
|
|
||||||
# Update button property for styling
|
# Update button property for styling
|
||||||
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0)
|
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0)
|
||||||
|
|
||||||
# Force style refresh
|
# Force style refresh
|
||||||
self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn)
|
self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn)
|
||||||
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
|
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
|
||||||
|
|
||||||
def download_thumbnail(self, url):
|
def download_thumbnail(self, url) -> None:
|
||||||
try:
|
try:
|
||||||
# Store both thumbnail URL and video URL
|
# Store both thumbnail URL and video URL
|
||||||
self.thumbnail_url = url
|
self.thumbnail_url = url
|
||||||
@@ -355,42 +360,39 @@ class VideoInfoMixin:
|
|||||||
# Display thumbnail
|
# Display thumbnail
|
||||||
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
|
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
|
||||||
img_byte_arr = BytesIO()
|
img_byte_arr = BytesIO()
|
||||||
image.save(img_byte_arr, format='PNG')
|
image.save(img_byte_arr, format="PNG")
|
||||||
pixmap = QPixmap()
|
pixmap = QPixmap()
|
||||||
pixmap.loadFromData(img_byte_arr.getvalue())
|
pixmap.loadFromData(img_byte_arr.getvalue())
|
||||||
self.thumbnail_label.setPixmap(pixmap)
|
self.thumbnail_label.setPixmap(pixmap)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading thumbnail: {str(e)}")
|
logger.error(f"Error loading thumbnail: {str(e)}")
|
||||||
|
|
||||||
def download_thumbnail_file(self, video_url, path):
|
def download_thumbnail_file(self, video_url, path) -> bool:
|
||||||
if not self.save_thumbnail:
|
if not self.save_thumbnail:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from yt_dlp import YoutubeDL
|
|
||||||
import requests # Use requests instead of urlopen
|
|
||||||
|
|
||||||
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
|
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'skip_download': True,
|
"skip_download": True,
|
||||||
'force_generic_extractor': False,
|
"force_generic_extractor": False,
|
||||||
'no_warnings': True,
|
"no_warnings": True,
|
||||||
'extract_flat': False
|
"extract_flat": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
with YoutubeDL(ydl_opts) as ydl:
|
with YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=False)
|
info = ydl.extract_info(video_url, download=False)
|
||||||
thumbnails = info.get('thumbnails', [])
|
thumbnails = info.get("thumbnails", [])
|
||||||
|
|
||||||
if not thumbnails:
|
if not thumbnails:
|
||||||
raise ValueError("No thumbnails available")
|
raise ValueError("No thumbnails available")
|
||||||
|
|
||||||
thumbnail_url = max(
|
thumbnail_url = max(
|
||||||
thumbnails,
|
thumbnails,
|
||||||
key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0)
|
key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0),
|
||||||
).get('url')
|
).get("url")
|
||||||
|
|
||||||
if not thumbnail_url:
|
if not thumbnail_url:
|
||||||
raise ValueError("Failed to extract thumbnail URL")
|
raise ValueError("Failed to extract thumbnail URL")
|
||||||
@@ -400,13 +402,13 @@ class VideoInfoMixin:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Save the thumbnail
|
# Save the thumbnail
|
||||||
thumb_dir = os.path.join(path, 'Thumbnails')
|
thumb_dir = Path(path).joinpath("Thumbnails")
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
thumb_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
filename = f"{self.sanitize_filename(info['title'])}.jpg"
|
filename = f"{self.sanitize_filename(info['title'])}.jpg"
|
||||||
thumbnail_path = os.path.join(thumb_dir, filename)
|
thumbnail_path = thumb_dir.joinpath(filename)
|
||||||
|
|
||||||
with open(thumbnail_path, 'wb') as f:
|
with open(thumbnail_path, "wb") as f:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
|
|
||||||
logger.info(f"Thumbnail saved to: {thumbnail_path}")
|
logger.info(f"Thumbnail saved to: {thumbnail_path}")
|
||||||
@@ -419,6 +421,6 @@ class VideoInfoMixin:
|
|||||||
self.signals.update_status.emit(error_msg)
|
self.signals.update_status.emit(error_msg)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def sanitize_filename(self, name):
|
def sanitize_filename(self, name) -> str:
|
||||||
"""Clean filename for filesystem safety"""
|
"""Clean filename for filesystem safety"""
|
||||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
|
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
This module defines centralized constants used across the YTSage application.
|
||||||
|
By storing shared values in one place, it improves consistency, readability,
|
||||||
|
and maintainability of the codebase.
|
||||||
|
|
||||||
|
Constants include:
|
||||||
|
- Asset paths for icons and notification sounds.
|
||||||
|
- OS detection and platform-specific directory paths for application data, binaries, logs, and configuration.
|
||||||
|
- Download URLs for yt-dlp and ffmpeg binaries.
|
||||||
|
- SUBPROCESS_CREATIONFLAGS: Used to specify subprocess creation flags (e.g., subprocess.CREATE_NO_WINDOW on Windows to hide the console window).
|
||||||
|
Directories are automatically created when the module is imported, ensuring the required structure exists for the application.
|
||||||
|
YTSage application constants.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Assets Constants
|
||||||
|
ICON_PATH: Path = Path("assets/Icon/icon.png")
|
||||||
|
SOUND_PATH: Path = Path("assets/sound/notification.mp3")
|
||||||
|
|
||||||
|
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
|
||||||
|
|
||||||
|
USER_HOME_DIR: Path = Path.home()
|
||||||
|
|
||||||
|
# OS Specific Constants
|
||||||
|
if OS_NAME == "Windows":
|
||||||
|
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
|
||||||
|
|
||||||
|
# APP_PATH will be from system environment path or fallback to Path.home()
|
||||||
|
APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage"
|
||||||
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||||
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||||
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||||
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||||
|
|
||||||
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||||
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe"
|
||||||
|
|
||||||
|
SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
elif OS_NAME == "Darwin": # macOS
|
||||||
|
_mac_version = platform.mac_ver()[0]
|
||||||
|
OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS"
|
||||||
|
|
||||||
|
APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage"
|
||||||
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||||
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||||
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||||
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||||
|
|
||||||
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||||
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
||||||
|
|
||||||
|
SUBPROCESS_CREATIONFLAGS: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
else: # Linux and other UNIX-like
|
||||||
|
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
|
||||||
|
|
||||||
|
APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage"
|
||||||
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||||
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||||
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||||
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||||
|
|
||||||
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
||||||
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
||||||
|
|
||||||
|
SUBPROCESS_CREATIONFLAGS: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ffmpeg download links
|
||||||
|
FFMPEG_7Z_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z"
|
||||||
|
FFMPEG_7Z_SHA256_URL = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build.7z.sha256"
|
||||||
|
FFMPEG_ZIP_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# If this file is run directly, print directory information; if imported, create the necessary directories for the application.
|
||||||
|
info = {
|
||||||
|
"OS_NAME": OS_NAME,
|
||||||
|
"OS_FULL_NAME": OS_FULL_NAME,
|
||||||
|
"USER_HOME_DIR": str(USER_HOME_DIR),
|
||||||
|
"APP_DIR": str(APP_DIR),
|
||||||
|
"APP_BIN_DIR": str(APP_BIN_DIR),
|
||||||
|
"APP_DATA_DIR": str(APP_DATA_DIR),
|
||||||
|
"APP_LOG_DIR": str(APP_LOG_DIR),
|
||||||
|
"APP_CONFIG_FILE": str(APP_CONFIG_FILE),
|
||||||
|
"YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL,
|
||||||
|
"YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH,
|
||||||
|
"SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS,
|
||||||
|
}
|
||||||
|
for key, value in info.items():
|
||||||
|
print(f"{key}: {value}")
|
||||||
|
else:
|
||||||
|
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
APP_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
Reference in New Issue
Block a user