Merge branch 'beta'

This commit is contained in:
oop7
2026-07-01 13:41:07 +03:00
7 changed files with 126 additions and 24 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ytsage" name = "ytsage"
version = "5.1.0" version = "5.2.0b"
description = "Modern YouTube downloader with a clean PySide6 interface." description = "Modern YouTube downloader with a clean PySide6 interface."
authors = [ authors = [
{ name = "oop7", email = "oop7_support@proton.me" }, { name = "oop7", email = "oop7_support@proton.me" },
+1 -1
View File
@@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader
A modern, user-friendly YouTube video downloader built with PySide6. A modern, user-friendly YouTube video downloader built with PySide6.
""" """
__version__ = "5.1.0" __version__ = "5.2.0b"
__author__ = "oop7" __author__ = "oop7"
+10 -9
View File
@@ -402,25 +402,26 @@ def load_saved_path(main_window_instance: Any) -> None:
main_window_instance.last_path = tempfile.gettempdir() main_window_instance.last_path = tempfile.gettempdir()
from ..utils.ytsage_config_manager import ConfigManager
def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool: def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
"""Save download path with enhanced error handling.""" """Save download path with enhanced error handling."""
try: try:
# Verify the path is valid and writable # Verify the path is valid and writable
if not Path(path).exists(): path_str = str(path)
if not Path(path_str).exists():
try: try:
Path(path).mkdir(exist_ok=True) Path(path_str).mkdir(parents=True, exist_ok=True)
except Exception as e: except Exception as e:
logger.exception(f"Error creating directory: {e}") logger.error(f"Error creating directory: {e}")
return False return False
if not os.access(path, os.W_OK): if not os.access(path_str, os.W_OK):
logger.info("Path is not writable") logger.error("Path is not writable")
return False return False
# Save the config # Save the config using ConfigManager
config = {"download_path": path} ConfigManager.set("download_path", path_str)
with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False)
return True return True
except Exception as e: except Exception as e:
@@ -255,7 +255,8 @@ class DownloadSettingsDialog(QDialog):
generic_mode_group_box = QGroupBox(_("settings.generic_mode")) generic_mode_group_box = QGroupBox(_("settings.generic_mode"))
generic_mode_layout = QVBoxLayout() generic_mode_layout = QVBoxLayout()
self.generic_mode_enabled = ConfigManager.get("generic_mode") or False generic_val = ConfigManager.get("generic_mode")
self.generic_mode_enabled = generic_val if generic_val is not None else True
self.generic_mode_checkbox = QCheckBox(_("settings.enable_generic_mode")) self.generic_mode_checkbox = QCheckBox(_("settings.enable_generic_mode"))
self.generic_mode_checkbox.setChecked(self.generic_mode_enabled) self.generic_mode_checkbox.setChecked(self.generic_mode_enabled)
+75 -8
View File
@@ -22,6 +22,34 @@ class FormatTableMixin:
text_width = font_metrics.horizontalAdvance(label) text_width = font_metrics.horizontalAdvance(label)
return max(text_width + padding, min_width) return max(text_width + padding, min_width)
def _get_audio_codec_display(self, format_info: dict) -> str:
"""
Generate a display string for audio codec information.
Highlights EAC3/AC3 and other surround sound formats with channel info.
"""
acodec = format_info.get("acodec")
if not acodec or acodec == "none":
return "N/A"
channels = format_info.get("audio_channels")
abr = format_info.get("abr")
# Build codec string
codec_str = str(acodec)
# Add channel info for surround sound formats
if channels:
codec_str += f" ({channels}ch)"
# Add bitrate if available
if abr and isinstance(abr, (int, float)):
if abr >= 1000:
codec_str += f" {abr/1000:.1f}Mbps"
else:
codec_str += f" {int(abr)}kbps"
return codec_str
def _apply_column_widths(self, header_labels: list[str], is_playlist_mode: bool = False) -> None: def _apply_column_widths(self, header_labels: list[str], is_playlist_mode: bool = False) -> None:
"""Apply responsive column widths to format table.""" """Apply responsive column widths to format table."""
self = cast("YTSageApp", self) self = cast("YTSageApp", self)
@@ -209,7 +237,8 @@ class FormatTableMixin:
for f in self.all_formats for f in self.all_formats
if (f.get("vcodec") == "none" or "audio only" in str(f.get("format_note") or "").lower()) if (f.get("vcodec") == "none" or "audio only" in str(f.get("format_note") or "").lower())
and f.get("acodec") != "none" and f.get("acodec") != "none"
and f.get("filesize") is not None # Removed filesize requirement - many audio-only formats (including EAC3/AC3) may not have filesize in yt-dlp output
# Include all audio formats to detect EAC3/AC3 and other surround sound codecs
] ]
# Sort formats by quality # Sort formats by quality
@@ -399,14 +428,41 @@ class FormatTableMixin:
# Audio Status column # Audio Status column
needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none" needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none"
audio_status = _("formats.will_merge_audio") if needs_audio else (_("formats.has_audio") if f.get("vcodec") != "none" else _("formats.audio_only")) acodec = f.get("acodec")
audio_item = QTableWidgetItem(audio_status)
# Enhanced audio status with support for surround sound formats (EAC3/AC3)
if needs_audio: if needs_audio:
audio_item.setForeground(QColor("#ffa500")) audio_status = _("formats.will_merge_audio")
elif audio_status == _("formats.audio_only"): audio_color = QColor("#ffa500")
audio_item.setForeground(QColor("#cccccc")) elif f.get("vcodec") != "none":
# Video format with audio
if acodec in ["ac3", "eac3"]:
# Highlight surround sound codecs
channels = f.get("audio_channels", "")
if channels:
audio_status = f"{_('formats.has_audio')} - {acodec.upper()} {channels}ch"
else:
audio_status = f"{_('formats.has_audio')} - {acodec.upper()}"
audio_color = QColor("#00ccff") # Cyan for surround sound
else:
audio_status = _("formats.has_audio")
audio_color = QColor("#00cc00")
else: else:
audio_item.setForeground(QColor("#00cc00")) # Audio-only format
if acodec in ["ac3", "eac3"]:
# Highlight surround sound audio-only
channels = f.get("audio_channels", "")
if channels:
audio_status = f"{acodec.upper()} {channels}ch"
else:
audio_status = acodec.upper()
audio_color = QColor("#00ccff") # Cyan for surround sound
else:
audio_status = _("formats.audio_only")
audio_color = QColor("#cccccc")
audio_item = QTableWidgetItem(audio_status)
audio_item.setForeground(audio_color)
audio_column_index = 5 if is_playlist_mode else 6 audio_column_index = 5 if is_playlist_mode else 6
self.format_table.setItem(row, audio_column_index, audio_item) self.format_table.setItem(row, audio_column_index, audio_item)
@@ -421,11 +477,22 @@ class FormatTableMixin:
# Column 5: Codec # Column 5: Codec
if f.get("vcodec") == "none": if f.get("vcodec") == "none":
# Audio-only format - display codec with channel info
codec = str(f.get("acodec") or "N/A") codec = str(f.get("acodec") or "N/A")
# Add audio channel information if available (e.g., for 5.1 detection)
channels = f.get("audio_channels")
if channels:
codec += f" ({channels}ch)"
else: else:
# Video format - display video codec and audio codec if present
codec = str(f.get("vcodec") or "N/A") codec = str(f.get("vcodec") or "N/A")
if f.get("acodec") != "none": if f.get("acodec") != "none":
codec += f" / {str(f.get('acodec') or 'N/A')}" acodec = str(f.get("acodec") or "N/A")
# Add audio channel info for video with audio
channels = f.get("audio_channels")
if channels:
acodec += f" ({channels}ch)"
codec += f" / {acodec}"
self.format_table.setItem(row, 5, QTableWidgetItem(codec)) self.format_table.setItem(row, 5, QTableWidgetItem(codec))
# Column 7: FPS (Frame Rate) # Column 7: FPS (Frame Rate)
+34 -3
View File
@@ -249,8 +249,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Initialize proxy settings from config # Initialize proxy settings from config
self.proxy_url = ConfigManager.get("proxy_url") self.proxy_url = ConfigManager.get("proxy_url")
self.geo_proxy_url = ConfigManager.get("geo_proxy_url") self.geo_proxy_url = ConfigManager.get("geo_proxy_url")
self.speed_limit_value = None # Store speed limit value # Initialize speed limit settings from config
self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s) self.speed_limit_value = ConfigManager.get("speed_limit_value") # Store speed limit value
self.speed_limit_unit_index = ConfigManager.get("speed_limit_unit_index") or 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
self.download_section = None self.download_section = None
self.force_keyframes = False self.force_keyframes = False
# Initialize output format settings # Initialize output format settings
@@ -259,7 +260,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.force_audio_format = ConfigManager.get("force_audio_format") or False self.force_audio_format = ConfigManager.get("force_audio_format") or False
self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best" self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best"
self.audio_normalization = ConfigManager.get("audio_normalization") or False self.audio_normalization = ConfigManager.get("audio_normalization") or False
self.generic_mode_enabled = ConfigManager.get("generic_mode") or False
generic_val = ConfigManager.get("generic_mode")
self.generic_mode_enabled = generic_val if generic_val is not None else True
# Track if video analysis is completed # Track if video analysis is completed
self.analysis_completed = False self.analysis_completed = False
@@ -269,6 +273,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.player.setAudioOutput(self.audio_output) self.player.setAudioOutput(self.audio_output)
self.init_ui() self.init_ui()
self._load_window_state()
# Defer heavy start-up tasks to ensure UI renders immediately # Defer heavy start-up tasks to ensure UI renders immediately
QTimer.singleShot(100, self._perform_startup_checks) QTimer.singleShot(100, self._perform_startup_checks)
@@ -287,6 +292,22 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Initialize UI state based on current mode # Initialize UI state based on current mode
self.handle_mode_change() self.handle_mode_change()
def _load_window_state(self):
"""Restore previous window geometry and state from config."""
from PySide6.QtCore import QByteArray
geo_b64 = ConfigManager.get("window_geometry")
if geo_b64:
try:
self.restoreGeometry(QByteArray.fromBase64(geo_b64.encode("ascii")))
except Exception as e:
logger.debug(f"Failed to restore geometry: {e}")
state_b64 = ConfigManager.get("window_state")
if state_b64:
try:
self.restoreState(QByteArray.fromBase64(state_b64.encode("ascii")))
except Exception as e:
logger.debug(f"Failed to restore state: {e}")
def _perform_startup_checks(self): def _perform_startup_checks(self):
"""Perform potentially blocking startup checks after UI is shown.""" """Perform potentially blocking startup checks after UI is shown."""
@@ -659,6 +680,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
logger.info( logger.info(
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'}" 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'}"
) )
# Save speed limit settings to config
ConfigManager.set("speed_limit_value", self.speed_limit_value)
ConfigManager.set("speed_limit_unit_index", self.speed_limit_unit_index)
# Update Output Format Settings # Update Output Format Settings
new_force_format = dialog.get_force_format_enabled() new_force_format = dialog.get_force_format_enabled()
@@ -1191,6 +1215,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.current_download.terminate() self.current_download.terminate()
self.current_download.wait(1000) # Wait for termination self.current_download.wait(1000) # Wait for termination
# Save the window size and state
try:
ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii"))
ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii"))
except Exception as w_e:
logger.debug(f"Failed to save window state: {w_e}")
logger.info("Application closing...") logger.info("Application closing...")
event.accept() event.accept()
except Exception as e: except Exception as e:
+3 -1
View File
@@ -71,7 +71,7 @@ class ConfigManager:
_settings: Dict[str, Any] = {} _settings: Dict[str, Any] = {}
_default_config: Dict[str, Any] = { _default_config: Dict[str, Any] = {
"download_path": str(USER_HOME_DIR / "Downloads"), "download_path": str(USER_HOME_DIR / "Downloads"),
"generic_mode": False, "generic_mode": True,
"speed_limit_value": None, "speed_limit_value": None,
"speed_limit_unit_index": 0, "speed_limit_unit_index": 0,
"cookie_source": "browser", # "browser" or "file" "cookie_source": "browser", # "browser" or "file"
@@ -96,6 +96,8 @@ class ConfigManager:
"preferred_audio_format": "best", "preferred_audio_format": "best",
"audio_normalization": False, "audio_normalization": False,
"filename_format": "%(title)s_%(resolution)s_[%(id)s].%(ext)s", "filename_format": "%(title)s_%(resolution)s_[%(id)s].%(ext)s",
"window_geometry": None,
"window_state": None,
"cached_versions": { "cached_versions": {
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},