diff --git a/main.py b/main.py
index 00cb8dc..cfbb542 100644
--- a/main.py
+++ b/main.py
@@ -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,28 +18,29 @@ 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")
yt_dlp_path = setup_ytdlp()
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__':
- main()
\ No newline at end of file
+
+if __name__ == "__main__":
+ main()
diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py
index a65222c..e923dff 100644
--- a/src/core/ytsage_downloader.py
+++ b/src/core/ytsage_downloader.py
@@ -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,205 +80,136 @@ 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
-
- 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
+ deleted_count = [0, 0]
+
+ def safe_delete(path: Path) -> bool:
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)}")
+ path.unlink(missing_ok=True)
+ logger.debug(f"Deleted subtitle file: {path.name}")
+ return True
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}")
-
+ logger.error(f"Error deleting subtitle file {path}: {e}")
+ return False
+
+ try:
+ # --- 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:
info = ydl.extract_info(self.url, download=False)
-
+
# 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}")
else:
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
except Exception as e:
logger.debug(f"Error checking if format is audio-only: {e}")
-
+
# For audio-only formats, don't try to merge with video
if is_audio_format:
cmd.extend(["-f", clean_format_id])
@@ -258,7 +217,7 @@ class DownloadThread(QThread):
else:
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
logger.debug(f"Using video format selection with audio: {clean_format_id}+bestaudio/best")
-
+
# Determine output format based on the selected format ID - only for video formats
if not is_audio_format:
try:
@@ -266,24 +225,24 @@ 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:
logger.debug(f"Detected format extension: {format_ext}")
# Ensure output matches the selected format - only for video formats
@@ -296,139 +255,136 @@ class DownloadThread(QThread):
# If no specific format ID, use resolution-based sorting (-S)
res_value = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified
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')
-
- cmd.extend(["-o", output_template])
-
+ output_template = self.path.joinpath("%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s")
+
+ cmd.extend(["-o", output_template.as_posix()])
+
# Add common options
cmd.append("--force-overwrites")
-
+
# Add playlist items if specified
if self.is_playlist and self.playlist_items:
cmd.extend(["--playlist-items", self.playlist_items])
-
+
# Add subtitle options if subtitles are selected
if self.subtitle_langs:
# Subtitles work with both audio-only and video formats
# For audio-only formats, subtitles will be downloaded as separate files
cmd.append("--write-subs")
-
+
# Get language codes from subtitle selections
lang_codes = []
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}")
-
+
if lang_codes:
cmd.extend(["--sub-langs", ",".join(lang_codes)])
cmd.append("--write-auto-subs") # Include auto-generated subtitles
-
+
# Only embed subtitles if merge is enabled
if self.merge_subs:
cmd.append("--embed-subs")
-
+
# Add SponsorBlock if enabled
if self.enable_sponsorblock and self.sponsorblock_categories:
cmd.append("--sponsorblock-remove")
cmd.append(",".join(self.sponsorblock_categories))
-
+
# Add description saving if enabled
if self.save_description:
cmd.append("--write-description")
-
+
# Add chapters embedding if enabled
if self.embed_chapters:
cmd.append("--embed-chapters")
-
+
# Add cookies if specified
if self.cookie_file:
cmd.extend(["--cookies", self.cookie_file])
-
+
# Add rate limit if specified
if self.rate_limit:
cmd.extend(["-r", self.rate_limit])
-
+
# Add download section if specified
if self.download_section:
cmd.extend(["--download-sections", self.download_section])
-
+
# Add force keyframes option if enabled
if self.force_keyframes:
cmd.append("--force-keyframes-at-cuts")
-
+
logger.debug(f"Added download section: {self.download_section}, Force keyframes: {self.force_keyframes}")
-
+
# Add the URL as the final argument
cmd.append(self.url)
-
+
return cmd
-
- def run(self):
+
+ def run(self) -> None:
try:
logger.debug("Starting download thread")
-
+
# First check if file already exists using original method
existing_file = self.check_file_exists()
if existing_file:
logger.debug(f"File exists, emitting signal: {existing_file}")
self.file_exists_signal.emit(existing_file)
return
-
+
logger.debug("No existing file found, proceeding with download")
-
+
# Get initial list of subtitle files to compare later
self.initial_subtitle_files = set()
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}")
-
+
if self.use_direct_command:
# Use direct CLI command instead of Python API
self._run_direct_command()
else:
# Original method using Python API - code left for reference
self._run_python_api()
-
+
except Exception as e:
# 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()
cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd)
logger.debug(f"Executing command: {cmd_str}")
-
+
self.status_signal.emit("🚀 Starting download...")
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,
stdout=subprocess.PIPE,
@@ -436,37 +392,39 @@ 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()
self.status_signal.emit("Download cancelled")
return
-
+
# Wait if paused
while self.paused and not self.cancelled:
time.sleep(0.1)
-
+
# Parse the line for download progress and status updates
self._parse_output_line(line)
-
+
# Wait for process to complete
return_code = self.process.wait()
-
+
# 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:
self.progress_signal.emit(100)
self.status_signal.emit("✅ Download completed!")
-
+
# Clean up subtitle files if they were merged, with a small delay
# to ensure the embedding process has completed
if self.merge_subs:
@@ -475,7 +433,7 @@ class DownloadThread(QThread):
self.status_signal.emit("✅ Download completed! Cleaning up...")
time.sleep(3) # Increased delay to 3 seconds
self.cleanup_subtitle_files()
-
+
self.finished_signal.emit()
else:
# Check if it was cancelled
@@ -484,217 +442,223 @@ 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()
-
+
except Exception as e:
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}")
- # Format IDs for audio typically have different patterns
+ # Format IDs for audio typically have different patterns
# (like 140, 251 for audio vs 137, 248 for video)
# 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:
self.status_signal.emit(f"⏬ Downloading video...")
return
-
+
elif "Downloading audio" in line:
self.status_signal.emit(f"⏬ Downloading audio...")
return
-
+
# 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))
self.progress_signal.emit(percent)
except (ValueError, IndexError):
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
status = f"Speed: {speed_str} | ETA: {eta_str}"
self.update_details.emit(status)
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()
-
- if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
+ ext = Path(filename).suffix.lower()
+
+ 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")
-
+
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:
self.status_signal.emit("✅ Download completed!")
else:
self.status_signal.emit("✅ Download completed!")
-
- self.update_details.emit("") # Clear details label on completion
-
- def _run_python_api(self):
+
+ self.update_details.emit("") # Clear details label on completion
+
+ 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:
try:
self.process.terminate()
except Exception:
- pass
\ No newline at end of file
+ pass
diff --git a/src/core/ytsage_ffmpeg.py b/src/core/ytsage_ffmpeg.py
index 4a423f2..c932d10 100644
--- a/src/core/ytsage_ffmpeg.py
+++ b/src/core/ytsage_ffmpeg.py
@@ -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))
-
- with open(dest_path, 'wb') as f:
+ total_size = int(response.headers.get("content-length", 0))
+
+ 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,17 +58,18 @@ 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
response = requests.get(expected_hash_url, timeout=10)
response.raise_for_status()
expected_hash = response.text.strip().split()[0] # Get just the hash part
-
+
# Calculate actual hash
actual_hash = get_file_sha256(file_path)
-
+
# Compare hashes
if actual_hash.lower() == expected_hash.lower():
logger.info("SHA-256 verification successful!")
@@ -75,20 +83,23 @@ def verify_sha256(file_path, expected_hash_url):
logger.info(f"⚠️ SHA-256 verification error: {str(e)}")
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,150 +107,159 @@ 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'],
- capture_output=True,
- text=True,
+ ["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
except Exception as e:
logger.error(f"Error finding ffmpeg in PATH: {e}")
-
+
# 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')
-
- if os.path.exists(ffmpeg_exe):
+ ffmpeg_exe = Path(ffmpeg_install_path).joinpath("ffmpeg")
+
+ 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')
-
- if os.path.exists(ffmpeg_exe):
+ ffmpeg_exe = Path(ffmpeg_path).joinpath("ffmpeg")
+
+ 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()
-
+
# Check if already installed
if check_ffmpeg_installed():
logger.info("FFmpeg is already installed!")
return True
-
+
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
else:
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
use_7zip = False
-
+
# 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,56 +294,62 @@ 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():
raise Exception("FFmpeg installation verification failed")
-
+
return True
except Exception as e:
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")
-
+
# Verify installation
if not check_ffmpeg_installed():
raise Exception("FFmpeg installation verification failed")
-
+
return True
except Exception as e:
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}")
- return False
\ No newline at end of file
+ logger.info(f"Unsupported operating system: {OS_NAME}")
+ return False
diff --git a/src/core/ytsage_logging.py b/src/core/ytsage_logging.py
index 6e050ae..7a17589 100644
--- a/src/core/ytsage_logging.py
+++ b/src/core/ytsage_logging.py
@@ -5,71 +5,82 @@ This module provides centralized logging configuration for the entire YTSage app
It replaces the inefficient print statements with structured logging using loguru.
"""
-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()
def setup_logging():
"""
Configure loguru logging for YTSage application.
-
+
Sets up multiple log levels and outputs:
- Console output for INFO and above
- - File output for DEBUG and above
+ - File output for DEBUG and above
- Separate error log file for ERROR and above
"""
-
+
if not LOGURU_AVAILABLE:
return logger
-
+
# Remove default logger to avoid duplicate output
try:
logger.remove()
except Exception:
pass
-
+
# 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:
log_dir.mkdir(parents=True, exist_ok=True)
@@ -80,11 +91,11 @@ def setup_logging():
log_dir.mkdir(exist_ok=True)
except Exception:
pass # If we still can't create it, we'll just log to console
-
+
# Console handler - INFO and above, with colors
# Check if stdout is available (it might be None in PyInstaller windowed apps)
stdout_available = sys.stdout is not None
-
+
if stdout_available:
try:
logger.add(
@@ -92,7 +103,7 @@ def setup_logging():
level="INFO",
format="
"
- 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.
"
- f"Please choose an option below:")
- info_label.setAlignment(Qt.AlignCenter)
+ # os_name logic moved to src\utils\ytsage_constants.py
+
+ info_label = QLabel(
+ f"YTSage requires yt-dlp to download videos.
"
+ 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.
"
+ 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)
-
+
# Radio buttons with minimal spacing
option_widget = QWidget()
option_layout = QVBoxLayout(option_widget)
option_layout.setSpacing(8)
option_layout.setContentsMargins(0, 0, 0, 0)
-
+
self.auto_radio = QRadioButton("Download automatically (Recommended)")
self.auto_radio.setChecked(True)
self.manual_radio = QRadioButton("Select path manually")
-
+
option_layout.addWidget(self.auto_radio)
option_layout.addWidget(self.manual_radio)
layout.addWidget(option_widget)
-
+
# Progress bar with proper sizing
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,61 +227,62 @@ 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)
-
+
# Add stretch to push buttons to bottom
layout.addStretch()
-
+
# Button layout with improved spacing
button_layout = QHBoxLayout()
button_layout.setSpacing(15)
button_layout.setContentsMargins(0, 10, 0, 0) # Add top margin for buttons
-
+
self.setup_button = QPushButton("Setup yt-dlp")
self.setup_button.clicked.connect(self.setup_ytdlp)
-
+
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.reject)
-
+
button_layout.addWidget(self.setup_button)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
-
+
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)
-
+
if success:
self.status_label.setText("yt-dlp was successfully installed!")
self.setup_complete.emit(result)
@@ -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,57 +348,42 @@ 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()}")
-
+
if result.returncode == 0:
# 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
shutil.copy2(file_path, target_path)
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")
-
+
# Return the path of the copied file
self.status_label.setText(f"yt-dlp successfully copied to {target_path}")
logger.debug(f"Emitting setup_complete signal with path: {target_path}")
@@ -430,10 +392,11 @@ class YtdlpSetupDialog(QDialog):
except Exception as copy_error:
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.
@@ -606,10 +558,11 @@ def get_yt_dlp_path():
if ytdlp_path:
logger.info(f"Using yt-dlp from: {ytdlp_path}")
return ytdlp_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):
"""
@@ -619,33 +572,33 @@ def setup_ytdlp(parent_widget=None):
"""
logger.debug("Starting yt-dlp setup dialog")
dialog = YtdlpSetupDialog(parent_widget)
-
+
# 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
-
+
# Connect to the setup_complete signal
dialog.setup_complete.connect(on_setup_complete)
-
+
# Show the dialog
result = dialog.exec()
- logger.debug(f"Dialog result: {result} (Accepted={QDialog.Accepted})")
-
- if result == QDialog.Accepted:
+ logger.debug(f"Dialog result: {result} (Accepted={QDialog.DialogCode.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,20 +606,21 @@ 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
-
+
# Something went wrong, show an error message
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,12 +639,13 @@ 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:
logger.debug("User cancelled the setup dialog")
-
+
# User cancelled or setup failed, return the fallback command
logger.debug("Returning fallback command 'yt-dlp'")
- return "yt-dlp"
\ No newline at end of file
+ return "yt-dlp"
diff --git a/src/gui/dialogs/__init__.py b/src/gui/dialogs/__init__.py
deleted file mode 100644
index a3a14b7..0000000
--- a/src/gui/dialogs/__init__.py
+++ /dev/null
@@ -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'
-]
diff --git a/src/gui/ytsage_gui_dialogs.py b/src/gui/ytsage_gui_dialogs.py
deleted file mode 100644
index a925021..0000000
--- a/src/gui/ytsage_gui_dialogs.py
+++ /dev/null
@@ -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'
-]
diff --git a/src/gui/ytsage_gui_dialogs/__init__.py b/src/gui/ytsage_gui_dialogs/__init__.py
new file mode 100644
index 0000000..013d97b
--- /dev/null
+++ b/src/gui/ytsage_gui_dialogs/__init__.py
@@ -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",
+]
diff --git a/src/gui/dialogs/ytsage_dialogs_base.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
similarity index 80%
rename from src/gui/dialogs/ytsage_dialogs_base.py
rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
index e8cb556..5f67b27 100644
--- a/src/gui/dialogs/ytsage_dialogs_base.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
@@ -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,17 +55,17 @@ 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
self.setMaximumSize(500, 480) # Reasonable maximum size
-
+
# Set window flags to make dialog independent of parent movement
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
-
+
layout = QVBoxLayout(self)
layout.setSpacing(15) # Reduced spacing
layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
@@ -84,7 +90,7 @@ class AboutDialog(QDialog):
button_layout = QHBoxLayout()
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
button_box.accepted.connect(self.accept)
-
+
# Center the button
button_layout.addStretch()
button_layout.addWidget(button_box)
@@ -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("YTSage")
+ title_label = QLabel(
+ "YTSage"
+ )
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
- version_label = QLabel(f"Version {getattr(self.parent, 'version', '4.7.0')}")
+ version_label = QLabel(
+ f"Version {getattr(self._parent, 'version', '4.7.0')}"
+ )
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version_label)
@@ -158,49 +170,56 @@ class AboutDialog(QDialog):
# Author and Links - compact single line
info_layout = QHBoxLayout()
info_layout.setSpacing(15)
-
- author_label = QLabel("By: oop7")
+
+ author_label = QLabel(
+ "By: oop7"
+ )
author_label.setOpenExternalLinks(True)
info_layout.addWidget(author_label)
- repo_label = QLabel("GitHub: YTSage")
+ repo_label = QLabel(
+ "GitHub: YTSage"
+ )
repo_label.setOpenExternalLinks(True)
info_layout.addWidget(repo_label)
-
+
# Center the info layout
info_container = QHBoxLayout()
info_container.addStretch()
info_container.addLayout(info_layout)
info_container.addStretch()
-
+
layout.addLayout(info_container)
-
+
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
main_layout.setContentsMargins(15, 10, 15, 10)
-
+
# Create header with title and refresh button on same line
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 5)
-
+
# System Information title
title_label = QLabel("System Information")
- title_label.setStyleSheet("""
+ title_label.setStyleSheet(
+ """
QLabel {
color: #ffffff;
font-size: 14px;
@@ -208,16 +227,18 @@ class AboutDialog(QDialog):
padding: 0px;
margin: 0px;
}
- """)
+ """
+ )
header_layout.addWidget(title_label)
-
+
# Add stretch to push refresh button to the right
header_layout.addStretch()
-
+
# 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,99 +257,103 @@ 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)
-
+
main_layout.addLayout(header_layout)
-
+
# Compact status grid layout
self.status_container = QVBoxLayout()
self.status_container.setSpacing(6) # Tight spacing
self.status_container.setContentsMargins(0, 0, 0, 0)
-
+
main_layout.addLayout(self.status_container)
-
+
# Show loading message initially
self._show_loading_message()
-
+
# Populate system information asynchronously
QTimer.singleShot(100, self.update_system_info)
-
+
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
item_height = 50 if path_text else 35
item_widget.setMaximumHeight(item_height)
item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
-
+
# Main layout
item_layout = QVBoxLayout(item_widget)
item_layout.setContentsMargins(8, 4, 8, 4)
item_layout.setSpacing(2)
-
+
# First row: Icon, name, status, version
first_row = QHBoxLayout()
first_row.setSpacing(8)
-
+
# Icon and name - compact
name_label = QLabel(f"{icon} {name}")
name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
name_label.setMinimumWidth(80)
first_row.addWidget(name_label)
-
+
# Status - compact
status_label = QLabel(status_text)
status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
status_label.setMinimumWidth(70)
first_row.addWidget(status_label)
-
+
# Version info - improved readability
version_info = version_text
if cache_status:
version_info += cache_status
-
+
version_label = QLabel(version_info)
version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
version_label.setWordWrap(False)
first_row.addWidget(version_label)
-
+
# Add stretch to push everything left
first_row.addStretch()
-
+
item_layout.addLayout(first_row)
-
+
# Second row: Path (if provided)
if path_text:
path_label = QLabel(f"📁 {path_text}")
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
path_label.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,86 +378,101 @@ class AboutDialog(QDialog):
# yt-dlp Status - compact version with path
ytdlp_found = check_ytdlp_installed()
- ytdlp_status_text = "✓ Detected" if ytdlp_found else "✗ Missing"
+ ytdlp_status_text = (
+ "✓ Detected" if ytdlp_found else "✗ Missing"
+ )
ytdlp_version = get_ytdlp_version()
-
+
# Get yt-dlp path
ytdlp_path = get_yt_dlp_path() if ytdlp_found else None
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
-
+
# 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" ({cache_time})" # 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 = "✓ Detected" if ffmpeg_found else "✗ Missing"
+ ffmpeg_status_text = (
+ "✓ Detected"
+ if ffmpeg_found
+ else "✗ Missing"
+ )
ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
-
+
# Get FFmpeg path
ffmpeg_path_text = None
if ffmpeg_found:
ffmpeg_path = get_ffmpeg_path()
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" ({cache_time})" # 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)
-
+
def run(self):
success = refresh_version_cache(force=True)
self.finished.emit(success)
-
+
self.refresh_thread = RefreshThread()
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)
-
+
if success:
self.update_system_info()
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()
diff --git a/src/gui/dialogs/ytsage_dialogs_custom.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
similarity index 73%
rename from src/gui/dialogs/ytsage_dialogs_custom.py
rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
index 68a4ef7..23103f2 100644
--- a/src/gui/dialogs/ytsage_dialogs_custom.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
@@ -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,43 +273,38 @@ 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
self.tab_widget = QTabWidget()
layout.addWidget(self.tab_widget)
-
+
# === Cookies Tab ===
cookies_tab = QWidget()
cookies_layout = QVBoxLayout(cookies_tab)
-
+
# Help text
help_text = QLabel(
"Select the Netscape-format cookies file for logging in.\n"
@@ -287,26 +318,26 @@ 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")
self.browse_button.clicked.connect(self.browse_cookie_file)
path_layout.addWidget(self.browse_button)
cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
-
+
# Status indicator for cookies
self.cookie_status = QLabel("")
self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
cookies_layout.addWidget(self.cookie_status)
-
+
cookies_layout.addStretch()
-
+
# === Custom Command Tab ===
command_tab = QWidget()
command_layout = QVBoxLayout(command_tab)
-
+
# Help text
cmd_help_text = QLabel(
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
@@ -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,21 +412,23 @@ class CustomOptionsDialog(QDialog):
font-family: Consolas, monospace;
font-size: 12px;
}
- """)
+ """
+ )
command_layout.addWidget(self.log_output)
-
+
# Add tabs to the tab widget
self.tab_widget.addTab(cookies_tab, "Login with Cookies")
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,65 +472,71 @@ 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
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,50 +564,59 @@ 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)
-
+
# Help text explaining the feature
help_text = QLabel(
"Download only specific parts of a video by specifying time ranges.\n"
@@ -570,11 +625,11 @@ class TimeRangeDialog(QDialog):
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
layout.addWidget(help_text)
-
+
# Time range section
time_group = QGroupBox("Time Range")
time_layout = QVBoxLayout()
-
+
# Start time row
start_layout = QHBoxLayout()
start_layout.addWidget(QLabel("Start Time:"))
@@ -582,7 +637,7 @@ class TimeRangeDialog(QDialog):
self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
start_layout.addWidget(self.start_time_input)
time_layout.addLayout(start_layout)
-
+
# End time row
end_layout = QHBoxLayout()
end_layout.addWidget(QLabel("End Time:"))
@@ -590,14 +645,15 @@ class TimeRangeDialog(QDialog):
self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
end_layout.addWidget(self.end_time_input)
time_layout.addLayout(end_layout)
-
+
time_group.setLayout(time_layout)
layout.addWidget(time_group)
-
+
# 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,24 +691,26 @@ class TimeRangeDialog(QDialog):
padding: 8px;
font-family: Consolas, monospace;
}
- """)
+ """
+ )
preview_layout.addWidget(self.preview_label)
preview_group.setLayout(preview_layout)
layout.addWidget(preview_group)
-
+
# Connect signals for live preview updates
self.start_time_input.textChanged.connect(self.update_preview)
self.end_time_input.textChanged.connect(self.update_preview)
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,15 +747,16 @@ 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()
-
+
if start and end:
time_range = f"*{start}-{end}"
elif start:
@@ -704,21 +765,21 @@ class TimeRangeDialog(QDialog):
time_range = f"*-{end}"
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()
-
+
if not start and not end:
return None # No selection made
-
+
if start and end:
time_range = f"*{start}-{end}"
elif start:
@@ -727,9 +788,9 @@ class TimeRangeDialog(QDialog):
time_range = f"*-{end}"
else:
return None # Shouldn't happen but just in case
-
+
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()
diff --git a/src/gui/dialogs/ytsage_dialogs_ffmpeg.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
similarity index 79%
rename from src/gui/dialogs/ytsage_dialogs_ffmpeg.py
rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
index a6f9314..0265e9b 100644
--- a/src/gui/dialogs/ytsage_dialogs_ffmpeg.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
@@ -3,57 +3,52 @@ 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):
success = auto_install_ffmpeg()
-
+
# Process captured output and emit progress signals
for line in output.getvalue().splitlines():
self.progress.emit(line)
-
+
self.finished.emit(success)
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)
-
+
# Set the window icon to match the main app
if parent and parent.windowIcon():
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,17 +84,18 @@ class FFmpegCheckDialog(QDialog):
font-size: 11px;
line-height: 1.2;
}
- """)
+ """
+ )
self.progress_label.hide()
layout.addWidget(self.progress_label)
-
+
# Add minimal stretch - just enough to push buttons down slightly
layout.addSpacing(10)
# Buttons container - simple approach that should work
button_layout = QHBoxLayout()
button_layout.setSpacing(15) # Simple spacing
-
+
# Install button
self.install_btn = QPushButton("Install FFmpeg")
self.install_btn.clicked.connect(self.start_installation)
@@ -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,16 +143,17 @@ 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)
-
+
# Check if FFmpeg is already installed
if check_ffmpeg_installed():
self.message_label.setText("FFmpeg is already installed!")
@@ -167,7 +163,7 @@ class FFmpegCheckDialog(QDialog):
self.manual_btn.hide()
self.close_btn.setEnabled(True)
return
-
+
self.message_label.setText("Installing FFmpeg... Please wait")
self.progress_label.show()
@@ -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.")
@@ -190,5 +186,5 @@ class FFmpegCheckDialog(QDialog):
self.progress_label.setText("Please try using the manual installation guide instead.")
self.install_btn.setEnabled(True)
self.manual_btn.setEnabled(True)
-
+
self.close_btn.setEnabled(True)
diff --git a/src/gui/dialogs/ytsage_dialogs_selection.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py
similarity index 77%
rename from src/gui/dialogs/ytsage_dialogs_selection.py
rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py
index e6bb07a..876c606 100644
--- a/src/gui/dialogs/ytsage_dialogs_selection.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py
@@ -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)
@@ -233,10 +253,11 @@ class PlaylistSelectionDialog(QDialog):
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
-
+
# 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,10 +332,10 @@ 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)
-
+
# Clear existing checkboxes if any (e.g., if repopulating)
while self.list_layout.count():
child = self.list_layout.takeAt(0)
@@ -317,18 +344,19 @@ class PlaylistSelectionDialog(QDialog):
self.checkboxes.clear()
for index, entry in enumerate(self.playlist_entries):
- if not entry:
+ if not entry:
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,132 +374,126 @@ 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)
class SponsorBlockCategoryDialog(QDialog):
"""Dialog for selecting SponsorBlock categories to remove from videos."""
-
+
# 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)
self.setMinimumHeight(400)
-
+
# Set the window icon to match the main app
if parent:
self.setWindowIcon(parent.windowIcon())
-
+
self.previously_selected = set(previously_selected) if previously_selected else set()
self.checkboxes = {}
-
+
self.init_ui()
self.apply_styling()
-
- def init_ui(self):
+
+ def init_ui(self) -> None:
layout = QVBoxLayout(self)
-
+
# Title and description
title_label = QLabel("SponsorBlock Categories")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
layout.addWidget(title_label)
-
+
desc_label = QLabel(
"Select which types of video segments to automatically remove during download.\n"
"SponsorBlock uses community-submitted data to identify these segments."
@@ -480,17 +502,17 @@ class SponsorBlockCategoryDialog(QDialog):
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
layout.addWidget(desc_label)
-
+
# Scroll area for categories
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setStyleSheet("QScrollArea { border: none; }")
-
+
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
scroll_layout.setContentsMargins(10, 0, 10, 0)
scroll_layout.setSpacing(8)
-
+
# Add category checkboxes
for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
# Create a container widget for each category
@@ -498,22 +520,23 @@ class SponsorBlockCategoryDialog(QDialog):
category_layout = QVBoxLayout(category_widget)
category_layout.setContentsMargins(0, 0, 0, 0)
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
if self.previously_selected:
# Use previously selected categories
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,61 +556,64 @@ 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)
-
+
category_layout.addWidget(checkbox)
category_layout.addWidget(desc_label)
-
+
self.checkboxes[category_id] = checkbox
scroll_layout.addWidget(category_widget)
-
+
scroll_layout.addStretch()
scroll_area.setWidget(scroll_widget)
layout.addWidget(scroll_area)
-
+
# Quick selection buttons
button_layout = QHBoxLayout()
-
+
select_defaults_btn = QPushButton("Select Defaults")
select_defaults_btn.clicked.connect(self.select_defaults)
select_defaults_btn.setStyleSheet(self._get_button_style())
-
+
select_all_btn = QPushButton("Select All")
select_all_btn.clicked.connect(self.select_all)
select_all_btn.setStyleSheet(self._get_button_style())
-
+
deselect_all_btn = QPushButton("Deselect All")
deselect_all_btn.clicked.connect(self.deselect_all)
deselect_all_btn.setStyleSheet(self._get_button_style())
-
+
button_layout.addWidget(select_defaults_btn)
button_layout.addWidget(select_all_btn)
button_layout.addWidget(deselect_all_btn)
button_layout.addStretch()
-
+
layout.addLayout(button_layout)
-
+
# Dialog buttons
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
-
+
# Style the dialog buttons
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 {
@@ -605,10 +631,11 @@ class SponsorBlockCategoryDialog(QDialog):
background-color: #555555;
}
"""
-
- 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,33 +646,34 @@ 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():
if checkbox.isChecked():
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 ""
diff --git a/src/gui/dialogs/ytsage_dialogs_settings.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
similarity index 82%
rename from src/gui/dialogs/ytsage_dialogs_settings.py
rename to src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
index dfe9e90..0fc0ef1 100644
--- a/src/gui/dialogs/ytsage_dialogs_settings.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
@@ -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,183 +302,187 @@ 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
-
+
# Get latest version from PyPI
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
response.raise_for_status()
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)
self.setMinimumHeight(300)
-
+
# Set the window icon to match the main app
if parent:
self.setWindowIcon(parent.windowIcon())
-
+
self.init_ui()
self.load_current_settings()
self.apply_styling()
-
- def init_ui(self):
+
+ def init_ui(self) -> None:
layout = QVBoxLayout(self)
-
+
# Title
title_label = QLabel("