diff --git a/pyproject.toml b/pyproject.toml index b702c7d..481af62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ytsage" -version = "5.1.0" +version = "5.2.0b" description = "Modern YouTube downloader with a clean PySide6 interface." authors = [ { name = "oop7", email = "oop7_support@proton.me" }, diff --git a/ytsage/__init__.py b/ytsage/__init__.py index 11805bd..67d4aed 100644 --- a/ytsage/__init__.py +++ b/ytsage/__init__.py @@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader A modern, user-friendly YouTube video downloader built with PySide6. """ -__version__ = "5.1.0" +__version__ = "5.2.0b" __author__ = "oop7" diff --git a/ytsage/core/ytsage_utils.py b/ytsage/core/ytsage_utils.py index 86cffea..78a096f 100644 --- a/ytsage/core/ytsage_utils.py +++ b/ytsage/core/ytsage_utils.py @@ -402,25 +402,26 @@ def load_saved_path(main_window_instance: Any) -> None: 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: """Save download path with enhanced error handling.""" try: # Verify the path is valid and writable - if not Path(path).exists(): + path_str = str(path) + if not Path(path_str).exists(): try: - Path(path).mkdir(exist_ok=True) + Path(path_str).mkdir(parents=True, exist_ok=True) except Exception as e: - logger.exception(f"Error creating directory: {e}") + logger.error(f"Error creating directory: {e}") return False - if not os.access(path, os.W_OK): - logger.info("Path is not writable") + if not os.access(path_str, os.W_OK): + logger.error("Path is not writable") return False - # Save the config - config = {"download_path": path} - with open(APP_CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False) + # Save the config using ConfigManager + ConfigManager.set("download_path", path_str) return True except Exception as e: diff --git a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py index 59d6e6b..e24822f 100644 --- a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py +++ b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py @@ -255,7 +255,8 @@ class DownloadSettingsDialog(QDialog): generic_mode_group_box = QGroupBox(_("settings.generic_mode")) 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.setChecked(self.generic_mode_enabled) diff --git a/ytsage/gui/ytsage_gui_format_table.py b/ytsage/gui/ytsage_gui_format_table.py index 5b2dea0..651ff1f 100644 --- a/ytsage/gui/ytsage_gui_format_table.py +++ b/ytsage/gui/ytsage_gui_format_table.py @@ -22,6 +22,34 @@ class FormatTableMixin: text_width = font_metrics.horizontalAdvance(label) 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: """Apply responsive column widths to format table.""" self = cast("YTSageApp", self) @@ -209,7 +237,8 @@ class FormatTableMixin: for f in self.all_formats if (f.get("vcodec") == "none" or "audio only" in str(f.get("format_note") or "").lower()) 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 @@ -399,14 +428,41 @@ class FormatTableMixin: # Audio Status column 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")) - audio_item = QTableWidgetItem(audio_status) + acodec = f.get("acodec") + + # Enhanced audio status with support for surround sound formats (EAC3/AC3) if needs_audio: - audio_item.setForeground(QColor("#ffa500")) - elif audio_status == _("formats.audio_only"): - audio_item.setForeground(QColor("#cccccc")) + audio_status = _("formats.will_merge_audio") + audio_color = QColor("#ffa500") + 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: - 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 self.format_table.setItem(row, audio_column_index, audio_item) @@ -421,11 +477,22 @@ class FormatTableMixin: # Column 5: Codec if f.get("vcodec") == "none": + # Audio-only format - display codec with channel info 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: + # Video format - display video codec and audio codec if present codec = str(f.get("vcodec") or "N/A") 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)) # Column 7: FPS (Frame Rate) diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 7743700..4958f50 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -249,8 +249,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Initialize proxy settings from config self.proxy_url = ConfigManager.get("proxy_url") self.geo_proxy_url = ConfigManager.get("geo_proxy_url") - 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) + # Initialize speed limit settings from config + 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.force_keyframes = False # 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.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best" 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 self.analysis_completed = False @@ -269,6 +273,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.player.setAudioOutput(self.audio_output) self.init_ui() + self._load_window_state() # Defer heavy start-up tasks to ensure UI renders immediately QTimer.singleShot(100, self._perform_startup_checks) @@ -287,6 +292,22 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Initialize UI state based on current mode 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): """Perform potentially blocking startup checks after UI is shown.""" @@ -659,6 +680,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): 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'}" ) + # 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 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.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...") event.accept() except Exception as e: diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index fcbe92f..05e61d2 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -71,7 +71,7 @@ class ConfigManager: _settings: Dict[str, Any] = {} _default_config: Dict[str, Any] = { "download_path": str(USER_HOME_DIR / "Downloads"), - "generic_mode": False, + "generic_mode": True, "speed_limit_value": None, "speed_limit_unit_index": 0, "cookie_source": "browser", # "browser" or "file" @@ -96,6 +96,8 @@ class ConfigManager: "preferred_audio_format": "best", "audio_normalization": False, "filename_format": "%(title)s_%(resolution)s_[%(id)s].%(ext)s", + "window_geometry": None, + "window_state": None, "cached_versions": { "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},