diff --git a/Icon/icon.png b/Icon/icon.png
new file mode 100644
index 0000000..8b32d05
Binary files /dev/null and b/Icon/icon.png differ
diff --git a/README.md b/README.md
index 6a3e526..0fd828b 100644
--- a/README.md
+++ b/README.md
@@ -27,12 +27,13 @@ Download videos in any quality, extract audio, fetch subtitles, and more.
-| Core Features | Advanced Features | Extra Features |
-|--------------|-------------------|----------------|
-| 🎥 Smart Video Quality | 🚫 SponsorBlock Integration | 💾 Save Download Path |
-| 🎵 Audio Extraction | 📝 Subtitle Support & Filtering | 🔄 Auto-Updates |
-| 📊 Real-time Progress | ⚙️ Custom Commands | 🛠️ FFmpeg Tools |
-| 📋 Playlist Support | 🖼️ Save thumbnail | ⚠️ Error Handling |
+| Core Features | Advanced Features | Extra Features |
+|-----------------------------------|-----------------------------------------|------------------------------------|
+| 🎥 Format Table | 🚫 SponsorBlock Integration | 💾 Save Download Path |
+| 🎵 Audio Extraction | 📝 Multi-Subtitle Select & Merge | 🔄 Auto-Updates |
+| ✨ Simple UI | 💾 Save Description | 🛠️ FFmpeg/yt-dlp Detection |
+| 📋 Playlist Support | 🖼️ Save thumbnail | ⚙️ Custom Commands |
+| 🖼️ Playlist Selector | 🚀 Speed Limiter | 🍪 Login with Cookies |
@@ -40,7 +41,7 @@ Download videos in any quality, extract audio, fetch subtitles, and more.
### Quick Install (Recommended)
```bash
-pip install YTSage
+pip install ytsage
```
```bash
# Run the application
@@ -52,10 +53,12 @@ ytsage
### Pre-built Executables
- 🪟 Windows: `YTSage.exe`
+- 🪟 Windows: `YTSage-ffmpeg.exe` (Includes FFmpeg)
+- 🐧 Linux: `YTSage-x86_64.AppImage`
+- 🍎 macOS: `YTSage-macOS-app.zip`
- 🍎 macOS: `YTSage.dmg`
-- 🐧 Linux: `YTSage.AppImage`
-### Manual Installation
+### Manual Installation from Source
```bash
# Clone repository
git clone https://github.com/oop7/YTSage.git
@@ -88,8 +91,8 @@ python main.py
 |
