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:
Viren Hirpara
2025-08-26 18:57:12 +05:30
committed by GitHub
parent 6652d7f00f
commit 9c13b4b61c
21 changed files with 2805 additions and 2628 deletions
+17 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+1 -1
View File
@@ -149,4 +149,4 @@ QMessageBox QLabel {
QMessageBox QPushButton { QMessageBox QPushButton {
min-width: 80px; min-width: 80px;
} }
""" """
+228 -258
View File
@@ -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
View File
@@ -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"
-41
View File
@@ -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'
]
-37
View File
@@ -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'
]
+60
View File
@@ -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",
]
@@ -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()
@@ -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()
@@ -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)
@@ -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 ""
@@ -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()
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+114 -112
View File
@@ -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]
View File
+102
View File
@@ -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)