""" Localization Manager Module ========================== This module provides centralized localization support for YTSage application. It handles loading language files, switching languages, and retrieving localized strings. Features -------- - Thread-safe operations for getting localized text - Fallback to English when translation is missing - Support for multiple languages via JSON files - Dynamic language switching without restart - Nested key support with dot notation Usage ----- from .ytsage_localization import LocalizationManager # Get localized text text = LocalizationManager.get_text("download.ready") button_text = LocalizationManager.get_text("buttons.download") # Change language LocalizationManager.set_language("es") # Get available languages languages = LocalizationManager.get_available_languages() """ import json import threading from pathlib import Path from typing import Any, Dict from .ytsage_logger import logger class LocalizationManager: """ Thread-safe localization manager for YTSage. Handles loading, caching, and retrieving localized strings from JSON language files. """ _lock = threading.RLock() _current_language = "en" _languages: Dict[str, Dict[str, Any]] = {} _languages_dir = Path(__file__).parent.parent / "languages" # Fallback English strings embedded in code _fallback_strings = { "app": { "title": "YTSage", "version": "v{version}", "ready": "Ready" }, "buttons": { "download": "Download", "pause": "Pause", "resume": "Resume", "cancel": "Cancel", "browse": "Browse", "clear": "Clear", "ok": "OK", "apply": "Apply", "close": "Close" }, "dialogs": { "custom_options": "Custom Options", "settings": "Settings" }, "settings": { "generic_mode": "Generic Mode", "enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)", "generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.", "app_updates_title": "SageTube Updates", "check_app_updates": "Check for SageTube updates on startup", "check_beta_updates": "Receive Beta Updates" }, # --- SageTube's own strings ------------------------------------- # These namespaces exist only in en.json: the fork added them and the # thirteen translated files predate them. get_text() falls back to this # dict before giving up and returning the key itself, so without these # a non-English UI literally reads "main_tabs.watch" on its tabs. # Keep in step with en.json when adding keys; translating the language # files is the separate, larger job. "main_tabs": { "watch": "Watch", "search": "Search", "feed": "Feed", "browse": "Browse", "downloads": "Downloads" }, "search": { "placeholder": "Search YouTube...", "button": "Search", "searching": "Searching...", "results_count": "{count} results", "failed": "Search failed: {error}", "button_tooltip": "Search YouTube" }, "browse": { "placeholder": "Paste a channel or playlist URL...", "open": "Open", "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", "play_all": "Play all", "download_playlist": "Download", "tab_videos": "Videos", "tab_shorts": "Shorts", "tab_live": "Live", "loading": "Loading...", "entry_count": "{count} videos", "failed": "Failed to load: {error}", "hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.", "open_tooltip": "Open this channel or playlist", "subscribe_tooltip": "Follow this channel so its uploads appear in your Feed", "unsubscribe_tooltip": "Stop following this channel", "play_all_tooltip": "Queue everything loaded here", "download_playlist_tooltip": "Open this playlist in Downloads", "queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest" }, "feed": { "mode_local": "Local subscriptions", "mode_account": "YouTube account (cookies)", "refresh": "Refresh", "subscriptions": "Subscriptions", "no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse", "refreshing": "Refreshing... {done}/{total}", "refreshed": "{count} videos in feed", "channel_failed": "A channel failed to refresh: {error}", "fetching_account": "Fetching your subscription feed...", "account_failed": "Account feed failed (are cookies valid?): {error}", "subscribed": "Subscribed to {title}", "unsubscribed": "Unsubscribed from {title}", "open_channel": "Open channel", "refresh_tooltip": "Fetch the latest uploads from every subscribed channel", "mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account", "refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached", "account_needs_refresh": "Press Refresh to load your account feed", "empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.", "empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.", "sign_in": "Sign in with cookies…", "sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed", "account_requires_cookies": "Requires signing in with cookies", "empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed." }, "cards": { "play": "▶ Play", "queue": "+ Queue", "download": "⬇", "load_more": "Load more", "empty": "Nothing here yet", "play_tooltip": "Play this video now", "queue_tooltip": "Add to the play queue", "download_tooltip": "Open in Downloads with this video ready to analyse", "load_more_tooltip": "Load the next page of results" }, "watch": { "clear_queue": "Clear queue" }, "player": { "quality_auto": "Auto", "subtitles": "CC", "unavailable": "Video player unavailable", "now_playing": "Now Playing", "queue": "Queue", "play": "Play", "pause": "Pause", "stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.", "play_pause": "Play / pause (Space)", "fullscreen": "Fullscreen (F)", "seek_tooltip": "Seek through the video", "quality_tooltip": "Maximum playback resolution", "speed_tooltip": "Playback speed", "volume_tooltip": "Volume", "subtitles_tooltip": "Toggle subtitles (C)", "previous": "Previous in queue (P)", "next": "Next in queue (N)", "buffering": "Buffering…", "act_play_pause": "Play / pause", "act_seek_back_5": "Back 5 seconds", "act_seek_fwd_5": "Forward 5 seconds", "act_seek_back_10": "Back 10 seconds", "act_seek_fwd_10": "Forward 10 seconds", "act_seek_back_1": "Back 1 second", "act_seek_fwd_1": "Forward 1 second", "act_frame_back": "Previous frame", "act_frame_fwd": "Next frame", "act_vol_up": "Volume up", "act_vol_down": "Volume down", "act_mute": "Mute", "act_unmute": "Unmute", "act_speed_up": "Speed up", "act_speed_down": "Slow down", "act_speed_reset": "Normal speed", "act_subtitles": "Subtitles on / off", "act_fullscreen": "Fullscreen", "act_leave_fullscreen": "Leave fullscreen", "act_next": "Next in queue", "act_previous": "Previous", "act_start": "Back to start", "act_end": "Jump to end", "act_copy_url": "Copy video link", "act_open_browser": "Open in browser" }, "account": { "signed_out": "Not signed in", "signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.", "signed_in": "Account: {source}", "signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.", "source_file": "cookie file" }, "update_dialog": { "skip_version": "Skip This Version", "skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.", "changelog_unavailable": "No release notes were published for this version." }, "tabs": { "cookies": "Login with Cookies", "custom_command": "Custom Command", "proxy": "Proxy", "language": "Language" }, "main_ui": { "url_placeholder": "Enter YouTube video or playlist URL", "url_placeholder_generic": "Enter video or playlist URL from any supported site", "settings_tooltip": "Current Path: {path}\nSpeed Limit: {speed_limit}", "speed_limit_none": "None" }, "language": { "select_language": "Select Language:", "current_language": "Current language: {language}", "restart_required": "Language changes will take effect after restarting the application.", "english": "English", "spanish": "Español (Spanish)", "portuguese": "Português (Portuguese)", "russian": "Русский (Russian)", "chinese": "中文 (简体) (Chinese Simplified)", "german": "Deutsch (German)", "french": "Français (French)", "hindi": "हिन्दी (Hindi)", "indonesian": "Bahasa Indonesia (Indonesian)", "turkish": "Türkçe (Turkish)", "polish": "Polski (Polish)", "italian": "Italiano (Italian)", "arabic": "العربية (Arabic)", "japanese": "日本語 (Japanese)" }, "download": { "preparing": "🚀 Preparing your download...", "completed": "✅ Download completed!", "video_completed": "✅ Video download completed!", "audio_completed": "✅ Audio download completed!", "subtitle_completed": "✅ Subtitle download completed!", "please_set_path": "Please set a download path using 'Change Path'", "please_enter_url": "Please enter a URL", "please_enter_url_and_path": "Please enter URL and set download path", "please_select_format": "Please select a format" }, "formats": { "show_formats": "Show formats:" }, "errors": { "download_failed_return_code_conflict": "Download failed with return code {return_code}. This may be due to a conflict with multiple yt-dlp installations. Try uninstalling any system-installed yt-dlp (e.g. through snap or apt) and restart the application.", "download_failed_return_code": "Download failed with return code {return_code}", "direct_command_error": "Error in direct command: {error}" }, "about": { "open_logs": "📂 Logs", "logs_tooltip": "Open application logs folder", "refresh": "🔄" } } @classmethod def _ensure_languages_dir(cls) -> None: """Ensure the languages directory exists.""" cls._languages_dir.mkdir(exist_ok=True) @classmethod def _load_language(cls, language_code: str) -> Dict[str, Any]: """ Load a language file from disk. Args: language_code: The language code (e.g., 'en', 'es') Returns: Dictionary containing the language strings, or empty dict if not found """ language_file = cls._languages_dir / f"{language_code}.json" if not language_file.exists(): logger.warning(f"Language file not found: {language_file}") return {} try: with open(language_file, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.error(f"Failed to load language file {language_file}: {e}") return {} @classmethod def _get_nested_value(cls, data: Dict[str, Any], key: str) -> Any: """ Get a nested value from dictionary using dot notation. Args: data: Dictionary to search in key: Dot-separated key (e.g., "app.title") Returns: The value if found, None otherwise """ parts = key.split(".") value = data for part in parts: if isinstance(value, dict) and part in value: value = value[part] else: return None return value @classmethod def get_text(cls, key: str, **kwargs) -> str: """ Get localized text for the given key. Args: key: Dot-separated key for the text (e.g., "app.title") **kwargs: Format parameters for the text Returns: Localized text, with fallback to English if not found """ with cls._lock: # Load current language if not cached if cls._current_language not in cls._languages: cls._languages[cls._current_language] = cls._load_language(cls._current_language) # Try to get from current language current_lang_data = cls._languages.get(cls._current_language, {}) text = cls._get_nested_value(current_lang_data, key) # Fallback to embedded English strings if text is None: text = cls._get_nested_value(cls._fallback_strings, key) # Final fallback to key itself if text is None: logger.warning(f"Localization key not found: {key}") text = key # Format the text with provided parameters if kwargs and isinstance(text, str): try: text = text.format(**kwargs) except (KeyError, ValueError) as e: logger.warning(f"Failed to format localized text '{key}': {e}") return str(text) @classmethod def set_language(cls, language_code: str) -> None: """ Set the current language. Args: language_code: The language code to set (e.g., 'en', 'es') """ with cls._lock: if language_code != cls._current_language: cls._current_language = language_code # Clear cache to force reload cls._languages.clear() logger.info(f"Language set to: {language_code}") @classmethod def get_current_language(cls) -> str: """Get the current language code.""" return cls._current_language @classmethod def get_available_languages(cls) -> Dict[str, str]: """ Get available languages from the languages directory. Returns: Dictionary mapping language codes to display names """ cls._ensure_languages_dir() available_languages = {"en": cls.get_text("language.english")} # Scan for language files for language_file in cls._languages_dir.glob("*.json"): lang_code = language_file.stem if lang_code != "en": # Skip English as it's already added # Try to get language display name from the file lang_data = cls._load_language(lang_code) display_name = cls._get_nested_value(lang_data, "language.display_name") if display_name: available_languages[lang_code] = display_name else: # Fallback display name available_languages[lang_code] = lang_code.upper() return available_languages @classmethod def initialize(cls, language_code: str = "en") -> None: """ Initialize the localization system. Args: language_code: Initial language code to use """ with cls._lock: cls._ensure_languages_dir() cls.set_language(language_code) logger.info(f"Localization system initialized with language: {language_code}") # Convenience function for getting localized text def _(key: str, **kwargs) -> str: """ Convenience function to get localized text. Args: key: Dot-separated key for the text **kwargs: Format parameters Returns: Localized text """ return LocalizationManager.get_text(key, **kwargs)