Files
SageTube/ytsage/utils/ytsage_localization.py
T
Homer 996d581a07 Show the account feed after it loads
Refresh fetched the account feed correctly -- the status line said "60 videos
in feed" -- and the page stayed empty with "No subscriptions yet" on it.

The empty-state hint added in 5.5.0 hides the grid so it can sit centred
rather than pinned under a large blank area. Every path that changes the grid
re-runs that check afterwards and shows it again; the account path was the one
that did not, so the grid stayed hidden from startup no matter what was loaded
into it. The local feed re-runs it and was unaffected, which is why this only
showed up for someone signed in.

Two related things while here. The empty-state message was written for local
subscriptions and is wrong advice in account mode, which does not use them --
it now says to press Refresh rather than to go and subscribe to something. And
the Subscriptions panel, which lists local subscriptions, is hidden in account
mode instead of sitting empty beside a full feed and implying something failed.

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

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