- | Audio Format Selection with Save Thumbnail |
- Subtitle Options merged with Remove Sponsor Segments |
+ Audio Format |
+ Subtitle Options |
@@ -107,9 +110,10 @@ python main.py
- `Audio Only` for audio extraction
5. **Choose Options:**
- Enable subtitles & select language
- - Enable subtitle embedding
+ - Enable subtitle merge
- Save thumbnail
- Remove sponsor segments
+ - Save description
6. **Select Output Directory**
7. **Click "Download"**
@@ -120,8 +124,9 @@ python main.py
1. **Paste Playlist URL**
2. **Click "Analyze"**
-3. **Select Best Quality**
-4. **Click "Download"**
+3. **Select videos from the playlist selector (optional, defaults to all)**
+4. **Choose desired format/quality**
+5. **Click "Download"**
> 💡 The application automatically handles the download queue
@@ -132,9 +137,18 @@ python main.py
- **Quality Selection:** Choose the highest resolution for best quality
- **Subtitle Options:** Filter languages and embed into video
-- **SponsorBlock:** Automatically skip promotional content
- **Custom Commands:** Access advanced yt-dlp features
-- **Output Directory:** Ensure sufficient storage space
+- **Save Description:** Save the description of the video
+- **Save Thumbnail:** Save the thumbnail of the video
+- **Remove Sponsor Segments:** Remove sponsor segments from the video
+- **Speed Limiter:** Limit the download speed
+- **Login with Cookies:** Login with cookies
+- **Save Download Path:** Save the download path
+- **Update yt-dlp:** Update yt-dlp
+- **FFmpeg/yt-dlp Detection:** Automatically detect FFmpeg/yt-dlp
+- **Custom Commands:** Access advanced yt-dlp features
+
+
@@ -148,6 +162,7 @@ Pillow
requests
FFmpeg
packaging
+markdown
```
## 👥 Contributing
@@ -191,6 +206,9 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
| [PySide6](https://wiki.qt.io/Qt_for_Python) | GUI Framework |
| [FFmpeg](https://ffmpeg.org/) | Media Processing |
| [Pillow](https://python-pillow.org/) | Image Processing |
+| [requests](https://requests.readthedocs.io/) | HTTP Requests |
+| [packaging](https://packaging.python.org/) | Packaging |
+| [markdown](https://python-markdown.github.io/) | Markdown Processing |
diff --git a/requirements.txt b/requirements.txt
index b195626..f5fd815 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,5 @@ PySide6
yt-dlp
requests
pillow
-packaging
\ No newline at end of file
+packaging
+markdown
\ No newline at end of file
diff --git a/ytsage_downloader.py b/ytsage_downloader.py
index e9b88a7..5b33505 100644
--- a/ytsage_downloader.py
+++ b/ytsage_downloader.py
@@ -1,8 +1,10 @@
-from PySide6.QtCore import QThread, Signal, QObject
+from PySide6.QtCore import QThread, Signal, QObject, QProcess, QTimer
import yt_dlp # Keep yt_dlp import here - only downloader uses it.
import time
import os
import re
+import subprocess # For direct CLI command execution
+import shlex # For safely parsing command arguments
from pathlib import Path
class SignalManager(QObject):
@@ -16,20 +18,32 @@ 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
- def __init__(self, url, path, format_id, subtitle_lang=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None):
+ def __init__(self, url, path, format_id, subtitle_langs=None, is_playlist=False, merge_subs=False, enable_sponsorblock=False, resolution='', playlist_items=None, save_description=False, cookie_file=None, rate_limit=None):
super().__init__()
self.url = url
self.path = path
self.format_id = format_id
- self.subtitle_lang = subtitle_lang
+ 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.resolution = resolution
self.playlist_items = playlist_items
+ self.save_description = save_description
+ self.cookie_file = cookie_file
+ self.rate_limit = rate_limit
self.paused = False
self.cancelled = False
+ self.process = None
+ 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
def cleanup_partial_files(self):
"""Delete any partial files including .part and unmerged format-specific files"""
@@ -46,14 +60,99 @@ class DownloadThread(QThread):
except Exception as e:
self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
+ def cleanup_subtitle_files(self):
+ """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
+ print(f"DEBUG: Deleted tracked subtitle file: {os.path.basename(subtitle_file)}")
+ except Exception as e:
+ print(f"Error deleting subtitle file {subtitle_file}: {str(e)}")
+
+ print(f"DEBUG: Deleted {deleted_count} of {len(self.subtitle_files)} tracked subtitle files")
+
+ # Method 2: Find newly created subtitle files by comparing with initial set
+ try:
+ new_subtitle_files = set()
+ for root, dirs, files in os.walk(self.path):
+ for file in files:
+ if file.endswith('.vtt') or file.endswith('.srt'):
+ full_path = os.path.join(root, file)
+ if full_path not in self.initial_subtitle_files:
+ new_subtitle_files.add(full_path)
+
+ if new_subtitle_files:
+ print(f"DEBUG: 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
+ print(f"DEBUG: Deleted new subtitle file: {os.path.basename(subtitle_file)}")
+ except Exception as e:
+ print(f"Error deleting new subtitle file {subtitle_file}: {str(e)}")
+ except Exception as e:
+ print(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
+ print(f"DEBUG: Deleted subtitle file by timestamp: {filename}")
+ except Exception as e:
+ print(f"Error deleting subtitle file {filename}: {str(e)}")
+
+ print(f"DEBUG: Total subtitle files deleted: {deleted_count}")
+
+ except Exception as e:
+ print(f"Error cleaning subtitle files: {str(e)}")
+
def check_file_exists(self):
"""Check if the file already exists before downloading"""
try:
print("DEBUG: Starting file existence check")
- # Use yt-dlp to get the filename without downloading
- with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl:
+ # 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
+ }
+ if self.cookie_file:
+ ydl_opts_check['cookiefile'] = self.cookie_file
+
+ 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:
+ print("DEBUG: Failed to extract info during file existence check. Skipping check.")
+ return False # Proceed with download attempt
+
# Get the title and sanitize it for filename
title = info.get('title', 'video')
# Don't remove colons and other special characters yet
@@ -106,10 +205,147 @@ class DownloadThread(QThread):
traceback.print_exc()
return None
+ def _build_yt_dlp_command(self):
+ """Build the yt-dlp command line with all options for direct execution."""
+ cmd = ["yt-dlp"]
+
+ # 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
+
+ # Check if this is an audio-only format
+ is_audio_format = False
+ try:
+ ydl_opts = {
+ '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():
+ is_audio_format = True
+ print(f"DEBUG: Detected audio-only format for ID: {clean_format_id}")
+ break
+ except Exception as e:
+ print(f"DEBUG: 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])
+ print(f"DEBUG: Using audio-only format selection: {clean_format_id}")
+ else:
+ cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
+ print(f"DEBUG: 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:
+ format_ext = None
+ print(f"DEBUG: Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
+ ydl_opts = {
+ 'quiet': True,
+ 'no_warnings': True,
+ 'skip_download': True,
+ }
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ info = ydl.extract_info(self.url, download=False)
+ # 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')
+ 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')
+ break
+
+ if format_ext:
+ print(f"DEBUG: Detected format extension: {format_ext}")
+ # Ensure output matches the selected format - only for video formats
+ cmd.extend(["--merge-output-format", format_ext])
+ except Exception as e:
+ print(f"DEBUG: Error detecting format extension: {e}")
+ # If we can't determine the format, don't specify merge-output-format
+ pass
+ else:
+ # 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')
+
+ # 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])
+
+ # 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 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_codes.append(lang_code)
+ except Exception as e:
+ print(f"Warning: 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
+
+ # Add embedding if requested - only applies to video formats
+ if self.merge_subs:
+ cmd.append("--embed-subs")
+
+ # Add SponsorBlock if enabled
+ if self.enable_sponsorblock:
+ cmd.append("--sponsorblock-remove")
+ cmd.append("sponsor")
+
+ # Add description saving if enabled
+ if self.save_description:
+ cmd.append("--write-description")
+
+ # 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 the URL as the final argument
+ cmd.append(self.url)
+
+ return cmd
+
def run(self):
try:
print("DEBUG: Starting download thread")
- # First check if file already exists
+
+ # First check if file already exists using original method
existing_file = self.check_file_exists()
if existing_file:
print(f"DEBUG: File exists, emitting signal: {existing_file}")
@@ -117,216 +353,305 @@ class DownloadThread(QThread):
return
print("DEBUG: No existing file found, proceeding with download")
- class DebugLogger:
- def debug(self, msg):
- # Print all debug messages to help diagnose issues
- print(f"YT-DLP DEBUG: {msg}")
-
- # Check for file exists message - look for both patterns
- if "already exists" in msg or "has already been downloaded" in msg:
- print(f"FILE EXISTS DETECTED: {msg}")
- # Try to extract the filename
- import re
- match = re.search(r'File (.*?) already exists', msg)
- if not match:
- match = re.search(r'(.*?) has already been downloaded', msg)
-
- if match:
- filename = os.path.basename(match.group(1))
- self.thread.file_exists_signal.emit(filename)
- raise Exception("FileExistsError")
-
- # Add detection of post-processing messages
- if "Downloading" in msg:
- self.thread.status_signal.emit("⚡ Downloading video...")
- elif "Post-process" in msg or "Sponsorblock" in msg:
- self.thread.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
- self.thread.progress_signal.emit(99) # Keep progress bar at 99%
- elif any(x in msg.lower() for x in ['downloading webpage', 'downloading api']):
- self.thread.status_signal.emit("🔍 Fetching video information...")
- self.thread.progress_signal.emit(0)
- elif 'extracting' in msg.lower():
- self.thread.status_signal.emit("📦 Extracting video data...")
- self.thread.progress_signal.emit(0)
- elif 'downloading m3u8' in msg.lower():
- self.thread.status_signal.emit("🎯 Preparing video streams...")
- self.thread.progress_signal.emit(0)
-
- def warning(self, msg):
- print(f"YT-DLP WARNING: {msg}")
- self.thread.status_signal.emit(f"⚠️ Warning: {msg}")
- # Also check for file exists in warnings
- if "already exists" in msg or "has already been downloaded" in msg:
- print(f"FILE EXISTS DETECTED IN WARNING: {msg}")
- import re
- match = re.search(r'File (.*?) already exists', msg)
- if not match:
- match = re.search(r'(.*?) has already been downloaded', msg)
-
- if match:
- filename = os.path.basename(match.group(1))
- self.thread.file_exists_signal.emit(filename)
- raise Exception("FileExistsError")
-
- def error(self, msg):
- print(f"YT-DLP ERROR: {msg}")
- self.thread.status_signal.emit(f"❌ Error: {msg}")
- # Also check for file exists in errors
- if "already exists" in msg or "has already been downloaded" in msg:
- print(f"FILE EXISTS DETECTED IN ERROR: {msg}")
- import re
- match = re.search(r'File (.*?) already exists', msg)
- if not match:
- match = re.search(r'(.*?) has already been downloaded', msg)
-
- if match:
- filename = os.path.basename(match.group(1))
- self.thread.file_exists_signal.emit(filename)
- raise Exception("FileExistsError")
-
- def __init__(self, thread):
- self.thread = thread
-
- def progress_hook(d):
- if self.cancelled:
- raise Exception("Download cancelled by user")
-
- if d['status'] == 'downloading':
- while self.paused and not self.cancelled:
- time.sleep(0.1)
- continue
-
- try:
- downloaded_bytes = d.get('downloaded_bytes', 0)
- total_bytes = d.get('total_bytes', 0) or d.get('total_bytes_estimate', 0)
-
- if total_bytes:
- progress = (downloaded_bytes / total_bytes) * 100
- self.progress_signal.emit(progress)
-
- speed = d.get('speed', 0)
- if speed:
- speed_str = f"{speed/1024/1024:.1f} MB/s"
- else:
- speed_str = "N/A"
-
- eta = d.get('eta', 0)
- if eta:
- eta_str = f"{eta//60}:{eta%60:02d}"
- else:
- eta_str = "N/A"
-
- filename = os.path.basename(d.get('filename', ''))
- status = f"Speed: {speed_str} | ETA: {eta_str} | File: {filename}"
- self.status_signal.emit(status)
-
- except Exception as e:
- self.status_signal.emit("⚡ Downloading...")
-
- elif d['status'] == 'finished':
- if self.enable_sponsorblock:
- self.progress_signal.emit(99)
- self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
- else:
- self.progress_signal.emit(100)
- self.status_signal.emit("✅ Download completed!")
-
- # Get the extension from the format_id
- with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
- try:
- info = ydl.extract_info(self.url, download=False)
- selected_format = next(
- f for f in info['formats']
- if str(f.get('format_id', '')) == self.format_id
- )
- output_ext = selected_format.get('ext', 'mp4')
- except Exception as e:
- self.error_signal.emit(f"Failed to get video information: {str(e)}")
- return
-
- # Base yt-dlp options with resolution in filename
- output_template = '%(title)s_%(resolution)s.%(ext)s'
- if self.is_playlist:
- output_template = '%(playlist_title)s/%(title)s_%(resolution)s.%(ext)s'
-
- ydl_opts = {
- 'format': f'{self.format_id}+bestaudio/best',
- 'outtmpl': os.path.join(self.path, output_template),
- 'progress_hooks': [progress_hook],
- 'merge_output_format': 'mp4',
- 'logger': DebugLogger(self),
- 'postprocessors': [{
- 'key': 'FFmpegVideoConvertor',
- 'preferedformat': 'mp4'
- }],
- 'force_overwrites': True
- }
-
- # Add subtitle options if selected
- if self.subtitle_lang:
- lang_code = self.subtitle_lang.split(' - ')[0]
- is_auto = 'Auto-generated' in self.subtitle_lang
- ydl_opts.update({
- 'writesubtitles': True,
- 'subtitleslangs': [lang_code],
- 'writeautomaticsub': True,
- 'skip_manual_subs': is_auto,
- 'skip_auto_subs': not is_auto,
- 'embedsubtitles': self.merge_subs,
- })
-
- if self.merge_subs:
- ydl_opts['postprocessors'].extend([
- {
- 'key': 'FFmpegSubtitlesConvertor',
- 'format': 'srt',
- },
- {
- 'key': 'FFmpegEmbedSubtitle',
- 'already_have_subtitle': False,
- }
- ])
-
- # Add SponsorBlock options if enabled
- if self.enable_sponsorblock:
- ydl_opts['postprocessors'].extend([{
- 'key': 'SponsorBlock',
- 'categories': ['sponsor'],
- 'api': 'https://sponsor.ajay.app'
- }, {
- 'key': 'ModifyChapters',
- 'remove_sponsor_segments': ['sponsor'],
- 'sponsorblock_chapter_title': '[SponsorBlock]',
- 'force_keyframes': False
- }])
-
- # Add playlist items if specified
- if self.playlist_items:
- ydl_opts['playlist_items'] = self.playlist_items
-
- try:
- # Download the video
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
- ydl.download([self.url])
-
- if not self.cancelled:
- self.finished_signal.emit()
-
- # Clean up subtitle files after successful download
- if self.merge_subs:
- for filename in os.listdir(self.path):
- if filename.lower().endswith(('.vtt', '.srt', '.ass')):
- try:
- os.remove(os.path.join(self.path, filename))
- except Exception as e:
- self.error_signal.emit(f"Error deleting subtitle file: {str(e)}")
- except Exception as e:
- if str(e) == "Download cancelled by user":
- self.cleanup_partial_files()
- self.error_signal.emit("Download cancelled")
- else:
- self.error_signal.emit(f"Download failed: {str(e)}")
-
+ # 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))
+ print(f"DEBUG: Found {len(self.initial_subtitle_files)} existing subtitle files before download")
+ except Exception as e:
+ print(f"Warning: 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:
- self.error_signal.emit(f"Critical error: {str(e)}")
\ No newline at end of file
+ # 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):
+ """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)
+ print(f"DEBUG: 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
+
+ self.process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1, # Line buffered
+ universal_newlines=True,
+ creationflags=creation_flags # Add this flag
+ )
+
+ # Process output line by line to update progress
+ for line in iter(self.process.stdout.readline, ''):
+ 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()
+
+ 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:
+ # Add a significant delay to ensure ffmpeg has released all file handles
+ # and any post-processing is complete
+ 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
+ if self.cancelled:
+ self.status_signal.emit("Download cancelled")
+ 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):
+ """Parse yt-dlp command output to update progress and status."""
+ line = line.strip()
+ # print(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)
+ if dest_match:
+ try:
+ filepath = dest_match.group(1).strip()
+ self.current_filename = os.path.basename(filepath)
+ self.last_file_path = filepath # Store the full path for later cleanup
+ print(f"DEBUG: 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:
+ is_audio_download = True
+ # Check if the format ID is mentioned earlier in the line
+ format_match = re.search(r'Downloading format (\d+)', line)
+ if format_match:
+ format_id = format_match.group(1)
+ print(f"DEBUG: Detected format ID: {format_id}")
+ # 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()
+
+ # Check if this is explicitly an audio stream download
+ 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']:
+ self.status_signal.emit(f"⏬ Downloading video...")
+ # Audio file extensions
+ 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']:
+ self.status_signal.emit(f"⏬ Downloading subtitle...")
+ # Default case
+ else:
+ self.status_signal.emit(f"⏬ Downloading...")
+ except Exception as e:
+ print(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
+
+ # 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)
+ 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 it's a relative path, make it absolute based on current path
+ subtitle_file = os.path.join(self.path, subtitle_file)
+ self.subtitle_files.append(subtitle_file)
+ print(f"DEBUG: 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:
+ self.status_signal.emit("🔍 Fetching video information...")
+ self.progress_signal.emit(0)
+ elif 'Downloading API JSON' in line:
+ self.status_signal.emit("📋 Processing playlist data...")
+ self.progress_signal.emit(0)
+ elif 'Downloading m3u8 information' in line:
+ self.status_signal.emit("🎯 Preparing video streams...")
+ self.progress_signal.emit(0)
+ elif '[download] Downloading video ' in line:
+ self.status_signal.emit("⏬ Downloading video...")
+ elif '[download] Downloading audio ' in line:
+ self.status_signal.emit("⏬ Downloading audio...")
+ elif 'Downloading format' in line:
+ # Try to detect if it's audio or video format
+ if ' - audio only' in line:
+ self.status_signal.emit("⏬ Downloading audio...")
+ 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)
+ 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:
+ # 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_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_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)
+ print(f"Error parsing download details line: {line} -> {e}")
+ 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:
+ self.status_signal.emit("✨ Post-processing: Merging formats...")
+ self.progress_signal.emit(95)
+ elif 'SponsorBlock' in line:
+ self.status_signal.emit("✨ Post-processing: Removing sponsor segments...")
+ self.progress_signal.emit(97)
+ elif 'Deleting original file' in line:
+ self.progress_signal.emit(98)
+ elif 'has already been downloaded' in line:
+ # File already exists - extract filename
+ match = re.search(r'(.*?) has already been downloaded', line)
+ if match:
+ filename = os.path.basename(match.group(1))
+ # 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']:
+ self.status_signal.emit(f"⚠️ Video file already exists")
+ 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']:
+ 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:
+ print(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.progress_signal.emit(100)
+
+ # Show completion message based on file type
+ if self.current_filename:
+ ext = os.path.splitext(self.current_filename)[1].lower()
+
+ # Video file extensions
+ 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']:
+ self.status_signal.emit(f"✅ Audio download completed!")
+ # Subtitle file extensions
+ 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):
+ """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):
+ self.paused = True
+
+ def resume(self):
+ self.paused = False
+
+ def cancel(self):
+ 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
diff --git a/ytsage_gui_dialogs.py b/ytsage_gui_dialogs.py
index 5c1a69c..592b7cc 100644
--- a/ytsage_gui_dialogs.py
+++ b/ytsage_gui_dialogs.py
@@ -4,7 +4,8 @@ import webbrowser
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
- QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup)
+ QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QListWidget,
+ QListWidgetItem, QDialogButtonBox, QScrollArea, QGroupBox)
from PySide6.QtCore import Qt, Signal, QObject, QThread, QProcess
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
import requests
@@ -75,9 +76,9 @@ class CustomCommandDialog(QDialog):
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
self.command_input.setStyleSheet("""
QPlainTextEdit {
- background-color: #363636;
+ background-color: #1d1e22;
color: #ffffff;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1d1e22;
border-radius: 4px;
padding: 8px;
font-family: Consolas, monospace;
@@ -96,17 +97,17 @@ class CustomCommandDialog(QDialog):
QCheckBox::indicator {
width: 18px;
height: 18px;
- border-radius: 9px; /* Make indicator round */
+ border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
- background: #2b2b2b;
- border-radius: 9px; /* Make indicator round */
+ background: #1d1e22;
+ border-radius: 9px;
}
QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- border-radius: 9px; /* Make indicator round */
+ border: 2px solid #c90000;
+ background: #c90000;
+ border-radius: 9px;
}
""")
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
@@ -129,9 +130,9 @@ class CustomCommandDialog(QDialog):
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
QTextEdit {
- background-color: #2b2b2b;
+ background-color: #1d1e22;
color: #ffffff;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1d1e22;
border-radius: 4px;
padding: 8px;
font-family: Consolas, monospace;
@@ -142,18 +143,18 @@ class CustomCommandDialog(QDialog):
self.setStyleSheet("""
QDialog {
- background-color: #2b2b2b;
+ background-color: #15181b;
}
QPushButton {
padding: 8px 15px;
- background-color: #ff0000;
+ background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
- background-color: #cc0000;
+ background-color: #a50000;
}
""")
@@ -398,6 +399,130 @@ class FFmpegCheckDialog(QDialog):
self.close_btn.setEnabled(True)
+class VersionCheckThread(QThread):
+ finished = Signal(str, str, str) # current_version, latest_version, error_message
+
+ def run(self):
+ current_version = ""
+ latest_version = ""
+ error_message = ""
+
+ try:
+ # Get the yt-dlp executable path
+ if getattr(sys, 'frozen', False):
+ if sys.platform == 'win32':
+ yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe')
+ else:
+ yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp')
+ else:
+ yt_dlp_path = 'yt-dlp'
+
+ # Get current version
+ try:
+ result = subprocess.run([yt_dlp_path, '--version'],
+ capture_output=True,
+ text=True,
+ startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE), # Hide console window on Windows
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) # Hide console window on Windows
+ if result.returncode == 0:
+ current_version = result.stdout.strip()
+ else: # Try fallback if command failed
+ import yt_dlp
+ current_version = yt_dlp.version.__version__
+ except Exception:
+ # Fallback to importing yt_dlp package directly if subprocess fails
+ try:
+ import yt_dlp
+ current_version = yt_dlp.version.__version__
+ except ImportError:
+ error_message = "yt-dlp not found or accessible."
+ self.finished.emit(current_version, latest_version, error_message)
+ return
+
+
+ # Get latest version from PyPI
+ response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) # Add timeout
+ response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
+ latest_version = response.json()["info"]["version"]
+
+ # Clean up version strings
+ current_version = current_version.replace('_', '.')
+ latest_version = latest_version.replace('_', '.')
+
+ except requests.RequestException as e:
+ error_message = f"Network error checking PyPI: {e}"
+ except Exception as e:
+ error_message = f"Error checking version: {e}"
+
+ self.finished.emit(current_version, latest_version, error_message)
+
+
+class UpdateThread(QThread):
+ update_status = Signal(str) # For status messages
+ update_finished = Signal(bool, str) # success (bool), message/error (str)
+
+ def run(self):
+ error_message = ""
+ success = False
+ try:
+ self.update_status.emit("Starting update process...")
+
+ # Determine paths (similar logic as before)
+ python_path = sys.executable # Default to current interpreter
+ yt_dlp_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.getcwd()
+
+ if getattr(sys, 'frozen', False) and sys.platform == 'win32':
+ alt_python_path = os.path.join(os.path.dirname(sys.executable), 'python.exe')
+ if os.path.exists(alt_python_path):
+ python_path = alt_python_path
+
+ # Create and configure QProcess
+ process = QProcess()
+ process.setWorkingDirectory(yt_dlp_dir)
+ process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) # Combine stdout/stderr
+
+ # Prepare command arguments
+ pip_args = ['install', '--upgrade', '--no-cache-dir', 'yt-dlp']
+ if sys.platform == 'win32':
+ command = python_path
+ args = ['-m', 'pip'] + pip_args
+ else:
+ # Assume pip is in PATH or use python -m pip for robustness
+ command = python_path
+ args = ['-m', 'pip'] + pip_args
+ # Alternative if pip is guaranteed in PATH: command = 'pip', args = pip_args
+
+ # Start the process
+ self.update_status.emit(f"Running: {command} {' '.join(args)}")
+ process.start(command, args)
+
+ # Wait for finish (use QProcess event loop, not blocking waitForFinished)
+ if not process.waitForStarted(5000): # Wait 5s for process to start
+ raise RuntimeError("Update process failed to start.")
+
+ if not process.waitForFinished(-1): # Wait indefinitely for finish
+ raise RuntimeError("Update process failed to finish.")
+
+ exit_code = process.exitCode()
+ output = process.readAll().data().decode(errors='ignore') # Read combined output
+
+ if exit_code == 0:
+ self.update_status.emit("Update completed successfully!")
+ success = True
+ error_message = "Update successful. Please restart the application."
+ else:
+ self.update_status.emit(f"Update failed (Exit Code: {exit_code})")
+ error_message = f"Update failed.\nExit Code: {exit_code}\nOutput:\n{output}"
+ success = False
+
+ except Exception as e:
+ error_message = f"Update failed with exception: {e}"
+ self.update_status.emit(error_message)
+ success = False
+
+ self.update_finished.emit(success, error_message)
+
+
class YTDLPUpdateDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
@@ -432,7 +557,7 @@ class YTDLPUpdateDialog(QDialog):
# Style
self.setStyleSheet("""
QDialog {
- background-color: #2b2b2b;
+ background-color: #15181b;
}
QLabel {
color: #ffffff;
@@ -441,7 +566,7 @@ class YTDLPUpdateDialog(QDialog):
}
QPushButton {
padding: 8px 15px;
- background-color: #ff0000;
+ background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
@@ -452,222 +577,723 @@ class YTDLPUpdateDialog(QDialog):
background-color: #666666;
}
QPushButton:hover {
- background-color: #cc0000;
+ background-color: #a50000;
}
QProgressBar {
- border: 2px solid #3d3d3d;
+ border: 2px solid #1d1e22;
border-radius: 4px;
text-align: center;
color: white;
- background-color: #363636;
+ background-color: #1d1e22;
height: 25px;
}
QProgressBar::chunk {
- background-color: #ff0000;
+ background-color: #c90000;
border-radius: 2px;
}
""")
- # Start version check
+ # Start version check in background
self.check_version()
def check_version(self):
+ self.status_label.setText("Checking for updates...")
+ self.update_btn.setEnabled(False)
+ self.version_check_thread = VersionCheckThread()
+ self.version_check_thread.finished.connect(self.on_version_check_finished)
+ self.version_check_thread.start()
+
+ def on_version_check_finished(self, current_version, latest_version, error_message):
+ if error_message:
+ self.status_label.setText(error_message)
+ self.update_btn.setEnabled(False)
+ return
+
+ if not current_version or not latest_version:
+ self.status_label.setText("Could not determine versions.")
+ self.update_btn.setEnabled(False)
+ return
+
try:
- # Get current version using yt-dlp command
- import subprocess
- import sys
- import os
-
- # Get the yt-dlp executable path
- if getattr(sys, 'frozen', False):
- if sys.platform == 'win32':
- yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe')
- else:
- yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp')
- else:
- yt_dlp_path = 'yt-dlp'
-
- # Get current version
- try:
- result = subprocess.run([yt_dlp_path, '--version'],
- capture_output=True,
- text=True)
- current_version = result.stdout.strip()
- except Exception:
- import yt_dlp
- current_version = yt_dlp.version.__version__
-
- # Get latest version from PyPI
- import requests
- response = requests.get("https://pypi.org/pypi/yt-dlp/json")
- latest_version = response.json()["info"]["version"]
-
- # Clean up version strings
- current_version = current_version.replace('_', '.')
- latest_version = latest_version.replace('_', '.')
-
# Compare versions
- from packaging import version
- try:
- current_ver = version.parse(current_version)
- latest_ver = version.parse(latest_version)
-
- if current_ver < latest_ver:
- self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
- self.update_btn.setEnabled(True)
- else:
- self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
- self.update_btn.setEnabled(False)
- except version.InvalidVersion:
- # If version parsing fails, do a simple string comparison
- if current_version != latest_version:
- self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
- self.update_btn.setEnabled(True)
- else:
- self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
- self.update_btn.setEnabled(False)
-
- except Exception as e:
- self.status_label.setText(f"Error checking version: {str(e)}")
- self.update_btn.setEnabled(False)
-
+ current_ver = version.parse(current_version)
+ latest_ver = version.parse(latest_version)
+
+ if current_ver < latest_ver:
+ self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
+ self.update_btn.setEnabled(True)
+ else:
+ self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
+ self.update_btn.setEnabled(False)
+ except version.InvalidVersion:
+ # If version parsing fails, do a simple string comparison
+ if current_version != latest_version:
+ self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}")
+ self.update_btn.setEnabled(True)
+ else:
+ self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
+ self.update_btn.setEnabled(False)
+ except Exception as e: # Catch any other unexpected errors during comparison
+ self.status_label.setText(f"Error comparing versions: {e}")
+ self.update_btn.setEnabled(False)
+
def perform_update(self):
- try:
- self.update_btn.setEnabled(False)
- self.close_btn.setEnabled(False)
- self.status_label.setText("Updating yt-dlp...")
- self.progress_bar.setRange(0, 0)
- self.progress_bar.show()
-
- # Get the yt-dlp path
- if getattr(sys, 'frozen', False):
- if sys.platform == 'win32':
- yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp.exe')
- else:
- yt_dlp_path = os.path.join(os.path.dirname(sys.executable), 'yt-dlp')
- else:
- yt_dlp_path = 'yt-dlp'
-
- # Create a QProcess for updating
- process = QProcess()
- process.setWorkingDirectory(os.path.dirname(yt_dlp_path))
-
- if sys.platform == 'win32':
- # For Windows, use pip to update
- python_path = os.path.join(os.path.dirname(sys.executable), 'python.exe')
- if not os.path.exists(python_path):
- python_path = sys.executable
-
- process.start(python_path, ['-m', 'pip', 'install', '--upgrade', '--no-cache-dir', 'yt-dlp'])
- else:
- # For Unix systems
- process.start('pip', ['install', '--upgrade', '--no-cache-dir', 'yt-dlp'])
-
- process.waitForFinished()
-
- if process.exitCode() == 0:
- self.status_label.setText("Update completed successfully!\nPlease restart the application.")
- self.check_version() # Recheck version after update
- else:
- error = process.readAllStandardError().data().decode()
- self.status_label.setText(f"Update failed: {error}")
-
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setValue(100)
- self.close_btn.setEnabled(True)
-
- except Exception as e:
- self.status_label.setText(f"Update failed: {str(e)}")
- self.close_btn.setEnabled(True)
- self.progress_bar.hide()
+ self.update_btn.setEnabled(False)
+ self.close_btn.setEnabled(False)
+ self.status_label.setText("Initializing update...")
+ self.progress_bar.setRange(0, 0) # Indeterminate progress
+ self.progress_bar.show()
+
+ # Create and start the update thread
+ self.update_thread = UpdateThread()
+ self.update_thread.update_status.connect(self.on_update_status) # Connect status signal
+ self.update_thread.update_finished.connect(self.on_update_finished) # Connect finished signal
+ self.update_thread.start()
+
+ def on_update_status(self, message):
+ """Slot to receive status messages from UpdateThread."""
+ self.status_label.setText(message)
+
+ def on_update_finished(self, success, message):
+ """Slot called when the UpdateThread finishes."""
+ self.progress_bar.setRange(0, 100) # Set determinate range
+ self.progress_bar.setValue(100) # Mark as complete
+ self.progress_bar.hide() # Optionally hide progress bar again
+ self.status_label.setText(message)
+ self.close_btn.setEnabled(True)
+
+ if success:
+ # Optionally re-check version automatically after successful update
+ self.check_version()
+ else:
+ # Re-enable update button only if failed?
+ # self.update_btn.setEnabled(True) # Decide if appropriate
+ pass # Keep update button disabled on failure for now
+
+ def closeEvent(self, event):
+ """Ensure threads are terminated if the dialog is closed prematurely."""
+ if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning():
+ self.version_check_thread.quit() # Ask thread to stop
+ self.version_check_thread.wait() # Wait for it to finish
+ if hasattr(self, 'update_thread') and self.update_thread.isRunning():
+ self.update_thread.quit()
+ self.update_thread.wait()
+ super().closeEvent(event)
class AboutDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
- self.setWindowTitle('About YTSage')
- self.setMinimumWidth(500)
-
+ self.parent = parent # Store parent to access version etc.
+ self.setWindowTitle("About YTSage")
+ self.setMinimumWidth(450)
+
layout = QVBoxLayout(self)
+ layout.setSpacing(15)
+ layout.setContentsMargins(20, 20, 20, 20)
- # App title and version
- title_label = QLabel("YTSage")
- title_label.setStyleSheet("""
- font-size: 24px;
- font-weight: bold;
- color: #ff0000;
- padding: 10px;
- """)
+ # Title and Version
+ title_label = QLabel("YTSage
")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
- version_label = QLabel(f"Version {parent.version}")
+ version_label = QLabel(f"Version: {getattr(self.parent, 'version', 'N/A')}")
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ version_label.setStyleSheet("color: #cccccc;")
layout.addWidget(version_label)
# Description
- description = QLabel(
- "A modern YouTube downloader with a clean interface.\n"
- "Download videos in any quality, extract audio, fetch subtitles,\n"
- "and view video metadata. Built with yt-dlp for reliable performance."
- )
- description.setWordWrap(True)
- description.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(description)
+ description_label = QLabel("A simple GUI frontend for the powerful yt-dlp video downloader.")
+ description_label.setWordWrap(True)
+ description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ description_label.setStyleSheet("color: #ffffff; padding-top: 10px;")
+ layout.addWidget(description_label)
- # Features list
- features_label = QLabel(
- "Key Features:\n"
- "• Smart video quality selection\n"
- "• Audio extraction\n"
- "• Subtitle support\n"
- "• Playlist downloads\n"
- "• Real-time progress tracking\n"
- "• Custom command support\n"
- "• SponsorBlock Integration\n"
- "• Save Thumbnail\n"
- "• One-click updates"
- )
- features_label.setStyleSheet("padding: 10px;")
- layout.addWidget(features_label)
+ # Separator
+ separator = QWidget()
+ separator.setFixedHeight(1)
+ separator.setStyleSheet("background-color: #1d1e22;")
+ layout.addWidget(separator)
- # Credits
- credits_label = QLabel(
- "Powered by:\n"
- "• yt-dlp\n"
- "• PySide6\n"
- "• FFmpeg"
- )
- credits_label.setStyleSheet("padding: 10px;")
- layout.addWidget(credits_label)
+ # Information Section
+ info_layout = QVBoxLayout()
+ info_layout.setSpacing(8)
- # GitHub link
- github_btn = QPushButton("Visit GitHub Repository")
- github_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/YTSage'))
- layout.addWidget(github_btn)
+ # Author
+ author_label = QLabel("Created by: oop7")
+ author_label.setOpenExternalLinks(True)
+ info_layout.addWidget(author_label)
- # Close button
- close_btn = QPushButton("Close")
- close_btn.clicked.connect(self.close)
- layout.addWidget(close_btn)
+ # GitHub Repo
+ repo_label = QLabel("GitHub: github.com/oop7/YTSage")
+ repo_label.setOpenExternalLinks(True)
+ info_layout.addWidget(repo_label)
- # Style the dialog
+ # yt-dlp path
+ yt_dlp_path = get_yt_dlp_path()
+ yt_dlp_path_text = yt_dlp_path if yt_dlp_path else 'yt-dlp not found in PATH'
+ yt_dlp_label = QLabel(f"yt-dlp Path: {yt_dlp_path_text}")
+ yt_dlp_label.setWordWrap(True)
+ info_layout.addWidget(yt_dlp_label)
+
+ # FFmpeg Status
+ ffmpeg_found = check_ffmpeg()
+ ffmpeg_status_text = "Detected" if ffmpeg_found else "Not Detected"
+ ffmpeg_label = QLabel(f"FFmpeg Status: {ffmpeg_status_text}")
+ info_layout.addWidget(ffmpeg_label)
+
+ layout.addLayout(info_layout)
+
+ # Close Button
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
+ button_box.accepted.connect(self.accept)
+ # Center the button box
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+ button_layout.addWidget(button_box)
+ button_layout.addStretch()
+ layout.addLayout(button_layout)
+
+ # Apply overall styling
self.setStyleSheet("""
- QDialog {
- background-color: #2b2b2b;
- }
- QLabel {
- color: #ffffff;
- font-size: 12px;
- }
+ QDialog { background-color: #15181b; color: #ffffff; }
+ QLabel { color: #cccccc; }
QPushButton {
- padding: 8px 15px;
- background-color: #ff0000;
+ padding: 8px 25px;
+ background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
- QPushButton:hover {
- background-color: #cc0000;
+ QPushButton:hover { background-color: #a50000; }
+ """)
+
+# --- New Subtitle Selection Dialog ---
+class SubtitleSelectionDialog(QDialog):
+ def __init__(self, available_manual, available_auto, previously_selected, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Select Subtitles")
+ self.setMinimumWidth(400)
+ self.setMinimumHeight(300)
+
+ self.available_manual = available_manual
+ self.available_auto = available_auto
+ self.previously_selected = set(previously_selected) # Use a set for quick lookups
+ self.selected_subtitles = list(previously_selected) # Initialize with previous selection
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(10)
+
+ # Filter input
+ 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("""
+ QLineEdit {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px;
+ min-height: 30px;
+ color: white;
}
- """)
\ No newline at end of file
+ QLineEdit:focus {
+ border-color: #ff0000;
+ }
+ """)
+ layout.addWidget(self.filter_input)
+
+ # Scroll Area for the list
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
+ layout.addWidget(scroll_area)
+
+ # Container widget for list items (needed for scroll area)
+ self.list_container = QWidget()
+ self.list_layout = QVBoxLayout(self.list_container)
+ self.list_layout.setContentsMargins(0, 0, 0, 0)
+ self.list_layout.setSpacing(2) # Compact spacing
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
+ scroll_area.setWidget(self.list_container)
+
+ # Populate the list initially
+ self.populate_list()
+
+ # OK and Cancel buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+
+ # Style the buttons
+ for button in button_box.buttons():
+ button.setStyleSheet("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px; /* Adjust padding */
+ min-height: 30px; /* Ensure consistent height */
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ 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; }")
+
+
+ layout.addWidget(button_box)
+
+ def populate_list(self, filter_text=""):
+ # Clear existing checkboxes from layout
+ while self.list_layout.count():
+ item = self.list_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.deleteLater()
+
+ filter_text = filter_text.lower()
+ combined_subs = {}
+
+ # 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"
+
+ # 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 combined_subs:
+ no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
+ no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;")
+ self.list_layout.addWidget(no_subs_label)
+ return
+
+ # Sort by language code
+ sorted_lang_codes = sorted(combined_subs.keys())
+
+ for lang_code in sorted_lang_codes:
+ item_text = combined_subs[lang_code]
+ checkbox = QCheckBox(item_text)
+ 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("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px; /* Square checkboxes */
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ 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):
+ self.populate_list(self.filter_input.text())
+
+ def update_selection(self, state):
+ sender = self.sender()
+ subtitle_id = sender.property("subtitle_id")
+ if state == Qt.CheckState.Checked.value:
+ if subtitle_id not in self.previously_selected:
+ self.previously_selected.add(subtitle_id)
+ else:
+ if subtitle_id in self.previously_selected:
+ self.previously_selected.remove(subtitle_id)
+
+ def get_selected_subtitles(self):
+ # Return the final set as a list
+ return list(self.previously_selected)
+
+ def accept(self):
+ # Update the final list before closing
+ self.selected_subtitles = self.get_selected_subtitles()
+ super().accept()
+
+# --- End Subtitle Selection Dialog ---
+
+
+# --- Playlist Video Selection Dialog ---
+
+class PlaylistSelectionDialog(QDialog):
+ def __init__(self, playlist_entries, previously_selected_string, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Select Playlist Videos")
+ self.setMinimumWidth(500)
+ self.setMinimumHeight(400) # Allow more vertical space
+
+ self.playlist_entries = playlist_entries
+ self.checkboxes = []
+
+ # Main layout
+ main_layout = QVBoxLayout(self)
+
+ # Top buttons (Select/Deselect All)
+ button_layout = QHBoxLayout()
+ select_all_btn = QPushButton("Select All")
+ deselect_all_btn = QPushButton("Deselect All")
+ 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("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px;
+ min-height: 30px;
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ 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)
+ button_layout.addStretch()
+ main_layout.addLayout(button_layout)
+
+ # Scrollable area for checkboxes
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
+ scroll_widget = QWidget()
+ self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes
+ self.list_layout.setContentsMargins(0, 0, 0, 0)
+ self.list_layout.setSpacing(2) # Compact spacing
+ self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
+ scroll_area.setWidget(scroll_widget)
+ main_layout.addWidget(scroll_area)
+
+ # Populate checkboxes
+ self._populate_list(previously_selected_string)
+
+ # Dialog buttons (OK/Cancel)
+ 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("""
+ QPushButton {
+ background-color: #363636;
+ border: 2px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 5px 15px;
+ min-height: 30px;
+ color: white;
+ }
+ QPushButton:hover {
+ background-color: #444444;
+ }
+ 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; }")
+
+ main_layout.addWidget(button_box)
+
+ # Apply styling to match subtitle dialog
+ self.setStyleSheet("""
+ QDialog { background-color: #15181b; }
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #ff0000;
+ background: #ff0000;
+ }
+ QWidget { background-color: #15181b; }
+ """)
+
+ def _parse_selection_string(self, selection_string):
+ """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(',')
+ for part in parts:
+ part = part.strip()
+ if '-' in part:
+ try:
+ start, end = map(int, part.split('-'))
+ if start <= end:
+ selected_indices.update(range(start, end + 1))
+ except ValueError:
+ pass # Ignore invalid ranges
+ else:
+ try:
+ selected_indices.add(int(part))
+ except ValueError:
+ pass # Ignore invalid numbers
+ return selected_indices
+
+ def _populate_list(self, previously_selected_string):
+ """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)
+ if child.widget():
+ child.widget().deleteLater()
+ self.checkboxes.clear()
+
+ for index, entry in enumerate(self.playlist_entries):
+ 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}')
+ # Shorten title if too long
+ 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("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #2b2b2b;
+ }
+ QCheckBox::indicator:checked {
+ 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):
+ for checkbox in self.checkboxes:
+ checkbox.setChecked(True)
+
+ def _deselect_all(self):
+ for checkbox in self.checkboxes:
+ checkbox.setChecked(False)
+
+ def _condense_indices(self, indices):
+ """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 ""
+
+ ranges = []
+ start = indices[0]
+ end = indices[0]
+ for i in range(1, len(indices)):
+ if indices[i] == end + 1:
+ end = indices[i]
+ 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}")
+ return ",".join(ranges)
+
+ def get_selected_items_string(self):
+ """Returns the selection string based on checked boxes."""
+ 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 self._condense_indices(selected_indices)
+
+ # Optional: Override accept to ensure the string is generated, although not strictly necessary
+ # def accept(self):
+ # self._selected_string = self.get_selected_items_string()
+ # super().accept()
+
+# --- End Playlist Video Selection Dialog ---
+
+class CookieLoginDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle('Login with Cookies')
+ self.setMinimumSize(400, 150)
+
+ layout = QVBoxLayout(self)
+
+ help_text = QLabel(
+ "Select the Netscape-format cookies file for logging in.\n"
+ "This allows downloading of private videos and premium quality audio."
+ )
+ help_text.setWordWrap(True)
+ help_text.setStyleSheet("color: #999999; padding: 10px;")
+ layout.addWidget(help_text)
+
+ # File path input and browse button
+ path_layout = QHBoxLayout()
+ self.cookie_path_input = QLineEdit()
+ self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
+ 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)
+
+ layout.addLayout(path_layout)
+
+ # Dialog buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ def browse_cookie_file(self):
+ # Open file dialog to select cookie file
+ file_dialog = QFileDialog(self)
+ file_dialog.setFileMode(QFileDialog.ExistingFile)
+ file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Assuming common cookie file extensions
+ if file_dialog.exec():
+ selected_files = file_dialog.selectedFiles()
+ if selected_files:
+ self.cookie_path_input.setText(selected_files[0])
+
+ def get_cookie_file_path(self):
+ # Return the selected cookie file path
+ return self.cookie_path_input.text()
+
+# === Renamed Dialog: Download Settings ===
+class DownloadSettingsDialog(QDialog): # Renamed class
+ def __init__(self, current_path, current_limit, current_unit_index, parent=None): # Added limit params
+ super().__init__(parent)
+ self.setWindowTitle("Download Settings") # Renamed window
+ self.setMinimumWidth(450)
+ self.current_path = current_path
+ self.current_limit = current_limit if current_limit is not None else "" # Handle None
+ self.current_unit_index = current_unit_index
+
+ layout = QVBoxLayout(self)
+
+ # --- Download Path Section ---
+ path_group_box = QGroupBox("Download Path")
+ path_layout = QVBoxLayout()
+
+ self.path_display = QLabel(self.current_path)
+ self.path_display.setWordWrap(True)
+ self.path_display.setStyleSheet("QLabel { color: #cccccc; padding: 5px; border: 1px solid #3d3d3d; border-radius: 4px; background-color: #363636; }")
+ path_layout.addWidget(self.path_display)
+
+ browse_button = QPushButton("Browse...")
+ browse_button.clicked.connect(self.browse_new_path)
+ path_layout.addWidget(browse_button)
+
+ path_group_box.setLayout(path_layout)
+ layout.addWidget(path_group_box)
+ # --- End Path Section ---
+
+ # --- Speed Limit Section ---
+ speed_group_box = QGroupBox("Speed Limit")
+ speed_layout = QHBoxLayout()
+
+ self.speed_limit_input = QLineEdit(str(self.current_limit)) # Set initial value
+ self.speed_limit_input.setPlaceholderText("None")
+ speed_layout.addWidget(self.speed_limit_input)
+
+ self.speed_limit_unit = QComboBox()
+ self.speed_limit_unit.addItems(["KB/s", "MB/s"])
+ self.speed_limit_unit.setCurrentIndex(self.current_unit_index) # Set initial unit
+ speed_layout.addWidget(self.speed_limit_unit)
+
+ speed_group_box.setLayout(speed_layout)
+ layout.addWidget(speed_group_box)
+ # --- End Speed Limit Section ---
+
+ # Dialog buttons (OK/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_new_path(self):
+ 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):
+ """Returns the confirmed path after the dialog is accepted."""
+ return self.current_path
+
+ def get_selected_speed_limit(self):
+ """Returns the entered speed limit value (as string or None)."""
+ limit_str = self.speed_limit_input.text().strip()
+ if not limit_str:
+ return None
+ # Optional: Add validation to ensure it's a number
+ try:
+ float(limit_str) # Check if convertible to float
+ return limit_str
+ except ValueError:
+ # Handle error? Or just return None? Returning None for simplicity.
+ print("Invalid speed limit input in dialog")
+ return None # Or raise an error / show message
+
+ def get_selected_unit_index(self):
+ """Returns the index of the selected speed limit unit."""
+ return self.speed_limit_unit.currentIndex()
\ No newline at end of file
diff --git a/ytsage_gui_format_table.py b/ytsage_gui_format_table.py
index d5b538d..5f05fb6 100644
--- a/ytsage_gui_format_table.py
+++ b/ytsage_gui_format_table.py
@@ -19,6 +19,9 @@ class FormatTableMixin:
self.format_table.setColumnCount(8)
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
+ # Enable alternating row colors
+ self.format_table.setAlternatingRowColors(True)
+
# Set specific column widths and resize modes
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select
self.format_table.setColumnWidth(0, 50) # Select column width
@@ -51,25 +54,32 @@ class FormatTableMixin:
self.format_table.setStyleSheet("""
QTableWidget {
- background-color: #363636;
- border: 2px solid #3d3d3d;
+ background-color: #1b2021;
+ border: 2px solid #1b2021;
border-radius: 4px;
- gridline-color: #3d3d3d;
+ gridline-color: #1b2021;
}
QTableWidget::item {
padding: 5px;
- border-bottom: 1px solid #3d3d3d;
+ border-bottom: 1px solid #1b2021;
}
QTableWidget::item:selected {
background-color: transparent;
}
QHeaderView::section {
- background-color: #2b2b2b;
+ background-color: #15181b;
padding: 5px;
- border: 1px solid #3d3d3d;
+ border: 1px solid #1b2021;
font-weight: bold;
color: white;
}
+ /* Style alternating rows with more contrast */
+ QTableWidget::item:alternate {
+ background-color: #212529;
+ }
+ QTableWidget::item {
+ background-color: #16191b;
+ }
QCheckBox::indicator {
width: 16px;
height: 16px;
@@ -77,11 +87,11 @@ class FormatTableMixin:
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
- background: #2b2b2b;
+ background: #15181b;
}
QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
+ border: 2px solid #c90000;
+ background: #c90000;
}
QWidget {
background-color: transparent;
@@ -145,21 +155,66 @@ class FormatTableMixin:
def _update_format_table(self, formats):
self.format_table.setRowCount(0)
self.format_checkboxes.clear()
-
- # Find best quality format for recommendations
- best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0)
+
+ is_playlist_mode = hasattr(self, 'is_playlist') and self.is_playlist
+
+ # Configure columns based on mode
+ if is_playlist_mode:
+ self.format_table.setColumnCount(5)
+ self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Resolution', 'Notes', 'Audio'])
+
+ # Configure column visibility and resizing for playlist mode
+ self.format_table.setColumnHidden(5, True)
+ self.format_table.setColumnHidden(6, True)
+ self.format_table.setColumnHidden(7, True)
+
+ # Set specific resize modes for playlist columns
+ self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(0, 50)
+ self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
+ self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
+ self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
+ self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
+
+ else:
+ self.format_table.setColumnCount(8)
+ self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
+ # Ensure all columns are visible
+ for i in range(2, 8):
+ self.format_table.setColumnHidden(i, False)
+
+ # Reapply resize modes for non-playlist mode if needed (optional, might be okay without)
+ self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(0, 50)
+ self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(1, 100)
+ self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(2, 80)
+ self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(3, 100)
+ self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(4, 100)
+ self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(5, 150)
+ self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
+ self.format_table.setColumnWidth(6, 120)
+ self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
+
+
+ # Find best quality format for recommendations (only needed for non-playlist mode notes)
+ best_video_size = 0
+ if not is_playlist_mode:
+ best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0)
for f in formats:
row = self.format_table.rowCount()
self.format_table.insertRow(row)
- # Add checkbox
+ # Column 0: Select Checkbox (Always shown)
checkbox = QCheckBox()
checkbox.format_id = str(f.get('format_id', ''))
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
self.format_checkboxes.append(checkbox)
-
- # Create a widget to center the checkbox
checkbox_widget = QWidget()
checkbox_widget.setStyleSheet("background-color: transparent;")
checkbox_layout = QHBoxLayout(checkbox_widget)
@@ -169,7 +224,7 @@ class FormatTableMixin:
checkbox_layout.setSpacing(0)
self.format_table.setCellWidget(row, 0, checkbox_widget)
- # Quality (replacing Format ID)
+ # Column 1: Quality (Always shown)
quality_text = self.get_quality_label(f)
quality_item = QTableWidgetItem(quality_text)
# Set color based on quality
@@ -183,46 +238,73 @@ class FormatTableMixin:
quality_item.setForeground(QColor('#ff5555')) # Red for low quality
self.format_table.setItem(row, 1, quality_item)
- # Extension
- self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper()))
-
- # Resolution
+ # --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) ---
+
+ # Column 2: Resolution (Always shown)
resolution = f.get('resolution', 'N/A')
if f.get('vcodec') == 'none':
resolution = 'Audio only'
- self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
-
- # File Size
- filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
- self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
-
- # Codec
- if f.get('vcodec') == 'none':
- codec = f.get('acodec', 'N/A')
+ self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
+
+ # Column 3: Notes for playlist mode, Extension for normal mode
+ if is_playlist_mode:
+ # Get notes for playlist mode
+ notes = self.get_format_notes(f, 0) # We don't need best_video_size for simple notes
+ notes_item = QTableWidgetItem(notes)
+ if "✨ Recommended" in notes:
+ notes_item.setForeground(QColor('#00ff00')) # Green for recommended
+ elif "💾 Storage friendly" in notes:
+ notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
+ elif "📱 Mobile friendly" in notes:
+ notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
+ self.format_table.setItem(row, 3, notes_item)
else:
- codec = f"{f.get('vcodec', 'N/A')}"
- if f.get('acodec') != 'none':
- codec += f" / {f.get('acodec', 'N/A')}"
- self.format_table.setItem(row, 5, QTableWidgetItem(codec))
-
- # Audio Status
- needs_audio = f.get('acodec') == 'none'
- audio_status = "Will merge audio" if needs_audio else "✓ Has Audio"
+ # Extension for normal mode (column 2)
+ self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper()))
+
+ # Column 4 in playlist mode, Column 6 in normal mode: Audio Status
+ needs_audio = f.get('acodec') == 'none' and f.get('vcodec') != 'none' # Only mark video-only as needing merge
+ audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get('vcodec') != 'none' else "Audio Only")
audio_item = QTableWidgetItem(audio_status)
if needs_audio:
audio_item.setForeground(QColor('#ffa500'))
- self.format_table.setItem(row, 6, audio_item)
+ elif audio_status == "Audio Only":
+ audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only
+ else: # Has Audio (Video+Audio)
+ audio_item.setForeground(QColor('#00cc00')) # Green for included audio
+ # Set item for correct column based on mode
+ audio_column_index = 4 if is_playlist_mode else 6
+ self.format_table.setItem(row, audio_column_index, audio_item)
- # Add Notes column
- notes = self.get_format_notes(f, best_video_size)
- notes_item = QTableWidgetItem(notes)
- if "✨ Recommended" in notes:
- notes_item.setForeground(QColor('#00ff00')) # Green for recommended
- elif "💾 Storage friendly" in notes:
- notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
- elif "📱 Mobile friendly" in notes:
- notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
- self.format_table.setItem(row, 7, notes_item)
+
+ # --- Populate columns only shown in non-playlist mode ---
+ if not is_playlist_mode:
+ # Column 3: Resolution
+ self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
+
+ # Column 4: File Size
+ filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
+ self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
+
+ # Column 5: Codec
+ if f.get('vcodec') == 'none':
+ codec = f.get('acodec', 'N/A')
+ else:
+ codec = f"{f.get('vcodec', 'N/A')}"
+ if f.get('acodec') != 'none':
+ codec += f" / {f.get('acodec', 'N/A')}"
+ self.format_table.setItem(row, 5, QTableWidgetItem(codec))
+
+ # Column 7: Notes
+ notes = self.get_format_notes(f, best_video_size)
+ notes_item = QTableWidgetItem(notes)
+ if "✨ Recommended" in notes:
+ notes_item.setForeground(QColor('#00ff00')) # Green for recommended
+ elif "💾 Storage friendly" in notes:
+ notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
+ elif "📱 Mobile friendly" in notes:
+ notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
+ self.format_table.setItem(row, 7, notes_item)
def handle_checkbox_click(self, clicked_checkbox):
for checkbox in self.format_checkboxes:
diff --git a/ytsage_gui_main.py b/ytsage_gui_main.py
index 898b096..6b8001b 100644
--- a/ytsage_gui_main.py
+++ b/ytsage_gui_main.py
@@ -4,8 +4,9 @@ import webbrowser
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
- QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QMessageBox)
-from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess
+ QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QMessageBox, QListWidget,
+ QListWidgetItem, QDialogButtonBox, QScrollArea)
+from PySide6.QtCore import Qt, Signal, QObject, QThread, QMetaObject, Q_ARG, QProcess, Slot
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
import requests
from io import BytesIO
@@ -17,10 +18,14 @@ from packaging import version
import subprocess
import re
import yt_dlp
+import markdown
from ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from ytsage_utils import check_ffmpeg, get_yt_dlp_path, load_saved_path, save_path # Import utility functions
-from ytsage_gui_dialogs import LogWindow, CustomCommandDialog, FFmpegCheckDialog, YTDLPUpdateDialog, AboutDialog # Import dialogs
+from ytsage_gui_dialogs import (LogWindow, CustomCommandDialog, FFmpegCheckDialog,
+ YTDLPUpdateDialog, AboutDialog, SubtitleSelectionDialog,
+ PlaylistSelectionDialog, CookieLoginDialog,
+ DownloadSettingsDialog) # <-- Use renamed dialog
from ytsage_gui_format_table import FormatTableMixin # Import FormatTableMixin
from ytsage_gui_video_info import VideoInfoMixin # Import VideoInfoMixin
@@ -31,11 +36,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
if not check_ffmpeg():
self.show_ffmpeg_dialog()
- self.version = "4.2.1"
+ self.version = "4.5.0"
self.check_for_updates()
self.config_file = Path.home() / '.ytsage_config.json'
load_saved_path(self)
- self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
+ # Load custom icon
+ icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png')
+ if os.path.exists(icon_path):
+ self.setWindowIcon(QIcon(icon_path))
+ else:
+ print(f"Warning: Icon file not found at {icon_path}. Using default icon.")
+ self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
self.signals = SignalManager()
self.download_paused = False
self.current_download = None
@@ -48,60 +59,67 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.is_playlist = False
self.playlist_info = None
self.video_info = None
+ self.playlist_entries = [] # Initialize playlist entries
+ self.selected_playlist_items = None # Initialize selection string
+ self.save_description = False # Initialize description state
self.subtitle_filter = ""
self.thumbnail_image = None
self.video_url = ""
+ self.selected_subtitles = [] # Initialize selected subtitles list
+ self.cookie_file_path = None # Initialize cookie file path
+ self.speed_limit_value = None # Store speed limit value
+ self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
self.init_ui()
self.setStyleSheet("""
QMainWindow {
- background-color: #2b2b2b;
+ background-color: #15181b;
}
QWidget {
- background-color: #2b2b2b;
+ background-color: #15181b;
color: #ffffff;
}
QLineEdit {
padding: 8px;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- background-color: #363636;
+ background-color: #1b2021;
color: #ffffff;
}
QPushButton {
padding: 8px 15px;
- background-color: #ff0000; /* YouTube red */
+ background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
- background-color: #cc0000; /* Darker red on hover */
+ background-color: #a50000;
}
QPushButton:pressed {
- background-color: #990000; /* Even darker red when pressed */
+ background-color: #800000;
}
QTableWidget {
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- background-color: #363636;
- gridline-color: #3d3d3d;
+ background-color: #1b2021;
+ gridline-color: #1b2021;
}
QHeaderView::section {
- background-color: #2b2b2b;
+ background-color: #15181b;
padding: 5px;
- border: 1px solid #3d3d3d;
+ border: 1px solid #1b2021;
color: #ffffff;
}
QProgressBar {
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
text-align: center;
color: white;
}
QProgressBar::chunk {
- background-color: #ff0000; /* YouTube red */
+ background-color: #c90000;
border-radius: 2px;
}
QLabel {
@@ -109,23 +127,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
/* Style for filter buttons */
QPushButton.filter-btn {
- background-color: #363636;
+ background-color: #1b2021;
padding: 5px 10px;
margin: 0 5px;
}
QPushButton.filter-btn:checked {
- background-color: #ff0000;
+ background-color: #c90000;
}
QPushButton.filter-btn:hover {
background-color: #444444;
}
QPushButton.filter-btn:checked:hover {
- background-color: #cc0000;
+ background-color: #a50000;
}
/* Modern Scrollbar Styling */
QScrollBar:vertical {
border: none;
- background: #2b2b2b;
+ background: #15181b;
width: 14px;
margin: 15px 0 15px 0;
border-radius: 7px;
@@ -140,7 +158,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
QScrollBar::sub-line:vertical {
border: none;
- background: #2b2b2b;
+ background: #15181b;
height: 15px;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
@@ -149,7 +167,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
QScrollBar::add-line:vertical {
border: none;
- background: #2b2b2b;
+ background: #15181b;
height: 15px;
border-bottom-left-radius: 7px;
border-bottom-right-radius: 7px;
@@ -171,7 +189,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
/* Horizontal Scrollbar */
QScrollBar:horizontal {
border: none;
- background: #2b2b2b;
+ background: #15181b;
height: 14px;
margin: 0 15px 0 15px;
border-radius: 7px;
@@ -186,7 +204,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
QScrollBar::sub-line:horizontal {
border: none;
- background: #2b2b2b;
+ background: #15181b;
width: 15px;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
@@ -195,7 +213,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
QScrollBar::add-line:horizontal {
border: none;
- background: #2b2b2b;
+ background: #15181b;
width: 15px;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
@@ -217,6 +235,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
""")
self.signals.update_progress.connect(self.update_progress_bar)
+ # After adding format buttons
+ self.video_button.clicked.connect(self.filter_formats) # Connect video button
+ self.audio_button.clicked.connect(self.filter_formats) # Connect audio button
+
+ # Add connections to handle video/audio mode-specific controls
+ self.video_button.clicked.connect(self.handle_mode_change)
+ self.audio_button.clicked.connect(self.handle_mode_change)
+
+ # Initialize UI state based on current mode
+ self.handle_mode_change()
+
def load_saved_path(self): # Using function from ytsage_utils now - no longer needed in class
pass # Handled in class init now via ytsage_utils.load_saved_path(self)
@@ -224,8 +253,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
save_path(self, path) # Call the utility function
def init_ui(self):
- self.setWindowTitle('YTSage v4.2.1')
- self.setMinimumSize(900, 650)
+ self.setWindowTitle('YTSage v4.5.0')
+ self.setMinimumSize(900, 750)
# Main widget and layout
main_widget = QWidget()
@@ -237,39 +266,62 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
# URL input section
url_layout = QHBoxLayout()
self.url_input = QLineEdit()
- self.url_input.setPlaceholderText('Enter YouTube URL...')
+ self.url_input.setPlaceholderText("Enter YouTube video or playlist URL")
+ self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key
- # Add Paste URL button
- self.paste_btn = QPushButton('Paste URL')
- self.paste_btn.clicked.connect(self.paste_url)
+ self.analyze_button = QPushButton("Analyze")
+ self.analyze_button.clicked.connect(self.analyze_url)
- self.analyze_btn = QPushButton('Analyze')
- self.analyze_btn.clicked.connect(self.analyze_url)
+ self.paste_button = QPushButton("Paste URL")
+ self.paste_button.clicked.connect(self.paste_url)
+
+ url_layout.addWidget(self.url_input, 1)
+ url_layout.addWidget(self.paste_button)
+ url_layout.addWidget(self.analyze_button)
- url_layout.addWidget(self.url_input)
- url_layout.addWidget(self.paste_btn)
- url_layout.addWidget(self.analyze_btn)
layout.addLayout(url_layout)
- # Video info container with smaller fixed height
+ # Video info container
video_info_container = QWidget()
- video_info_container.setFixedHeight(220)
video_info_layout = QVBoxLayout(video_info_container)
video_info_layout.setSpacing(5)
video_info_layout.setContentsMargins(0, 0, 0, 0)
- # Add media info layout
+ # Add media info layout (Thumbnail | Video Details)
media_info_layout = self.setup_video_info_section()
video_info_layout.addLayout(media_info_layout)
- # Add playlist info with minimal height
- self.playlist_info_label = self.setup_playlist_info_section()
- self.playlist_info_label.setMaximumHeight(30)
- video_info_layout.addWidget(self.playlist_info_label)
-
# Add video info container to main layout
layout.addWidget(video_info_container)
+ # --- Add Playlist Info Section Directly to Main Layout ---
+ # Add playlist info label (initially hidden)
+ self.playlist_info_label = self.setup_playlist_info_section()
+ layout.addWidget(self.playlist_info_label)
+
+ # Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit
+ self.playlist_select_btn = QPushButton("Select Videos...")
+ self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog)
+ self.playlist_select_btn.setVisible(False)
+ self.playlist_select_btn.setStyleSheet("""
+ QPushButton {
+ padding: 6px 12px;
+ background-color: #1d1e22;
+ border: 1px solid #c90000;
+ border-radius: 4px;
+ color: white;
+ font-weight: normal;
+ text-align: left;
+ padding-left: 10px;
+ }
+ QPushButton:hover {
+ background-color: #2a2d36;
+ border-color: #a50000;
+ }
+ """)
+ layout.addWidget(self.playlist_select_btn)
+ # --- End Playlist Info Section ---
+
# Format controls section with minimal spacing
layout.addSpacing(5)
@@ -292,20 +344,20 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.video_button.setStyleSheet("""
QPushButton {
padding: 8px 15px;
- background-color: #363636;
+ background-color: #1d1e22;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
- background-color: #ff0000;
+ background-color: #c90000;
}
QPushButton:hover {
- background-color: #444444;
+ background-color: #2a2d36;
}
QPushButton:checked:hover {
- background-color: #cc0000;
+ background-color: #a50000;
}
""")
self.format_buttons.addButton(self.video_button)
@@ -317,27 +369,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.audio_button.setStyleSheet("""
QPushButton {
padding: 8px 15px;
- background-color: #363636;
+ background-color: #1d1e22;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:checked {
- background-color: #ff0000;
+ background-color: #c90000;
}
QPushButton:hover {
- background-color: #444444;
+ background-color: #2a2d36;
}
QPushButton:checked:hover {
- background-color: #cc0000;
+ background-color: #a50000;
}
""")
self.format_buttons.addButton(self.audio_button)
self.format_layout.addWidget(self.audio_button)
- # Connect format buttons
- self.format_buttons.buttonClicked.connect(self.handle_format_selection)
+ # Add Merge Subtitles checkbox (Moved here)
+ self.merge_subs_checkbox = QCheckBox("Merge Subtitles")
+ self.merge_subs_checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ margin-left: 20px; /* Consistent margin */
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ }
+ QCheckBox::indicator:unchecked {
+ border: 2px solid #666666;
+ background: #1d1e22;
+ border-radius: 9px;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #c90000;
+ background: #c90000;
+ border-radius: 9px;
+ }
+ /* Add disabled state styling if needed */
+ QCheckBox:disabled { color: #888888; }
+ QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
+ """)
+ # Initially disable it, will be enabled if subtitles are selected later
+ self.merge_subs_checkbox.setEnabled(False)
+ self.format_layout.addWidget(self.merge_subs_checkbox)
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
@@ -345,7 +425,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
QCheckBox {
color: #ffffff;
padding: 5px;
- margin-left: 20px;
+ margin-left: 20px; /* Consistent margin */
}
QCheckBox::indicator {
width: 18px;
@@ -354,45 +434,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
- background: #2b2b2b;
+ background: #1d1e22;
border-radius: 9px;
}
QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
+ border: 2px solid #c90000;
+ background: #c90000;
border-radius: 9px;
}
""")
self.format_layout.addWidget(self.sponsorblock_checkbox)
- # Add Save Thumbnail checkbox with same style as SponsorBlock
+ # Add Save Thumbnail Checkbox (Moved here)
self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail")
+ self.save_thumbnail_checkbox.setChecked(False)
+ self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_save_thumbnail)
self.save_thumbnail_checkbox.setStyleSheet("""
QCheckBox {
color: #ffffff;
padding: 5px;
margin-left: 20px;
}
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 9px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #2b2b2b;
- border-radius: 9px;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- border-radius: 9px;
- }
+ QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; }
+ QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; }
+ QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; }
+ QCheckBox:disabled { color: #888888; }
+ QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
""")
- self.save_thumbnail_checkbox.clicked.connect(self.toggle_save_thumbnail)
self.format_layout.addWidget(self.save_thumbnail_checkbox)
+ # Add Save Description Checkbox (Moved here)
+ self.save_description_checkbox = QCheckBox("Save Description")
+ self.save_description_checkbox.setChecked(False)
+ self.save_description_checkbox.stateChanged.connect(self.toggle_save_description)
+ self.save_description_checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #ffffff;
+ padding: 5px;
+ margin-left: 20px;
+ }
+ QCheckBox::indicator { width: 18px; height: 18px; border-radius: 9px; }
+ QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; border-radius: 9px; }
+ QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; border-radius: 9px; }
+ QCheckBox:disabled { color: #888888; }
+ QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
+ """)
+ self.format_layout.addWidget(self.save_description_checkbox)
+
self.format_layout.addStretch()
+
layout.addLayout(self.format_layout)
# Format table with stretch
@@ -406,17 +496,21 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.custom_cmd_btn = QPushButton('Custom Command')
self.custom_cmd_btn.clicked.connect(self.show_custom_command)
+ # New button for cookie login
+ self.cookie_login_button = QPushButton("Login with Cookies")
+ self.cookie_login_button.clicked.connect(self.show_cookie_login_dialog) # Connect to a new method
+
self.update_ytdlp_btn = QPushButton('Update yt-dlp')
self.update_ytdlp_btn.clicked.connect(self.update_ytdlp)
self.about_btn = QPushButton('About')
self.about_btn.clicked.connect(self.show_about_dialog)
- self.path_input = QLineEdit(self.last_path)
- self.path_input.setPlaceholderText('Download path...')
-
- self.browse_btn = QPushButton('Browse')
- self.browse_btn.clicked.connect(self.browse_path)
+ # --- Rename Path Button to Settings Button ---
+ self.settings_button = QPushButton("Download Settings") # Renamed button
+ self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
+ self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip
+ # --- End Settings Button ---
self.download_btn = QPushButton('Download')
self.download_btn.clicked.connect(self.start_download)
@@ -432,10 +526,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
# Add all buttons to layout in the correct order
download_layout.addWidget(self.custom_cmd_btn)
+ download_layout.addWidget(self.cookie_login_button)
download_layout.addWidget(self.update_ytdlp_btn)
download_layout.addWidget(self.about_btn)
- download_layout.addWidget(self.path_input)
- download_layout.addWidget(self.browse_btn)
+ download_layout.addWidget(self.settings_button)
download_layout.addWidget(self.download_btn)
download_layout.addWidget(self.pause_btn)
download_layout.addWidget(self.cancel_btn)
@@ -491,10 +585,6 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.signals.update_status.connect(self.status_label.setText)
self.signals.update_progress.connect(self.update_progress_bar)
- # After adding format buttons
- self.video_button.clicked.connect(self.filter_formats) # Connect video button
- self.audio_button.clicked.connect(self.filter_formats) # Connect audio button
-
def analyze_url(self):
url = self.url_input.text().strip()
if not url:
@@ -507,17 +597,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
def _analyze_url_thread(self, url):
try:
- self.signals.update_status.emit("Analyzing (20%)... Extracting basic info")
+ self.signals.update_status.emit("Analyzing (15%)... Extracting basic info")
# Clean up the URL to handle both playlist and video URLs
if 'list=' in url and 'watch?v=' in url:
playlist_id = url.split('list=')[1].split('&')[0]
url = f'https://www.youtube.com/playlist?list={playlist_id}'
- # Initial extraction with basic options
+ # Initial extraction with basic options - suppress warnings here too
ydl_opts = {
'quiet': False,
- 'no_warnings': False,
+ 'no_warnings': True, # <-- Suppress warnings for initial check
'extract_flat': True,
'force_generic_extractor': False,
'ignoreerrors': True,
@@ -525,6 +615,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
'verbose': True
}
+ # Add cookies argument if cookie file path is set
+ if self.cookie_file_path:
+ ydl_opts['cookiefile'] = self.cookie_file_path
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
basic_info = ydl.extract_info(url, download=False)
@@ -534,9 +628,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
print(f"First extraction failed: {str(e)}")
raise Exception("Could not extract video information, please check your link")
- self.signals.update_status.emit("Analyzing (40%)... Extracting detailed info")
- # Configure options for detailed extraction
- ydl_opts.update({
+ self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info")
+ # Configure options for detailed extraction (keep other options)
+ # Add no_warnings here as well, as this is where detailed info is fetched
+ ydl_opts_detail = {
'extract_flat': False,
'format': None,
'writesubtitles': True,
@@ -545,75 +640,122 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
'playliststart': 1,
'playlistend': 1,
'youtube_include_dash_manifest': True,
- 'youtube_include_hls_manifest': True
- })
+ 'youtube_include_hls_manifest': True,
+ 'no_warnings': True # <-- Add flag here for detailed extraction
+ }
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ # Add cookies argument if cookie file path is set
+ if self.cookie_file_path:
+ ydl_opts_detail['cookiefile'] = self.cookie_file_path
+
+ # Use a separate options dict for the detailed extraction
+ with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail:
try:
- self.signals.update_status.emit("Analyzing (60%)... Processing video data")
+ self.signals.update_status.emit("Analyzing (45%)... Processing video data")
if basic_info.get('_type') == 'playlist':
self.is_playlist = True
self.playlist_info = basic_info
-
- # Store all playlist entries
- self.playlist_entries = [entry for entry in basic_info['entries'] if entry]
-
- # Update playlist info text
- playlist_text = (f"Playlist: {basic_info.get('title', 'Unknown')} | "
- f"{len(self.playlist_entries)} videos | "
- f"Enter video numbers (e.g. 1-5,7,9-11)")
+ self.selected_playlist_items = None # Reset selection for new playlist
+ self.playlist_entries = [entry for entry in basic_info.get('entries', []) if entry] # Store entries
+
+ # Ensure there are entries before proceeding
+ if not self.playlist_entries:
+ raise Exception("Playlist contains no valid videos.")
+
+ # Extract detailed info for the FIRST video in the playlist
+ # This provides formats/subs for the UI, assuming consistency
+ first_video_url = self.playlist_entries[0].get('url')
+ if not first_video_url:
+ raise Exception("Could not get URL for the first playlist video.")
+ try:
+ # Use the ydl_detail instance with no_warnings
+ self.video_info = ydl_detail.extract_info(first_video_url, download=False)
+ except Exception as first_video_error:
+ raise Exception(f"Failed to extract info for the first playlist video: {first_video_error}")
+
+ # Update playlist info label text (remains the same)
+ playlist_text = (f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | "
+ f"{len(self.playlist_entries)} videos") # Simplified label
QMetaObject.invokeMethod(
- self.playlist_info_label,
- "setText",
- Qt.ConnectionType.QueuedConnection,
+ self.playlist_info_label, "setText", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, playlist_text)
)
QMetaObject.invokeMethod(
- self.playlist_info_label,
- "setVisible",
- Qt.ConnectionType.QueuedConnection,
+ self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True)
)
- # Show playlist selection input
+ # Show playlist selection BUTTON
QMetaObject.invokeMethod(
- self.playlist_selection_input,
- "setVisible",
+ self, # Target object is the YTSageApp instance
+ 'update_playlist_button_text', # Name of the slot
Qt.ConnectionType.QueuedConnection,
+ Q_ARG(str, "Select Videos... (All selected)") # Argument for the slot
+ )
+ QMetaObject.invokeMethod(
+ self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True)
)
- else:
+ else: # Single video
self.is_playlist = False
- self.video_info = ydl.extract_info(url, download=False)
- self.playlist_info_label.setVisible(False)
+ # Use ydl_detail instance here too for consistency
+ self.video_info = ydl_detail.extract_info(url, download=False)
+ self.playlist_entries = [] # Clear entries
+ self.selected_playlist_items = None # Clear selection
+
+ # Hide playlist info label and button
+ QMetaObject.invokeMethod(
+ self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(bool, False)
+ )
+ QMetaObject.invokeMethod(
+ self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection,
+ Q_ARG(bool, False)
+ )
# Verify we have format information
if not self.video_info or 'formats' not in self.video_info:
print(f"Debug - video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
raise Exception("No format information available")
- self.signals.update_status.emit("Analyzing (80%)... Processing formats")
+ self.signals.update_status.emit("Analyzing (60%)... Processing formats")
self.all_formats = self.video_info['formats']
# Update UI
self.update_video_info(self.video_info)
# Update thumbnail
- self.signals.update_status.emit("Analyzing (90%)... Loading thumbnail")
- self.download_thumbnail(self.video_info.get('thumbnail'))
+ self.signals.update_status.emit("Analyzing (75%)... Loading thumbnail")
+ thumbnail_url = None
+ if self.is_playlist:
+ # Try to get thumbnail from playlist info first
+ thumbnail_url = self.playlist_info.get('thumbnail')
+
+ # Fallback to video thumbnail if playlist thumbnail not found or not a playlist
+ if not thumbnail_url:
+ thumbnail_url = self.video_info.get('thumbnail')
+
+ self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled - use the stored VIDEO URL
if self.save_thumbnail:
self.download_thumbnail_file(self.video_url, self.path_input.text())
- # Update subtitles
- self.signals.update_status.emit("Analyzing (95%)... Processing subtitles")
+ # --- Subtitle Handling ---
+ self.signals.update_status.emit("Analyzing (85%)... Processing subtitles")
+ # Clear previous selections when analyzing a new video
+ self.selected_subtitles = []
self.available_subtitles = self.video_info.get('subtitles', {})
self.available_automatic_subtitles = self.video_info.get('automatic_captions', {})
- self.update_subtitle_list()
+ # Update the UI elements related to subtitle selection state
+ QMetaObject.invokeMethod(self.selected_subs_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "0 selected"))
+ # QMetaObject.invokeMethod(self.subtitle_select_btn, "setProperty", Qt.ConnectionType.QueuedConnection, Q_ARG(str, "subtitlesSelected"), Q_ARG(bool, False)) # <-- COMMENT OUT THIS LINE
+ # REMOVE the merge_subs_checkbox update call from here
+ # QMetaObject.invokeMethod(self.merge_subs_checkbox, "setEnabled", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False))
+
# Update format table
- self.signals.update_status.emit("Analyzing (98%)... Updating format table")
+ self.signals.update_status.emit("Analyzing (95%)... Updating format table")
self.video_button.setChecked(True)
self.audio_button.setChecked(False)
self.filter_formats()
@@ -628,6 +770,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
error_message = str(e)
print(f"Error in analysis: {error_message}")
self.signals.update_status.emit(f"Error: {error_message}")
+ # Ensure playlist UI is hidden on error too
+ QMetaObject.invokeMethod(self.playlist_info_label, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False))
+ QMetaObject.invokeMethod(self.playlist_select_btn, "setVisible", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False))
def paste_url(self):
clipboard = QApplication.clipboard()
@@ -637,20 +782,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
dialog = YTDLPUpdateDialog(self)
dialog.exec()
- def browse_path(self):
- path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.last_path)
- if path:
- self.path_input.setText(path)
- self.save_path(path) # Use save_path utility function
- self.last_path = path
+ def show_download_settings_dialog(self): # Renamed method
+ dialog = DownloadSettingsDialog(
+ self.last_path,
+ self.speed_limit_value,
+ self.speed_limit_unit_index,
+ self
+ )
+ if dialog.exec():
+ # Update Path
+ new_path = dialog.get_selected_path()
+ path_changed = False
+ if new_path != self.last_path:
+ self.last_path = new_path
+ self.save_path(self.last_path) # Save the updated path
+ path_changed = True
+ print(f"Download path updated to: {self.last_path}")
+
+ # Update Speed Limit
+ new_limit_value = dialog.get_selected_speed_limit()
+ new_unit_index = dialog.get_selected_unit_index()
+ limit_changed = False
+ if new_limit_value != self.speed_limit_value or new_unit_index != self.speed_limit_unit_index:
+ self.speed_limit_value = new_limit_value
+ self.speed_limit_unit_index = new_unit_index
+ limit_changed = True
+ print(f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}")
+
+ # Update Tooltip if anything changed
+ if path_changed or limit_changed:
+ limit_text = "None"
+ if self.speed_limit_value:
+ limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}"
+ self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: {limit_text}")
def start_download(self):
url = self.url_input.text().strip()
- path = self.path_input.text().strip()
+ # --- Use self.last_path instead of reading from QLineEdit ---
+ path = self.last_path
if not url or not path:
- self.status_label.setText("Please enter URL and download path")
+ # More specific error message if path is missing
+ if not path:
+ self.status_label.setText("Please set a download path using 'Change Path'")
+ elif not url:
+ self.status_label.setText("Please enter a URL")
+ else:
+ self.status_label.setText("Please enter URL and set download path")
return
+ # --- End Path Change ---
# Get selected format
format_id = self.get_selected_format()
@@ -671,39 +851,60 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
resolution = parts[0].strip().lower()
break
- # Get subtitle selection if available
- subtitle_lang = None
- if hasattr(self, 'subtitle_combo') and self.subtitle_combo.currentIndex() > 0:
- subtitle_lang = self.subtitle_combo.currentText()
+ # Get subtitle selection if available - Now get the list
+ selected_subs = self.selected_subtitles if hasattr(self, 'selected_subtitles') else []
- # Check if it's a playlist
- is_playlist = 'playlist' in url.lower() and '/watch?' not in url
+ # Get playlist selection IF in playlist mode - USE STORED VALUE
+ playlist_items_to_download = None
+ if self.is_playlist:
+ playlist_items_to_download = self.selected_playlist_items # Use the stored selection string
- # Get playlist selection if available
- playlist_items = None
- if self.is_playlist and self.playlist_selection_input.isVisible():
- playlist_items = self.playlist_selection_input.text().strip() or None
+ # --- Use stored speed limit values ---
+ rate_limit = None
+ if self.speed_limit_value:
+ try:
+ limit_value = float(self.speed_limit_value)
+ if self.speed_limit_unit_index == 0: # KB/s
+ rate_limit = f"{int(limit_value * 1024)}"
+ elif self.speed_limit_unit_index == 1: # MB/s
+ rate_limit = f"{int(limit_value * 1024 * 1024)}"
+ except ValueError:
+ # Use a signal to show error in status bar, similar to URL/Path errors
+ self.signals.update_status.emit("❌ Error: Invalid speed limit value set in settings.")
+ return
+ # --- End speed limit update ---
# Save thumbnail if enabled
if self.save_thumbnail:
- self.download_thumbnail_file(url, path)
+ # Consider moving thumbnail download *after* successful video download
+ # Or handle errors more gracefully if thumbnail download fails
+ try:
+ self.download_thumbnail_file(url, path)
+ except Exception as e:
+ print(f"Warning: Thumbnail download failed: {e}")
+ # Optionally inform the user, but don't stop the main download
+
# Create download thread with resolution in output template
self.download_thread = DownloadThread(
url=url,
path=path,
format_id=format_id,
- subtitle_lang=subtitle_lang,
- is_playlist=is_playlist,
+ subtitle_langs=selected_subs, # Pass the list of selected subs
+ is_playlist=self.is_playlist, # Use the flag directly
merge_subs=self.merge_subs_checkbox.isChecked(),
enable_sponsorblock=self.sponsorblock_checkbox.isChecked(),
resolution=resolution,
- playlist_items=playlist_items
+ playlist_items=playlist_items_to_download, # Pass the selection string
+ save_description=self.save_description, # Pass the new flag here
+ cookie_file=self.cookie_file_path, # Pass the cookie file path
+ rate_limit=rate_limit # Pass the calculated rate limit
)
# Connect signals
self.download_thread.progress_signal.connect(self.update_progress_bar)
- self.download_thread.status_signal.connect(self.signals.update_status.emit)
+ self.download_thread.status_signal.connect(self.status_label.setText)
+ self.download_thread.update_details.connect(self.download_details_label.setText)
self.download_thread.finished_signal.connect(self.download_finished)
self.download_thread.error_signal.connect(self.download_error)
self.download_thread.file_exists_signal.connect(self.file_already_exists)
@@ -727,13 +928,31 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.progress_bar.setValue(100)
- self.status_label.setText("Download completed!")
+
+ # Set completion message based on the file type of last downloaded file
+ if self.download_thread and self.download_thread.current_filename:
+ filename = self.download_thread.current_filename
+ ext = os.path.splitext(filename)[1].lower()
+
+ # Video file extensions
+ if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
+ self.status_label.setText(f"✅ Video download completed!")
+ # Audio file extensions
+ elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']:
+ self.status_label.setText(f"✅ Audio download completed!")
+ # Subtitle file extensions
+ elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
+ self.status_label.setText(f"✅ Subtitle download completed!")
+ # Default case
+ else:
+ self.status_label.setText("✅ Download completed!")
def download_error(self, error_message):
self.toggle_download_controls(True)
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.status_label.setText(f"Error: {error_message}")
+ self.download_details_label.setText("") # Clear details label on error
def update_progress_bar(self, value):
try:
@@ -767,26 +986,65 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
# Compare versions
if version.parse(latest_version) > version.parse(self.version):
- self.show_update_dialog(latest_version, latest_release["html_url"])
+ changelog = latest_release.get("body", "No changelog available.") # Get changelog body
+ self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog
except Exception as e:
print(f"Failed to check for updates: {str(e)}")
- def show_update_dialog(self, latest_version, release_url):
+ def show_update_dialog(self, latest_version, release_url, changelog): # Added changelog parameter
msg = QDialog(self)
msg.setWindowTitle("Update Available")
- msg.setMinimumWidth(400)
+ msg.setMinimumWidth(500) # Increased width for changelog
+ msg.setMinimumHeight(400) # Added min height
+
+ # Set custom icon directly
+ icon_path = os.path.join(os.path.dirname(__file__), 'Icon', 'icon.png')
+ if os.path.exists(icon_path):
+ msg.setWindowIcon(QIcon(icon_path))
+ else:
+ # Fallback to main window icon if file not found
+ msg.setWindowIcon(self.windowIcon())
layout = QVBoxLayout(msg)
+ layout.setSpacing(10) # Added spacing
# Update message
message_label = QLabel(
- f"A new version of YTSage is available!\n\n"
+ f"A new version of YTSage is available!\n\n"
f"Current version: {self.version}\n"
f"Latest version: {latest_version}"
)
message_label.setWordWrap(True)
layout.addWidget(message_label)
+ # Changelog Section
+ changelog_label = QLabel("Changelog:")
+ layout.addWidget(changelog_label)
+
+ changelog_text = QTextEdit()
+ changelog_text.setReadOnly(True)
+ # Convert Markdown to HTML and set it
+ try:
+ html_changelog = markdown.markdown(changelog, extensions=['markdown.extensions.tables', 'markdown.extensions.fenced_code'])
+ changelog_text.setHtml(html_changelog)
+ except Exception as e:
+ print(f"Error converting changelog markdown to HTML: {e}")
+ changelog_text.setPlainText(changelog) # Fallback to plain text
+
+ changelog_text.setStyleSheet("""
+ QTextEdit {
+ background-color: #1e1e1e;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ color: #cccccc;
+ padding: 5px;
+ font-family: Consolas, monospace;
+ font-size: 11px; /* Slightly smaller font */
+ }
+ """)
+ changelog_text.setMaximumHeight(200) # Limit height
+ layout.addWidget(changelog_text)
+
# Buttons
button_layout = QHBoxLayout()
@@ -808,7 +1066,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
QLabel {
color: #ffffff;
font-size: 12px;
- padding: 10px;
+ padding: 5px 0; /* Adjusted padding */
}
QPushButton {
padding: 8px 15px;
@@ -817,6 +1075,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
border-radius: 4px;
color: white;
font-weight: bold;
+ min-width: 120px; /* Adjusted min-width */
}
QPushButton:hover {
background-color: #cc0000;
@@ -835,7 +1094,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
def cancel_download(self):
if self.current_download:
self.current_download.cancelled = True
- self.signals.update_status.emit("Cancelling download...")
+ self.status_label.setText("Cancelling download...") # Set status directly
+ self.download_details_label.setText("") # Clear details label on cancellation
def show_ffmpeg_dialog(self):
dialog = FFmpegCheckDialog(self)
@@ -844,21 +1104,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
def toggle_download_controls(self, enabled=True):
"""Enable or disable download-related controls"""
self.url_input.setEnabled(enabled)
- self.analyze_btn.setEnabled(enabled)
+ self.analyze_button.setEnabled(enabled)
self.format_table.setEnabled(enabled) # Changed from format_scroll_area to format_table
- self.path_input.setEnabled(enabled)
- self.browse_btn.setEnabled(enabled)
self.download_btn.setEnabled(enabled)
if hasattr(self, 'subtitle_combo'):
self.subtitle_combo.setEnabled(enabled)
self.video_button.setEnabled(enabled)
self.audio_button.setEnabled(enabled)
self.sponsorblock_checkbox.setEnabled(enabled)
+ self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox
+ self.custom_cmd_btn.setEnabled(enabled) # Enable/disable custom command button
+ self.cookie_login_button.setEnabled(enabled) # Enable/disable login button
+ self.update_ytdlp_btn.setEnabled(enabled) # Enable/disable update button
+ self.settings_button.setEnabled(enabled) # Enable/disable settings button
+
+ # Clear progress/status when controls are re-enabled
+ if enabled:
+ self.progress_bar.setValue(0)
+ self.status_label.setText("Ready")
+ self.download_details_label.setText("") # Clear details label
def handle_format_selection(self, button):
# Update formats
self.filter_formats()
+ def handle_mode_change(self):
+ """Enable or disable features based on video/audio mode"""
+ if self.audio_button.isChecked():
+ # In Audio Only mode, disable video-specific features
+ self.sponsorblock_checkbox.setEnabled(False)
+ self.sponsorblock_checkbox.setChecked(False) # Uncheck when disabled
+ self.merge_subs_checkbox.setEnabled(False)
+ self.merge_subs_checkbox.setChecked(False) # Uncheck when disabled
+
+ # Allow subtitle selection in Audio Only mode too
+ if hasattr(self, 'subtitle_select_btn'):
+ self.subtitle_select_btn.setEnabled(True)
+ else:
+ # In Video mode, enable video-specific features
+ self.sponsorblock_checkbox.setEnabled(True)
+ # Don't auto-check - leave it to user preference
+
+ # Enable merge_subs only if subtitles are selected
+ has_subs_selected = len(getattr(self, 'selected_subtitles', [])) > 0
+ self.merge_subs_checkbox.setEnabled(has_subs_selected)
+
+ # Re-enable subtitle selection button in Video mode
+ if hasattr(self, 'subtitle_select_btn'):
+ self.subtitle_select_btn.setEnabled(True)
+
def show_about_dialog(self): # ADDED METHOD HERE
dialog = AboutDialog(self)
dialog.exec()
@@ -869,7 +1163,22 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
self.pause_btn.setVisible(False)
self.cancel_btn.setVisible(False)
self.progress_bar.setValue(100)
- self.status_label.setText(f"⚠️ File already exists: {filename}")
+
+ # Determine file type based on extension
+ ext = os.path.splitext(filename)[1].lower()
+
+ # Video file extensions
+ if ext in ['.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv']:
+ self.status_label.setText(f"⚠️ Video file already exists")
+ # Audio file extensions
+ elif ext in ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.opus', '.flac']:
+ self.status_label.setText(f"⚠️ Audio file already exists")
+ # Subtitle file extensions
+ elif ext in ['.vtt', '.srt', '.ass', '.ssa']:
+ self.status_label.setText(f"⚠️ Subtitle file already exists")
+ # Default case
+ else:
+ self.status_label.setText("⚠️ File already exists")
# Show a simple message dialog
msg_box = QMessageBox()
@@ -904,4 +1213,55 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from m
}
""")
- msg_box.exec()
\ No newline at end of file
+ msg_box.exec()
+
+ # --- Add Toggle Methods Here ---
+ def toggle_save_thumbnail(self, state):
+ print(f"Raw thumbnail state received: {state}") # Debug: Print raw state
+ self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state)
+ print(f"Save thumbnail toggled: {self.save_thumbnail}")
+
+ def toggle_save_description(self, state):
+ print(f"Raw description state received: {state}") # Debug: Print raw state
+ self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state)
+ print(f"Save description toggled: {self.save_description}")
+ # --- End Toggle Methods ---
+
+ def open_playlist_selection_dialog(self):
+ if not self.is_playlist or not self.playlist_entries:
+ print("No playlist data available to select from.")
+ return
+
+ dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
+
+ if dialog.exec():
+ self.selected_playlist_items = dialog.get_selected_items_string()
+ print(f"Playlist items selected: {self.selected_playlist_items}")
+
+ # Update button text (this call is safe as it happens in the main thread after dialog closes)
+ if self.selected_playlist_items is None:
+ button_text = "Select Videos... (All selected)"
+ else:
+ selected_indices = dialog._parse_selection_string(self.selected_playlist_items)
+ count = len(selected_indices)
+ display_text = self.selected_playlist_items if len(self.selected_playlist_items) < 30 else f"{count} videos selected"
+ button_text = f"Select Videos... ({display_text})"
+ self.playlist_select_btn.setText(button_text) # Direct call is fine here
+
+ # --- New Slot for Updating Playlist Button Text ---
+ @Slot(str)
+ def update_playlist_button_text(self, text):
+ """Safely updates the playlist selection button's text from any thread."""
+ if hasattr(self, 'playlist_select_btn'):
+ self.playlist_select_btn.setText(text)
+ # --- End New Slot ---
+
+ def show_cookie_login_dialog(self):
+ dialog = CookieLoginDialog(self)
+ if dialog.exec():
+ self.cookie_file_path = dialog.get_cookie_file_path()
+ if self.cookie_file_path:
+ print(f"Selected cookie file: {self.cookie_file_path}") # For debugging
+ QMessageBox.information(self, "Cookie File Selected", f"Cookie file selected: {self.cookie_file_path}")
+ else:
+ self.cookie_file_path = None # Clear path if dialog accepted but no file selected
\ No newline at end of file
diff --git a/ytsage_gui_video_info.py b/ytsage_gui_video_info.py
index bad0511..c05f591 100644
--- a/ytsage_gui_video_info.py
+++ b/ytsage_gui_video_info.py
@@ -17,6 +17,7 @@ from packaging import version
import subprocess
import re
import yt_dlp
+from ytsage_gui_dialogs import SubtitleSelectionDialog
class VideoInfoMixin:
def setup_video_info_section(self):
@@ -55,9 +56,10 @@ class VideoInfoMixin:
self.views_label = QLabel()
self.date_label = QLabel()
self.duration_label = QLabel()
+ self.like_count_label = QLabel()
# Style the info labels
- for label in [self.channel_label, self.views_label, self.date_label, self.duration_label]:
+ for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]:
label.setStyleSheet("""
QLabel {
color: #cccccc;
@@ -70,115 +72,53 @@ class VideoInfoMixin:
video_info_layout.addWidget(self.title_label)
video_info_layout.addWidget(self.channel_label)
video_info_layout.addWidget(self.views_label)
+ video_info_layout.addWidget(self.like_count_label)
video_info_layout.addWidget(self.date_label)
video_info_layout.addWidget(self.duration_label)
# Add spacing before subtitle section
video_info_layout.addSpacing(10)
- # Create a horizontal layout for subtitle controls
+ # --- Subtitle Section ---
subtitle_layout = QHBoxLayout()
- subtitle_layout.setSpacing(5) # Reduce spacing between elements
+ subtitle_layout.setSpacing(10)
- # Create subtitle button
- self.subtitle_check = QPushButton("Download Subtitles")
- self.subtitle_check.setFixedHeight(30)
- self.subtitle_check.setFixedWidth(150) # Set fixed width
- self.subtitle_check.setCheckable(True)
- self.subtitle_check.clicked.connect(self.toggle_subtitle_controls)
- self.subtitle_check.setStyleSheet("""
+ # Subtitle selection button
+ self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text
+ self.subtitle_select_btn.setFixedHeight(30)
+ # self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
+ self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
+ self.subtitle_select_btn.setStyleSheet("""
QPushButton {
- background-color: #363636;
- border: 2px solid #3d3d3d;
+ background-color: #1d1e22;
+ border: 2px solid #1d1e22;
border-radius: 4px;
- padding: 5px;
- min-height: 30px;
+ padding: 5px 10px; /* Adjusted padding */
}
- QPushButton:checked {
- background-color: #ff0000;
- border-color: #cc0000;
+ QPushButton:hover { background-color: #2a2d36; }
+ /* Optional: Style differently if subtitles ARE selected */
+ QPushButton[subtitlesSelected="true"] {
+ border-color: #c90000; /* Indicate selection */
+ }
+ /* Style for disabled state */
+ QPushButton:disabled {
+ background-color: #3d3d3d;
+ color: #888888;
+ border-color: #3d3d3d;
}
""")
- subtitle_layout.addWidget(self.subtitle_check)
+ self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
+ subtitle_layout.addWidget(self.subtitle_select_btn)
- # Create subtitle combo box
- self.subtitle_combo = QComboBox()
- self.subtitle_combo.setFixedHeight(30)
- self.subtitle_combo.setFixedWidth(200)
- self.subtitle_combo.setVisible(False)
- self.subtitle_combo.setStyleSheet("""
- QComboBox {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px;
- min-height: 30px;
- }
- """)
- subtitle_layout.addWidget(self.subtitle_combo)
+ # Label to show number of selected subtitles
+ self.selected_subs_label = QLabel("0 selected")
+ self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
+ subtitle_layout.addWidget(self.selected_subs_label)
- # Create merge subtitles checkbox
- self.merge_subs_checkbox = QCheckBox("Merge Subtitles")
- self.merge_subs_checkbox.setFixedHeight(30)
- self.merge_subs_checkbox.setVisible(False)
- self.merge_subs_checkbox.setStyleSheet("""
- QCheckBox {
- color: #ffffff;
- padding: 5px;
- margin-left: 10px;
- }
- QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 9px;
- }
- QCheckBox::indicator:unchecked {
- border: 2px solid #666666;
- background: #2b2b2b;
- border-radius: 9px;
- }
- QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
- border-radius: 9px;
- }
- """)
- subtitle_layout.addWidget(self.merge_subs_checkbox)
-
- # Add stretch to push everything to the left
- subtitle_layout.addStretch()
-
- # Create a second row for the filter input
- filter_layout = QHBoxLayout()
- filter_layout.setSpacing(5)
-
- # Create subtitle filter input
- self.subtitle_filter_input = QLineEdit()
- self.subtitle_filter_input.setFixedHeight(30)
- self.subtitle_filter_input.setFixedWidth(200)
- self.subtitle_filter_input.setPlaceholderText("Filter languages (e.g., en, es)")
- self.subtitle_filter_input.textChanged.connect(self.filter_subtitles)
- self.subtitle_filter_input.setVisible(False)
- self.subtitle_filter_input.setStyleSheet("""
- QLineEdit {
- background-color: #363636;
- border: 2px solid #3d3d3d;
- border-radius: 4px;
- padding: 5px;
- min-height: 30px;
- color: white;
- }
- QLineEdit:focus {
- border-color: #ff0000;
- }
- """)
- filter_layout.addWidget(self.subtitle_filter_input)
- filter_layout.addStretch()
-
- # Add both layouts to video info
+ # Add the subtitle layout to the main video info layout
video_info_layout.addLayout(subtitle_layout)
- video_info_layout.addLayout(filter_layout)
-
+ # --- End Subtitle Section ---
+
# Add stretch at the bottom
video_info_layout.addStretch()
@@ -193,11 +133,11 @@ class VideoInfoMixin:
self.playlist_info_label.setStyleSheet("""
QLabel {
font-size: 12px;
- color: #ff9900;
+ color: #ffffff;
padding: 5px 8px;
margin: 0;
- background-color: #2b2b2b;
- border: 1px solid #3d3d3d;
+ background-color: #1d1e22;
+ border: 1px solid #c90000;
border-radius: 4px;
min-height: 30px;
max-height: 30px;
@@ -207,63 +147,109 @@ class VideoInfoMixin:
return self.playlist_info_label
def update_video_info(self, info):
- # Format view count with commas
- views = int(info.get('view_count', 0))
- formatted_views = f"{views:,}"
-
- # Format upload date
- upload_date = info.get('upload_date', '')
- if upload_date:
- date_obj = datetime.strptime(upload_date, '%Y%m%d')
- formatted_date = date_obj.strftime('%B %d, %Y')
+ if hasattr(self, 'is_playlist') and self.is_playlist:
+ # Playlist Mode: Show playlist title and video count
+ self.title_label.setText(self.playlist_info.get('title', 'Unknown Playlist'))
+
+ num_videos = len(getattr(self, 'playlist_entries', []))
+ self.duration_label.setText(f"Total Videos: {num_videos}")
+
+ # Hide video-specific info
+ self.channel_label.setText("")
+ self.views_label.setText("")
+ self.date_label.setText("")
+ self.like_count_label.setText("")
+ self.channel_label.setVisible(False)
+ self.views_label.setVisible(False)
+ self.date_label.setVisible(False)
+ self.like_count_label.setVisible(False)
else:
- formatted_date = 'Unknown date'
+ # Single Video Mode: Show standard video info
+ # Ensure labels are visible first
+ self.channel_label.setVisible(True)
+ self.views_label.setVisible(True)
+ self.date_label.setVisible(True)
+ self.like_count_label.setVisible(True)
- # Format duration
- duration = info.get('duration', 0)
- minutes = duration // 60
- seconds = duration % 60
- duration_str = f"{minutes}:{seconds:02d}"
+ # Format view count with commas
+ views = info.get('view_count')
+ formatted_views = f"{views:,}" if views is not None else 'N/A'
- # Update labels
- self.title_label.setText(info.get('title', 'Unknown title'))
- self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
- self.views_label.setText(f"Views: {formatted_views}")
- self.date_label.setText(f"Upload date: {formatted_date}")
- self.duration_label.setText(f"Duration: {duration_str}")
+ # Format like count with commas
+ likes = info.get('like_count')
+ formatted_likes = f"{likes:,}" if likes is not None else 'N/A'
+
+ # Format upload date
+ upload_date = info.get('upload_date', '')
+ if upload_date:
+ date_obj = datetime.strptime(upload_date, '%Y%m%d')
+ formatted_date = date_obj.strftime('%B %d, %Y')
+ else:
+ formatted_date = 'Unknown date'
+
+ # Format duration
+ duration = info.get('duration', 0)
+ minutes = duration // 60
+ seconds = duration % 60
+ duration_str = f"{minutes}:{seconds:02d}"
+
+ # Update labels
+ self.title_label.setText(info.get('title', 'Unknown title'))
+ self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
+ self.views_label.setText(f"Views: {formatted_views}")
+ self.like_count_label.setText(f"Likes: {formatted_likes}")
+ self.date_label.setText(f"Upload date: {formatted_date}")
+ self.duration_label.setText(f"Duration: {duration_str}")
- def toggle_subtitle_controls(self):
- is_checked = self.subtitle_check.isChecked()
- self.subtitle_combo.setVisible(is_checked)
- self.subtitle_filter_input.setVisible(is_checked)
- self.merge_subs_checkbox.setVisible(is_checked)
+ def open_subtitle_dialog(self):
+ if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'):
+ print("Subtitle info not loaded yet.")
+ return
- def update_subtitle_list(self):
- self.subtitle_combo.clear()
+ if not hasattr(self, 'selected_subtitles'):
+ self.selected_subtitles = []
- if not (self.available_subtitles or self.available_automatic_subtitles):
- self.subtitle_combo.addItem("No subtitles available")
- return
+ dialog = SubtitleSelectionDialog(
+ self.available_subtitles,
+ self.available_automatic_subtitles,
+ self.selected_subtitles,
+ self # Parent for the dialog
+ )
- # Add subtitle options
- self.subtitle_combo.addItem("Select subtitle language")
+ # Access the main application window (parent of the mixin's widget)
+ # to find the merge checkbox
+ main_window = self # In this context, self should be the YTSageApp instance
+ if not isinstance(main_window, QMainWindow):
+ # If the structure is different, this might need adjustment
+ # Maybe self.parentWidget() or similar depending on how Mixin is used
+ print("Warning: Cannot find main window to access merge checkbox.")
+ merge_checkbox = None
+ else:
+ merge_checkbox = getattr(main_window, 'merge_subs_checkbox', None)
- # Filter and add subtitles
- filter_text = self.subtitle_filter_input.text().lower()
- # Add manual subtitles
- for lang_code, subtitle_info in self.available_subtitles.items():
- if not filter_text or filter_text in lang_code.lower():
- self.subtitle_combo.addItem(f"{lang_code} - Manual")
+ if dialog.exec(): # If user clicks OK
+ self.selected_subtitles = dialog.get_selected_subtitles()
+ print(f"Selected subtitles: {self.selected_subtitles}")
+ # Update UI to reflect selection
+ count = len(self.selected_subtitles)
+ self.selected_subs_label.setText(f"{count} selected")
+ self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0)
- # Add auto-generated subtitles
- for lang_code, subtitle_info in self.available_automatic_subtitles.items():
- if not filter_text or filter_text in lang_code.lower():
- self.subtitle_combo.addItem(f"{lang_code} - Auto-generated")
+ # Enable/disable the merge checkbox in the parent window
+ if merge_checkbox:
+ # Only enable merge checkbox if we're not in Audio Only mode
+ is_audio_only = hasattr(main_window, 'audio_button') and main_window.audio_button.isChecked()
+ # In audio-only mode, we still allow subtitle selection but not merging
+ should_enable = count > 0 and not is_audio_only
+ merge_checkbox.setEnabled(should_enable)
+ else:
+ print("Warning: merge_subs_checkbox not found on parent window.")
- def filter_subtitles(self):
- self.subtitle_filter = self.subtitle_filter_input.text()
- self.update_subtitle_list()
+ # Re-apply stylesheet to update button border if property changed
+ self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn)
+ self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
+ # No else needed for cancel, state remains unchanged
def download_thumbnail(self, url):
try:
@@ -285,10 +271,6 @@ class VideoInfoMixin:
except Exception as e:
print(f"Error loading thumbnail: {str(e)}")
- def toggle_save_thumbnail(self):
- self.save_thumbnail = self.save_thumbnail_checkbox.isChecked()
- print(f"Save thumbnail toggled: {self.save_thumbnail}") # Debug print
-
def download_thumbnail_file(self, video_url, path):
if not self.save_thumbnail:
return False
diff --git a/ytsage_style.py b/ytsage_style.py
index 84c3b8d..6ad7f38 100644
--- a/ytsage_style.py
+++ b/ytsage_style.py
@@ -1,24 +1,24 @@
MAIN_STYLE = """
QMainWindow {
- background-color: #2b2b2b;
+ background-color: #15181b;
}
QWidget {
- background-color: #2b2b2b;
+ background-color: #15181b;
color: #ffffff;
font-size: 12px;
}
QLineEdit {
padding: 8px;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- background-color: #363636;
+ background-color: #1b2021;
color: #ffffff;
- selection-background-color: #ff0000;
+ selection-background-color: #c90000;
selection-color: #ffffff;
}
QPushButton {
padding: 8px 15px;
- background-color: #ff0000; /* YouTube red */
+ background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
@@ -26,33 +26,33 @@ QPushButton {
min-height: 20px;
}
QPushButton:hover {
- background-color: #cc0000; /* Darker red on hover */
+ background-color: #a50000;
}
QPushButton:pressed {
- background-color: #990000; /* Even darker red when pressed */
+ background-color: #800000;
}
QPushButton:disabled {
- background-color: #666666; /* Gray when disabled */
+ background-color: #666666;
color: #999999;
}
QTableWidget {
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- background-color: #363636;
- gridline-color: #3d3d3d;
- selection-background-color: #ff0000;
+ background-color: #1b2021;
+ gridline-color: #1b2021;
+ selection-background-color: #c90000;
selection-color: #ffffff;
}
QHeaderView::section {
- background-color: #2b2b2b;
+ background-color: #15181b;
padding: 5px;
- border: 1px solid #3d3d3d;
+ border: 1px solid #1b2021;
color: #ffffff;
font-weight: bold;
}
QScrollBar:vertical {
border: none;
- background-color: #2b2b2b;
+ background-color: #15181b;
width: 12px;
margin: 0px;
}
@@ -62,27 +62,27 @@ QScrollBar::handle:vertical {
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
- background-color: #ff0000;
+ background-color: #c90000;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QProgressBar {
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
text-align: center;
color: white;
- background-color: #363636;
+ background-color: #1b2021;
}
QProgressBar::chunk {
- background-color: #ff0000;
+ background-color: #c90000;
border-radius: 2px;
}
QComboBox {
padding: 5px;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- background-color: #363636;
+ background-color: #1b2021;
color: #ffffff;
min-height: 20px;
}
@@ -106,25 +106,25 @@ QCheckBox::indicator {
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
- background: #2b2b2b;
+ background: #15181b;
}
QCheckBox::indicator:checked {
- border: 2px solid #ff0000;
- background: #ff0000;
+ border: 2px solid #c90000;
+ background: #c90000;
}
QLabel {
color: #ffffff;
}
QTextEdit, QPlainTextEdit {
- background-color: #363636;
+ background-color: #1b2021;
color: #ffffff;
- border: 2px solid #3d3d3d;
+ border: 2px solid #1b2021;
border-radius: 4px;
- selection-background-color: #ff0000;
+ selection-background-color: #c90000;
selection-color: #ffffff;
}
QMessageBox {
- background-color: #2b2b2b;
+ background-color: #15181b;
}
QMessageBox QLabel {
color: #ffffff;
diff --git a/ytsage_utils.py b/ytsage_utils.py
index 1666c83..698625d 100644
--- a/ytsage_utils.py
+++ b/ytsage_utils.py
@@ -4,6 +4,7 @@ import json
from pathlib import Path
import subprocess
import tempfile
+import shutil
from ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
def check_ffmpeg():
@@ -49,45 +50,24 @@ def check_ffmpeg():
return False
def get_yt_dlp_path():
- """Get the appropriate yt-dlp path with enhanced error handling."""
+ """Get the yt-dlp command or path, prioritizing the system PATH."""
try:
- if getattr(sys, 'frozen', False):
- if sys.platform == 'darwin':
- # For macOS .app bundle
- if 'Contents/MacOS' in sys.executable:
- base_path = os.path.dirname(sys.executable)
- else:
- # Fallback to user's home directory for macOS
- base_path = os.path.expanduser('~/Library/Application Support/YTSage')
- elif sys.platform == 'win32':
- # For Windows executable
- app_data = os.getenv('APPDATA')
- base_path = os.path.join(app_data, 'YTSage') if app_data else os.path.dirname(sys.executable)
- else:
- # For Linux AppImage or binary
- if 'APPIMAGE' in os.environ:
- xdg_data = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
- base_path = os.path.join(xdg_data, 'YTSage')
- else:
- base_path = os.path.dirname(sys.executable)
-
- # Create directory if it doesn't exist
- try:
- os.makedirs(base_path, exist_ok=True)
- except Exception as e:
- print(f"Error creating directory: {e}")
- base_path = os.path.dirname(sys.executable)
-
- return os.path.join(base_path, 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
+ # Use shutil.which to find yt-dlp in the system's PATH
+ yt_dlp_executable = shutil.which('yt-dlp')
+
+ if yt_dlp_executable:
+ print(f"Found yt-dlp executable in PATH: {yt_dlp_executable}")
+ return yt_dlp_executable
else:
- # For development/script mode
- return os.path.join(os.path.dirname(os.path.abspath(__file__)),
- 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
-
+ # If not found in PATH, assume 'yt-dlp' is the command name
+ print("yt-dlp not found in PATH. Will attempt to use 'yt-dlp' as the command.")
+ return 'yt-dlp'
+
except Exception as e:
- print(f"Error determining yt-dlp path: {e}")
- # Fallback to current directory
- return os.path.join(os.getcwd(), 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp')
+ print(f"Error finding yt-dlp path: {e}")
+ # Fallback to the command name on any error
+ print("An error occurred during yt-dlp path detection. Falling back to command 'yt-dlp'.")
+ return 'yt-dlp'
def load_saved_path(main_window_instance):
"""Load saved download path with enhanced error handling."""