996d581a07
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>
441 lines
18 KiB
Python
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) |