Files
SageTube/ytsage/utils/ytsage_localization.py
T
Homer b07a56082a Fall back to English instead of showing raw keys
Every namespace the fork added -- main_tabs, feed, cards, watch, browse,
search, player, and now account -- exists only in en.json, because the thirteen
translated language files predate them. get_text() consults the embedded
fallback dict and then gives up and returns the key, so a non-English UI read
"main_tabs.watch" on its own tabs. This round made that worse by adding more
English-only keys.

The fallback dict now carries those namespaces verbatim from en.json. It does
not translate anything -- a German UI shows English for these strings -- but
nothing shows a dotted key, and keys added later degrade the same way instead
of breaking. Translations that do exist still win: buttons.download is still
"Herunterladen" in German.

Verified across all fourteen language files: no key in the fork's namespaces
resolves to itself in any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:22:08 +02:00

440 lines
18 KiB
Python

"""
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"
},
"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)