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
+14 -7
View File
@@ -1,8 +1,14 @@
import sys
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_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):
error_dialog = QMessageBox()
@@ -12,13 +18,13 @@ def show_error_dialog(message):
error_dialog.setWindowTitle("Error")
error_dialog.exec()
def main():
try:
logger.info("Starting YTSage application")
app = QApplication(sys.argv)
# Get the expected binary path and check if it exists
expected_path = get_ytdlp_executable_path()
if not check_ytdlp_binary():
# No app-specific binary found, show setup dialog regardless of Python package
logger.warning("No yt-dlp binary found, starting setup process")
@@ -26,14 +32,15 @@ def main():
if yt_dlp_path == "yt-dlp": # If user canceled or something went wrong
logger.warning("yt-dlp not configured properly")
window = YTSageApp() # Instantiate the main application class
window = YTSageApp() # Instantiate the main application class
window.show()
logger.info("Application window shown, entering main loop")
sys.exit(app.exec())
except Exception as e:
logger.critical(f"Critical application error: {str(e)}", exc_info=True)
show_error_dialog(f"Critical error: {str(e)}")
logger.critical(f"Critical application error: {e}", exc_info=True)
show_error_dialog(f"Critical error: {e}")
sys.exit(1)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+184 -220
View File
@@ -1,24 +1,34 @@
from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer
from .ytsage_logging import logger
import re
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:
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
except ImportError:
YT_DLP_AVAILABLE = False
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):
update_formats = Signal(list)
update_status = Signal(str)
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):
progress_signal = Signal(float)
@@ -26,18 +36,36 @@ class DownloadThread(QThread):
finished_signal = Signal()
error_signal = Signal(str)
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__()
self.url = url
self.path = path
self.path = Path(path)
self.format_id = format_id
self.subtitle_langs = subtitle_langs if subtitle_langs else []
self.is_playlist = is_playlist
self.merge_subs = merge_subs
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.playlist_items = playlist_items
self.save_description = save_description
@@ -52,108 +80,70 @@ class DownloadThread(QThread):
self.use_direct_command = True # Flag to use direct CLI command instead of Python API
self.last_output_time = time.time()
self.timeout_timer = None
self.current_filename = None # Initialize filename storage
self.last_file_path = None # Initialize full file path storage
self.subtitle_files = [] # Track subtitle files that are created
self.initial_subtitle_files = set() # Track initial subtitle files before download
self.current_filename = None # Initialize filename storage
self.last_file_path = None # Initialize full file path storage
self.subtitle_files = [] # Track subtitle files that are created
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"""
try:
pattern = re.compile(r'\.f\d+\.') # Pattern to match format codes like .f243.
for filename in os.listdir(self.path):
file_path = os.path.join(self.path, filename)
if filename.endswith('.part') or pattern.search(filename):
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
for file_path in self.path.iterdir():
if file_path.suffix == ".part" or pattern.search(file_path.name):
try:
if os.path.isfile(file_path):
os.remove(file_path)
file_path.unlink(missing_ok=True)
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:
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"""
if not self.merge_subs:
return # Only cleanup if merge_subs is enabled
deleted_count = [0, 0]
def safe_delete(path: Path) -> bool:
try:
path.unlink(missing_ok=True)
logger.debug(f"Deleted subtitle file: {path.name}")
return True
except Exception as e:
logger.error(f"Error deleting subtitle file {path}: {e}")
return False
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:
new_subtitle_files = set()
for root, dirs, files in os.walk(self.path):
for file in files:
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:
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
if self.last_file_path and deleted_count == 0:
target_dir = os.path.dirname(self.last_file_path)
# Look for subtitle files created in last 5 minutes
now = time.time()
for filename in os.listdir(target_dir):
if filename.endswith('.vtt') or filename.endswith('.srt'):
file_path = os.path.join(target_dir, filename)
# Check if it was created in the last 5 minutes
file_time = os.path.getctime(file_path)
if now - file_time < 300: # 5 minutes
try:
os.remove(file_path)
deleted_count += 1
logger.debug(f"Deleted subtitle file by timestamp: {filename}")
except Exception as e:
logger.error(f"Error deleting subtitle file {filename}: {str(e)}")
logger.debug(f"Total subtitle files deleted: {deleted_count}")
# --- Method 1: Delete tracked subtitle files ---
for f in self.subtitle_files or []:
deleted_count[0] += safe_delete(path=Path(f))
else:
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
# --- Method 2: Delete new subtitle files not in initial set ---
new_subtitle_files = {
f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files
}
for subtitle_file in new_subtitle_files:
deleted_count[1] += safe_delete(path=subtitle_file)
else:
logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files")
except Exception as 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"""
try:
logger.debug("Starting file existence check")
# Use yt-dlp to get the filename without downloading, suppressing warnings
ydl_opts_check = {
'quiet': True,
'skip_download': True,
'no_warnings': True, # <-- Suppress warnings during check
'ignoreerrors': True, # Also ignore other potential errors during this check
'outtmpl': {'default': os.path.join(self.path, '%(title)s.%(ext)s')},
'format': self.format_id if self.format_id else 'best' # Use selected format or best
"quiet": True,
"skip_download": True,
"no_warnings": True, # <-- Suppress warnings during check
"ignoreerrors": True, # Also ignore other potential errors during this check
"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
}
if self.cookie_file:
ydl_opts_check['cookiefile'] = self.cookie_file
ydl_opts_check["cookiefile"] = self.cookie_file
if YT_DLP_AVAILABLE:
with yt_dlp.YoutubeDL(ydl_opts_check) as ydl:
@@ -162,18 +152,18 @@ class DownloadThread(QThread):
# Handle cases where info extraction fails silently
if not info:
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
title = info.get('title', 'video')
title = info.get("title", "video")
# Don't remove colons and other special characters yet
logger.debug(f"Original video title: {title}")
# Get resolution for better matching
resolution = ""
for format_info in info.get('formats', []):
if format_info.get('format_id') == self.format_id:
resolution = format_info.get('resolution', '')
for format_info in info.get("formats", []):
if format_info.get("format_id") == self.format_id:
resolution = format_info.get("resolution", "")
break
logger.debug(f"Resolution: {resolution}")
@@ -181,70 +171,39 @@ class DownloadThread(QThread):
logger.debug("yt-dlp not available, skipping file existence check")
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:
logger.debug(f"Error checking file existence: {str(e)}")
import traceback
traceback.print_exc()
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."""
# Use the new yt-dlp path function from ytsage_yt_dlp module
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}")
# Format selection strategy - use format ID if provided or fallback to resolution
if self.format_id:
# 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
is_audio_format = False
try:
if YT_DLP_AVAILABLE:
ydl_opts = {
'quiet': True,
'no_warnings': True,
'skip_download': True,
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(self.url, download=False)
for fmt in info.get('formats', []):
if fmt.get('format_id') == clean_format_id:
if fmt.get('vcodec') == 'none' or 'audio only' in fmt.get('format_note', '').lower():
info = ydl.extract_info(self.url, download=False) or {}
for fmt in info.get("formats", []):
if fmt.get("format_id") == clean_format_id:
if fmt.get("vcodec") == "none" or "audio only" in fmt.get("format_note", "").lower():
is_audio_format = True
logger.debug(f"Detected audio-only format for ID: {clean_format_id}")
break
@@ -266,22 +225,22 @@ class DownloadThread(QThread):
logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
if YT_DLP_AVAILABLE:
ydl_opts = {
'quiet': True,
'no_warnings': True,
'skip_download': True,
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
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
for fmt in info.get('formats', []):
if fmt.get('format_id') == clean_format_id:
format_ext = fmt.get('ext')
for fmt in info.get("formats", []):
if fmt.get("format_id") == clean_format_id:
format_ext = fmt.get("ext")
break
# If not found, try the original ID as fallback
if not format_ext:
for fmt in info.get('formats', []):
if fmt.get('format_id') == self.format_id:
format_ext = fmt.get('ext')
for fmt in info.get("formats", []):
if fmt.get("format_id") == self.format_id:
format_ext = fmt.get("ext")
break
if format_ext:
@@ -298,14 +257,14 @@ class DownloadThread(QThread):
cmd.extend(["-S", f"res:{res_value}"])
# 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
if self.is_playlist:
# 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
cmd.append("--force-overwrites")
@@ -325,7 +284,7 @@ class DownloadThread(QThread):
for sub_selection in self.subtitle_langs:
try:
# 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)
except Exception as e:
logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}")
@@ -374,7 +333,7 @@ class DownloadThread(QThread):
return cmd
def run(self):
def run(self) -> None:
try:
logger.debug("Starting download thread")
@@ -392,10 +351,9 @@ class DownloadThread(QThread):
if self.merge_subs:
try:
# Scan for existing subtitle files in the directory
for root, dirs, files in os.walk(self.path):
for file in files:
if file.endswith('.vtt') or file.endswith('.srt'):
self.initial_subtitle_files.add(os.path.join(root, file))
for file in self.path.rglob("*"):
if file.suffix in {".vtt", ".srt"}:
self.initial_subtitle_files.add(file)
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
except Exception as e:
logger.warning(f"Error scanning for initial subtitle files: {e}")
@@ -411,9 +369,10 @@ class DownloadThread(QThread):
# Catch errors during setup
self.error_signal.emit(f"Critical error in download thread: {str(e)}")
import traceback
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."""
try:
cmd = self._build_yt_dlp_command()
@@ -424,10 +383,7 @@ class DownloadThread(QThread):
self.progress_signal.emit(0)
# Start the process
# Add creationflags=subprocess.CREATE_NO_WINDOW to hide console on Windows
creation_flags = 0
if os.name == 'nt': # Only use flag on Windows
creation_flags = subprocess.CREATE_NO_WINDOW
# Extra logic moved to src\utils\ytsage_constants.py
self.process = subprocess.Popen(
cmd,
@@ -436,11 +392,11 @@ class DownloadThread(QThread):
text=True,
bufsize=1, # Line buffered
universal_newlines=True,
creationflags=creation_flags # Add this flag
creationflags=SUBPROCESS_CREATIONFLAGS,
)
# 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:
self.process.terminate()
self.cleanup_partial_files()
@@ -460,7 +416,9 @@ class DownloadThread(QThread):
# Special handling for specific errors
# return code 127 typically means command not found
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
if return_code == 0:
@@ -484,7 +442,9 @@ class DownloadThread(QThread):
else:
# Provide more descriptive error message for possible yt-dlp conflicts
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:
self.error_signal.emit(f"Download failed with return code {return_code}")
self.cleanup_partial_files()
@@ -493,30 +453,30 @@ class DownloadThread(QThread):
self.error_signal.emit(f"Error in direct command: {str(e)}")
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."""
line = line.strip()
# logger.info(f"yt-dlp: {line}") # Log all output - OPTIONALLY UNCOMMENT FOR VERBOSE DEBUG
# Extract filename when the destination line appears
# 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:
try:
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
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
is_audio_download = False
# Look for audio format indicators in the current line or preceding output
# 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
# 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:
format_id = format_match.group(1)
logger.debug(f"Detected format ID: {format_id}")
@@ -525,27 +485,27 @@ class DownloadThread(QThread):
# This is just a heuristic since format IDs can vary
# 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
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...")
# 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...")
# 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...")
# Subtitle file extensions
elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
self.status_signal.emit(f"⏬ Downloading subtitle...")
# Default case
else:
self.status_signal.emit(f"⏬ Downloading...")
except Exception as e:
logger.error(f"Error extracting filename from line '{line}': {e}")
self.status_signal.emit("⚡ Downloading...") # Fallback status
return # Don't process this line further for speed/ETA
self.status_signal.emit("⚡ Downloading...") # Fallback status
return # Don't process this line further for speed/ETA
# Check for specific download types in the output
if "Downloading video" in line:
@@ -558,46 +518,50 @@ class DownloadThread(QThread):
# Detect subtitle file creation
# 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:
subtitle_file = subtitle_match.group(1).strip()
# Show subtitle download message
self.status_signal.emit(f"⏬ Downloading subtitle...")
# Store the subtitle file path for later deletion if merging is enabled
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
subtitle_file = os.path.join(self.path, subtitle_file)
subtitle_file = Path.joinpath(self.path, subtitle_file)
self.subtitle_files.append(subtitle_file)
logger.debug(f"Tracking subtitle file for later cleanup: {subtitle_file}")
return
# 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.progress_signal.emit(0)
elif 'Downloading API JSON' in line:
elif "Downloading API JSON" in line:
self.status_signal.emit("📋 Processing playlist data...")
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.progress_signal.emit(0)
elif '[download] Downloading video ' in line:
elif "[download] Downloading video " in line:
self.status_signal.emit("⏬ Downloading video...")
elif '[download] Downloading audio ' in line:
elif "[download] Downloading audio " in line:
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
if ' - audio only' in line:
if " - audio only" in line:
self.status_signal.emit("⏬ Downloading audio...")
elif ' - video only' in line:
elif " - video only" in line:
self.status_signal.emit("⏬ Downloading video...")
else:
# Don't emit generic message - format is unclear
pass
# Look for download percentage
percent_match = re.search(r'(\d+\.\d+)%', line)
percent_match = re.search(r"(\d+\.\d+)%", line)
if percent_match:
try:
percent = float(percent_match.group(1))
@@ -606,15 +570,15 @@ class DownloadThread(QThread):
pass
# 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:
# 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"
# 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"
# Simplify status message to only show the speed and ETA
@@ -623,30 +587,30 @@ class DownloadThread(QThread):
except Exception as e:
# If parsing fails, just show basic status (maybe log the error)
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
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.progress_signal.emit(95)
elif 'SponsorBlock' in line:
elif "SponsorBlock" in line:
self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
self.progress_signal.emit(97)
elif 'Deleting original file' in line:
elif "Deleting original file" in line:
self.progress_signal.emit(98)
elif 'has already been downloaded' in line:
elif "has already been downloaded" in line:
# 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:
filename = os.path.basename(match.group(1))
filename = Path(match.group(1)).name
# 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")
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")
elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
elif ext in [".vtt", ".srt", ".ass", ".ssa"]:
self.status_signal.emit(f"⚠️ Subtitle file already exists")
else:
self.status_signal.emit(f"⚠️ File already exists")
@@ -654,22 +618,22 @@ class DownloadThread(QThread):
self.file_exists_signal.emit(filename)
else:
logger.info(f"Could not extract filename from 'already downloaded' line: {line}")
self.status_signal.emit("⚠️ File already exists") # Fallback status
elif 'Finished downloading' in line:
self.status_signal.emit("⚠️ File already exists") # Fallback status
elif "Finished downloading" in line:
self.progress_signal.emit(100)
# Show completion message based on file type
if self.current_filename:
ext = os.path.splitext(self.current_filename)[1].lower()
ext = Path(self.current_filename).suffix.lower()
# 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!")
# 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!")
# 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!")
# Default case
else:
@@ -677,20 +641,20 @@ class DownloadThread(QThread):
else:
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."""
# The existing run method code using yt_dlp.YoutubeDL starts here
# This method is no longer used by default
def pause(self):
def pause(self) -> None:
self.paused = True
def resume(self):
def resume(self) -> None:
self.paused = False
def cancel(self):
def cancel(self) -> None:
self.cancelled = True
# Terminate the subprocess if it's running
if self.process:
+133 -104
View File
@@ -1,33 +1,39 @@
import os
import sys
import subprocess
import requests
import shutil
import tempfile
import hashlib
import os
import shutil
import subprocess
import tempfile
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."""
try:
subprocess.run(['7z', '--help'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
subprocess.run(["7z", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=SUBPROCESS_CREATIONFLAGS)
return True
except (subprocess.SubprocessError, FileNotFoundError):
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."""
try:
response = requests.get(url, stream=True, timeout=30) # Added timeout
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:
f.write(response.content)
else:
@@ -43,7 +49,8 @@ def download_file(url, dest_path, progress_callback=None):
logger.info(f"Download error: {str(e)}")
return False
def get_file_sha256(file_path):
def get_file_sha256(file_path) -> str:
"""Calculate SHA-256 hash of a file."""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
@@ -51,7 +58,8 @@ def get_file_sha256(file_path):
sha256_hash.update(chunk)
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."""
try:
# Download the SHA-256 hash
@@ -75,20 +83,23 @@ def verify_sha256(file_path, expected_hash_url):
logger.info(f"⚠️ SHA-256 verification error: {str(e)}")
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.
Returns:
@@ -96,27 +107,23 @@ def get_ffmpeg_path():
"""
try:
# 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
startupinfo = None
if hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(
['where', 'ffmpeg'],
["where", "ffmpeg"],
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
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
else:
# 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():
ffmpeg_path = result.stdout.strip()
return ffmpeg_path
@@ -125,46 +132,50 @@ def get_ffmpeg_path():
# If not found in PATH, check the installation directory
ffmpeg_install_path = get_ffmpeg_install_path()
if sys.platform == 'win32':
ffmpeg_exe = os.path.join(ffmpeg_install_path, 'ffmpeg.exe')
if OS_NAME == "Windows":
ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg.exe")
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 command name as fallback
return "ffmpeg"
def check_ffmpeg_installed():
def check_ffmpeg_installed() -> bool:
"""Check if FFmpeg is installed and accessible."""
try:
# First try the PATH
result = subprocess.run(['ffmpeg', '-version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0,
timeout=5) # Added timeout
result = subprocess.run(
["ffmpeg", "-version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
creationflags=SUBPROCESS_CREATIONFLAGS,
timeout=5,
) # Added timeout
return True
except (subprocess.SubprocessError, FileNotFoundError):
# If not in PATH, check the installation directory
ffmpeg_path = get_ffmpeg_install_path()
if sys.platform == 'win32':
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe')
if OS_NAME == "Windows":
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
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
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 False
except Exception as e:
logger.info(f"FFmpeg check error: {str(e)}")
return False
def install_ffmpeg_windows():
def install_ffmpeg_windows() -> bool:
"""Install FFmpeg on Windows using 7z method primarily, with zip as fallback."""
ffmpeg_path = get_ffmpeg_install_path()
@@ -175,35 +186,38 @@ def install_ffmpeg_windows():
try:
# 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_zip_url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip"
sha256_url = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-7.1.1-full_build.7z.sha256"
extract_dir = os.path.join(os.getenv('LOCALAPPDATA'), 'ffmpeg')
full_build_dir = os.path.join(extract_dir, 'ffmpeg-7.1.1-full_build')
bin_dir = os.path.join(full_build_dir, 'bin')
# ffmpeg variables moved to src\utils\ytsage_constants.py
extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
full_build_dir = extract_dir / "ffmpeg-7.1.1-full_build"
bin_dir = full_build_dir / "bin"
# 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)
use_7zip = check_7zip_installed()
if use_7zip:
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
if not download_file(ffmpeg_7z_url, temp_file,
progress_callback=lambda msg: logger.debug(msg)):
if not download_file(
FFMPEG_7Z_DOWNLOAD_URL,
temp_file,
progress_callback=lambda msg: logger.debug(msg),
):
logger.error("Failed to download 7z file, trying zip fallback...")
use_7zip = False
else:
# 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...")
try:
subprocess.run(['7z', 'x', temp_file, f'-o{extract_dir}', '-y'],
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0,
timeout=300) # 5-minute timeout
subprocess.run(
["7z", "x", temp_file, f"-o{extract_dir}", "-y"],
creationflags=SUBPROCESS_CREATIONFLAGS,
timeout=300,
) # 5-minute timeout
except Exception as e:
logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...")
use_7zip = False
@@ -214,32 +228,38 @@ def install_ffmpeg_windows():
# Fallback to zip method if 7z failed or not available
if not use_7zip:
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
if not download_file(ffmpeg_zip_url, temp_file,
progress_callback=lambda msg: logger.debug(msg)):
if not download_file(
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)")
logger.info("Extracting FFmpeg components from zip archive...")
try:
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)
except Exception as e:
raise Exception(f"Extraction failed: {str(e)}")
logger.info("Configuring system paths...")
# Add to System Path
user_path = os.environ.get('PATH', '')
if bin_dir not in user_path:
subprocess.run(['setx', 'PATH', f"{user_path};{bin_dir}"],
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
os.environ['PATH'] = f"{user_path};{bin_dir}"
user_path = os.environ.get("PATH", "")
if str(bin_dir) not in user_path.split(os.pathsep):
subprocess.run(
["setx", "PATH", f"{user_path};{bin_dir}"],
creationflags=SUBPROCESS_CREATIONFLAGS,
)
os.environ["PATH"] = f"{user_path};{bin_dir}"
# Clean up
try:
os.unlink(temp_file)
Path(temp_file).unlink(missing_ok=True)
except Exception:
pass # Ignore cleanup errors
@@ -254,16 +274,19 @@ def install_ffmpeg_windows():
logger.error(f"Error installing FFmpeg: {str(e)}")
return False
def install_ffmpeg_macos():
def install_ffmpeg_macos() -> bool:
"""Install FFmpeg on macOS using Homebrew."""
try:
# Check if Homebrew is installed
try:
subprocess.run(['brew', '--version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
timeout=5)
subprocess.run(
["brew", "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
timeout=5,
)
except (subprocess.SubprocessError, FileNotFoundError):
logger.info("Installing Homebrew...")
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
@@ -271,7 +294,7 @@ def install_ffmpeg_macos():
# Install 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
if not check_ffmpeg_installed():
@@ -283,23 +306,28 @@ def install_ffmpeg_macos():
logger.error(f"Error installing FFmpeg: {str(e)}")
return False
def install_ffmpeg_linux():
def install_ffmpeg_linux() -> bool:
"""Install FFmpeg on Linux using appropriate package manager."""
try:
# Detect the package manager
if shutil.which('apt'):
if shutil.which("apt"):
# Debian/Ubuntu
subprocess.run(['sudo', 'apt', 'update'], check=True, timeout=60)
subprocess.run(['sudo', 'apt', 'install', '-y', 'ffmpeg'], check=True, timeout=300)
elif shutil.which('dnf'):
subprocess.run(["sudo", "apt", "update"], check=True, timeout=60)
subprocess.run(["sudo", "apt", "install", "-y", "ffmpeg"], check=True, timeout=300)
elif shutil.which("dnf"):
# Fedora
subprocess.run(['sudo', 'dnf', 'install', '-y', 'ffmpeg'], check=True, timeout=300)
elif shutil.which('pacman'):
subprocess.run(["sudo", "dnf", "install", "-y", "ffmpeg"], check=True, timeout=300)
elif shutil.which("pacman"):
# Arch Linux
subprocess.run(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg'], check=True, timeout=300)
elif shutil.which('snap'):
subprocess.run(
["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"],
check=True,
timeout=300,
)
elif shutil.which("snap"):
# Universal snap package
subprocess.run(['sudo', 'snap', 'install', 'ffmpeg'], check=True, timeout=300)
subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300)
else:
raise Exception("No supported package manager found")
@@ -313,14 +341,15 @@ def install_ffmpeg_linux():
logger.error(f"Error installing FFmpeg: {str(e)}")
return False
def auto_install_ffmpeg():
def auto_install_ffmpeg() -> bool:
"""Automatically install FFmpeg based on the operating system."""
if sys.platform == 'win32':
if OS_NAME == "Windows":
return install_ffmpeg_windows()
elif sys.platform == 'darwin':
elif OS_NAME == "Darwin":
return install_ffmpeg_macos()
elif sys.platform.startswith('linux'):
elif OS_NAME == "Linux":
return install_ffmpeg_linux()
else:
logger.info(f"Unsupported operating system: {sys.platform}")
logger.info(f"Unsupported operating system: {OS_NAME}")
return False
+42 -28
View File
@@ -5,30 +5,50 @@ This module provides centralized logging configuration for the entire YTSage app
It replaces the inefficient print statements with structured logging using loguru.
"""
import os
import sys
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:
from loguru import logger
LOGURU_AVAILABLE = True
except ImportError:
LOGURU_AVAILABLE = False
# Create a dummy logger class that does nothing
class DummyLogger:
def info(self, *args, **kwargs): pass
def debug(self, *args, **kwargs): pass
def warning(self, *args, **kwargs): 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
def info(self, *args, **kwargs):
pass
def debug(self, *args, **kwargs):
pass
def warning(self, *args, **kwargs):
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
def _core(self):
class Core:
handlers = []
return Core()
logger = DummyLogger()
@@ -55,20 +75,11 @@ def setup_logging():
# Get the application data directory with fallbacks
try:
if sys.platform == 'win32':
localappdata = os.environ.get('LOCALAPPDATA')
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'
# logic moved to src\utils\ytsage_constants.py
log_dir = APP_LOG_DIR
except Exception:
# Ultimate fallback - use current directory
log_dir = Path.cwd() / 'logs'
log_dir = Path.cwd() / "logs"
# Create log directory if it doesn't exist
try:
@@ -92,7 +103,7 @@ def setup_logging():
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>",
colorize=True,
catch=True
catch=True,
)
except Exception:
# Fallback to basic console logging without colors
@@ -101,7 +112,7 @@ def setup_logging():
sys.stdout,
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
catch=True
catch=True,
)
except Exception:
stdout_available = False
@@ -114,7 +125,7 @@ def setup_logging():
sys.stderr,
level="WARNING", # Only warnings and errors to stderr
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
catch=True
catch=True,
)
except Exception:
# If even stderr fails, we'll rely only on file logging
@@ -131,7 +142,7 @@ def setup_logging():
rotation="10 MB", # Rotate when file reaches 10MB
retention="7 days", # Keep logs for 7 days
compression="zip", # Compress old logs
catch=True
catch=True,
)
# Error log file - ERROR and above only
@@ -142,7 +153,7 @@ def setup_logging():
rotation="5 MB",
retention="30 days", # Keep error logs longer
compression="zip",
catch=True
catch=True,
)
except Exception as e:
# If file logging fails, just log to console
@@ -161,6 +172,7 @@ def setup_logging():
# Add a minimal handler that just discards messages
# This prevents loguru from complaining about no handlers
import tempfile
try:
# Try to add a temporary file handler as last resort
temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log"
@@ -173,7 +185,7 @@ def setup_logging():
return logger
def get_logger(name: str = None):
def get_logger(name: str | None = None):
"""
Get a logger instance for a specific module.
@@ -191,6 +203,7 @@ def get_logger(name: str = None):
# Initialize logging when module is imported - with maximum safety
_setup_complete = False
def safe_setup():
"""Safely initialize logging with multiple fallback strategies."""
global _setup_complete
@@ -213,6 +226,7 @@ def safe_setup():
return logger
# Try to set up logging, but don't let it crash the module import
try:
safe_setup()
@@ -221,4 +235,4 @@ except Exception:
pass
# Export the main logger for convenience
__all__ = ['logger', 'get_logger', 'setup_logging']
__all__ = ["logger", "get_logger", "setup_logging"]
+168 -198
View File
@@ -1,81 +1,94 @@
import sys
import os
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import subprocess
import tempfile
import shutil
import pkg_resources
from packaging import version
import requests
from .ytsage_logging import logger
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 packaging import version
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
_version_cache = {
'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0},
'ffmpeg': {'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},
}
# Cache expiry time in seconds (5 minutes)
CACHE_EXPIRY = 300
def get_file_mtime(filepath):
def get_file_mtime(filepath) -> float:
"""Get file modification time safely."""
try:
if filepath and os.path.exists(filepath):
return os.path.getmtime(filepath)
if filepath and Path(filepath).exists():
return Path(filepath).stat().st_mtime
except Exception:
pass
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."""
cache = _version_cache.get(tool_name, {})
current_time = time.time()
# Always refresh if no cached data
if not cache.get('version'):
if not cache.get("version"):
return True
# Refresh if path changed
if cache.get('path') != current_path:
if cache.get("path") != current_path:
return True
# Refresh if file was modified
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
# 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 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."""
current_time = time.time()
current_mtime = get_file_mtime(path)
_version_cache[tool_name] = {
'version': version_info,
'path': path,
'last_check': current_time,
'path_mtime': current_mtime
"version": version_info,
"path": path,
"last_check": current_time,
"path_mtime": current_mtime,
}
# Save to persistent config
if force_save:
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."""
try:
config = load_config()
cached_versions = config.get('cached_versions', {})
cached_versions = config.get("cached_versions", {})
for tool_name, cache_data in cached_versions.items():
if tool_name in _version_cache:
@@ -83,23 +96,25 @@ def load_version_cache_from_config():
except Exception as 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."""
try:
config = load_config()
config['cached_versions'] = _version_cache.copy()
config["cached_versions"] = _version_cache.copy()
save_config(config)
except Exception as 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."""
try:
current_path = get_yt_dlp_path()
# Check if we need to refresh cache
if not should_refresh_cache('ytdlp', current_path):
cached_version = _version_cache['ytdlp'].get('version')
if not should_refresh_cache("ytdlp", current_path):
cached_version = _version_cache["ytdlp"].get("version")
if cached_version:
return cached_version
@@ -107,22 +122,23 @@ def get_ytdlp_version_cached():
version_info = get_ytdlp_version_direct(current_path)
# Update cache
update_version_cache('ytdlp', version_info, current_path)
update_version_cache("ytdlp", version_info, current_path)
return version_info
except Exception as e:
logger.error(f"Error getting cached yt-dlp version: {e}")
return "Error getting version"
def get_ffmpeg_version_cached():
def get_ffmpeg_version_cached() -> str:
"""Get FFmpeg version with caching support."""
try:
# Try to find ffmpeg path
current_path = "ffmpeg" # Default to system PATH
# Check if we need to refresh cache
if not should_refresh_cache('ffmpeg', current_path):
cached_version = _version_cache['ffmpeg'].get('version')
if not should_refresh_cache("ffmpeg", current_path):
cached_version = _version_cache["ffmpeg"].get("version")
if cached_version:
return cached_version
@@ -130,39 +146,43 @@ def get_ffmpeg_version_cached():
version_info = get_ffmpeg_version_direct()
# Update cache
update_version_cache('ffmpeg', version_info, current_path)
update_version_cache("ffmpeg", version_info, current_path)
return version_info
except Exception as e:
logger.error(f"Error getting cached FFmpeg version: {e}")
return "Error getting version"
def refresh_version_cache(force=False):
def refresh_version_cache(force=False) -> bool:
"""Manually refresh version cache for both tools."""
try:
# Refresh yt-dlp
current_path = get_yt_dlp_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
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
except Exception as e:
logger.error(f"Error refreshing version cache: {e}")
return False
def get_ytdlp_version():
def get_ytdlp_version() -> str:
"""Get the version of yt-dlp (uses cached version for performance)."""
return get_ytdlp_version_cached()
def get_ffmpeg_version():
def get_ffmpeg_version() -> str:
"""Get the version of FFmpeg (uses cached version for performance)."""
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."""
try:
if yt_dlp_path is None:
@@ -171,19 +191,9 @@ def get_ytdlp_version_direct(yt_dlp_path=None):
if not yt_dlp_path or yt_dlp_path == "yt-dlp":
return "Not found"
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(
[yt_dlp_path, '--version'],
capture_output=True,
text=True,
timeout=10,
startupinfo=startupinfo
[yt_dlp_path, "--version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
)
if result.returncode == 0:
@@ -194,34 +204,25 @@ def get_ytdlp_version_direct(yt_dlp_path=None):
logger.error(f"Error getting yt-dlp version: {e}")
return "Error getting version"
def get_ffmpeg_version_direct():
def get_ffmpeg_version_direct() -> str:
"""Get FFmpeg version directly without caching."""
try:
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(
['ffmpeg', '-version'],
capture_output=True,
text=True,
timeout=10,
startupinfo=startupinfo
["ffmpeg", "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
)
if result.returncode == 0:
# Parse the first line to get version info
lines = result.stdout.split('\n')
lines = result.stdout.split("\n")
if lines:
first_line = lines[0]
# 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()
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 first_line.strip()
return "Unknown version"
@@ -231,28 +232,24 @@ def get_ffmpeg_version_direct():
# If ffmpeg is not in PATH, try the installation directory
try:
ffmpeg_path = get_ffmpeg_install_path()
if sys.platform == 'win32':
ffmpeg_exe = os.path.join(ffmpeg_path, 'ffmpeg.exe')
if OS_NAME == "Windows":
ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg.exe")
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(
[ffmpeg_exe, '-version'],
capture_output=True,
text=True,
timeout=10,
startupinfo=startupinfo
[ffmpeg_exe, "-version"], capture_output=True, text=True, timeout=10, creationflags=SUBPROCESS_CREATIONFLAGS
)
if result.returncode == 0:
lines = result.stdout.split('\n')
lines = result.stdout.split("\n")
if lines:
first_line = lines[0]
if 'version' in first_line:
if "version" in first_line:
parts = first_line.split()
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 first_line.strip()
return "Unknown version"
@@ -264,49 +261,32 @@ def get_ffmpeg_version_direct():
logger.error(f"Error getting FFmpeg version: {e}")
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 the path to the main configuration file."""
return get_app_data_dir() / 'ytsage_config.json'
# get_app_data_dir() moved to src\utils\ytsage_constants.py
# get_config_file_path() moved to src\utils\ytsage_constants.py
# 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."""
config_file = get_config_file_path()
default_config = {
'download_path': str(Path.home() / 'Downloads'),
'speed_limit_value': None,
'speed_limit_unit_index': 0,
'cookie_file_path': None,
'last_used_cookie_file': None,
'auto_update_ytdlp': True, # Enable auto-update by default
'auto_update_frequency': 'daily', # daily, weekly, or startup
'last_update_check': 0, # timestamp of last check
'cached_versions': {
'ytdlp': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0},
'ffmpeg': {'version': None, 'path': None, 'last_check': 0, 'path_mtime': 0}
}
"download_path": str(USER_HOME_DIR / "Downloads"),
"speed_limit_value": None,
"speed_limit_unit_index": 0,
"cookie_file_path": None,
"last_used_cookie_file": None,
"auto_update_ytdlp": True, # Enable auto-update by default
"auto_update_frequency": "daily", # daily, weekly, or startup
"last_update_check": 0, # timestamp of last check
"cached_versions": {
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
},
}
try:
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
if APP_CONFIG_FILE.exists():
with open(APP_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults to ensure all keys exist
for key, value in default_config.items():
@@ -320,21 +300,19 @@ def load_config():
return default_config
def save_config(config):
"""Save the application configuration to file."""
config_file = get_config_file_path()
try:
# Ensure the config directory exists
ensure_app_data_dir()
with open(config_file, 'w', encoding='utf-8') as f:
def save_config(config) -> bool:
"""Save the application configuration to file."""
try:
with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error(f"Error saving config: {e}")
return False
def check_ffmpeg():
def check_ffmpeg() -> bool:
"""Check if FFmpeg is installed and accessible with enhanced error handling."""
try:
# Use the enhanced FFmpeg check from ytsage_ffmpeg
@@ -342,29 +320,29 @@ def check_ffmpeg():
return True
# For Windows, try to add the FFmpeg path to environment
if sys.platform == 'win32':
if OS_NAME == "Windows":
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:
# 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
except Exception as e:
logger.error(f"Error updating PATH: {e}")
return False
# For macOS, check common paths
elif sys.platform == 'darwin':
elif OS_NAME == "Darwin":
common_paths = [
'/usr/local/bin/ffmpeg',
'/opt/homebrew/bin/ffmpeg',
'/usr/bin/ffmpeg'
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"/usr/bin/ffmpeg",
]
for path in common_paths:
if os.path.exists(path):
if Path(path).exists():
try:
ffmpeg_dir = os.path.dirname(path)
os.environ['PATH'] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
ffmpeg_dir = Path(path).parent
os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
return True
except Exception as e:
logger.error(f"Error updating PATH: {e}")
@@ -376,29 +354,29 @@ def check_ffmpeg():
logger.error(f"Error checking FFmpeg: {e}")
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."""
config_file = get_config_file_path()
try:
if config_file.exists():
if APP_CONFIG_FILE.exists():
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)
saved_path = config.get('download_path', '')
if os.path.exists(saved_path) and os.access(saved_path, os.W_OK):
saved_path = config.get("download_path", "")
if Path(saved_path).exists() and os.access(saved_path, os.W_OK):
main_window_instance.last_path = saved_path
return
except (json.JSONDecodeError, UnicodeError) as e:
logger.error(f"Error reading config file: {e}")
# If config file is corrupted, try to remove it
try:
os.remove(config_file)
APP_CONFIG_FILE.unlink(missing_ok=True)
except Exception:
pass
# Fallback to Downloads folder
downloads_path = str(Path.home() / 'Downloads')
if os.path.exists(downloads_path) and os.access(downloads_path, os.W_OK):
downloads_path = USER_HOME_DIR / "Downloads"
if downloads_path.exists() and os.access(downloads_path, os.W_OK):
main_window_instance.last_path = downloads_path
else:
# Final fallback to temp directory if Downloads is not accessible
@@ -408,14 +386,14 @@ def load_saved_path(main_window_instance):
logger.error(f"Error loading saved settings: {e}")
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."""
config_file = get_config_file_path()
try:
# Verify the path is valid and writable
if not os.path.exists(path):
if not Path(path).exists():
try:
os.makedirs(path, exist_ok=True)
Path(path).mkdir(exist_ok=True)
except Exception as e:
logger.error(f"Error creating directory: {e}")
return False
@@ -424,12 +402,9 @@ def save_path(main_window_instance, path):
logger.info("Path is not writable")
return False
# Ensure the config directory exists
ensure_app_data_dir()
# Save the config
config = {'download_path': path}
with open(config_file, 'w', encoding='utf-8') as f:
config = {"download_path": path}
with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False)
return True
@@ -437,58 +412,45 @@ def save_path(main_window_instance, path):
logger.error(f"Error saving settings: {e}")
return False
def update_yt_dlp():
def update_yt_dlp() -> bool:
"""Check for yt-dlp updates and update if a newer version is available."""
try:
# Get the yt-dlp path
yt_dlp_path = get_yt_dlp_path()
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
# For binaries downloaded with our app, use direct binary update approach
if os.path.dirname(yt_dlp_path) in [
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'))
]:
if yt_dlp_path.samefile(YTDLP_APP_BIN_PATH):
# We're using a binary installed by our app, update directly
logger.info(f"Updating yt-dlp binary at {yt_dlp_path}")
# Determine the URL based on OS
if sys.platform == 'win32':
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"
# Extra logic moved to src\utils\ytsage_constants.py
# Download the latest version
try:
response = requests.get(url, stream=True)
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
if response.status_code == 200:
# Create a temporary file
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):
f.write(chunk)
# Make executable on Unix systems
if sys.platform != 'win32':
if OS_NAME != "Windows":
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)
if OS_NAME == "Windows" and yt_dlp_path.exists():
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")
return True
except Exception as e:
@@ -524,11 +486,18 @@ def update_yt_dlp():
if version.parse(latest_version) > version.parse(current_version):
logger.info(f"Updating yt-dlp from {current_version} to {latest_version}...")
update_result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
[
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"yt-dlp",
],
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if update_result.returncode == 0:
logger.info("yt-dlp successfully updated")
@@ -548,28 +517,28 @@ def update_yt_dlp():
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."""
try:
config = load_config()
# Check if auto-update is enabled
if not config.get('auto_update_ytdlp', False):
if not config.get("auto_update_ytdlp", False):
return False
frequency = config.get('auto_update_frequency', 'daily')
last_check = config.get('last_update_check', 0)
frequency = config.get("auto_update_frequency", "daily")
last_check = config.get("last_update_check", 0)
current_time = time.time()
# Calculate time since 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
return time_diff > 3600 # 1 hour
elif frequency == 'daily':
elif frequency == "daily":
return time_diff > 86400 # 24 hours
elif frequency == 'weekly':
elif frequency == "weekly":
return time_diff > 604800 # 7 days
return False
@@ -578,7 +547,7 @@ def should_check_for_auto_update():
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."""
try:
logger.info("Performing automatic yt-dlp update check...")
@@ -596,14 +565,15 @@ def check_and_update_ytdlp_auto():
latest_version = response.json()["info"]["version"]
# Clean up version strings
current_version = current_version.replace('_', '.')
latest_version = latest_version.replace('_', '.')
current_version = current_version.replace("_", ".")
latest_version = latest_version.replace("_", ".")
logger.info(f"Current yt-dlp version: {current_version}")
logger.info(f"Latest yt-dlp version: {latest_version}")
# Compare versions
from packaging import version as version_parser
if version_parser.parse(latest_version) > version_parser.parse(current_version):
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
@@ -612,7 +582,7 @@ def check_and_update_ytdlp_auto():
logger.info("Auto-update completed successfully!")
# Update the last check timestamp
config = load_config()
config['last_update_check'] = time.time()
config["last_update_check"] = time.time()
save_config(config)
return True
else:
@@ -622,7 +592,7 @@ def check_and_update_ytdlp_auto():
logger.info("yt-dlp is already up to date")
# Still update the timestamp even if no update was needed
config = load_config()
config['last_update_check'] = time.time()
config["last_update_check"] = time.time()
save_config(config)
return True
@@ -638,22 +608,22 @@ def check_and_update_ytdlp_auto():
return False
def get_auto_update_settings():
def get_auto_update_settings() -> dict:
"""Get current auto-update settings from config."""
config = load_config()
return {
'enabled': config.get('auto_update_ytdlp', True),
'frequency': config.get('auto_update_frequency', 'daily'),
'last_check': config.get('last_update_check', 0)
"enabled": config.get("auto_update_ytdlp", True),
"frequency": config.get("auto_update_frequency", "daily"),
"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."""
try:
config = load_config()
config['auto_update_ytdlp'] = enabled
config['auto_update_frequency'] = frequency
config["auto_update_ytdlp"] = enabled
config["auto_update_frequency"] = frequency
save_config(config)
return True
except Exception as e:
+134 -179
View File
@@ -1,87 +1,64 @@
import os
import sys
import platform
import shutil
import subprocess
import requests
from pathlib import Path
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QPushButton,
QProgressBar, QRadioButton, QHBoxLayout,
QMessageBox, QFileDialog, QWidget)
from PySide6.QtCore import QThread, Signal, Qt
from typing import Optional
import requests
from PySide6.QtCore import Qt, QThread, Signal
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
YTDLP_URLS = {
"windows": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe",
"macos": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos",
"linux": "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
}
from src.core.ytsage_logging import logger
from src.utils.ytsage_constants import (
APP_BIN_DIR,
ICON_PATH,
OS_FULL_NAME,
OS_NAME,
SUBPROCESS_CREATIONFLAGS,
YTDLP_APP_BIN_PATH,
YTDLP_DOWNLOAD_URL,
)
# Define installation paths
def get_ytdlp_install_dir():
"""Get the OS-specific yt-dlp installation directory"""
if sys.platform == 'win32':
# Windows: %LOCALAPPDATA%\YTSage\bin\
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'))
# YTDLP_URLS moved to src\utils\ytsage_constants.py
# get_ytdlp_install_dir() moved to src\utils\ytsage_constants.py
# get_ytdlp_executable_path() moved to src\utils\ytsage_constants.py
# get_os_type() moved to src\utils\ytsage_constants.py
# ensure_install_dir_exists() moved to src\utils\ytsage_constants.py
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):
progress_signal = Signal(int)
finished_signal = Signal(bool, str)
def __init__(self, os_type):
def __init__(self):
super().__init__()
self.os_type = os_type
def run(self):
def run(self) -> None:
try:
url = YTDLP_URLS[self.os_type]
install_dir = ensure_install_dir_exists()
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")
# Extra logic moved to src\utils\ytsage_constants.py
exe_path = YTDLP_APP_BIN_PATH
# Download with progress reporting
response = requests.get(url, stream=True)
total_size = int(response.headers.get('content-length', 0))
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
total_size = int(response.headers.get("content-length", 0))
block_size = 1024 # 1 Kibibyte
if total_size == 0:
self.progress_signal.emit(100)
with open(exe_path, 'wb') as f:
with open(exe_path, "wb") as f:
downloaded = 0
for data in response.iter_content(block_size):
f.write(data)
@@ -91,7 +68,7 @@ class DownloadYtdlpThread(QThread):
self.progress_signal.emit(progress)
# Make executable on macOS and Linux
if self.os_type != "windows":
if OS_NAME != "Windows":
os.chmod(exe_path, 0o755)
self.finished_signal.emit(True, exe_path)
@@ -99,12 +76,12 @@ class DownloadYtdlpThread(QThread):
except Exception as e:
self.finished_signal.emit(False, str(e))
class YtdlpSetupDialog(QDialog):
setup_complete = Signal(str) # Signal emitting the path to yt-dlp
def __init__(self, parent=None):
super().__init__(parent)
self.os_type = get_os_type()
self.setWindowTitle("yt-dlp Setup Required")
self.setMinimumWidth(520)
self.setMinimumHeight(350)
@@ -114,19 +91,16 @@ class YtdlpSetupDialog(QDialog):
if parent and parent.windowIcon():
self.setWindowIcon(parent.windowIcon())
else:
# Try to load the icon directly if parent not available
# Navigate from src/core/ to project root, then to assets/Icon/
current_dir = os.path.dirname(os.path.abspath(__file__)) # core/
src_dir = os.path.dirname(current_dir) # src/
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))
# icon_path logic moved to src\utils\ytsage_constants.py
icon_path = ICON_PATH
if Path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path.as_posix()))
self.init_ui()
# Apply dark theme styling to match app
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -190,9 +164,10 @@ class YtdlpSetupDialog(QDialog):
border: 2px solid #c90000;
background: #c90000;
}
""")
"""
)
def init_ui(self):
def init_ui(self) -> None:
layout = QVBoxLayout()
layout.setSpacing(15)
layout.setContentsMargins(25, 25, 25, 25)
@@ -200,22 +175,19 @@ class YtdlpSetupDialog(QDialog):
# Header title
title_label = QLabel("yt-dlp Setup Required")
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)
# Information label with improved styling
if self.os_type == "windows":
os_name = "Windows"
elif self.os_type == "macos":
os_name = "macOS"
else:
os_name = "Linux"
# os_name logic moved to src\utils\ytsage_constants.py
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. "
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 = QLabel(
f"YTSage requires yt-dlp to download videos.<br><br>"
f"yt-dlp was not found in the app's local directory. "
f"YTSage needs to set up yt-dlp for your {OS_FULL_NAME} system.<br><br>"
f"Please choose an option below:"
)
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
info_label.setWordWrap(True)
info_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 5px; line-height: 1.4;")
layout.addWidget(info_label)
@@ -238,7 +210,8 @@ class YtdlpSetupDialog(QDialog):
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
self.progress_bar.setFixedHeight(20) # Fixed height for consistency
self.progress_bar.setStyleSheet("""
self.progress_bar.setStyleSheet(
"""
QProgressBar {
border: 1px solid #3d3d3d;
border-radius: 8px;
@@ -254,12 +227,13 @@ class YtdlpSetupDialog(QDialog):
border-radius: 6px;
margin: 1px;
}
""")
"""
)
layout.addWidget(self.progress_bar)
# Status label with better spacing
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.setWordWrap(True)
layout.addWidget(self.status_label)
@@ -284,28 +258,28 @@ class YtdlpSetupDialog(QDialog):
self.setLayout(layout)
def setup_ytdlp(self):
def setup_ytdlp(self) -> None:
if self.auto_radio.isChecked():
self.download_ytdlp()
else:
self.select_ytdlp_path()
def download_ytdlp(self):
def download_ytdlp(self) -> None:
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self.status_label.setText("Downloading yt-dlp...")
self.setup_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.finished_signal.connect(self.download_finished)
self.download_thread.start()
def update_progress(self, value):
def update_progress(self, value) -> None:
self.progress_bar.setValue(value)
def download_finished(self, success, result):
def download_finished(self, success, result) -> None:
self.setup_button.setEnabled(True)
self.cancel_button.setEnabled(True)
@@ -316,12 +290,13 @@ class YtdlpSetupDialog(QDialog):
else:
self.status_label.setText(f"Error: {result}")
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Critical)
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Download Failed")
error_dialog.setText(f"Failed to download yt-dlp: {result}")
# Set the window icon to match the main dialog
error_dialog.setWindowIcon(self.windowIcon())
error_dialog.setStyleSheet("""
error_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -340,18 +315,20 @@ class YtdlpSetupDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
error_dialog.exec()
def select_ytdlp_path(self):
if self.os_type == "windows":
def select_ytdlp_path(self) -> None:
if OS_NAME == "Windows":
file_filter = "Executable Files (*.exe)"
else:
file_filter = "All Files (*)"
# Apply style to QFileDialog
file_dialog = QFileDialog(self)
file_dialog.setStyleSheet("""
file_dialog.setStyleSheet(
"""
QFileDialog {
background-color: #15181b;
color: #ffffff;
@@ -371,31 +348,20 @@ class YtdlpSetupDialog(QDialog):
QPushButton:hover {
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:
logger.debug(f"User selected file: {file_path}")
# Verify the selected file
try:
# Set up startupinfo to hide console window on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
# Try to run yt-dlp --version
logger.debug(f"Verifying file with --version command")
result = subprocess.run(
[file_path, "--version"],
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
[file_path, "--version"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS
)
logger.debug(f"Version check result: {result.returncode}, Output: {result.stdout.strip()}")
@@ -403,14 +369,10 @@ class YtdlpSetupDialog(QDialog):
# File is valid, copy it to our app's bin directory
try:
# Ensure the bin directory exists
install_dir = ensure_install_dir_exists()
logger.debug(f"Install directory: {install_dir}")
logger.debug(f"Install directory: {APP_BIN_DIR}")
# Determine the target filename based on OS
if self.os_type == "windows":
target_path = os.path.join(install_dir, "yt-dlp.exe")
else:
target_path = os.path.join(install_dir, "yt-dlp")
target_path = YTDLP_APP_BIN_PATH
logger.debug(f"Target path: {target_path}")
# Copy the file
@@ -418,7 +380,7 @@ class YtdlpSetupDialog(QDialog):
logger.debug(f"File copied successfully")
# Set executable permissions on Unix systems
if self.os_type != "windows":
if OS_NAME != "Windows":
os.chmod(target_path, 0o755)
logger.debug(f"Permissions set on Unix system")
@@ -430,10 +392,11 @@ class YtdlpSetupDialog(QDialog):
except Exception as copy_error:
logger.debug(f"Error copying file: {str(copy_error)}")
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Critical)
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Setup Error")
error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}")
error_dialog.setStyleSheet("""
error_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -452,15 +415,17 @@ class YtdlpSetupDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
error_dialog.exec()
else:
logger.debug(f"File verification failed with return code: {result.returncode}")
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Warning)
error_dialog.setIcon(QMessageBox.Icon.Warning)
error_dialog.setWindowTitle("Invalid Executable")
error_dialog.setText("The selected file does not appear to be a valid yt-dlp executable.")
error_dialog.setStyleSheet("""
error_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -479,15 +444,17 @@ class YtdlpSetupDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
error_dialog.exec()
except Exception as e:
logger.debug(f"Exception during verification: {str(e)}")
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Critical)
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Error")
error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}")
error_dialog.setStyleSheet("""
error_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -506,63 +473,57 @@ class YtdlpSetupDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
error_dialog.exec()
def check_ytdlp_binary():
def check_ytdlp_binary() -> Optional[Path]:
"""
Check if yt-dlp binary exists in the expected location.
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()
if os.path.exists(exe_path):
exe_path = YTDLP_APP_BIN_PATH
if exe_path.exists():
# 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:
os.chmod(exe_path, 0o755)
logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
except Exception as e:
logger.warning(f"Could not set executable permissions on {exe_path}: {e}")
return None
return exe_path
# If not found in app directory, check if yt-dlp is available in PATH
try:
# 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
startupinfo = None
if hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(
['where', 'yt-dlp'],
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
["where", "yt-dlp"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS
)
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}")
return yt_dlp_path
return Path(yt_dlp_path)
else:
# 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():
yt_dlp_path = result.stdout.strip()
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
return yt_dlp_path
return Path(yt_dlp_path)
except Exception as 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
def check_ytdlp_installed():
def check_ytdlp_installed() -> bool:
"""
Check if yt-dlp is installed and accessible.
Returns:
@@ -573,19 +534,9 @@ def check_ytdlp_installed():
if ytdlp_path:
# Try to run yt-dlp --version to verify it's working
try:
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
result = subprocess.run(
[ytdlp_path, '--version'],
capture_output=True,
text=True,
timeout=5,
startupinfo=startupinfo
[ytdlp_path, "--version"], capture_output=True, text=True, timeout=5, creationflags=SUBPROCESS_CREATIONFLAGS
)
return result.returncode == 0
except Exception:
@@ -594,7 +545,8 @@ def check_ytdlp_installed():
except Exception:
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.
This replaces the function in ytsage_utils.py.
@@ -609,7 +561,8 @@ def get_yt_dlp_path():
# 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")
return "yt-dlp"
return "yt-dlp" # type: ignore[return-value]
def setup_ytdlp(parent_widget=None):
"""
@@ -623,7 +576,7 @@ def setup_ytdlp(parent_widget=None):
# Store the setup result from the signal
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}")
setup_result["path"] = path
@@ -632,20 +585,20 @@ def setup_ytdlp(parent_widget=None):
# Show the dialog
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
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']}")
return setup_result["path"]
# 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}")
# 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}")
return expected_path
else:
@@ -653,7 +606,7 @@ def setup_ytdlp(parent_widget=None):
# Try to use the get_yt_dlp_path function to find yt-dlp elsewhere
yt_dlp_path = get_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}")
return yt_dlp_path
@@ -661,12 +614,13 @@ def setup_ytdlp(parent_widget=None):
logger.debug(f"Setup failed, showing error dialog")
if 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.setText("Failed to set up yt-dlp. Some features may not work correctly.")
# Set the window icon to match the parent
error_dialog.setWindowIcon(parent_widget.windowIcon())
error_dialog.setStyleSheet("""
error_dialog.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -685,7 +639,8 @@ def setup_ytdlp(parent_widget=None):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
error_dialog.exec()
logger.warning(f"yt-dlp setup failed, path does not exist: {expected_path}")
else:
-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.
"""
import sys
import os
import webbrowser
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QTextEdit, QWidget, QDialogButtonBox, QSizePolicy,
QPushButton, QMessageBox, QScrollArea)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ...core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_path
from ...core.ytsage_yt_dlp import get_yt_dlp_path, check_ytdlp_installed
from ...core.ytsage_utils import (check_ffmpeg, get_ytdlp_version, get_ffmpeg_version,
refresh_version_cache, _version_cache)
from src.core.ytsage_ffmpeg import get_ffmpeg_path
from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache
from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path
class LogWindow(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('yt-dlp Log')
self.setWindowTitle("yt-dlp Log")
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setStyleSheet("""
self.log_text.setStyleSheet(
"""
QTextEdit {
background-color: #2b2b2b;
color: #ffffff;
@@ -37,11 +42,12 @@ class LogWindow(QDialog):
border: 2px solid #3d3d3d;
border-radius: 4px;
}
""")
"""
)
layout.addWidget(self.log_text)
def append_log(self, message):
def append_log(self, message) -> None:
self.log_text.append(message)
# Auto-scroll to bottom
scrollbar = self.log_text.verticalScrollBar()
@@ -49,9 +55,9 @@ class LogWindow(QDialog):
class AboutDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
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.setMinimumSize(460, 420) # Slightly increased to accommodate paths
self.resize(460, 440) # Slightly increased initial size
@@ -92,7 +98,8 @@ class AboutDialog(QDialog):
layout.addLayout(button_layout)
# Apply overall styling - improved consistency
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -131,20 +138,25 @@ class AboutDialog(QDialog):
color: #ffffff;
font-size: 14px;
}
""")
"""
)
def _create_app_info_section(self):
def _create_app_info_section(self) -> QWidget:
"""Create the application information section - compact version"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setSpacing(6) # Reduced spacing
# 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)
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)
layout.addWidget(version_label)
@@ -159,11 +171,15 @@ class AboutDialog(QDialog):
info_layout = QHBoxLayout()
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)
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)
info_layout.addWidget(repo_label)
@@ -177,18 +193,20 @@ class AboutDialog(QDialog):
return widget
def _create_system_info_section(self):
def _create_system_info_section(self) -> QWidget:
"""Create the system information section with compact design"""
# Create main container with compact styling
container = QWidget()
container.setStyleSheet("""
container.setStyleSheet(
"""
QWidget {
border: 1px solid #333333;
border-radius: 8px;
background-color: #15181b;
margin-top: 5px;
}
""")
"""
)
main_layout = QVBoxLayout(container)
main_layout.setSpacing(8) # Compact spacing
@@ -200,7 +218,8 @@ class AboutDialog(QDialog):
# System Information title
title_label = QLabel("System Information")
title_label.setStyleSheet("""
title_label.setStyleSheet(
"""
QLabel {
color: #ffffff;
font-size: 14px;
@@ -208,7 +227,8 @@ class AboutDialog(QDialog):
padding: 0px;
margin: 0px;
}
""")
"""
)
header_layout.addWidget(title_label)
# Add stretch to push refresh button to the right
@@ -217,7 +237,8 @@ class AboutDialog(QDialog):
# Create refresh button
self.refresh_btn = QPushButton("🔄")
self.refresh_btn.setFixedSize(16, 16)
self.refresh_btn.setStyleSheet("""
self.refresh_btn.setStyleSheet(
"""
QPushButton {
padding: 0px;
background-color: transparent;
@@ -236,7 +257,8 @@ class AboutDialog(QDialog):
color: #c90000;
background-color: rgba(201, 0, 0, 0.1);
}
""")
"""
)
self.refresh_btn.clicked.connect(self.refresh_version_info)
header_layout.addWidget(self.refresh_btn)
@@ -257,22 +279,24 @@ class AboutDialog(QDialog):
return container
def _show_loading_message(self):
def _show_loading_message(self) -> None:
"""Show a compact loading message while system information is being gathered."""
loading_label = QLabel("🔄 Loading system information...")
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
loading_label.setStyleSheet("""
loading_label.setStyleSheet(
"""
QLabel {
color: #cccccc;
font-size: 11px;
font-style: italic;
padding: 10px;
}
""")
"""
)
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"""
item_widget = QWidget()
# Adjust height based on whether we have path info
@@ -322,13 +346,14 @@ class AboutDialog(QDialog):
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
path_label.setWordWrap(False)
# Truncate very long paths
if len(path_text) > 60:
truncated_path = "..." + path_text[-57:]
if len(str(path_text)) > 60:
truncated_path = "..." + str(path_text)[-57:]
path_label.setText(f"📁 {truncated_path}")
item_layout.addWidget(path_label)
# Subtle background with minimal border
item_widget.setStyleSheet("""
item_widget.setStyleSheet(
"""
QWidget {
background-color: rgba(45, 45, 45, 0.3);
border: 1px solid #2a2a2a;
@@ -338,11 +363,12 @@ class AboutDialog(QDialog):
QWidget:hover {
background-color: rgba(60, 60, 60, 0.4);
}
""")
"""
)
return item_widget
def update_system_info(self):
def update_system_info(self) -> None:
"""Update the system information display with compact layout."""
# Clear existing items
for i in reversed(range(self.status_container.count())):
@@ -352,7 +378,9 @@ class AboutDialog(QDialog):
# yt-dlp Status - compact version with path
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()
# Get yt-dlp path
@@ -360,22 +388,31 @@ class AboutDialog(QDialog):
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
# Simplified cache status
ytdlp_cache = _version_cache.get('ytdlp', {})
last_check = ytdlp_cache.get('last_check', 0)
ytdlp_cache = _version_cache.get("ytdlp", {})
last_check = ytdlp_cache.get("last_check", 0)
cache_status = ""
if last_check > 0:
from datetime import datetime
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
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)
# FFmpeg Status - compact version with path
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"
# Get FFmpeg path
@@ -385,27 +422,30 @@ class AboutDialog(QDialog):
ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None
# Simplified cache status for FFmpeg
ffmpeg_cache = _version_cache.get('ffmpeg', {})
last_check = ffmpeg_cache.get('last_check', 0)
ffmpeg_cache = _version_cache.get("ffmpeg", {})
last_check = ffmpeg_cache.get("last_check", 0)
cache_status = ""
if last_check > 0 and ffmpeg_found:
from datetime import datetime
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
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)
def refresh_version_info(self):
def refresh_version_info(self) -> None:
"""Refresh version information manually."""
self.refresh_btn.setText("🔄 Refreshing...")
self.refresh_btn.setEnabled(False)
# Perform refresh in a separate thread to avoid blocking UI
from PySide6.QtCore import QThread, Signal
class RefreshThread(QThread):
finished = Signal(bool)
@@ -417,7 +457,7 @@ class AboutDialog(QDialog):
self.refresh_thread.finished.connect(self.on_refresh_finished)
self.refresh_thread.start()
def on_refresh_finished(self, success):
def on_refresh_finished(self, success) -> None:
"""Handle refresh completion."""
self.refresh_btn.setText("🔄 Refresh")
self.refresh_btn.setEnabled(True)
@@ -427,11 +467,12 @@ class AboutDialog(QDialog):
else:
# Show error message with proper styling
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.setWindowTitle("Refresh Failed")
msg_box.setText("Failed to refresh version information.")
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -451,5 +492,6 @@ class AboutDialog(QDialog):
QMessageBox QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
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.
"""
import os
import sys
import threading
import subprocess
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTextEdit, QPlainTextEdit,
QCheckBox, QTabWidget, QWidget, QDialogButtonBox,
QFileDialog, QGroupBox)
from PySide6.QtCore import Qt, QMetaObject, Q_ARG
import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
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:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
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):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Custom yt-dlp Command')
self._parent = self.parent()
self.setWindowTitle("Custom yt-dlp Command")
self.setMinimumSize(600, 400)
layout = QVBoxLayout(self)
@@ -44,7 +61,8 @@ class CustomCommandDialog(QDialog):
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
self.command_input.setStyleSheet("""
self.command_input.setStyleSheet(
"""
QPlainTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -53,12 +71,14 @@ class CustomCommandDialog(QDialog):
padding: 8px;
font-family: Consolas, monospace;
}
""")
"""
)
layout.addWidget(self.command_input)
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
self.sponsorblock_checkbox.setStyleSheet("""
self.sponsorblock_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -79,7 +99,8 @@ class CustomCommandDialog(QDialog):
background: #c90000;
border-radius: 9px;
}
""")
"""
)
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
# Buttons
@@ -98,7 +119,8 @@ class CustomCommandDialog(QDialog):
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
self.log_output.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -108,10 +130,12 @@ class CustomCommandDialog(QDialog):
font-family: Consolas, monospace;
font-size: 12px;
}
""")
"""
)
layout.addWidget(self.log_output)
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -126,35 +150,38 @@ class CustomCommandDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
def run_custom_command(self):
url = self.parent.url_input.text().strip()
def run_custom_command(self) -> None:
url = self._parent.url_input.text().strip() # type: ignore[reportAttributeAccessIssue]
if not url:
self.log_output.append("Error: No URL provided")
return
command = self.command_input.toPlainText().strip()
path = self.parent.path_input.text().strip()
path = self._parent.path_input.text().strip() # type: ignore[reportAttributeAccessIssue]
self.log_output.clear()
self.log_output.append(f"Running command with URL: {url}")
self.run_btn.setEnabled(False)
# Start command in thread
threading.Thread(target=self._run_command_thread,
args=(command, url, path),
daemon=True).start()
threading.Thread(target=self._run_command_thread, 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:
class CommandLogger:
def debug(self, msg):
self.dialog.log_output.append(msg)
def warning(self, msg):
self.dialog.log_output.append(f"Warning: {msg}")
def error(self, msg):
self.dialog.log_output.append(f"Error: {msg}")
def __init__(self, dialog):
self.dialog = dialog
@@ -163,34 +190,43 @@ class CustomCommandDialog(QDialog):
# Base options
ydl_opts = {
'logger': CommandLogger(self),
'paths': {'home': path},
'debug_printout': True,
'postprocessors': []
"logger": CommandLogger(self),
"paths": {"home": path},
"debug_printout": True,
"postprocessors": [],
}
# Add SponsorBlock options if enabled
if self.sponsorblock_checkbox.isChecked():
ydl_opts['postprocessors'].extend([{
'key': 'SponsorBlock',
'categories': ['sponsor', 'selfpromo', 'interaction'],
'api': 'https://sponsor.ajay.app'
}, {
'key': 'ModifyChapters',
'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'],
'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l',
'force_keyframes': True
}])
ydl_opts["postprocessors"].extend(
[
{
"key": "SponsorBlock",
"categories": ["sponsor", "selfpromo", "interaction"],
"api": "https://sponsor.ajay.app",
},
{
"key": "ModifyChapters",
"remove_sponsor_segments": [
"sponsor",
"selfpromo",
"interaction",
],
"sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l",
"force_keyframes": True,
},
]
)
# Add custom arguments
for i in range(0, len(args), 2):
if i + 1 < len(args):
key = args[i].lstrip('-').replace('-', '_')
key = args[i].lstrip("-").replace("-", "_")
value = args[i + 1]
try:
# Try to convert to appropriate type
if value.lower() in ('true', 'false'):
value = value.lower() == 'true'
if value.lower() in ("true", "false"):
value = value.lower() == "true"
elif value.isdigit():
value = int(value)
ydl_opts[key] = value
@@ -209,9 +245,9 @@ class CustomCommandDialog(QDialog):
class CookieLoginDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('Login with Cookies')
self.setWindowTitle("Login with Cookies")
self.setMinimumSize(400, 150)
layout = QVBoxLayout(self)
@@ -237,33 +273,28 @@ class CookieLoginDialog(QDialog):
layout.addLayout(path_layout)
# 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.rejected.connect(self.reject)
layout.addWidget(button_box)
def browse_cookie_file(self):
def browse_cookie_file(self) -> None:
# Open file dialog to select cookie file
file_dialog = QFileDialog(self)
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])
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
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 self.cookie_path_input.text()
class CustomOptionsDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Custom Options')
self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking.
self.setWindowTitle("Custom Options")
self.setMinimumSize(600, 500)
layout = QVBoxLayout(self)
# Create tab widget to organize content
@@ -287,8 +318,8 @@ class CustomOptionsDialog(QDialog):
path_layout = QHBoxLayout()
self.cookie_path_input = QLineEdit()
self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path:
self.cookie_path_input.setText(parent.cookie_file_path)
if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
self.cookie_path_input.setText(self._parent.cookie_file_path.as_posix())
path_layout.addWidget(self.cookie_path_input)
self.browse_button = QPushButton("Browse")
@@ -319,7 +350,8 @@ class CustomOptionsDialog(QDialog):
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
self.sponsorblock_checkbox.setStyleSheet("""
self.sponsorblock_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -340,13 +372,15 @@ class CustomOptionsDialog(QDialog):
background: #c90000;
border-radius: 9px;
}
""")
"""
)
command_layout.addWidget(self.sponsorblock_checkbox)
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
self.command_input.setStyleSheet("""
self.command_input.setStyleSheet(
"""
QPlainTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -355,7 +389,8 @@ class CustomOptionsDialog(QDialog):
padding: 8px;
font-family: Consolas, monospace;
}
""")
"""
)
command_layout.addWidget(self.command_input)
# Run command button
@@ -366,7 +401,8 @@ class CustomOptionsDialog(QDialog):
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
self.log_output.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -376,7 +412,8 @@ class CustomOptionsDialog(QDialog):
font-family: Consolas, monospace;
font-size: 12px;
}
""")
"""
)
command_layout.addWidget(self.log_output)
# Add tabs to the tab widget
@@ -384,13 +421,14 @@ class CustomOptionsDialog(QDialog):
self.tab_widget.addTab(command_tab, "Custom Command")
# 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.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply global styles
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -434,29 +472,27 @@ class CustomOptionsDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
def browse_cookie_file(self):
def browse_cookie_file(self) -> None:
# Open file dialog to select cookie file
file_dialog = QFileDialog(self)
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;")
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
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
path = self.cookie_path_input.text().strip()
if path and os.path.exists(path):
path = Path(self.cookie_path_input.text().strip())
if path and path.exists():
return path
return None
def run_custom_command(self):
url = self.parent.url_input.text().strip()
def run_custom_command(self) -> None:
url = self._parent.url_input.text().strip()
if not url:
self.log_output.append("Error: No URL provided")
return
@@ -464,35 +500,43 @@ class CustomOptionsDialog(QDialog):
command = self.command_input.toPlainText().strip()
# Get download path from parent
path = self.parent.last_path
path = self._parent.last_path
self.log_output.clear()
self.log_output.append(f"Running command with URL: {url}")
self.run_btn.setEnabled(False)
# Start command in thread
threading.Thread(target=self._run_command_thread,
args=(command, url, path),
daemon=True).start()
threading.Thread(target=self._run_command_thread, 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:
class CommandLogger:
def debug(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, msg)
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, msg),
)
def warning(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Warning: {msg}")
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Warning: {msg}"),
)
def error(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {msg}")
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {msg}"),
)
def __init__(self, dialog):
self.dialog = dialog
@@ -504,12 +548,14 @@ class CustomOptionsDialog(QDialog):
base_cmd = [yt_dlp_path] + args + [url]
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
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Full command: {' '.join(base_cmd)}")
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Full command: {' '.join(base_cmd)}"),
)
# Run the command
@@ -518,46 +564,55 @@ class CustomOptionsDialog(QDialog):
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace'
encoding="utf-8",
errors="replace",
)
# Stream output
for line in proc.stdout:
for line in proc.stdout: # type: ignore[reportOptionalIterable]
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, line.rstrip())
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, line.rstrip()),
)
ret = proc.wait()
if ret != 0:
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Command exited with code {ret}")
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Command exited with code {ret}"),
)
else:
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, "Command completed successfully")
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, "Command completed successfully"),
)
except Exception as e:
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {str(e)}")
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {str(e)}"),
)
finally:
# Re-enable the run button
QMetaObject.invokeMethod(
self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True)
self.run_btn,
b"setEnabled",
Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True),
)
class TimeRangeDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Download Video Section')
self.setWindowTitle("Download Video Section")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
@@ -597,7 +652,8 @@ class TimeRangeDialog(QDialog):
# Force keyframes option
self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
self.force_keyframes.setChecked(True)
self.force_keyframes.setStyleSheet("""
self.force_keyframes.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -617,14 +673,16 @@ class TimeRangeDialog(QDialog):
background: #c90000;
border-radius: 4px;
}
""")
"""
)
layout.addWidget(self.force_keyframes)
# Format preview
preview_group = QGroupBox("Command Preview")
preview_layout = QVBoxLayout()
self.preview_label = QLabel("--download-sections \"*-\"")
self.preview_label.setStyleSheet("""
self.preview_label = QLabel('--download-sections "*-"')
self.preview_label.setStyleSheet(
"""
QLabel {
background-color: #1d1e22;
color: #ffffff;
@@ -633,7 +691,8 @@ class TimeRangeDialog(QDialog):
padding: 8px;
font-family: Consolas, monospace;
}
""")
"""
)
preview_layout.addWidget(self.preview_label)
preview_group.setLayout(preview_layout)
layout.addWidget(preview_group)
@@ -644,13 +703,14 @@ class TimeRangeDialog(QDialog):
self.force_keyframes.stateChanged.connect(self.update_preview)
# 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.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply styling
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -687,12 +747,13 @@ class TimeRangeDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
# Initialize preview
self.update_preview()
def update_preview(self):
def update_preview(self) -> None:
start = self.start_time_input.text().strip()
end = self.end_time_input.text().strip()
@@ -705,13 +766,13 @@ class TimeRangeDialog(QDialog):
else:
time_range = "*-" # Full video
preview = f"--download-sections \"{time_range}\""
preview = f'--download-sections "{time_range}"'
if self.force_keyframes.isChecked():
preview += " --force-keyframes-at-cuts"
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"""
start = self.start_time_input.text().strip()
end = self.end_time_input.text().strip()
@@ -730,6 +791,6 @@ class TimeRangeDialog(QDialog):
return time_range
def get_force_keyframes(self):
def get_force_keyframes(self) -> bool:
"""Returns whether to force keyframes at cuts"""
return self.force_keyframes.isChecked()
@@ -3,24 +3,23 @@ FFmpeg installation dialogs for YTSage application.
Contains dialogs and threads for checking and installing FFmpeg.
"""
import sys
import os
import webbrowser
import contextlib
import webbrowser
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):
finished = Signal(bool)
progress = Signal(str)
def run(self):
def run(self) -> None:
# Redirect stdout to capture progress messages
output = StringIO()
with contextlib.redirect_stdout(output):
@@ -34,9 +33,9 @@ class FFmpegInstallThread(QThread):
class FFmpegCheckDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('FFmpeg Installation')
self.setWindowTitle("FFmpeg Installation")
self.setMinimumWidth(450)
self.setMinimumHeight(200)
self.resize(450, 220)
@@ -46,14 +45,10 @@ class FFmpegCheckDialog(QDialog):
self.setWindowIcon(parent.windowIcon())
else:
# Try to load the icon directly if parent not available
# Navigate from src/gui/dialogs/ to project root, then to assets/Icon/
current_dir = os.path.dirname(os.path.abspath(__file__)) # dialogs/
gui_dir = os.path.dirname(current_dir) # gui/
src_dir = os.path.dirname(gui_dir) # src/
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))
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
layout = QVBoxLayout(self)
layout.setSpacing(15)
@@ -66,10 +61,7 @@ class FFmpegCheckDialog(QDialog):
layout.addWidget(header_text)
# Message
self.message_label = QLabel(
"YTSage needs FFmpeg to process videos.\n\n"
"Choose an installation option below:"
)
self.message_label = QLabel("YTSage needs FFmpeg to process videos.\n\n" "Choose an installation option below:")
self.message_label.setWordWrap(True)
self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -80,7 +72,8 @@ class FFmpegCheckDialog(QDialog):
self.progress_label.setWordWrap(True)
self.progress_label.setMinimumHeight(60) # Smaller but visible area
self.progress_label.setMaximumHeight(80) # Limit maximum height
self.progress_label.setStyleSheet("""
self.progress_label.setStyleSheet(
"""
QLabel {
background-color: #1d1e22;
color: #cccccc;
@@ -91,7 +84,8 @@ class FFmpegCheckDialog(QDialog):
font-size: 11px;
line-height: 1.2;
}
""")
"""
)
self.progress_label.hide()
layout.addWidget(self.progress_label)
@@ -109,7 +103,7 @@ class FFmpegCheckDialog(QDialog):
# Manual install button
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)
# Close button
@@ -120,7 +114,8 @@ class FFmpegCheckDialog(QDialog):
layout.addLayout(button_layout)
# Style the dialog to match app theme
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -148,12 +143,13 @@ class FFmpegCheckDialog(QDialog):
background-color: #666666;
color: #999999;
}
""")
"""
)
# Initialize installation thread
self.install_thread = None
def start_installation(self):
def start_installation(self) -> None:
self.install_btn.setEnabled(False)
self.manual_btn.setEnabled(False)
self.close_btn.setEnabled(False)
@@ -176,10 +172,10 @@ class FFmpegCheckDialog(QDialog):
self.install_thread.progress.connect(self.update_progress)
self.install_thread.start()
def update_progress(self, message):
def update_progress(self, message) -> None:
self.progress_label.setText(message)
def installation_finished(self, success):
def installation_finished(self, success) -> None:
if success:
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.")
@@ -3,14 +3,23 @@ Selection dialogs for YTSage application.
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.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
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)
self.setWindowTitle("Select Subtitles")
self.setMinimumWidth(400)
@@ -28,7 +37,8 @@ class SubtitleSelectionDialog(QDialog):
self.filter_input = QLineEdit()
self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
self.filter_input.textChanged.connect(self.filter_list)
self.filter_input.setStyleSheet("""
self.filter_input.setStyleSheet(
"""
QLineEdit {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -40,7 +50,8 @@ class SubtitleSelectionDialog(QDialog):
QLineEdit:focus {
border-color: #ff0000;
}
""")
"""
)
layout.addWidget(self.filter_input)
# Scroll Area for the list
@@ -67,7 +78,8 @@ class SubtitleSelectionDialog(QDialog):
# Style the buttons
for button in button_box.buttons():
button.setStyleSheet("""
button.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -82,14 +94,18 @@ class SubtitleSelectionDialog(QDialog):
QPushButton:pressed {
background-color: #555555;
}
""")
# Style the OK button specifically if needed
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
"""
)
# Style the OK button specifically if needed
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)
def populate_list(self, filter_text=""):
def populate_list(self, filter_text="") -> None:
# Clear existing checkboxes from layout
while self.list_layout.count():
item = self.list_layout.takeAt(0)
@@ -102,14 +118,14 @@ class SubtitleSelectionDialog(QDialog):
# Add manual subs
for lang_code, sub_info in self.available_manual.items():
if not filter_text or filter_text in lang_code.lower():
combined_subs[lang_code] = f"{lang_code} - Manual"
if not filter_text or filter_text in lang_code.lower():
combined_subs[lang_code] = f"{lang_code} - Manual"
# Add auto subs (only if no manual exists and matches filter)
for lang_code, sub_info in self.available_auto.items():
if lang_code not in combined_subs: # Don't overwrite manual
if not filter_text or filter_text in lang_code.lower():
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
if not filter_text or filter_text in lang_code.lower():
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
if not combined_subs:
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.setChecked(item_text in self.previously_selected) # Check if previously selected
checkbox.stateChanged.connect(self.update_selection)
checkbox.setStyleSheet("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -144,15 +161,16 @@ class SubtitleSelectionDialog(QDialog):
border: 2px solid #ff0000;
background: #ff0000;
}
""")
"""
)
self.list_layout.addWidget(checkbox)
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())
def update_selection(self, state):
def update_selection(self, state) -> None:
sender = self.sender()
subtitle_id = sender.property("subtitle_id")
if state == Qt.CheckState.Checked.value:
@@ -162,18 +180,18 @@ class SubtitleSelectionDialog(QDialog):
if subtitle_id in self.previously_selected:
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 list(self.previously_selected)
def accept(self):
def accept(self) -> None:
# Update the final list before closing
self.selected_subtitles = self.get_selected_subtitles()
super().accept()
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)
self.setWindowTitle("Select Playlist Videos")
self.setMinimumWidth(500)
@@ -192,7 +210,8 @@ class PlaylistSelectionDialog(QDialog):
select_all_btn.clicked.connect(self._select_all)
deselect_all_btn.clicked.connect(self._deselect_all)
# Style the buttons to match the subtitle dialog
select_all_btn.setStyleSheet("""
select_all_btn.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -207,7 +226,8 @@ class PlaylistSelectionDialog(QDialog):
QPushButton:pressed {
background-color: #555555;
}
""")
"""
)
deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
button_layout.addWidget(select_all_btn)
button_layout.addWidget(deselect_all_btn)
@@ -236,7 +256,8 @@ class PlaylistSelectionDialog(QDialog):
# Style the buttons to match subtitle dialog
for button in button_box.buttons():
button.setStyleSheet("""
button.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -251,15 +272,20 @@ class PlaylistSelectionDialog(QDialog):
QPushButton:pressed {
background-color: #555555;
}
""")
"""
)
# Style the OK button specifically if needed
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)
# Apply styling to match subtitle dialog
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog { background-color: #15181b; }
QCheckBox {
color: #ffffff;
@@ -279,21 +305,22 @@ class PlaylistSelectionDialog(QDialog):
background: #ff0000;
}
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."""
selected_indices = set()
if not selection_string:
# If no previous selection, assume all are selected initially
return set(range(1, len(self.playlist_entries) + 1))
parts = selection_string.split(',')
parts = selection_string.split(",")
for part in parts:
part = part.strip()
if '-' in part:
if "-" in part:
try:
start, end = map(int, part.split('-'))
start, end = map(int, part.split("-"))
if start <= end:
selected_indices.update(range(start, end + 1))
except ValueError:
@@ -305,7 +332,7 @@ class PlaylistSelectionDialog(QDialog):
pass # Ignore invalid numbers
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."""
selected_indices = self._parse_selection_string(previously_selected_string)
@@ -321,14 +348,15 @@ class PlaylistSelectionDialog(QDialog):
continue # Skip None entries if yt-dlp returns them
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
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.setChecked(video_index in selected_indices)
checkbox.setProperty("video_index", video_index) # Store index
checkbox.setStyleSheet("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -346,56 +374,50 @@ class PlaylistSelectionDialog(QDialog):
border: 2px solid #ff0000;
background: #ff0000;
}
""")
"""
)
self.list_layout.addWidget(checkbox)
self.checkboxes.append(checkbox)
self.list_layout.addStretch() # Push checkboxes to the top
def _select_all(self):
def _select_all(self) -> None:
for checkbox in self.checkboxes:
checkbox.setChecked(True)
def _deselect_all(self):
def _deselect_all(self) -> None:
for checkbox in self.checkboxes:
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."""
if not indices:
return ""
indices = sorted(list(set(indices)))
if not indices: # Check again after sorting/set conversion
return ""
# Remove duplicates and sort in one step
indices = sorted(set(indices))
ranges = []
start = indices[0]
end = indices[0]
for i in range(1, len(indices)):
if indices[i] == end + 1:
end = indices[i]
start = end = indices[0]
for num in indices[1:]:
if num == end + 1:
end = num
else:
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
start = indices[i]
end = indices[i]
# Add the last range
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
ranges.append(f"{start}-{end}" if start != end else str(start))
start = end = num
# Append the last range
ranges.append(f"{start}-{end}" if start != end else str(start))
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."""
selected_indices = [
cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
]
selected_indices = [cb.property("video_index") for cb in self.checkboxes if cb.isChecked()]
# Check if all items are selected
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)
@@ -405,49 +427,49 @@ class SponsorBlockCategoryDialog(QDialog):
# Default SponsorBlock categories with descriptions
SPONSORBLOCK_CATEGORIES = {
'sponsor': {
'name': 'Sponsor',
'description': 'Paid promotion, paid referrals and direct advertisements',
'default': True
"sponsor": {
"name": "Sponsor",
"description": "Paid promotion, paid referrals and direct advertisements",
"default": True,
},
'selfpromo': {
'name': 'Unpaid/Self Promotion',
'description': 'Unpaid promotion of creators\' own content',
'default': True
"selfpromo": {
"name": "Unpaid/Self Promotion",
"description": "Unpaid promotion of creators' own content",
"default": True,
},
'interaction': {
'name': 'Interaction Reminder',
'description': 'Asking viewers to like, subscribe, or follow social media',
'default': True
"interaction": {
"name": "Interaction Reminder",
"description": "Asking viewers to like, subscribe, or follow social media",
"default": True,
},
'intro': {
'name': 'Intro',
'description': 'Video introduction that can be skipped',
'default': False
"intro": {
"name": "Intro",
"description": "Video introduction that can be skipped",
"default": False,
},
'outro': {
'name': 'Outro/End Cards',
'description': 'Credits or when the video ends',
'default': False
"outro": {
"name": "Outro/End Cards",
"description": "Credits or when the video ends",
"default": False,
},
'preview': {
'name': 'Preview/Recap',
'description': 'Quick recap of previous videos or preview of what\'s coming up',
'default': False
"preview": {
"name": "Preview/Recap",
"description": "Quick recap of previous videos or preview of what's coming up",
"default": False,
},
'music_offtopic': {
'name': 'Non-Music Section',
'description': 'Only for music videos. Marks non-music sections',
'default': False
"music_offtopic": {
"name": "Non-Music Section",
"description": "Only for music videos. Marks non-music sections",
"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)
self.setWindowTitle("SponsorBlock Categories")
self.setMinimumWidth(500)
@@ -463,7 +485,7 @@ class SponsorBlockCategoryDialog(QDialog):
self.init_ui()
self.apply_styling()
def init_ui(self):
def init_ui(self) -> None:
layout = QVBoxLayout(self)
# Title and description
@@ -500,7 +522,7 @@ class SponsorBlockCategoryDialog(QDialog):
category_layout.setSpacing(2)
# Create checkbox with just the name
checkbox = QCheckBox(category_info['name'])
checkbox = QCheckBox(category_info["name"])
checkbox.setProperty("category_id", category_id)
# Determine if this category should be checked
@@ -509,11 +531,12 @@ class SponsorBlockCategoryDialog(QDialog):
is_checked = category_id in self.previously_selected
else:
# Use default values for first time
is_checked = category_info['default']
is_checked = category_info["default"]
checkbox.setChecked(is_checked)
checkbox.setStyleSheet("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 4px;
@@ -533,10 +556,11 @@ class SponsorBlockCategoryDialog(QDialog):
border: 2px solid #ff0000;
background: #ff0000;
}
""")
"""
)
# 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.setWordWrap(True)
@@ -581,13 +605,15 @@ class SponsorBlockCategoryDialog(QDialog):
for button in button_box.buttons():
button.setStyleSheet(self._get_button_style())
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; }"
)
layout.addWidget(button_box)
def _get_button_style(self):
def _get_button_style(self) -> str:
"""Returns the standard button style for this dialog."""
return """
QPushButton {
@@ -606,9 +632,10 @@ class SponsorBlockCategoryDialog(QDialog):
}
"""
def apply_styling(self):
def apply_styling(self) -> None:
"""Apply the dialog styling to match the rest of the application."""
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -619,25 +646,26 @@ class SponsorBlockCategoryDialog(QDialog):
QWidget {
background-color: #15181b;
}
""")
"""
)
def select_defaults(self):
def select_defaults(self) -> None:
"""Select only the default categories."""
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)
def select_all(self):
def select_all(self) -> None:
"""Select all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(True)
def deselect_all(self):
def deselect_all(self) -> None:
"""Deselect all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(False)
def get_selected_categories(self):
def get_selected_categories(self) -> list:
"""Returns a list of selected category IDs."""
selected = []
for category_id, checkbox in self.checkboxes.items():
@@ -645,7 +673,7 @@ class SponsorBlockCategoryDialog(QDialog):
selected.append(category_id)
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."""
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.
"""
import os
import requests
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
import time
from datetime import datetime
from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings,
check_and_update_ytdlp_auto, get_ytdlp_version)
import requests
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):
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)
self.setWindowTitle("Download Settings")
self.setMinimumWidth(450)
@@ -28,7 +45,8 @@ class DownloadSettingsDialog(QDialog):
self.current_unit_index = current_unit_index
# Apply main app styling
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -137,7 +155,8 @@ class DownloadSettingsDialog(QDialog):
selection-background-color: #c90000;
selection-color: #ffffff;
}
""")
"""
)
layout = QVBoxLayout(self)
@@ -147,7 +166,9 @@ class DownloadSettingsDialog(QDialog):
self.path_display = QLabel(self.current_path)
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)
browse_button = QPushButton("Browse...")
@@ -182,7 +203,7 @@ class DownloadSettingsDialog(QDialog):
# Enable/Disable auto-update checkbox
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)
# Frequency options
@@ -195,10 +216,10 @@ class DownloadSettingsDialog(QDialog):
self.weekly_radio = QRadioButton("Check weekly")
# Set current selection based on saved settings
current_frequency = auto_settings['frequency']
if current_frequency == 'startup':
current_frequency = auto_settings["frequency"]
if current_frequency == "startup":
self.startup_radio.setChecked(True)
elif current_frequency == 'daily':
elif current_frequency == "daily":
self.daily_radio.setChecked(True)
else: # weekly
self.weekly_radio.setChecked(True)
@@ -224,17 +245,17 @@ class DownloadSettingsDialog(QDialog):
button_box.rejected.connect(self.reject)
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)
if new_path:
self.current_path = new_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."""
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)."""
limit_str = self.speed_limit_input.text().strip()
if not limit_str:
@@ -246,18 +267,19 @@ class DownloadSettingsDialog(QDialog):
logger.info("Invalid speed limit input in dialog")
return None
def get_selected_unit_index(self):
def get_selected_unit_index(self) -> int:
"""Returns the index of the selected speed limit unit."""
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."""
msg_box = QMessageBox(self)
msg_box.setIcon(icon)
msg_box.setWindowTitle(title)
msg_box.setText(text)
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -280,19 +302,20 @@ class DownloadSettingsDialog(QDialog):
QMessageBox QPushButton:pressed {
background-color: #800000;
}
""")
"""
)
return msg_box
def test_update_check(self):
def test_update_check(self) -> None:
"""Test the update check functionality."""
try:
# Get current version
current_version = get_ytdlp_version()
if "Error" in current_version:
msg_box = self._create_styled_message_box(
QMessageBox.Warning,
QMessageBox.Icon.Warning,
"Update Check",
"Could not determine current yt-dlp version."
"Could not determine current yt-dlp version.",
)
msg_box.exec()
return
@@ -303,67 +326,69 @@ class DownloadSettingsDialog(QDialog):
latest_version = response.json()["info"]["version"]
# Clean up version strings
current_version = current_version.replace('_', '.')
latest_version = latest_version.replace('_', '.')
current_version = current_version.replace("_", ".")
latest_version = latest_version.replace("_", ".")
from packaging import version as version_parser
if version_parser.parse(latest_version) > version_parser.parse(current_version):
msg_box = self._create_styled_message_box(
QMessageBox.Information,
QMessageBox.Icon.Information,
"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()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Information,
QMessageBox.Icon.Information,
"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()
except Exception as e:
msg_box = self._create_styled_message_box(
QMessageBox.Warning,
QMessageBox.Icon.Warning,
"Update Check",
f"Error checking for updates: {str(e)}"
f"Error checking for updates: {str(e)}",
)
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."""
enabled = self.auto_update_enabled.isChecked()
if self.startup_radio.isChecked():
frequency = 'startup'
frequency = "startup"
elif self.daily_radio.isChecked():
frequency = 'daily'
frequency = "daily"
else: # weekly_radio is checked
frequency = 'weekly'
frequency = "weekly"
return enabled, frequency
def accept(self):
def accept(self) -> None:
"""Override accept to save auto-update settings."""
try:
# Save auto-update settings
enabled, frequency = self.get_auto_update_settings()
if update_auto_update_settings(enabled, frequency):
QMessageBox.information(self, "Settings Saved",
"Auto-update settings have been saved successfully!")
QMessageBox.information(
self,
"Settings Saved",
"Auto-update settings have been saved successfully!",
)
else:
QMessageBox.warning(self, "Error",
"Failed to save auto-update settings.")
QMessageBox.warning(self, "Error", "Failed to save auto-update settings.")
except Exception as e:
QMessageBox.critical(self, "Error",
f"Error saving auto-update settings: {str(e)}")
QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {str(e)}")
# Call the parent accept method to close the dialog
super().accept()
class AutoUpdateSettingsDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Auto-Update Settings")
self.setMinimumWidth(400)
@@ -377,7 +402,7 @@ class AutoUpdateSettingsDialog(QDialog):
self.load_current_settings()
self.apply_styling()
def init_ui(self):
def init_ui(self) -> None:
layout = QVBoxLayout(self)
# Title
@@ -455,8 +480,9 @@ class AutoUpdateSettingsDialog(QDialog):
layout.addLayout(button_layout)
def apply_styling(self):
self.setStyleSheet("""
def apply_styling(self) -> None:
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -518,24 +544,22 @@ class AutoUpdateSettingsDialog(QDialog):
background-color: #666666;
color: #999999;
}
""")
"""
)
def load_current_settings(self):
def load_current_settings(self) -> None:
"""Load current auto-update settings from config."""
try:
import time
from datetime import datetime
settings = get_auto_update_settings()
# Set checkbox
self.enable_checkbox.setChecked(settings['enabled'])
self.enable_checkbox.setChecked(settings["enabled"])
# Set frequency
frequency = settings['frequency']
if frequency == 'startup':
frequency = settings["frequency"]
if frequency == "startup":
self.startup_radio.setChecked(True)
elif frequency == 'weekly':
elif frequency == "weekly":
self.weekly_radio.setChecked(True)
else: # daily
self.daily_radio.setChecked(True)
@@ -544,7 +568,7 @@ class AutoUpdateSettingsDialog(QDialog):
current_version = get_ytdlp_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:
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}")
@@ -555,23 +579,20 @@ class AutoUpdateSettingsDialog(QDialog):
self.update_next_check_label()
# Update UI state
self.on_enable_toggled(settings['enabled'])
self.on_enable_toggled(settings["enabled"])
except Exception as 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."""
try:
if not self.enable_checkbox.isChecked():
self.next_check_label.setText("Next check: Disabled")
return
import time
from datetime import datetime, timedelta
settings = get_auto_update_settings()
last_check = settings['last_check']
last_check = settings["last_check"]
frequency = self.get_selected_frequency()
if last_check == 0:
@@ -579,11 +600,11 @@ class AutoUpdateSettingsDialog(QDialog):
return
next_check_time = last_check
if frequency == 'startup':
if frequency == "startup":
next_check_time += 3600 # 1 hour
elif frequency == 'daily':
elif frequency == "daily":
next_check_time += 86400 # 24 hours
elif frequency == 'weekly':
elif frequency == "weekly":
next_check_time += 604800 # 7 days
current_time = time.time()
@@ -597,7 +618,7 @@ class AutoUpdateSettingsDialog(QDialog):
self.next_check_label.setText("Next check: Error calculating")
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."""
# Enable/disable frequency options
for i in range(self.frequency_group.buttons().__len__()):
@@ -605,27 +626,28 @@ class AutoUpdateSettingsDialog(QDialog):
self.update_next_check_label()
def get_selected_frequency(self):
def get_selected_frequency(self) -> str:
"""Get the selected frequency setting."""
if self.startup_radio.isChecked():
return 'startup'
return "startup"
elif self.weekly_radio.isChecked():
return 'weekly'
return "weekly"
else:
return 'daily'
return "daily"
def manual_check(self):
def manual_check(self) -> None:
"""Perform a manual update check."""
self.manual_check_btn.setEnabled(False)
self.manual_check_btn.setText("🔄 Checking...")
# Force an immediate update check
def check_in_thread():
def check_in_thread() -> None:
try:
result = check_and_update_ytdlp_auto()
# Update UI in main thread
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
except Exception as e:
logger.error(f"Error during manual check: {e}")
@@ -633,16 +655,18 @@ class AutoUpdateSettingsDialog(QDialog):
# Run in separate thread to avoid blocking UI
import threading
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."""
msg_box = QMessageBox(self)
msg_box.setIcon(icon)
msg_box.setWindowTitle(title)
msg_box.setText(text)
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -665,33 +689,34 @@ class AutoUpdateSettingsDialog(QDialog):
QMessageBox QPushButton:pressed {
background-color: #800000;
}
""")
"""
)
return msg_box
def manual_check_finished(self, success):
def manual_check_finished(self, success) -> None:
"""Handle completion of manual update check."""
self.manual_check_btn.setEnabled(True)
self.manual_check_btn.setText("🔍 Check for Updates Now")
if success:
msg_box = self._create_styled_message_box(
QMessageBox.Information,
QMessageBox.Icon.Information,
"Update Check",
"✅ Update check completed successfully!\nCheck the console for details."
"✅ Update check completed successfully!\nCheck the console for details.",
)
msg_box.exec()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Warning,
QMessageBox.Icon.Warning,
"Update Check",
"❌ Update check failed.\nCheck the console for error details."
"❌ Update check failed.\nCheck the console for error details.",
)
msg_box.exec()
# Refresh the current settings display
self.load_current_settings()
def save_settings(self):
def save_settings(self) -> None:
"""Save the auto-update settings."""
try:
enabled = self.enable_checkbox.isChecked()
@@ -699,24 +724,20 @@ class AutoUpdateSettingsDialog(QDialog):
if update_auto_update_settings(enabled, frequency):
msg_box = self._create_styled_message_box(
QMessageBox.Information,
QMessageBox.Icon.Information,
"Settings Saved",
"✅ Auto-update settings have been saved successfully!"
"✅ Auto-update settings have been saved successfully!",
)
msg_box.exec()
self.accept()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Warning,
QMessageBox.Icon.Warning,
"Error",
"❌ Failed to save auto-update settings.\nPlease try again."
"❌ Failed to save auto-update settings.\nPlease try again.",
)
msg_box.exec()
except Exception as e:
logger.error(f"Error saving auto-update settings: {e}")
msg_box = self._create_styled_message_box(
QMessageBox.Critical,
"Error",
f"❌ Error saving settings: {str(e)}"
)
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
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.
"""
import sys
import os
import requests
import subprocess
import sys
import time
from packaging import version
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QProgressBar, QMessageBox)
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from pathlib import Path
from ...core.ytsage_yt_dlp import get_yt_dlp_path
from ...core.ytsage_utils import get_ytdlp_version, load_config, save_config
from ...core.ytsage_logging import logger
import requests
from packaging import version
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:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
@@ -27,7 +30,7 @@ except ImportError:
class VersionCheckThread(QThread):
finished = Signal(str, str, str) # current_version, latest_version, error_message
def run(self):
def run(self) -> None:
current_version = ""
latest_version = ""
error_message = ""
@@ -38,17 +41,18 @@ class VersionCheckThread(QThread):
# Get current version with timeout
try:
result = subprocess.run([yt_dlp_path, '--version'],
capture_output=True,
text=True,
timeout=30, # 30 second timeout
startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE),
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
result = subprocess.run(
[yt_dlp_path, "--version"],
capture_output=True,
text=True,
timeout=30, # 30 second timeout
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
current_version = result.stdout.strip()
else: # Try fallback if command failed
if YT_DLP_AVAILABLE:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
else:
error_message = "yt-dlp not available."
self.finished.emit(current_version, latest_version, error_message)
@@ -56,19 +60,19 @@ class VersionCheckThread(QThread):
except subprocess.TimeoutExpired:
# Try fallback if timeout
if YT_DLP_AVAILABLE:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
else:
error_message = "yt-dlp version check timed out and package not found."
self.finished.emit(current_version, latest_version, error_message)
return
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:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
else:
error_message = "yt-dlp not found or accessible."
self.finished.emit(current_version, latest_version, error_message)
return
error_message = "yt-dlp not found or accessible."
self.finished.emit(current_version, latest_version, error_message)
return
# Get latest version from PyPI
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
@@ -76,8 +80,8 @@ class VersionCheckThread(QThread):
latest_version = response.json()["info"]["version"]
# Clean up version strings
current_version = current_version.replace('_', '.')
latest_version = latest_version.replace('_', '.')
current_version = current_version.replace("_", ".")
latest_version = latest_version.replace("_", ".")
except requests.RequestException as e:
error_message = f"Network error checking PyPI: {e}"
@@ -92,7 +96,7 @@ class UpdateThread(QThread):
update_progress = Signal(int) # For progress percentage (0-100)
update_finished = Signal(bool, str) # success (bool), message/error (str)
def run(self):
def run(self) -> None:
error_message = ""
success = False
try:
@@ -102,36 +106,25 @@ class UpdateThread(QThread):
# Get the yt-dlp path
try:
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:
self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
return
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
self.update_progress.emit(20)
# Check if we're using an app-managed binary or system installation
app_managed_dirs = [
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)
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
if is_app_managed:
self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
success = self._update_binary(yt_dlp_path)
else:
self.update_status.emit("🐍 Updating system yt-dlp via pip...")
success = self._update_via_pip(startupinfo)
success = self._update_via_pip()
if success:
self.update_progress.emit(100)
@@ -150,82 +143,46 @@ class UpdateThread(QThread):
self.update_finished.emit(success, error_message)
def _update_binary(self, yt_dlp_path):
"""Update yt-dlp binary directly from GitHub releases."""
def _update_binary(self, yt_dlp_path: Path) -> bool:
"""Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread)."""
try:
self.update_status.emit("🌐 Determining download URL...")
self.update_progress.emit(30)
logger.info("UpdateThread: Checking for yt-dlp updates...")
# Determine the URL based on OS
if sys.platform == 'win32':
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"
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
self.update_status.emit("⬇️ Downloading latest yt-dlp binary...")
self.update_progress.emit(40)
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
# Download with progress tracking and timeout
response = requests.get(url, stream=True, timeout=30)
if response.status_code != 200:
self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}")
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)
logger.info("UpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
self.update_status.emit("✅ Binary successfully updated!")
self.update_progress.emit(95)
return True
except Exception as e:
self.update_status.emit(f"Error installing binary: {e}")
# Clean up temp file if it exists
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except:
pass
else:
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
self.update_status.emit(f"yt-dlp update failed: {result.stderr.strip()}")
return False
except requests.RequestException as e:
self.update_status.emit(f"❌ Network error: {e}")
return False
except Exception as e:
self.update_status.emit(f"❌ Binary update failed: {e}")
except subprocess.TimeoutExpired:
logger.error("UpdateThread: yt-dlp update timed out.")
self.update_status.emit("❌ yt-dlp update timed out.")
return False
def _update_via_pip(self, startupinfo):
except Exception as 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
def _update_via_pip(self) -> bool:
"""Update yt-dlp via pip."""
try:
import pkg_resources
@@ -270,7 +227,7 @@ class UpdateThread(QThread):
text=True,
check=False,
timeout=300, # 5 minute timeout for pip install
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
self.update_progress.emit(85)
@@ -300,7 +257,7 @@ class UpdateThread(QThread):
class YTDLPUpdateDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Update yt-dlp")
self.setMinimumWidth(450)
@@ -335,7 +292,8 @@ class YTDLPUpdateDialog(QDialog):
layout.addLayout(button_layout)
# Style
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -375,21 +333,22 @@ class YTDLPUpdateDialog(QDialog):
border-radius: 4px;
margin: 1px;
}
""")
"""
)
# Start version check in background
self.check_version()
def check_version(self):
def check_version(self) -> None:
self.status_label.setText("Checking for updates...")
self.update_btn.setEnabled(False)
self.version_check_thread = VersionCheckThread()
self.version_check_thread.finished.connect(self.on_version_check_finished)
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
if hasattr(self, '_closing') and self._closing:
if hasattr(self, "_closing") and self._closing:
return
if error_message:
@@ -398,9 +357,9 @@ class YTDLPUpdateDialog(QDialog):
return
if not current_version or not latest_version:
self.status_label.setText("Could not determine versions.")
self.update_btn.setEnabled(False)
return
self.status_label.setText("Could not determine versions.")
self.update_btn.setEnabled(False)
return
try:
# Compare versions
@@ -408,7 +367,9 @@ class YTDLPUpdateDialog(QDialog):
latest_ver = version.parse(latest_version)
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)
else:
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
@@ -416,16 +377,18 @@ class YTDLPUpdateDialog(QDialog):
except version.InvalidVersion:
# If version parsing fails, do a simple string comparison
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)
else:
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
self.update_btn.setEnabled(False)
except Exception as e:
self.status_label.setText(f"Error comparing versions: {e}")
self.update_btn.setEnabled(False)
self.status_label.setText(f"Error comparing versions: {e}")
self.update_btn.setEnabled(False)
def perform_update(self):
def perform_update(self) -> None:
# Immediate visual feedback
self.update_btn.setEnabled(False)
self.close_btn.setEnabled(False)
@@ -440,7 +403,7 @@ class YTDLPUpdateDialog(QDialog):
# Start the update thread
self._start_update_thread()
def _start_update_thread(self):
def _start_update_thread(self) -> None:
"""Start the actual update thread."""
# Create and start the update thread
self.update_thread = UpdateThread()
@@ -449,20 +412,20 @@ class YTDLPUpdateDialog(QDialog):
self.update_thread.update_finished.connect(self.on_update_finished)
self.update_thread.start()
def on_update_status(self, message):
def on_update_status(self, message) -> None:
"""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)
def on_update_progress(self, progress):
def on_update_progress(self, progress) -> None:
"""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)
def on_update_finished(self, success, message):
def on_update_finished(self, success, message) -> None:
"""Slot called when the UpdateThread finishes."""
# Check if dialog is closing to avoid unnecessary updates
if hasattr(self, '_closing') and self._closing:
if hasattr(self, "_closing") and self._closing:
return
self.progress_bar.setValue(100)
@@ -475,19 +438,22 @@ class YTDLPUpdateDialog(QDialog):
QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
else:
# 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."""
# Set a flag to indicate dialog is closing
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()
if not self.version_check_thread.wait(3000): # Wait up to 3 seconds
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()
if not self.update_thread.wait(5000): # Wait up to 5 seconds for update to finish
self.update_thread.terminate()
@@ -497,9 +463,10 @@ class YTDLPUpdateDialog(QDialog):
class AutoUpdateThread(QThread):
"""Thread for performing automatic background updates without UI feedback."""
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."""
try:
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
@@ -518,8 +485,8 @@ class AutoUpdateThread(QThread):
latest_version = response.json()["info"]["version"]
# Clean up version strings
current_version = current_version.replace('_', '.')
latest_version = latest_version.replace('_', '.')
current_version = current_version.replace("_", ".")
latest_version = latest_version.replace("_", ".")
logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
@@ -535,9 +502,12 @@ class AutoUpdateThread(QThread):
logger.info("AutoUpdateThread: Auto-update completed successfully!")
# Update the last check timestamp
config = load_config()
config['last_update_check'] = time.time()
config["last_update_check"] = time.time()
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:
logger.warning("AutoUpdateThread: Auto-update failed")
self.update_finished.emit(False, "Auto-update failed")
@@ -545,35 +515,36 @@ class AutoUpdateThread(QThread):
logger.info("AutoUpdateThread: yt-dlp is already up to date")
# Still update the timestamp even if no update was needed
config = load_config()
config['last_update_check'] = time.time()
config["last_update_check"] = time.time()
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:
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
self.update_finished.emit(False, f"Network error: {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}")
except Exception as e:
logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
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."""
try:
# Get the yt-dlp path
yt_dlp_path = get_yt_dlp_path()
# Check if we're using an app-managed binary or system installation
app_managed_dirs = [
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'))
]
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
if is_app_managed:
logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...")
@@ -586,63 +557,41 @@ class AutoUpdateThread(QThread):
logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
return False
def _update_binary(self, yt_dlp_path):
"""Update yt-dlp binary directly from GitHub releases (silent version)."""
def _update_binary(self, yt_dlp_path: Path) -> bool:
"""Update yt-dlp binary using its built-in updater."""
try:
# Determine the URL based on OS
if sys.platform == 'win32':
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"
logger.info("AutoUpdateThread: Checking for yt-dlp updates...")
logger.info("AutoUpdateThread: Downloading latest yt-dlp binary...")
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
# Download without progress tracking (silent)
response = requests.get(url, stream=True)
if response.status_code != 200:
logger.error(f"AutoUpdateThread: Download failed: HTTP {response.status_code}")
return False
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
temp_file = f"{yt_dlp_path}.new"
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!")
logger.info("AutoUpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
return True
except Exception as e:
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
else:
logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}")
return False
except Exception as e:
logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True)
except subprocess.TimeoutExpired:
logger.error("AutoUpdateThread: yt-dlp update timed out.")
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)."""
try:
import pkg_resources
@@ -673,12 +622,7 @@ class AutoUpdateThread(QThread):
if version.parse(latest_version) > version.parse(current_version):
logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
# Create startupinfo to hide console on Windows
startupinfo = None
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
# Extra logic moved to src\utils\ytsage_constants.py
# Run pip update
logger.info("AutoUpdateThread: Running pip install --upgrade...")
@@ -687,7 +631,7 @@ class AutoUpdateThread(QThread):
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if update_result.returncode == 0:
+100 -76
View File
@@ -1,23 +1,31 @@
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, QScrollArea,
QSizePolicy)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
class FormatSignals(QObject):
format_update = Signal(list)
class FormatTableMixin:
def setup_format_table(self):
def setup_format_table(self) -> QTableWidget:
self.format_signals = FormatSignals()
# Format table with improved styling
self.format_table = QTableWidget()
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
self.format_table.setAlternatingRowColors(True)
@@ -52,7 +60,8 @@ class FormatTableMixin:
# Set selection mode to no selection (since we're using checkboxes)
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
self.format_table.setStyleSheet("""
self.format_table.setStyleSheet(
"""
QTableWidget {
background-color: #1b2021;
border: 2px solid #1b2021;
@@ -96,7 +105,8 @@ class FormatTableMixin:
QWidget {
background-color: transparent;
}
""")
"""
)
# Store format checkboxes and formats
self.format_checkboxes = []
@@ -113,8 +123,8 @@ class FormatTableMixin:
return self.format_table
def filter_formats(self):
if not hasattr(self, 'all_formats'):
def filter_formats(self) -> None:
if not hasattr(self, "all_formats"):
return
# Clear current table
@@ -124,44 +134,46 @@ class FormatTableMixin:
# Determine which formats to show
filtered_formats = []
if hasattr(self, 'video_button') and self.video_button.isChecked():
filtered_formats.extend([f for f in self.all_formats
if f.get('vcodec') != 'none'
and f.get('filesize') is not None])
if hasattr(self, "video_button") and self.video_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
filtered_formats.extend([f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None])
if hasattr(self, 'audio_button') and self.audio_button.isChecked():
filtered_formats.extend([f for f in self.all_formats
if (f.get('vcodec') == 'none'
or 'audio only' in f.get('format_note', '').lower())
and f.get('acodec') != 'none'
and f.get('filesize') is not None])
if hasattr(self, "audio_button") and self.audio_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
filtered_formats.extend(
[
f
for f in self.all_formats
if (f.get("vcodec") == "none" or "audio only" in f.get("format_note", "").lower())
and f.get("acodec") != "none"
and f.get("filesize") is not None
]
)
# Sort formats by quality
def get_quality(f):
if f.get('vcodec') != 'none':
res = f.get('resolution', '0x0').split('x')[-1]
if f.get("vcodec") != "none":
res = f.get("resolution", "0x0").split("x")[-1]
try:
return int(res)
except ValueError:
return 0
else:
return f.get('abr', 0)
return f.get("abr", 0)
filtered_formats.sort(key=get_quality, reverse=True)
# Update table with 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_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
if is_playlist_mode:
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
self.format_table.setColumnHidden(5, True)
@@ -178,10 +190,21 @@ class FormatTableMixin:
else:
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
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)
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
@@ -200,11 +223,13 @@ class FormatTableMixin:
self.format_table.setColumnWidth(6, 120)
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
# Find best quality format for recommendations (only needed for non-playlist mode notes)
best_video_size = 0
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:
row = self.format_table.rowCount()
@@ -212,7 +237,7 @@ class FormatTableMixin:
# Column 0: Select Checkbox (Always shown)
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))
self.format_checkboxes.append(checkbox)
checkbox_widget = QWidget()
@@ -229,21 +254,21 @@ class FormatTableMixin:
quality_item = QTableWidgetItem(quality_text)
# Set color based on quality
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:
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:
quality_item.setForeground(QColor('#ffaa00')) # Orange for medium quality
quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality
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)
# --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) ---
# Column 2: Resolution (Always shown)
resolution = f.get('resolution', 'N/A')
if f.get('vcodec') == 'none':
resolution = 'Audio only'
resolution = f.get("resolution", "N/A")
if f.get("vcodec") == "none":
resolution = "Audio only"
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
# Column 3: Notes for playlist mode, Extension for normal mode
@@ -252,31 +277,30 @@ class FormatTableMixin:
notes = self._get_format_notes(f)
notes_item = QTableWidgetItem(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:
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
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)
else:
# 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
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")
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_item = QTableWidgetItem(audio_status)
if needs_audio:
audio_item.setForeground(QColor('#ffa500'))
audio_item.setForeground(QColor("#ffa500"))
elif audio_status == "Audio Only":
audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only
else: # Has Audio (Video+Audio)
audio_item.setForeground(QColor('#00cc00')) # Green for included audio
audio_item.setForeground(QColor("#cccccc")) # Neutral color for audio only
else: # Has Audio (Video+Audio)
audio_item.setForeground(QColor("#00cc00")) # Green for included audio
# Set item for correct column based on mode
audio_column_index = 4 if is_playlist_mode else 6
self.format_table.setItem(row, audio_column_index, audio_item)
# --- Populate columns only shown in non-playlist mode ---
if not is_playlist_mode:
# Column 3: Resolution
@@ -287,11 +311,11 @@ class FormatTableMixin:
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
# Column 5: Codec
if f.get('vcodec') == 'none':
codec = f.get('acodec', 'N/A')
if f.get("vcodec") == "none":
codec = f.get("acodec", "N/A")
else:
codec = f"{f.get('vcodec', 'N/A')}"
if f.get('acodec') != 'none':
if f.get("acodec") != "none":
codec += f" / {f.get('acodec', 'N/A')}"
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
@@ -299,14 +323,14 @@ class FormatTableMixin:
notes = self._get_format_notes(f)
notes_item = QTableWidgetItem(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:
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
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)
def handle_checkbox_click(self, clicked_checkbox):
def handle_checkbox_click(self, clicked_checkbox) -> None:
for checkbox in self.format_checkboxes:
if checkbox != clicked_checkbox:
checkbox.setChecked(False)
@@ -317,15 +341,15 @@ class FormatTableMixin:
return checkbox.format_id
return None
def update_format_table(self, formats):
def update_format_table(self, formats) -> None:
self.all_formats = 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"""
if format_info.get('vcodec') == 'none':
if format_info.get("vcodec") == "none":
# Audio quality
abr = format_info.get('abr', 0)
abr = format_info.get("abr", 0)
if abr >= 256:
return "Best Audio"
elif abr >= 192:
@@ -337,10 +361,10 @@ class FormatTableMixin:
else:
# Video quality
height = 0
resolution = format_info.get('resolution', '')
resolution = format_info.get("resolution", "")
if resolution:
try:
height = int(resolution.split('x')[1])
height = int(resolution.split("x")[1])
except:
pass
@@ -357,17 +381,17 @@ class FormatTableMixin:
else:
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."""
notes = []
# Add storage indicator with more granular categories
file_size = format_info.get('filesize') or format_info.get('filesize_approx', 0)
resolution = format_info.get('resolution', '')
file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0)
resolution = format_info.get("resolution", "")
height = 0
if resolution:
try:
height = int(resolution.split('x')[1])
height = int(resolution.split("x")[1])
except:
pass
@@ -382,17 +406,17 @@ class FormatTableMixin:
notes.append("Small size")
# Add codec quality indicator
vcodec = format_info.get('vcodec', '')
if vcodec != 'none':
if 'avc1' in vcodec: # H.264
vcodec = format_info.get("vcodec", "")
if vcodec != "none":
if "avc1" in vcodec: # H.264
notes.append("Compatible")
elif 'av01' in vcodec: # AV1
elif "av01" in vcodec: # AV1
notes.append("Efficient")
elif 'vp9' in vcodec: # VP9
elif "vp9" in vcodec: # VP9
notes.append("High quality")
# 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")
# Return simple string
+420 -384
View File
File diff suppressed because it is too large Load Diff
+94 -92
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
from ..core.ytsage_logging import logger
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
from .ytsage_gui_dialogs import SubtitleSelectionDialog, SponsorBlockCategoryDialog
from datetime import datetime
from io import BytesIO
from pathlib import Path
import requests
from PIL import Image
from PySide6.QtCore import Qt
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:
def setup_video_info_section(self):
def setup_video_info_section(self) -> QHBoxLayout:
# Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout()
media_info_layout.setSpacing(15)
@@ -65,14 +57,22 @@ class VideoInfoMixin:
self.like_count_label = QLabel()
# Style the info labels
for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]:
label.setStyleSheet("""
for label in [
self.channel_label,
self.views_label,
self.date_label,
self.duration_label,
self.like_count_label,
]:
label.setStyleSheet(
"""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 1px 0;
}
""")
"""
)
# Add labels to video info layout
video_info_layout.addWidget(self.title_label)
@@ -90,11 +90,12 @@ class VideoInfoMixin:
subtitle_layout.setSpacing(10)
# 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.setFixedWidth(150) # Let it size naturally or adjust as needed
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
self.subtitle_select_btn.setStyleSheet("""
self.subtitle_select_btn.setStyleSheet(
"""
QPushButton {
background-color: #1d1e22;
border: 2px solid #1d1e22;
@@ -112,8 +113,9 @@ class VideoInfoMixin:
color: #888888;
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)
# Label to show number of selected subtitles
@@ -134,7 +136,8 @@ class VideoInfoMixin:
self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...")
self.sponsorblock_select_btn.setFixedHeight(30)
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
self.sponsorblock_select_btn.setStyleSheet("""
self.sponsorblock_select_btn.setStyleSheet(
"""
QPushButton {
background-color: #1d1e22;
border: 2px solid #1d1e22;
@@ -152,7 +155,8 @@ class VideoInfoMixin:
color: #888888;
border-color: #3d3d3d;
}
""")
"""
)
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
@@ -180,10 +184,11 @@ class VideoInfoMixin:
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.setVisible(False)
self.playlist_info_label.setStyleSheet("""
self.playlist_info_label.setStyleSheet(
"""
QLabel {
font-size: 12px;
color: #ffffff;
@@ -195,16 +200,17 @@ class VideoInfoMixin:
min-height: 30px;
max-height: 30px;
}
""")
"""
)
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
return self.playlist_info_label
def update_video_info(self, info):
if hasattr(self, 'is_playlist') and self.is_playlist:
def update_video_info(self, info) -> None:
if hasattr(self, "is_playlist") and self.is_playlist:
# 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}")
# Hide video-specific info
@@ -225,63 +231,62 @@ class VideoInfoMixin:
self.like_count_label.setVisible(True)
# Format view count with commas
views = info.get('view_count')
formatted_views = f"{views:,}" if views is not None else 'N/A'
views = info.get("view_count")
formatted_views = f"{views:,}" if views is not None else "N/A"
# Format like count with commas
likes = info.get('like_count')
formatted_likes = f"{likes:,}" if likes is not None else 'N/A'
likes = info.get("like_count")
formatted_likes = f"{likes:,}" if likes is not None else "N/A"
# Format upload date
upload_date = info.get('upload_date', '')
upload_date = info.get("upload_date", "")
if upload_date:
date_obj = datetime.strptime(upload_date, '%Y%m%d')
formatted_date = date_obj.strftime('%B %d, %Y')
date_obj = datetime.strptime(upload_date, "%Y%m%d")
formatted_date = date_obj.strftime("%B %d, %Y")
else:
formatted_date = 'Unknown date'
formatted_date = "Unknown date"
# Format duration
duration = info.get('duration', 0)
duration = info.get("duration", 0)
minutes = duration // 60
seconds = duration % 60
duration_str = f"{minutes}:{seconds:02d}"
# Update labels
self.title_label.setText(info.get('title', 'Unknown title'))
self.title_label.setText(info.get("title", "Unknown title"))
self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
self.views_label.setText(f"Views: {formatted_views}")
self.like_count_label.setText(f"Likes: {formatted_likes}")
self.date_label.setText(f"Upload date: {formatted_date}")
self.duration_label.setText(f"Duration: {duration_str}")
def open_subtitle_dialog(self):
if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'):
logger.warning("Subtitle info not loaded yet.")
return
def open_subtitle_dialog(self) -> None:
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
logger.warning("Subtitle info not loaded yet.")
return
if not hasattr(self, 'selected_subtitles'):
if not hasattr(self, "selected_subtitles"):
self.selected_subtitles = []
dialog = SubtitleSelectionDialog(
self.available_subtitles,
self.available_automatic_subtitles,
self.available_subtitles, # type: ignore[reportAttributeAccessIssue]
self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue]
self.selected_subtitles,
self # Parent for the dialog
self, # Parent for the dialog
)
# Access the main application window (parent of the mixin's widget)
# 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 the structure is different, this might need adjustment
# Maybe self.parentWidget() or similar depending on how Mixin is used
logger.warning("Cannot find main window to access merge checkbox.")
merge_checkbox = None
# If the structure is different, this might need adjustment
# Maybe self.parentWidget() or similar depending on how Mixin is used
logger.warning("Cannot find main window to access merge checkbox.")
merge_checkbox = None
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()
logger.info(f"Selected subtitles: {self.selected_subtitles}")
# Update UI to reflect selection
@@ -292,7 +297,7 @@ class VideoInfoMixin:
# Enable/disable the merge checkbox in the parent window
if merge_checkbox:
# 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
should_enable = count > 0 and not is_audio_only
merge_checkbox.setEnabled(should_enable)
@@ -304,10 +309,10 @@ class VideoInfoMixin:
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
# 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."""
# 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
dialog_categories = None
else:
@@ -320,9 +325,9 @@ class VideoInfoMixin:
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
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."""
if not hasattr(self, 'selected_sponsorblock_categories'):
if not hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = []
count = len(self.selected_sponsorblock_categories)
@@ -342,7 +347,7 @@ class VideoInfoMixin:
self.sponsorblock_select_btn.style().unpolish(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:
# Store both thumbnail URL and video URL
self.thumbnail_url = url
@@ -355,42 +360,39 @@ class VideoInfoMixin:
# Display thumbnail
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
img_byte_arr = BytesIO()
image.save(img_byte_arr, format='PNG')
image.save(img_byte_arr, format="PNG")
pixmap = QPixmap()
pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap)
except Exception as 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:
return False
try:
from yt_dlp import YoutubeDL
import requests # Use requests instead of urlopen
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = {
'quiet': True,
'skip_download': True,
'force_generic_extractor': False,
'no_warnings': True,
'extract_flat': False
"quiet": True,
"skip_download": True,
"force_generic_extractor": False,
"no_warnings": True,
"extract_flat": False,
}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
thumbnails = info.get('thumbnails', [])
thumbnails = info.get("thumbnails", [])
if not thumbnails:
raise ValueError("No thumbnails available")
thumbnail_url = max(
thumbnails,
key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0)
).get('url')
key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0),
).get("url")
if not thumbnail_url:
raise ValueError("Failed to extract thumbnail URL")
@@ -400,13 +402,13 @@ class VideoInfoMixin:
response.raise_for_status()
# Save the thumbnail
thumb_dir = os.path.join(path, 'Thumbnails')
os.makedirs(thumb_dir, exist_ok=True)
thumb_dir = Path(path).joinpath("Thumbnails")
thumb_dir.mkdir(exist_ok=True)
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)
logger.info(f"Thumbnail saved to: {thumbnail_path}")
@@ -419,6 +421,6 @@ class VideoInfoMixin:
self.signals.update_status.emit(error_msg)
return False
def sanitize_filename(self, name):
def sanitize_filename(self, name) -> str:
"""Clean filename for filesystem safety"""
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)