b07a56082a
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>
440 lines
18 KiB
Python
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) |