From c5916d57c091a7de4d18077b8e181ec92c254c67 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Sep 2025 20:53:59 +0300 Subject: [PATCH] Add localization support and language selection Introduces English and Spanish translation files, integrates localization throughout dialogs, and adds a language selection tab to the custom options dialog. UI strings are now loaded via the localization manager, enabling dynamic language switching and improved internationalization. --- languages/en.json | 316 ++++++++++++++++++ languages/es.json | 313 +++++++++++++++++ .../ytsage_gui_dialogs/ytsage_dialogs_base.py | 34 +- .../ytsage_dialogs_custom.py | 211 ++++++++---- .../ytsage_dialogs_selection.py | 80 +++-- .../ytsage_dialogs_settings.py | 96 +++--- .../ytsage_dialogs_update.py | 67 ++-- src/gui/ytsage_gui_format_table.py | 84 ++--- src/gui/ytsage_gui_main.py | 105 +++--- src/gui/ytsage_gui_video_info.py | 31 +- src/utils/ytsage_config_manager.py | 1 + src/utils/ytsage_localization.py | 266 +++++++++++++++ 12 files changed, 1293 insertions(+), 311 deletions(-) create mode 100644 languages/en.json create mode 100644 languages/es.json create mode 100644 src/utils/ytsage_localization.py diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..335cbca --- /dev/null +++ b/languages/en.json @@ -0,0 +1,316 @@ +{ + "app": { + "title": "YTSage", + "version": "v{version}", + "ready": "Ready" + }, + "formats": { + "show_formats": "Show Formats:", + "video_format": "Video Format:", + "audio_format": "Audio Format:", + "no_formats": "No formats available", + "loading": "Loading formats...", + "select": "Select", + "quality": "Quality", + "extension": "Extension", + "resolution": "Resolution", + "file_size": "File Size", + "codec": "Codec", + "audio": "Audio", + "notes": "Notes", + "will_merge_audio": "Will merge audio", + "has_audio": "✓ Has Audio", + "audio_only": "Audio Only", + "large_size": "Large size", + "medium_size": "Medium size", + "standard_size": "Standard size", + "compatible": "Compatible", + "efficient": "Efficient", + "high_quality": "High quality", + "best_4k": "Best (4K)", + "best_2k": "Best (2K)", + "high_1080p": "High (1080p)", + "high_720p": "High (720p)", + "medium_480p": "Medium (480p)", + "low_quality": "Low Quality", + "best_audio": "Best Audio", + "high_audio": "High Audio", + "medium_audio": "Medium Audio", + "low_audio": "Low Audio", + "audio_only_resolution": "Audio only" + }, + "buttons": { + "download": "Download", + "pause": "Pause", + "resume": "Resume", + "cancel": "Cancel", + "browse": "Browse", + "clear": "Clear", + "ok": "OK", + "apply": "Apply", + "close": "Close", + "run_command": "Run Command", + "custom_command_help": "Help", + "about": "About", + "analyze": "Analyze", + "paste_url": "Paste URL", + "select_videos": "Select Videos...", + "video": "Video", + "audio_only": "Audio Only", + "custom_options": "Custom Options", + "trim_video": "Trim Video", + "download_settings": "Download Settings", + "update": "Update yt-dlp", + "select_defaults": "Select Defaults", + "select_all": "Select All", + "deselect_all": "Deselect All" + }, + "dialogs": { + "custom_options": "Custom Options", + "settings": "Settings", + "select_folder": "Select Download Folder", + "sponsorblock_categories": "SponsorBlock Categories", + "sponsorblock_description": "Select which types of video segments to automatically remove during download.\nSponsorBlock uses community-submitted data to identify these segments.", + "select_subtitles": "Select Subtitles", + "filter_languages_placeholder": "Filter languages (e.g., en, es)...", + "no_subtitles_available": "No subtitles available", + "matching": "matching" + }, + "tabs": { + "cookies": "Login with Cookies", + "custom_command": "Custom Command", + "proxy": "Proxy", + "language": "Language" + }, + "cookies": { + "help_text": "Choose how to provide cookies for logging in.\nThis allows downloading of private videos and premium quality audio.", + "cookie_source": "Cookie Source", + "use_cookie_file": "Use cookie file", + "extract_from_browser": "Extract from browser", + "cookie_file": "Cookie File", + "cookie_file_placeholder": "Path to cookies.txt file...", + "browser_selection": "Browser Selection", + "browser_help": "Select the browser to extract cookies from:", + "browser_label": "Browser:", + "profile_label": "Profile:", + "profile_placeholder": "default", + "browser_extract_message": "Browser cookies will be extracted when applied", + "file_selected_message": "Cookie file selected - Click OK to apply", + "select_file_title": "Select Cookie File", + "file_filter": "Cookies files (*.txt *.lwp)" + }, + "custom_command": { + "help_text": "Enter your custom yt-dlp command below. The current URL will be appended automatically.

For the full list of options and usage examples, click here to view the official yt-dlp documentation.

Note: Download path and filename template will be handled automatically.", + "input_label": "yt-dlp Arguments:", + "input_placeholder": "Enter yt-dlp arguments here...\n\ne.g. --extract-audio --audio-format mp3", + "output_label": "Command Output:", + "output_placeholder": "Command output will appear here...", + "command_placeholder": "Enter custom yt-dlp command...", + "command_help": "Available placeholders:\n{url} - Video URL\n{output} - Output directory\n\nExample: --write-info-json --write-thumbnail", + "full_command": "🔧 Full command: {command}", + "command_success": "✅ Custom command completed successfully!", + "command_failed": "❌ Command failed with exit code {code}", + "command_error": "❌ Error running custom command: {error}" + }, + "proxy": { + "help_text": "Configure proxy settings for downloading. Leave empty to use direct connection.", + "main_proxy": "Main Proxy", + "main_proxy_help": "Primary proxy server for all downloads. Supports HTTP/HTTPS and SOCKS5 protocols.", + "proxy_url_label": "Proxy URL:", + "proxy_url_placeholder": "http://proxy:port or socks5://proxy:port", + "proxy_examples": "Examples:\n• HTTP: http://proxy.example.com:8080\n• SOCKS5: socks5://proxy.example.com:1080", + "geo_proxy": "Geo-bypass Proxy", + "geo_proxy_help": "Secondary proxy specifically for bypassing geographic restrictions.", + "geo_proxy_url_label": "Geo-bypass Proxy URL:", + "geo_proxy_url_placeholder": "http://geo-proxy:port", + "clear_main_proxy": "Clear Main Proxy", + "clear_geo_proxy": "Clear Geo Proxy", + "proxy_url": "Proxy URL", + "proxy_placeholder": "http://proxy:port or socks5://proxy:port", + "geo_bypass": "Geo-bypass proxy (for geographic restrictions)", + "geo_bypass_placeholder": "http://proxy:port for bypassing geo-blocks", + "invalid_main_url": "Invalid main proxy URL format", + "invalid_geo_url": "Invalid geo proxy URL format", + "main_configured": "Main proxy configured", + "geo_configured": "Geo proxy configured" + }, + "language": { + "help_text": "Select your preferred language for the interface.", + "current_language": "Current Language:", + "restart_notice": "Language change will take effect after restarting the application." + }, + "download": { + "preparing": "Preparing download...", + "please_enter_url": "Please enter a URL first.", + "please_set_path": "Please set a download path first.", + "please_enter_url_and_path": "Please enter a URL and set download path.", + "please_select_format": "Please select a format first." + }, + "update": { + "title": "Update yt-dlp", + "checking": "Checking for updates...", + "update_available": "Update available!\nCurrent version: {current}\nLatest version: {latest}", + "up_to_date": "yt-dlp is up to date (version {version})", + "could_not_determine": "Could not determine versions.", + "error_comparing": "Error comparing versions: {error}", + "update_available_failed": "Update available! (Comparison failed)\nCurrent: {current}\nLatest: {latest}", + "updating": "Updating...", + "initializing": "🚀 Initializing update process...", + "checking_current": "🔍 Checking current installation...", + "found_at": "📍 Found yt-dlp at: {path}", + "error_getting_path": "❌ Error getting yt-dlp path: {error}", + "updating_binary": "📦 Updating app-managed yt-dlp binary...", + "updating_pip": "🐍 Updating system yt-dlp via pip...", + "update_failed": "❌ Failed to update yt-dlp. Please try again or check your internet connection.", + "binary_updated": "✅ Binary successfully updated!", + "update_failed_stderr": "❌ yt-dlp update failed: {error}", + "update_timeout": "❌ yt-dlp update timed out.", + "unexpected_error": "❌ Unexpected error during update: {error}", + "checking_pip": "🔍 Checking current pip installation...", + "current_version": "📋 Current version: {version}", + "not_found_pip": "⚠️ yt-dlp not found via pip, attempting installation...", + "checking_latest": "🌐 Checking for latest version...", + "failed_check_updates": "❌ Failed to check for updates", + "latest_version": "🆕 Latest version: {version}", + "updating_from_to": "⬆️ Updating from {current} to {latest}...", + "running_pip_install": "📦 Running pip install --upgrade...", + "pip_completed": "✅ Pip update completed successfully!", + "pip_failed": "❌ Pip update failed: {error}" + }, + "about": { + "title": "About YTSage", + "version": "Version: {version}", + "description": "A modern YouTube downloader with advanced features", + "author": "Created with ❤️", + "license": "License: MIT", + "system_info": "System Information", + "python_version": "Python Version: {version}", + "pyside_version": "PySide6 Version: {version}", + "ytdlp_version": "yt-dlp Version: {version}", + "ffmpeg_status": "FFmpeg Status: {status}", + "ffmpeg_available": "Available", + "ffmpeg_not_available": "Not Available", + "error_getting_info": "Error getting system information: {error}" + }, + "time_range": { + "title": "Trim Video", + "start_time": "Start Time (HH:MM:SS)", + "end_time": "End Time (HH:MM:SS)", + "start_placeholder": "00:00:00", + "end_placeholder": "00:00:00", + "help_text": "Set the start and end times to trim the video.\nLeave empty to download the full video.", + "invalid_format": "Invalid time format. Use HH:MM:SS format.", + "start_after_end": "Start time cannot be after end time." + }, + "settings": { + "title": "Download Settings", + "download_path": "Download Path", + "browse": "Browse...", + "speed_limit": "Speed Limit", + "speed_limit_placeholder": "None", + "auto_update_ytdlp": "Auto-Update yt-dlp", + "enable_auto_updates": "Enable automatic yt-dlp updates", + "update_frequency": "Update frequency:", + "check_startup": "Check on every startup (minimum 1 hour between checks)", + "check_daily": "Check daily", + "check_weekly": "Check weekly", + "check_updates_now": "Check for Updates Now", + "update_check_title": "Update Check", + "could_not_determine_version": "Could not determine current yt-dlp version.", + "update_available_dialog": "Update available!\n\nCurrent: {current}\nLatest: {latest}\n\nUse the 'Update yt-dlp' button in the main window to update.", + "up_to_date_dialog": "yt-dlp is up to date!\n\nCurrent version: {version}", + "error_checking_updates": "Error checking for updates: {error}", + "settings_saved_title": "Settings Saved", + "settings_saved_message": "Auto-update settings have been saved successfully!", + "error_title": "Error", + "failed_save_settings": "Failed to save auto-update settings.", + "error_saving_settings": "Error saving auto-update settings: {error}", + "auto_update_title": "Auto-Update Settings", + "auto_update_header": "🔄 Auto-Update Settings", + "auto_update_description": "Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.", + "current_status": "Current Status", + "current_version_label": "Current yt-dlp version: Checking...", + "last_check_label": "Last update check: Never", + "next_check_label": "Next check: Based on settings", + "manual_check_button": "🔍 Check for Updates Now", + "update_frequency_group": "Update Frequency", + "save_settings": "Save Settings", + "settings_saved_successfully": "✅ Settings saved successfully!", + "error_saving": "❌ Error saving settings: {error}" + }, + "main_ui": { + "url_placeholder": "Enter YouTube video or playlist URL", + "merge_subtitles": "Merge Subtitles", + "save_thumbnail": "Save Thumbnail", + "save_description": "Save Description", + "embed_chapters": "Embed Chapters", + "subtitles_selected": "{count} selected", + "all_selected": "All selected", + "select_videos_all": "Select Videos... (All selected)", + "please_enter_url": "Please enter a URL first", + "cookie_file_selected_title": "Cookie File Selected", + "cookie_file_selected_message": "Cookie file selected: {path}", + "browser_cookies_selected_title": "Browser Cookies Selected", + "browser_cookies_selected_message": "Browser cookies will be extracted from: {browser}", + "error_no_format_info": "Error: No format information available.", + "error_extract_info": "Error: Could not extract basic video information. Please check your link.", + "analyzing_preparing": "Analyzing (0%)... Preparing request", + "analyzing_extracting_basic": "Analyzing (15%)... Extracting basic info", + "analyzing_extracting_detailed": "Analyzing (30%)... Extracting detailed info", + "analyzing_processing_video": "Analyzing (45%)... Processing video data", + "analyzing_processing_formats": "Analyzing (60%)... Processing formats", + "analyzing_loading_thumbnail": "Analyzing (75%)... Loading thumbnail", + "analyzing_processing_subtitles": "Analyzing (85%)... Processing subtitles", + "analyzing_updating_table": "Analyzing (95%)... Updating format table", + "analysis_complete": "Analysis complete!", + "analyzing_extracting_ytdlp": "Analyzing (30%)... Extracting info with yt-dlp executable", + "analyzing_processing_data": "Analyzing (60%)... Processing data", + "analyzing_processing_formats_ytdlp": "Analyzing (75%)... Processing formats", + "analyzing_loading_thumbnail_ytdlp": "Analyzing (85%)... Loading thumbnail", + "analyzing_processing_subtitles_ytdlp": "Analyzing (90%)... Processing subtitles", + "select_subtitles": "Select Subtitles...", + "sponsorblock_categories": "SponsorBlock Categories..." + }, + "sponsorblock": { + "sponsor": "Sponsor", + "sponsor_desc": "Paid promotion, paid referrals and direct advertisements", + "selfpromo": "Unpaid/Self Promotion", + "selfpromo_desc": "Unpaid promotion of creators' own content", + "interaction": "Interaction Reminder", + "interaction_desc": "Asking viewers to like, subscribe, or follow social media", + "intro": "Intro", + "intro_desc": "Video introduction that can be skipped", + "outro": "Outro/End Cards", + "outro_desc": "Credits or when the video ends", + "preview": "Preview/Recap", + "preview_desc": "Quick recap of previous videos or preview of what's coming up", + "music_offtopic": "Non-Music Section", + "music_offtopic_desc": "Only for music videos. Marks non-music sections", + "filler": "Filler Tangent", + "filler_desc": "Tangential scenes added only for filler or humor" + }, + "video_info": { + "channel": "Channel", + "views": "Views", + "likes": "Likes", + "upload_date": "Upload date", + "duration": "Duration", + "unknown_channel": "Unknown channel", + "unknown_date": "Unknown date", + "unknown_title": "Unknown title" + }, + "command": { + "running": "Running...", + "run_command": "Run Command" + }, + "selection": { + "none_selected": "0 selected", + "one_selected": "1 category selected", + "count_selected": "{count} selected" + }, + "status": { + "ready": "Ready", + "file_exists": "⚠️ File already exists", + "cancelling": "Cancelling download..." + } +} \ No newline at end of file diff --git a/languages/es.json b/languages/es.json new file mode 100644 index 0000000..b5d2941 --- /dev/null +++ b/languages/es.json @@ -0,0 +1,313 @@ +{ + "language": { + "display_name": "Español (Spanish)", + "select_language": "Seleccionar idioma:", + "current_language": "Idioma actual: {language}", + "restart_required": "Los cambios de idioma tendrán efecto después de reiniciar la aplicación.", + "english": "Inglés", + "spanish": "Español (Spanish)" + }, + "app": { + "title": "YTSage", + "version": "v{version}", + "ready": "Listo" + }, + "buttons": { + "download": "Descargar", + "pause": "Pausar", + "resume": "Reanudar", + "cancel": "Cancelar", + "browse": "Examinar", + "clear": "Limpiar", + "ok": "Aceptar", + "apply": "Aplicar", + "close": "Cerrar", + "run_command": "Ejecutar Comando", + "about": "Acerca de", + "update": "Actualizar", + "analyze": "Analizar", + "paste_url": "Pegar URL", + "select_videos": "Seleccionar Videos...", + "video": "Video", + "audio_only": "Solo Audio", + "custom_options": "Opciones Personalizadas", + "trim_video": "Recortar Video", + "download_settings": "Configuración de Descarga", + "select_defaults": "Seleccionar Predeterminados", + "select_all": "Seleccionar Todos", + "deselect_all": "Deseleccionar Todos" + }, + "dialogs": { + "custom_options": "Opciones Personalizadas", + "settings": "Configuración", + "select_folder": "Seleccionar Carpeta de Descarga", + "sponsorblock_categories": "Categorías SponsorBlock", + "sponsorblock_description": "Selecciona qué tipos de segmentos de video se eliminarán automáticamente durante la descarga.\nSponsorBlock utiliza datos enviados por la comunidad para identificar estos segmentos.", + "select_subtitles": "Seleccionar Subtítulos", + "filter_languages_placeholder": "Filtrar idiomas (ej., en, es)...", + "no_subtitles_available": "No hay subtítulos disponibles", + "matching": "que coincidan con" + }, + "tabs": { + "cookies": "Iniciar sesión con Cookies", + "custom_command": "Comando Personalizado", + "proxy": "Proxy", + "language": "Idioma" + }, + "cookies": { + "help_text": "Elige cómo proporcionar cookies para iniciar sesión.\nEsto permite la descarga de videos privados y audio de calidad premium.", + "cookie_source": "Origen de Cookie", + "use_cookie_file": "Usar archivo de cookie (formato Netscape)", + "extract_from_browser": "Extraer cookies del navegador", + "cookie_file": "Archivo de Cookie", + "cookie_file_placeholder": "Ruta al archivo de cookies (formato Netscape)", + "browser_selection": "Selección de Navegador", + "browser_help": "Selecciona el navegador del cual extraer cookies. Asegúrate de que el navegador esté cerrado antes de la extracción.", + "browser_label": "Navegador:", + "profile_label": "Perfil (opcional):", + "profile_placeholder": "Nombre o ruta del perfil (dejar vacío para el predeterminado)", + "browser_extract_message": "Las cookies del navegador se extraerán al aplicar", + "file_selected_message": "Archivo de cookies seleccionado - Haz clic en Aceptar para aplicar", + "select_file_title": "Seleccionar Archivo de Cookies", + "file_filter": "Archivos de cookies (*.txt *.lwp)" + }, + "custom_command": { + "help_text": "Ingresa tu comando yt-dlp personalizado a continuación. La URL actual se añadirá automáticamente.

Para ver la lista completa de opciones y ejemplos de uso, haz clic aquí para ver la documentación oficial de yt-dlp.

Nota: La ruta de descarga y la plantilla de nombre de archivo se manejarán automáticamente.", + "input_label": "Argumentos de yt-dlp:", + "input_placeholder": "Ingresa argumentos de yt-dlp aquí...\n\nej. --extract-audio --audio-format mp3", + "output_label": "Salida del Comando:", + "output_placeholder": "La salida del comando aparecerá aquí...", + "full_command": "🔧 Comando completo: {command}", + "command_failed": "❌ El comando falló con código de salida {code}", + "command_success": "✅ ¡Comando completado exitosamente!", + "command_error": "❌ Error ejecutando comando: {error}" + }, + "proxy": { + "help_text": "Configurar ajustes de proxy para conexiones de red y geo-verificación.\nEl proxy puede ayudar a evitar restricciones regionales y mejorar el rendimiento de descarga.", + "main_proxy": "Proxy Principal", + "main_proxy_help": "Usar el proxy HTTP/HTTPS/SOCKS especificado para todas las conexiones.", + "proxy_url_label": "URL del Proxy:", + "proxy_url_placeholder": "ej., http://proxy.example.com:8080 o socks5://user:pass@127.0.0.1:1080", + "proxy_examples": "Ejemplos: http://proxy.com:8080, https://proxy.com:8080, socks5://127.0.0.1:1080", + "geo_proxy": "Proxy de Geo-verificación", + "geo_proxy_help": "Usar este proxy para verificar dirección IP para sitios con restricciones geográficas. El proxy principal (si está configurado) se usa para la descarga real.", + "geo_proxy_url_label": "URL del Geo Proxy:", + "geo_proxy_url_placeholder": "ej., http://us-proxy.example.com:8080", + "clear_main_proxy": "Limpiar Proxy Principal", + "clear_geo_proxy": "Limpiar Geo Proxy", + "invalid_main_url": "Formato de URL de proxy principal inválido", + "invalid_geo_url": "Formato de URL de geo proxy inválido", + "main_configured": "Proxy principal configurado", + "geo_configured": "Geo proxy configurado" + }, + "download": { + "preparing": "🚀 Preparando tu descarga...", + "completed": "✅ ¡Descarga completada!", + "video_completed": "✅ ¡Descarga de video completada!", + "audio_completed": "✅ ¡Descarga de audio completada!", + "subtitle_completed": "✅ ¡Descarga de subtítulos completada!", + "please_set_path": "Por favor establece una ruta de descarga usando 'Cambiar Ruta'", + "please_enter_url": "Por favor ingresa una URL", + "please_enter_url_and_path": "Por favor ingresa URL y establece ruta de descarga", + "please_select_format": "Por favor selecciona un formato" + }, + "formats": { + "show_formats": "Mostrar formatos:", + "select": "Seleccionar", + "quality": "Calidad", + "extension": "Extensión", + "resolution": "Resolución", + "file_size": "Tamaño", + "codec": "Códec", + "audio": "Audio", + "notes": "Notas", + "will_merge_audio": "Se combinará audio", + "has_audio": "✓ Tiene Audio", + "audio_only": "Solo Audio", + "large_size": "Tamaño grande", + "medium_size": "Tamaño mediano", + "standard_size": "Tamaño estándar", + "compatible": "Compatible", + "efficient": "Eficiente", + "high_quality": "Alta calidad", + "best_4k": "Óptima (4K)", + "best_2k": "Óptima (2K)", + "high_1080p": "Alta (1080p)", + "high_720p": "Alta (720p)", + "medium_480p": "Media (480p)", + "low_quality": "Baja Calidad", + "best_audio": "Mejor Audio", + "high_audio": "Audio Alto", + "medium_audio": "Audio Medio", + "low_audio": "Audio Bajo", + "audio_only_resolution": "Solo audio" + }, + "about": { + "title": "Acerca de YTSage", + "version": "Versión {version}", + "description": "Descargador moderno de YouTube con una interfaz limpia de PySide6.", + "author": "Por: {author}", + "github": "GitHub: {repo}", + "system_info": "Información del Sistema", + "loading": "🔄 Cargando información del sistema...", + "refresh": "🔄", + "refreshing": "🔄 Actualizando...", + "refresh_failed": "Actualización Fallida", + "refresh_failed_message": "No se pudo actualizar la información de versión.", + "detected": "✓ Detectado", + "missing": "✗ Faltante", + "not_available": "No Disponible" + }, + "time_range": { + "title": "Descargar Sección del Video", + "help_text": "Descarga solo partes específicas de un video especificando rangos de tiempo.\nUsa formato HH:MM:SS o segundos. Deja inicio o fin vacío para descargar desde el principio o hasta el final.", + "time_range_group": "Rango de Tiempo", + "start_time": "Tiempo de Inicio:", + "end_time": "Tiempo de Fin:", + "start_time_placeholder": "00:00:00 (o dejar vacío para inicio)", + "end_time_placeholder": "00:10:00 (o dejar vacío para fin)", + "force_keyframes": "Forzar fotogramas clave en cortes (mejor precisión, más lento)" + }, + "update": { + "title": "Actualizar yt-dlp", + "checking": "Verificando actualizaciones...", + "update_available": "¡Actualización disponible!\nVersión actual: {current}\nÚltima versión: {latest}", + "up_to_date": "yt-dlp está actualizado (versión {version})", + "could_not_determine": "No se pudieron determinar las versiones.", + "error_comparing": "Error al comparar versiones: {error}", + "update_available_failed": "¡Actualización disponible! (Comparación fallida)\nActual: {current}\nÚltima: {latest}", + "updating": "Actualizando...", + "initializing": "🚀 Inicializando proceso de actualización...", + "checking_current": "🔍 Verificando instalación actual...", + "found_at": "📍 Se encontró yt-dlp en: {path}", + "error_getting_path": "❌ Error al obtener la ruta de yt-dlp: {error}", + "updating_binary": "📦 Actualizando binario de yt-dlp administrado por la app...", + "updating_pip": "🐍 Actualizando yt-dlp del sistema vía pip...", + "update_failed": "❌ Falló la actualización de yt-dlp. Inténtalo de nuevo o verifica tu conexión a internet.", + "binary_updated": "✅ ¡Binario actualizado exitosamente!", + "update_failed_stderr": "❌ Falló la actualización de yt-dlp: {error}", + "update_timeout": "❌ Tiempo agotado en la actualización de yt-dlp.", + "unexpected_error": "❌ Error inesperado durante la actualización: {error}", + "checking_pip": "🔍 Verificando instalación actual de pip...", + "current_version": "📋 Versión actual: {version}", + "not_found_pip": "⚠️ yt-dlp no encontrado vía pip, intentando instalación...", + "checking_latest": "🌐 Verificando última versión...", + "failed_check_updates": "❌ Falló la verificación de actualizaciones", + "latest_version": "🆕 Última versión: {version}", + "updating_from_to": "⬆️ Actualizando de {current} a {latest}...", + "running_pip_install": "📦 Ejecutando pip install --upgrade...", + "pip_completed": "✅ ¡Actualización de pip completada exitosamente!", + "pip_failed": "❌ Actualización de pip falló: {error}" + }, + "settings": { + "title": "Configuración de Descarga", + "download_path": "Ruta de Descarga", + "browse": "Examinar...", + "speed_limit": "Límite de Velocidad", + "speed_limit_placeholder": "Ninguno", + "auto_update_ytdlp": "Auto-Actualizar yt-dlp", + "enable_auto_updates": "Habilitar actualizaciones automáticas de yt-dlp", + "update_frequency": "Frecuencia de actualización:", + "check_startup": "Verificar en cada inicio (mínimo 1 hora entre verificaciones)", + "check_daily": "Verificar diariamente", + "check_weekly": "Verificar semanalmente", + "check_updates_now": "Verificar Actualizaciones Ahora", + "update_check_title": "Verificación de Actualizaciones", + "could_not_determine_version": "No se pudo determinar la versión actual de yt-dlp.", + "update_available_dialog": "¡Actualización disponible!\n\nActual: {current}\nÚltima: {latest}\n\nUsa el botón 'Actualizar yt-dlp' en la ventana principal para actualizar.", + "up_to_date_dialog": "¡yt-dlp está actualizado!\n\nVersión actual: {version}", + "error_checking_updates": "Error al verificar actualizaciones: {error}", + "settings_saved_title": "Configuración Guardada", + "settings_saved_message": "¡La configuración de auto-actualización se ha guardado exitosamente!", + "error_title": "Error", + "failed_save_settings": "Falló al guardar la configuración de auto-actualización.", + "error_saving_settings": "Error al guardar la configuración de auto-actualización: {error}", + "auto_update_title": "Configuración de Auto-Actualización", + "auto_update_header": "🔄 Configuración de Auto-Actualización", + "auto_update_description": "Configura las actualizaciones automáticas de yt-dlp para asegurar que siempre tengas las últimas funciones y correcciones de errores.", + "current_status": "Estado Actual", + "current_version_label": "Versión actual de yt-dlp: Verificando...", + "last_check_label": "Última verificación de actualización: Nunca", + "next_check_label": "Próxima verificación: Basado en configuración", + "manual_check_button": "🔍 Verificar Actualizaciones Ahora", + "update_frequency_group": "Frecuencia de Actualización", + "save_settings": "Guardar Configuración", + "settings_saved_successfully": "✅ ¡Configuración guardada exitosamente!", + "error_saving": "❌ Error al guardar configuración: {error}" + }, + "main_ui": { + "url_placeholder": "Ingresa URL de video o lista de YouTube", + "merge_subtitles": "Combinar Subtítulos", + "save_thumbnail": "Guardar Miniatura", + "save_description": "Guardar Descripción", + "embed_chapters": "Incrustar Capítulos", + "subtitles_selected": "{count} seleccionados", + "all_selected": "Todos seleccionados", + "select_videos_all": "Seleccionar Videos... (Todos seleccionados)", + "please_enter_url": "Por favor ingresa primero una URL", + "cookie_file_selected_title": "Archivo de Cookies Seleccionado", + "cookie_file_selected_message": "Archivo de cookies seleccionado: {path}", + "browser_cookies_selected_title": "Cookies de Navegador Seleccionadas", + "browser_cookies_selected_message": "Las cookies del navegador serán extraídas de: {browser}", + "error_no_format_info": "Error: No hay información de formato disponible.", + "error_extract_info": "Error: No se pudo extraer la información básica del video. Por favor verifica tu enlace.", + "analyzing_preparing": "Analizando (0%)... Preparando solicitud", + "analyzing_extracting_basic": "Analizando (15%)... Extrayendo información básica", + "analyzing_extracting_detailed": "Analizando (30%)... Extrayendo información detallada", + "analyzing_processing_video": "Analizando (45%)... Procesando datos de video", + "analyzing_processing_formats": "Analizando (60%)... Procesando formatos", + "analyzing_loading_thumbnail": "Analizando (75%)... Cargando miniatura", + "analyzing_processing_subtitles": "Analizando (85%)... Procesando subtítulos", + "analyzing_updating_table": "Analizando (95%)... Actualizando tabla de formatos", + "analysis_complete": "¡Análisis completo!", + "analyzing_extracting_ytdlp": "Analizando (30%)... Extrayendo información con ejecutable yt-dlp", + "analyzing_processing_data": "Analizando (60%)... Procesando datos", + "analyzing_processing_formats_ytdlp": "Analizando (75%)... Procesando formatos", + "analyzing_loading_thumbnail_ytdlp": "Analizando (85%)... Cargando miniatura", + "analyzing_processing_subtitles_ytdlp": "Analizando (90%)... Procesando subtítulos", + "select_subtitles": "Seleccionar Subtítulos...", + "sponsorblock_categories": "Categorías SponsorBlock..." + }, + "sponsorblock": { + "sponsor": "Patrocinador", + "sponsor_desc": "Promoción pagada, referencias pagadas y anuncios directos", + "selfpromo": "Autopromoción No Pagada", + "selfpromo_desc": "Promoción no pagada del propio contenido del creador", + "interaction": "Recordatorio de Interacción", + "interaction_desc": "Pedir a los espectadores que den like, se suscriban o sigan en redes sociales", + "intro": "Introducción", + "intro_desc": "Introducción del video que se puede omitir", + "outro": "Outro/Tarjetas Finales", + "outro_desc": "Créditos o cuando termina el video", + "preview": "Vista Previa/Resumen", + "preview_desc": "Resumen rápido de videos anteriores o vista previa de lo que viene", + "music_offtopic": "Sección No Musical", + "music_offtopic_desc": "Solo para videos musicales. Marca secciones no musicales", + "filler": "Relleno/Tangente", + "filler_desc": "Escenas tangenciales agregadas solo como relleno o humor" + }, + "video_info": { + "channel": "Canal", + "views": "Visualizaciones", + "likes": "Me gusta", + "upload_date": "Fecha de subida", + "duration": "Duración", + "unknown_channel": "Canal desconocido", + "unknown_date": "Fecha desconocida", + "unknown_title": "Título desconocido" + }, + "command": { + "running": "Ejecutando...", + "run_command": "Ejecutar Comando" + }, + "selection": { + "none_selected": "0 seleccionados", + "one_selected": "1 categoría seleccionada", + "count_selected": "{count} seleccionados" + }, + "status": { + "ready": "Listo", + "file_exists": "⚠️ El archivo ya existe", + "cancelling": "Cancelando descarga..." + } +} \ No newline at end of file diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py index e625171..712b7ed 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py @@ -19,6 +19,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from src.utils.ytsage_localization import _ + from src.core.ytsage_ffmpeg import get_ffmpeg_path from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path @@ -60,7 +62,7 @@ class AboutDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) self._parent = parent # Store parent to access version etc. - self.setWindowTitle("About YTSage") + self.setWindowTitle(_("about.title")) self.setMinimumSize(460, 420) # Slightly increased to accommodate paths self.resize(460, 440) # Slightly increased initial size self.setMaximumSize(500, 480) # Reasonable maximum size @@ -157,13 +159,13 @@ class AboutDialog(QDialog): layout.addWidget(title_label) version_label = QLabel( - f"Version {getattr(self._parent, 'version', '4.9.0b')}" + f"{_('about.version', version=getattr(self._parent, 'version', '4.9.0b'))}" ) version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(version_label) # Description - more compact - description_label = QLabel("Modern YouTube downloader with a clean PySide6 interface.") + description_label = QLabel(_("about.description")) description_label.setWordWrap(True) description_label.setAlignment(Qt.AlignmentFlag.AlignCenter) description_label.setStyleSheet("color: #ffffff; font-size: 11px; margin: 6px 0;") @@ -174,13 +176,13 @@ class AboutDialog(QDialog): info_layout.setSpacing(15) author_label = QLabel( - "By: oop7" + f"{_('about.author', author='oop7')}" ) author_label.setOpenExternalLinks(True) info_layout.addWidget(author_label) repo_label = QLabel( - "GitHub: YTSage" + f"{_('about.github', repo='YTSage')}" ) repo_label.setOpenExternalLinks(True) info_layout.addWidget(repo_label) @@ -219,7 +221,7 @@ class AboutDialog(QDialog): header_layout.setContentsMargins(0, 0, 0, 5) # System Information title - title_label = QLabel("System Information") + title_label = QLabel(_("about.system_info")) title_label.setStyleSheet( """ QLabel { @@ -237,7 +239,7 @@ class AboutDialog(QDialog): header_layout.addStretch() # Create refresh button - self.refresh_btn = QPushButton("🔄") + self.refresh_btn = QPushButton(_("about.refresh")) self.refresh_btn.setFixedSize(16, 16) self.refresh_btn.setStyleSheet( """ @@ -283,7 +285,7 @@ class AboutDialog(QDialog): def _show_loading_message(self) -> None: """Show a compact loading message while system information is being gathered.""" - loading_label = QLabel("🔄 Loading system information...") + loading_label = QLabel(_("about.loading")) loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) loading_label.setStyleSheet( """ @@ -381,7 +383,7 @@ class AboutDialog(QDialog): # yt-dlp Status - compact version with path ytdlp_found = check_ytdlp_installed() ytdlp_status_text = ( - "✓ Detected" if ytdlp_found else "✗ Missing" + f"{_('about.detected')}" if ytdlp_found else f"{_('about.missing')}" ) ytdlp_version = get_ytdlp_version() @@ -409,11 +411,11 @@ class AboutDialog(QDialog): # FFmpeg Status - compact version with path ffmpeg_found = check_ffmpeg() ffmpeg_status_text = ( - "✓ Detected" + f"{_('about.detected')}" if ffmpeg_found - else "✗ Missing" + else f"{_('about.missing')}" ) - ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available" + ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else _('about.not_available') # Get FFmpeg path ffmpeg_path_text = None @@ -440,7 +442,7 @@ class AboutDialog(QDialog): def refresh_version_info(self) -> None: """Refresh version information manually.""" - self.refresh_btn.setText("🔄 Refreshing...") + self.refresh_btn.setText(_('about.refreshing')) self.refresh_btn.setEnabled(False) # Perform refresh in a separate thread to avoid blocking UI @@ -457,7 +459,7 @@ class AboutDialog(QDialog): def on_refresh_finished(self, success) -> None: """Handle refresh completion.""" - self.refresh_btn.setText("🔄 Refresh") + self.refresh_btn.setText(_('about.refresh')) self.refresh_btn.setEnabled(True) if success: @@ -466,8 +468,8 @@ class AboutDialog(QDialog): # Show error message with proper styling msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.setWindowTitle("Refresh Failed") - msg_box.setText("Failed to refresh version information.") + msg_box.setWindowTitle(_('about.refresh_failed')) + msg_box.setText(_('about.refresh_failed_message')) msg_box.setWindowIcon(self.windowIcon()) msg_box.setStyleSheet( """ diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py index 61218d0..b3ef419 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py @@ -31,6 +31,8 @@ from PySide6.QtWidgets import ( from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.utils.ytsage_constants import YTDLP_DOCS_URL from src.utils.ytsage_config_manager import ConfigManager +from src.utils.ytsage_localization import LocalizationManager, _ +from src.utils.ytsage_logger import logger if TYPE_CHECKING: from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import) @@ -68,7 +70,7 @@ class CommandWorker(QObject): base_cmd.append(self.url) # Emit the full command - self.output_received.emit(f"🔧 Full command: {' '.join(str(cmd) for cmd in base_cmd)}") + self.output_received.emit(_('custom_command.full_command', command=' '.join(str(cmd) for cmd in base_cmd))) self.output_received.emit("=" * 50) # Run the command @@ -90,22 +92,22 @@ class CommandWorker(QObject): self.output_received.emit("=" * 50) if ret != 0: - self.output_received.emit(f"❌ Command failed with exit code {ret}") + self.output_received.emit(_('custom_command.command_failed', code=ret)) self.command_finished.emit(False, ret) else: - self.output_received.emit("✅ Command completed successfully!") + self.output_received.emit(_('custom_command.command_success')) self.command_finished.emit(True, ret) except Exception as e: self.output_received.emit("=" * 50) - self.error_occurred.emit(f"❌ Error executing command: {str(e)}") + self.error_occurred.emit(_('custom_command.command_error', error=str(e))) class CustomOptionsDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking. - self.setWindowTitle("Custom Options") + self.setWindowTitle(_("dialogs.custom_options")) self.setMinimumSize(550, 400) # Made even shorter layout = QVBoxLayout(self) @@ -118,38 +120,35 @@ class CustomOptionsDialog(QDialog): cookies_layout = QVBoxLayout(cookies_tab) # Help text - help_text = QLabel( - "Choose how to provide cookies for logging in.\n" - "This allows downloading of private videos and premium quality audio." - ) + help_text = QLabel(_('cookies.help_text')) help_text.setWordWrap(True) help_text.setStyleSheet("color: #999999; padding: 10px;") cookies_layout.addWidget(help_text) # Cookie source selection - cookie_source_group = QGroupBox("Cookie Source") + cookie_source_group = QGroupBox(_('cookies.cookie_source')) cookie_source_layout = QVBoxLayout(cookie_source_group) # Radio buttons for cookie source - self.cookie_file_radio = QRadioButton("Use cookie file (Netscape format)") + self.cookie_file_radio = QRadioButton(_('cookies.use_cookie_file')) self.cookie_file_radio.setChecked(True) self.cookie_file_radio.toggled.connect(self.on_cookie_source_changed) cookie_source_layout.addWidget(self.cookie_file_radio) - self.cookie_browser_radio = QRadioButton("Extract cookies from browser") + self.cookie_browser_radio = QRadioButton(_('cookies.extract_from_browser')) self.cookie_browser_radio.toggled.connect(self.on_cookie_source_changed) cookie_source_layout.addWidget(self.cookie_browser_radio) cookies_layout.addWidget(cookie_source_group) # Cookie file section - self.cookie_file_group = QGroupBox("Cookie File") + self.cookie_file_group = QGroupBox(_('cookies.cookie_file')) file_layout = QVBoxLayout(self.cookie_file_group) # File path input and browse button path_layout = QHBoxLayout() self.cookie_path_input = QLineEdit() - self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)") + self.cookie_path_input.setPlaceholderText(_('cookies.cookie_file_placeholder')) if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path: # Convert Path to string properly and validate cookie_path_str = str(self._parent.cookie_file_path) @@ -158,7 +157,7 @@ class CustomOptionsDialog(QDialog): self.cookie_path_input.setText(cookie_path_str) path_layout.addWidget(self.cookie_path_input) - self.browse_button = QPushButton("Browse") + self.browse_button = QPushButton(_('buttons.browse')) self.browse_button.clicked.connect(self.browse_cookie_file) path_layout.addWidget(self.browse_button) file_layout.addLayout(path_layout) @@ -166,16 +165,16 @@ class CustomOptionsDialog(QDialog): cookies_layout.addWidget(self.cookie_file_group) # Browser selection section - self.cookie_browser_group = QGroupBox("Browser Selection") + self.cookie_browser_group = QGroupBox(_('cookies.browser_selection')) browser_layout = QVBoxLayout(self.cookie_browser_group) - browser_help = QLabel("Select the browser to extract cookies from. Make sure the browser is closed before extraction.") + browser_help = QLabel(_('cookies.browser_help')) browser_help.setWordWrap(True) browser_help.setStyleSheet("color: #999999; font-size: 11px;") browser_layout.addWidget(browser_help) browser_select_layout = QHBoxLayout() - browser_select_layout.addWidget(QLabel("Browser:")) + browser_select_layout.addWidget(QLabel(_('cookies.browser_label'))) self.browser_combo = QComboBox() self.browser_combo.addItems(["chrome", "firefox", "safari", "edge", "opera", "brave", "chromium", "vivaldi"]) @@ -184,9 +183,9 @@ class CustomOptionsDialog(QDialog): # Optional profile field profile_layout = QHBoxLayout() - profile_layout.addWidget(QLabel("Profile (optional):")) + profile_layout.addWidget(QLabel(_('cookies.profile_label'))) self.profile_input = QLineEdit() - self.profile_input.setPlaceholderText("Profile name or path (leave empty for default)") + self.profile_input.setPlaceholderText(_('cookies.profile_placeholder')) profile_layout.addWidget(self.profile_input) browser_layout.addLayout(profile_layout) @@ -207,12 +206,7 @@ class CustomOptionsDialog(QDialog): command_layout = QVBoxLayout(command_tab) # Improved help text - cmd_help_text = QLabel( - "Enter your custom yt-dlp command below. The current URL will be automatically appended.

" - "For complete list of options and usage examples, " - f'click here to view the official yt-dlp documentation.

' - "Note: Download path and output filename template will be automatically handled." - ) + cmd_help_text = QLabel(_('custom_command.help_text', docs_url=YTDLP_DOCS_URL)) cmd_help_text.setWordWrap(True) cmd_help_text.setOpenExternalLinks(True) # Enable clicking links cmd_help_text.setTextFormat(Qt.TextFormat.RichText) # Enable HTML rendering @@ -238,13 +232,13 @@ class CustomOptionsDialog(QDialog): command_layout.addWidget(cmd_help_text) # Command input label - input_label = QLabel("yt-dlp Arguments:") + input_label = QLabel(_('custom_command.input_label')) input_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin-top: 10px;") command_layout.addWidget(input_label) # Command input self.command_input = QPlainTextEdit() - self.command_input.setPlaceholderText("Enter yt-dlp arguments here...\n\n" "e.g. --extract-audio --audio-format mp3") + self.command_input.setPlaceholderText(_('custom_command.input_placeholder')) self.command_input.setMinimumHeight(80) # Reduced further from 100 self.command_input.setStyleSheet( """ @@ -269,7 +263,7 @@ class CustomOptionsDialog(QDialog): button_layout = QHBoxLayout() button_layout.setSpacing(10) - clear_btn = QPushButton("Clear") + clear_btn = QPushButton(_('buttons.clear')) clear_btn.clicked.connect(lambda: self.command_input.clear()) clear_btn.setStyleSheet( """ @@ -292,7 +286,7 @@ class CustomOptionsDialog(QDialog): button_layout.addStretch() # Push run button to the right # Run command button - self.run_btn = QPushButton("Run Command") + self.run_btn = QPushButton(_('buttons.run_command')) self.run_btn.clicked.connect(self.run_custom_command) self.run_btn.setDefault(True) button_layout.addWidget(self.run_btn) @@ -300,14 +294,14 @@ class CustomOptionsDialog(QDialog): command_layout.addLayout(button_layout) # Output label - output_label = QLabel("Command Output:") + output_label = QLabel(_('custom_command.output_label')) output_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin-top: 15px;") command_layout.addWidget(output_label) # Log output self.log_output = QTextEdit() self.log_output.setReadOnly(True) - self.log_output.setPlaceholderText("Command output will appear here...") + self.log_output.setPlaceholderText(_('custom_command.output_placeholder')) self.log_output.setMinimumHeight(100) # Reduced further from 120 self.log_output.setStyleSheet( """ @@ -332,56 +326,50 @@ class CustomOptionsDialog(QDialog): proxy_layout = QVBoxLayout(proxy_tab) # Help text - proxy_help_text = QLabel( - "Configure proxy settings for network connections and geo-verification.\n" - "Proxy can help bypass regional restrictions and improve download performance." - ) + proxy_help_text = QLabel(_('proxy.help_text')) proxy_help_text.setWordWrap(True) proxy_help_text.setStyleSheet("color: #999999; padding: 10px;") proxy_layout.addWidget(proxy_help_text) # Main Proxy section - main_proxy_group = QGroupBox("Main Proxy") + main_proxy_group = QGroupBox(_('proxy.main_proxy')) main_proxy_layout = QVBoxLayout(main_proxy_group) - main_proxy_help = QLabel("Use the specified HTTP/HTTPS/SOCKS proxy for all connections.") + main_proxy_help = QLabel(_('proxy.main_proxy_help')) main_proxy_help.setWordWrap(True) main_proxy_help.setStyleSheet("color: #999999; font-size: 11px;") main_proxy_layout.addWidget(main_proxy_help) # Main proxy input main_proxy_input_layout = QHBoxLayout() - main_proxy_input_layout.addWidget(QLabel("Proxy URL:")) + main_proxy_input_layout.addWidget(QLabel(_('proxy.proxy_url_label'))) self.proxy_url_input = QLineEdit() - self.proxy_url_input.setPlaceholderText("e.g., http://proxy.example.com:8080 or socks5://user:pass@127.0.0.1:1080") + self.proxy_url_input.setPlaceholderText(_('proxy.proxy_url_placeholder')) self.proxy_url_input.textChanged.connect(self.validate_proxy_inputs) main_proxy_input_layout.addWidget(self.proxy_url_input) main_proxy_layout.addLayout(main_proxy_input_layout) # Example text - example_label = QLabel("Examples: http://proxy.com:8080, https://proxy.com:8080, socks5://127.0.0.1:1080") + example_label = QLabel(_('proxy.proxy_examples')) example_label.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;") main_proxy_layout.addWidget(example_label) proxy_layout.addWidget(main_proxy_group) # Geo-verification Proxy section - geo_proxy_group = QGroupBox("Geo-verification Proxy") + geo_proxy_group = QGroupBox(_('proxy.geo_proxy')) geo_proxy_layout = QVBoxLayout(geo_proxy_group) - geo_proxy_help = QLabel( - "Use this proxy to verify IP address for geo-restricted sites. " - "The main proxy (if set) is used for actual downloading." - ) + geo_proxy_help = QLabel(_('proxy.geo_proxy_help')) geo_proxy_help.setWordWrap(True) geo_proxy_help.setStyleSheet("color: #999999; font-size: 11px;") geo_proxy_layout.addWidget(geo_proxy_help) # Geo proxy input geo_proxy_input_layout = QHBoxLayout() - geo_proxy_input_layout.addWidget(QLabel("Geo Proxy URL:")) + geo_proxy_input_layout.addWidget(QLabel(_('proxy.geo_proxy_url_label'))) self.geo_proxy_url_input = QLineEdit() - self.geo_proxy_url_input.setPlaceholderText("e.g., http://us-proxy.example.com:8080") + self.geo_proxy_url_input.setPlaceholderText(_('proxy.geo_proxy_url_placeholder')) self.geo_proxy_url_input.textChanged.connect(self.validate_proxy_inputs) geo_proxy_input_layout.addWidget(self.geo_proxy_url_input) geo_proxy_layout.addLayout(geo_proxy_input_layout) @@ -395,7 +383,7 @@ class CustomOptionsDialog(QDialog): # Clear buttons clear_layout = QHBoxLayout() - clear_main_proxy_btn = QPushButton("Clear Main Proxy") + clear_main_proxy_btn = QPushButton(_('proxy.clear_main_proxy')) clear_main_proxy_btn.clicked.connect(lambda: self.proxy_url_input.clear()) clear_main_proxy_btn.setStyleSheet( """ @@ -414,7 +402,7 @@ class CustomOptionsDialog(QDialog): ) clear_layout.addWidget(clear_main_proxy_btn) - clear_geo_proxy_btn = QPushButton("Clear Geo Proxy") + clear_geo_proxy_btn = QPushButton(_('proxy.clear_geo_proxy')) clear_geo_proxy_btn.clicked.connect(lambda: self.geo_proxy_url_input.clear()) clear_geo_proxy_btn.setStyleSheet( """ @@ -438,13 +426,75 @@ class CustomOptionsDialog(QDialog): proxy_layout.addStretch() + # === Language Tab === + language_tab = QWidget() + language_layout = QVBoxLayout(language_tab) + + # Help text + language_help_text = QLabel(_("language.select_language")) + language_help_text.setWordWrap(True) + language_help_text.setStyleSheet("color: #ffffff; font-size: 14px; font-weight: bold; padding: 10px;") + language_layout.addWidget(language_help_text) + + # Current language info + current_lang = ConfigManager.get("language") or "en" + available_languages = LocalizationManager.get_available_languages() + current_lang_display = available_languages.get(current_lang, current_lang.upper()) + + current_lang_label = QLabel(_("language.current_language", language=current_lang_display)) + current_lang_label.setWordWrap(True) + current_lang_label.setStyleSheet("color: #999999; padding: 10px;") + language_layout.addWidget(current_lang_label) + + # Language selection group + language_group = QGroupBox(_("language.select_language")) + language_group_layout = QVBoxLayout(language_group) + + # Language selection combo box + language_select_layout = QHBoxLayout() + language_select_layout.addWidget(QLabel(_("language.select_language") + ":")) + + self.language_combo = QComboBox() + + # Populate language combo with available languages + for lang_code, display_name in available_languages.items(): + self.language_combo.addItem(display_name, lang_code) + + # Set current selection + current_index = self.language_combo.findData(current_lang) + if current_index >= 0: + self.language_combo.setCurrentIndex(current_index) + + # Connect language change event + self.language_combo.currentIndexChanged.connect(self.on_language_changed) + + language_select_layout.addWidget(self.language_combo) + language_group_layout.addLayout(language_select_layout) + + language_layout.addWidget(language_group) + + # Restart notice + self.restart_notice = QLabel(_("language.restart_required")) + self.restart_notice.setWordWrap(True) + self.restart_notice.setStyleSheet( + "color: #ffaa00; font-style: italic; padding: 10px; " + "background-color: #2a2d36; border-radius: 6px; margin: 10px;" + ) + self.restart_notice.setVisible(False) # Initially hidden + language_layout.addWidget(self.restart_notice) + + language_layout.addStretch() + # Add tabs to the tab widget - self.tab_widget.addTab(cookies_tab, "Login with Cookies") - self.tab_widget.addTab(command_tab, "Custom Command") - self.tab_widget.addTab(proxy_tab, "Proxy") + self.tab_widget.addTab(cookies_tab, _("tabs.cookies")) + self.tab_widget.addTab(command_tab, _("tabs.custom_command")) + self.tab_widget.addTab(proxy_tab, _("tabs.proxy")) + self.tab_widget.addTab(language_tab, _("tabs.language")) # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) @@ -524,7 +574,6 @@ class CustomOptionsDialog(QDialog): border: none; width: 12px; height: 12px; - background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIHZpZXdCb3g9IjAgMCAxMiAxMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgNEw2IDdMOSA0IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=); } QComboBox QAbstractItemView { background-color: #1d1e22; @@ -610,18 +659,18 @@ class CustomOptionsDialog(QDialog): else: self.cookie_file_group.setVisible(False) self.cookie_browser_group.setVisible(True) - self.cookie_status.setText("Browser cookies will be extracted when applied") + self.cookie_status.setText(_("cookies.browser_extract_message")) self.cookie_status.setStyleSheet("color: #ffaa00; font-style: italic;") def browse_cookie_file(self) -> None: # Open file dialog to select cookie file - selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)") + selected_files, _ = QFileDialog.getOpenFileName(self, _("cookies.select_file_title"), "", _("cookies.file_filter")) if selected_files: # Ensure we have a valid full path cookie_path = Path(selected_files).resolve() self.cookie_path_input.setText(str(cookie_path)) - self.cookie_status.setText("Cookie file selected - Click OK to apply") + self.cookie_status.setText(_("cookies.file_selected_message")) self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;") def get_cookie_file_path(self) -> Path | None: @@ -760,7 +809,7 @@ class CustomOptionsDialog(QDialog): self.log_output.append(f"📁 Download path: {path}") self.log_output.append("=" * 50) self.run_btn.setEnabled(False) - self.run_btn.setText("Running...") + self.run_btn.setText(_("command.running")) # Create worker and thread self.worker = CommandWorker(command, url, path) @@ -781,49 +830,63 @@ class CustomOptionsDialog(QDialog): def on_command_finished(self, success: bool, exit_code: int): """Slot for when command finishes""" self.run_btn.setEnabled(True) - self.run_btn.setText("Run Command") + self.run_btn.setText(_("command.run_command")) def on_error_occurred(self, error_msg: str): """Slot for handling errors""" self.log_output.append(error_msg) self.run_btn.setEnabled(True) - self.run_btn.setText("Run Command") + self.run_btn.setText(_("buttons.run_command")) + + def on_language_changed(self) -> None: + """Handle language selection change""" + selected_lang_code = self.language_combo.currentData() + if selected_lang_code: + current_lang = ConfigManager.get("language") or "en" + if selected_lang_code != current_lang: + # Save the new language preference + ConfigManager.set("language", selected_lang_code) + + # Update LocalizationManager + LocalizationManager.set_language(selected_lang_code) + + # Show restart notice + self.restart_notice.setVisible(True) + + logger.info(f"Language changed to: {selected_lang_code}") class TimeRangeDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("Download Video Section") + self.setWindowTitle(_('time_range.title')) self.setMinimumWidth(400) layout = QVBoxLayout(self) # Help text explaining the feature - help_text = QLabel( - "Download only specific parts of a video by specifying time ranges.\n" - "Use HH:MM:SS format or seconds. Leave start or end empty to download from beginning or to end." - ) + help_text = QLabel(_('time_range.help_text')) help_text.setWordWrap(True) help_text.setStyleSheet("color: #999999; padding: 10px;") layout.addWidget(help_text) # Time range section - time_group = QGroupBox("Time Range") + time_group = QGroupBox(_('time_range.time_range_group')) time_layout = QVBoxLayout() # Start time row start_layout = QHBoxLayout() - start_layout.addWidget(QLabel("Start Time:")) + start_layout.addWidget(QLabel(_('time_range.start_time'))) self.start_time_input = QLineEdit() - self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)") + self.start_time_input.setPlaceholderText(_('time_range.start_time_placeholder')) start_layout.addWidget(self.start_time_input) time_layout.addLayout(start_layout) # End time row end_layout = QHBoxLayout() - end_layout.addWidget(QLabel("End Time:")) + end_layout.addWidget(QLabel(_('time_range.end_time'))) self.end_time_input = QLineEdit() - self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)") + self.end_time_input.setPlaceholderText(_('time_range.end_time_placeholder')) end_layout.addWidget(self.end_time_input) time_layout.addLayout(end_layout) @@ -831,7 +894,7 @@ class TimeRangeDialog(QDialog): layout.addWidget(time_group) # Force keyframes option - self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)") + self.force_keyframes = QCheckBox(_('time_range.force_keyframes')) self.force_keyframes.setChecked(True) self.force_keyframes.setStyleSheet( """ @@ -859,7 +922,9 @@ class TimeRangeDialog(QDialog): layout.addWidget(self.force_keyframes) # Buttons - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py index 876c606..946ea76 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_selection.py @@ -17,11 +17,13 @@ from PySide6.QtWidgets import ( QWidget, ) +from src.utils.ytsage_localization import _ + class SubtitleSelectionDialog(QDialog): def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("Select Subtitles") + self.setWindowTitle(_("dialogs.select_subtitles")) self.setMinimumWidth(400) self.setMinimumHeight(300) @@ -35,7 +37,7 @@ class SubtitleSelectionDialog(QDialog): # Filter input self.filter_input = QLineEdit() - self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...") + self.filter_input.setPlaceholderText(_("dialogs.filter_languages_placeholder")) self.filter_input.textChanged.connect(self.filter_list) self.filter_input.setStyleSheet( """ @@ -72,7 +74,9 @@ class SubtitleSelectionDialog(QDialog): self.populate_list() # OK and Cancel buttons - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) @@ -128,7 +132,8 @@ class SubtitleSelectionDialog(QDialog): combined_subs[lang_code] = f"{lang_code} - Auto-generated" if not combined_subs: - no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else "")) + matching_text = _("dialogs.matching") if filter_text else "" + no_subs_label = QLabel(_("dialogs.no_subtitles_available") + (f" {matching_text} '{filter_text}'" if filter_text else "")) no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;") self.list_layout.addWidget(no_subs_label) return @@ -205,8 +210,8 @@ class PlaylistSelectionDialog(QDialog): # Top buttons (Select/Deselect All) button_layout = QHBoxLayout() - select_all_btn = QPushButton("Select All") - deselect_all_btn = QPushButton("Deselect All") + select_all_btn = QPushButton(_("buttons.select_all")) + deselect_all_btn = QPushButton(_("buttons.deselect_all")) select_all_btn.clicked.connect(self._select_all) deselect_all_btn.clicked.connect(self._deselect_all) # Style the buttons to match the subtitle dialog @@ -250,7 +255,9 @@ class PlaylistSelectionDialog(QDialog): self._populate_list(previously_selected_string) # Dialog buttons (OK/Cancel) - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) @@ -428,50 +435,50 @@ class SponsorBlockCategoryDialog(QDialog): # Default SponsorBlock categories with descriptions SPONSORBLOCK_CATEGORIES = { "sponsor": { - "name": "Sponsor", - "description": "Paid promotion, paid referrals and direct advertisements", + "name_key": "sponsorblock.sponsor", + "description_key": "sponsorblock.sponsor_desc", "default": True, }, "selfpromo": { - "name": "Unpaid/Self Promotion", - "description": "Unpaid promotion of creators' own content", + "name_key": "sponsorblock.selfpromo", + "description_key": "sponsorblock.selfpromo_desc", "default": True, }, "interaction": { - "name": "Interaction Reminder", - "description": "Asking viewers to like, subscribe, or follow social media", + "name_key": "sponsorblock.interaction", + "description_key": "sponsorblock.interaction_desc", "default": True, }, "intro": { - "name": "Intro", - "description": "Video introduction that can be skipped", + "name_key": "sponsorblock.intro", + "description_key": "sponsorblock.intro_desc", "default": False, }, "outro": { - "name": "Outro/End Cards", - "description": "Credits or when the video ends", + "name_key": "sponsorblock.outro", + "description_key": "sponsorblock.outro_desc", "default": False, }, "preview": { - "name": "Preview/Recap", - "description": "Quick recap of previous videos or preview of what's coming up", + "name_key": "sponsorblock.preview", + "description_key": "sponsorblock.preview_desc", "default": False, }, "music_offtopic": { - "name": "Non-Music Section", - "description": "Only for music videos. Marks non-music sections", + "name_key": "sponsorblock.music_offtopic", + "description_key": "sponsorblock.music_offtopic_desc", "default": False, }, "filler": { - "name": "Filler Tangent", - "description": "Tangential scenes added only for filler or humor", + "name_key": "sponsorblock.filler", + "description_key": "sponsorblock.filler_desc", "default": False, }, } def __init__(self, previously_selected=None, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("SponsorBlock Categories") + self.setWindowTitle(_("dialogs.sponsorblock_categories")) self.setMinimumWidth(500) self.setMinimumHeight(400) @@ -489,15 +496,12 @@ class SponsorBlockCategoryDialog(QDialog): layout = QVBoxLayout(self) # Title and description - title_label = QLabel("SponsorBlock Categories") + title_label = QLabel(_("dialogs.sponsorblock_categories")) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;") layout.addWidget(title_label) - desc_label = QLabel( - "Select which types of video segments to automatically remove during download.\n" - "SponsorBlock uses community-submitted data to identify these segments." - ) + desc_label = QLabel(_("dialogs.sponsorblock_description")) desc_label.setWordWrap(True) desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;") @@ -521,8 +525,8 @@ class SponsorBlockCategoryDialog(QDialog): category_layout.setContentsMargins(0, 0, 0, 0) category_layout.setSpacing(2) - # Create checkbox with just the name - checkbox = QCheckBox(category_info["name"]) + # Create checkbox with localized name + checkbox = QCheckBox(_(category_info["name_key"])) checkbox.setProperty("category_id", category_id) # Determine if this category should be checked @@ -559,8 +563,8 @@ class SponsorBlockCategoryDialog(QDialog): """ ) - # Create description label - desc_label = QLabel(category_info["description"]) + # Create description label with localized text + desc_label = QLabel(_(category_info["description_key"])) desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;") desc_label.setWordWrap(True) @@ -577,15 +581,15 @@ class SponsorBlockCategoryDialog(QDialog): # Quick selection buttons button_layout = QHBoxLayout() - select_defaults_btn = QPushButton("Select Defaults") + select_defaults_btn = QPushButton(_("buttons.select_defaults")) select_defaults_btn.clicked.connect(self.select_defaults) select_defaults_btn.setStyleSheet(self._get_button_style()) - select_all_btn = QPushButton("Select All") + select_all_btn = QPushButton(_("buttons.select_all")) select_all_btn.clicked.connect(self.select_all) select_all_btn.setStyleSheet(self._get_button_style()) - deselect_all_btn = QPushButton("Deselect All") + deselect_all_btn = QPushButton(_("buttons.deselect_all")) deselect_all_btn.clicked.connect(self.deselect_all) deselect_all_btn.setStyleSheet(self._get_button_style()) @@ -597,7 +601,9 @@ class SponsorBlockCategoryDialog(QDialog): layout.addLayout(button_layout) # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py index bac857d..958b03e 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py @@ -34,12 +34,13 @@ from src.core.ytsage_utils import ( update_auto_update_settings, ) from src.utils.ytsage_logger import logger +from src.utils.ytsage_localization import _ class DownloadSettingsDialog(QDialog): def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("Download Settings") + self.setWindowTitle(_("settings.title")) self.setMinimumWidth(450) self.setMinimumHeight(400) self.current_path = current_path @@ -163,7 +164,7 @@ class DownloadSettingsDialog(QDialog): layout = QVBoxLayout(self) # --- Download Path Section --- - path_group_box = QGroupBox("Download Path") + path_group_box = QGroupBox(_("settings.download_path")) path_layout = QVBoxLayout() self.path_display = QLabel(str(self.current_path)) @@ -173,7 +174,8 @@ class DownloadSettingsDialog(QDialog): ) path_layout.addWidget(self.path_display) - browse_button = QPushButton("Browse...") + + browse_button = QPushButton(_("settings.browse")) browse_button.clicked.connect(self.browse_new_path) path_layout.addWidget(browse_button) @@ -181,11 +183,11 @@ class DownloadSettingsDialog(QDialog): layout.addWidget(path_group_box) # --- Speed Limit Section --- - speed_group_box = QGroupBox("Speed Limit") + speed_group_box = QGroupBox(_("settings.speed_limit")) speed_layout = QHBoxLayout() self.speed_limit_input = QLineEdit(str(self.current_limit)) - self.speed_limit_input.setPlaceholderText("None") + self.speed_limit_input.setPlaceholderText(_("settings.speed_limit_placeholder")) speed_layout.addWidget(self.speed_limit_input) self.speed_limit_unit = QComboBox() @@ -197,25 +199,25 @@ class DownloadSettingsDialog(QDialog): layout.addWidget(speed_group_box) # --- Auto-Update yt-dlp Section --- - auto_update_group_box = QGroupBox("Auto-Update yt-dlp") + auto_update_group_box = QGroupBox(_("settings.auto_update_ytdlp")) auto_update_layout = QVBoxLayout() # Load current auto-update settings auto_settings = get_auto_update_settings() # Enable/Disable auto-update checkbox - self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates") + self.auto_update_enabled = QCheckBox(_("settings.enable_auto_updates")) self.auto_update_enabled.setChecked(auto_settings["enabled"]) auto_update_layout.addWidget(self.auto_update_enabled) # Frequency options - frequency_label = QLabel("Update frequency:") + frequency_label = QLabel(_("settings.update_frequency")) frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;") auto_update_layout.addWidget(frequency_label) - self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)") - self.daily_radio = QRadioButton("Check daily") - self.weekly_radio = QRadioButton("Check weekly") + self.startup_radio = QRadioButton(_("settings.check_startup")) + self.daily_radio = QRadioButton(_("settings.check_daily")) + self.weekly_radio = QRadioButton(_("settings.check_weekly")) # Set current selection based on saved settings current_frequency = auto_settings["frequency"] @@ -232,7 +234,7 @@ class DownloadSettingsDialog(QDialog): # Test update button test_update_layout = QHBoxLayout() - test_update_button = QPushButton("Check for Updates Now") + test_update_button = QPushButton(_("settings.check_updates_now")) test_update_button.clicked.connect(self.test_update_check) test_update_layout.addWidget(test_update_button) test_update_layout.addStretch() @@ -242,13 +244,15 @@ class DownloadSettingsDialog(QDialog): layout.addWidget(auto_update_group_box) # Dialog buttons (OK/Cancel) - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + button_box = QDialogButtonBox() + ok_button = button_box.addButton(_("buttons.ok"), QDialogButtonBox.ButtonRole.AcceptRole) + cancel_button = button_box.addButton(_("buttons.cancel"), QDialogButtonBox.ButtonRole.RejectRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) def browse_new_path(self) -> None: - new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", str(self.current_path)) + new_path = QFileDialog.getExistingDirectory(self, _("dialogs.select_folder"), str(self.current_path)) if new_path: self.current_path = new_path self.path_display.setText(self.current_path) @@ -316,8 +320,8 @@ class DownloadSettingsDialog(QDialog): if "Error" in current_version: msg_box = self._create_styled_message_box( QMessageBox.Icon.Warning, - "Update Check", - "Could not determine current yt-dlp version.", + _("settings.update_check_title"), + _("settings.could_not_determine_version"), ) msg_box.exec() return @@ -334,22 +338,22 @@ class DownloadSettingsDialog(QDialog): if version_parser.parse(latest_version) > version_parser.parse(current_version): msg_box = self._create_styled_message_box( QMessageBox.Icon.Information, - "Update Check", - f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update.", + _("settings.update_check_title"), + _("settings.update_available_dialog", current=current_version, latest=latest_version), ) msg_box.exec() else: msg_box = self._create_styled_message_box( QMessageBox.Icon.Information, - "Update Check", - f"yt-dlp is up to date!\n\nCurrent version: {current_version}", + _("settings.update_check_title"), + _("settings.up_to_date_dialog", version=current_version), ) msg_box.exec() except Exception as e: msg_box = self._create_styled_message_box( QMessageBox.Icon.Warning, - "Update Check", - f"Error checking for updates: {e}", + _("settings.update_check_title"), + _("settings.error_checking_updates", error=str(e)), ) msg_box.exec() @@ -375,13 +379,13 @@ class DownloadSettingsDialog(QDialog): if update_auto_update_settings(enabled, frequency): QMessageBox.information( self, - "Settings Saved", - "Auto-update settings have been saved successfully!", + _("settings.settings_saved_title"), + _("settings.settings_saved_message"), ) else: - QMessageBox.warning(self, "Error", "Failed to save auto-update settings.") + QMessageBox.warning(self, _("settings.error_title"), _("settings.failed_save_settings")) except Exception as e: - QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {e}") + QMessageBox.critical(self, _("settings.error_title"), _("settings.error_saving_settings", error=str(e))) # Call the parent accept method to close the dialog super().accept() @@ -390,7 +394,7 @@ class DownloadSettingsDialog(QDialog): class AutoUpdateSettingsDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("Auto-Update Settings") + self.setWindowTitle(_("settings.auto_update_title")) self.setMinimumWidth(400) self.setMinimumHeight(300) @@ -406,32 +410,32 @@ class AutoUpdateSettingsDialog(QDialog): layout = QVBoxLayout(self) # Title - title_label = QLabel("

🔄 Auto-Update Settings

") + title_label = QLabel(f"

{_("settings.auto_update_header")}

") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title_label) # Description - desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.") + desc_label = QLabel(_("settings.auto_update_description")) desc_label.setWordWrap(True) desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;") layout.addWidget(desc_label) # Enable/Disable auto-update - self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates") + self.enable_checkbox = QCheckBox(_("settings.enable_auto_updates")) self.enable_checkbox.setChecked(True) # Default enabled self.enable_checkbox.toggled.connect(self.on_enable_toggled) layout.addWidget(self.enable_checkbox) # Frequency options - frequency_group = QGroupBox("Update Frequency") + frequency_group = QGroupBox(_("settings.update_frequency_group")) frequency_layout = QVBoxLayout() self.frequency_group = QButtonGroup(self) - self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)") - self.daily_radio = QRadioButton("Check daily") - self.weekly_radio = QRadioButton("Check weekly") + self.startup_radio = QRadioButton(_("settings.check_startup")) + self.daily_radio = QRadioButton(_("settings.check_daily")) + self.weekly_radio = QRadioButton(_("settings.check_weekly")) self.daily_radio.setChecked(True) # Default to daily @@ -447,12 +451,12 @@ class AutoUpdateSettingsDialog(QDialog): layout.addWidget(frequency_group) # Current status - status_group = QGroupBox("Current Status") + status_group = QGroupBox(_("settings.current_status")) status_layout = QVBoxLayout() - self.current_version_label = QLabel("Current yt-dlp version: Checking...") - self.last_check_label = QLabel("Last update check: Never") - self.next_check_label = QLabel("Next check: Based on settings") + self.current_version_label = QLabel(_("settings.current_version_label")) + self.last_check_label = QLabel(_("settings.last_check_label")) + self.next_check_label = QLabel(_("settings.next_check_label")) status_layout.addWidget(self.current_version_label) status_layout.addWidget(self.last_check_label) @@ -462,17 +466,17 @@ class AutoUpdateSettingsDialog(QDialog): layout.addWidget(status_group) # Manual check button - self.manual_check_btn = QPushButton("🔍 Check for Updates Now") + self.manual_check_btn = QPushButton(_("settings.manual_check_button")) self.manual_check_btn.clicked.connect(self.manual_check) layout.addWidget(self.manual_check_btn) # Buttons button_layout = QHBoxLayout() - self.save_btn = QPushButton("Save Settings") + self.save_btn = QPushButton(_("settings.save_settings")) self.save_btn.clicked.connect(self.save_settings) - self.cancel_btn = QPushButton("Cancel") + self.cancel_btn = QPushButton(_("buttons.cancel")) self.cancel_btn.clicked.connect(self.reject) button_layout.addWidget(self.save_btn) @@ -721,19 +725,19 @@ class AutoUpdateSettingsDialog(QDialog): if update_auto_update_settings(enabled, frequency): msg_box = self._create_styled_message_box( QMessageBox.Icon.Information, - "Settings Saved", - "✅ Auto-update settings have been saved successfully!", + _("settings.settings_saved_title"), + _("settings.settings_saved_successfully"), ) msg_box.exec() self.accept() else: msg_box = self._create_styled_message_box( QMessageBox.Icon.Warning, - "Error", - "❌ Failed to save auto-update settings.\nPlease try again.", + _("settings.error_title"), + _("settings.failed_save_settings"), ) msg_box.exec() except Exception as e: logger.exception(f"Error saving auto-update settings: {e}") - msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {e}") + msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings", "error_saving", error=str(e))) msg_box.exec() diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py index 7b94f44..d0b38ce 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py @@ -18,6 +18,7 @@ from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushB from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL +from src.utils.ytsage_localization import _ from src.utils.ytsage_logger import logger try: @@ -118,15 +119,15 @@ class UpdateThread(QThread): error_message = "" success = False try: - self.update_status.emit("🔍 Checking current installation...") + self.update_status.emit(_('update.checking_current')) self.update_progress.emit(10) # Get the yt-dlp path try: yt_dlp_path = get_yt_dlp_path() - self.update_status.emit(f"📍 Found yt-dlp at: {yt_dlp_path}") + self.update_status.emit(_('update.found_at', path=yt_dlp_path)) except Exception as e: - self.update_status.emit(f"❌ Error getting yt-dlp path: {e}") + self.update_status.emit(_('update.error_getting_path', error=e)) self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}") return @@ -152,17 +153,17 @@ class UpdateThread(QThread): is_app_managed = False if is_app_managed: - self.update_status.emit("📦 Updating app-managed yt-dlp binary...") + self.update_status.emit(_('update.updating_binary')) success = self._update_binary(yt_dlp_path) else: - self.update_status.emit("🐍 Updating system yt-dlp via pip...") + self.update_status.emit(_('update.updating_pip')) success = self._update_via_pip() if success: self.update_progress.emit(100) error_message = "✅ yt-dlp has been successfully updated!" else: - error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection." + error_message = _('update.update_failed') except requests.RequestException as e: error_message = f"❌ Network error during update: {e}" @@ -196,61 +197,61 @@ class UpdateThread(QThread): logger.info("UpdateThread: yt-dlp update completed successfully.") if result.stdout: logger.debug(f"yt-dlp output: {result.stdout.strip()}") - self.update_status.emit("✅ Binary successfully updated!") + self.update_status.emit(_('update.binary_updated')) self.update_progress.emit(95) return True else: logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}") - self.update_status.emit(f"❌ yt-dlp update failed: {result.stderr.strip()}") + self.update_status.emit(_('update.update_failed_stderr', error=result.stderr.strip())) return False except subprocess.TimeoutExpired: logger.error("UpdateThread: yt-dlp update timed out.") - self.update_status.emit("❌ yt-dlp update timed out.") + self.update_status.emit(_('update.update_timeout')) return False except Exception as e: logger.exception(f"UpdateThread: Unexpected error during update: {e}") - self.update_status.emit(f"❌ Unexpected error during update: {e}") + self.update_status.emit(_('update.unexpected_error', error=e)) return False def _update_via_pip(self) -> bool: """Update yt-dlp via pip.""" try: - self.update_status.emit("🔍 Checking current pip installation...") + self.update_status.emit(_('update.checking_pip')) self.update_progress.emit(30) # Get current version try: current_version = get_version("yt-dlp") - self.update_status.emit(f"📋 Current version: {current_version}") + self.update_status.emit(_('update.current_version', version=current_version)) except PackageNotFoundError: - self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...") + self.update_status.emit(_('update.not_found_pip')) current_version = "0.0.0" self.update_progress.emit(40) # Get the latest version from PyPI - self.update_status.emit("🌐 Checking for latest version...") + self.update_status.emit(_('update.checking_latest')) response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) if response.status_code != 200: - self.update_status.emit("❌ Failed to check for updates") + self.update_status.emit(_('update.failed_check_updates')) return False data = response.json() latest_version = data["info"]["version"] - self.update_status.emit(f"🆕 Latest version: {latest_version}") + self.update_status.emit(_('update.latest_version', version=latest_version)) self.update_progress.emit(50) # Compare versions if version.parse(latest_version) > version.parse(current_version): - self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...") + self.update_status.emit(_('update.updating_from_to', current=current_version, latest=latest_version)) self.update_progress.emit(60) try: # Run pip update with timeout - self.update_status.emit("📦 Running pip install --upgrade...") + self.update_status.emit(_('update.running_pip_install')) update_result = subprocess.run( [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"], capture_output=True, @@ -263,11 +264,11 @@ class UpdateThread(QThread): self.update_progress.emit(85) if update_result.returncode == 0: - self.update_status.emit("✅ Pip update completed successfully!") + self.update_status.emit(_('update.pip_completed')) self.update_progress.emit(95) return True else: - self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}") + self.update_status.emit(_('update.pip_failed', error=update_result.stderr)) return False except subprocess.TimeoutExpired: @@ -289,7 +290,7 @@ class UpdateThread(QThread): class YTDLPUpdateDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - self.setWindowTitle("Update yt-dlp") + self.setWindowTitle(_('update.title')) self.setMinimumWidth(450) self.setMinimumHeight(200) self._closing = False # Flag to track if dialog is closing @@ -297,7 +298,7 @@ class YTDLPUpdateDialog(QDialog): layout = QVBoxLayout(self) # Status label - self.status_label = QLabel("Checking for updates...") + self.status_label = QLabel(_('update.checking')) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setWordWrap(True) self.status_label.setMinimumHeight(60) @@ -310,11 +311,11 @@ class YTDLPUpdateDialog(QDialog): # Buttons button_layout = QHBoxLayout() - self.update_btn = QPushButton("Update") + self.update_btn = QPushButton(_('buttons.update')) self.update_btn.clicked.connect(self.perform_update) self.update_btn.setEnabled(False) - self.close_btn = QPushButton("Close") + self.close_btn = QPushButton(_('buttons.close')) self.close_btn.clicked.connect(self.close) button_layout.addWidget(self.update_btn) @@ -370,7 +371,7 @@ class YTDLPUpdateDialog(QDialog): self.check_version() def check_version(self) -> None: - self.status_label.setText("Checking for updates...") + self.status_label.setText(_('update.checking')) self.update_btn.setEnabled(False) self.version_check_thread = VersionCheckThread() self.version_check_thread.finished.connect(self.on_version_check_finished) @@ -387,7 +388,7 @@ class YTDLPUpdateDialog(QDialog): return if not current_version or not latest_version: - self.status_label.setText("Could not determine versions.") + self.status_label.setText(_('update.could_not_determine')) self.update_btn.setEnabled(False) return @@ -398,7 +399,7 @@ class YTDLPUpdateDialog(QDialog): if current_ver < latest_ver: self.status_label.setText( - f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}" + _('update.update_available', current=current_version, latest=latest_version) ) self.update_btn.setEnabled(True) else: @@ -408,22 +409,22 @@ class YTDLPUpdateDialog(QDialog): # If version parsing fails, do a simple string comparison if current_version != latest_version: self.status_label.setText( - f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}" + _('update.update_available_failed', current=current_version, latest=latest_version) ) self.update_btn.setEnabled(True) else: - self.status_label.setText(f"yt-dlp is up to date (version {current_version})") + self.status_label.setText(_('update.up_to_date', version=current_version)) self.update_btn.setEnabled(False) except Exception as e: - self.status_label.setText(f"Error comparing versions: {e}") + self.status_label.setText(_('update.error_comparing', error=e)) self.update_btn.setEnabled(False) def perform_update(self) -> None: # Immediate visual feedback self.update_btn.setEnabled(False) self.close_btn.setEnabled(False) - self.update_btn.setText("Updating...") - self.status_label.setText("🚀 Initializing update process...") + self.update_btn.setText(_('update.updating')) + self.status_label.setText(_('update.initializing')) # Show progress bar immediately self.progress_bar.setRange(0, 100) @@ -461,7 +462,7 @@ class YTDLPUpdateDialog(QDialog): self.progress_bar.setValue(100) self.status_label.setText(message) self.close_btn.setEnabled(True) - self.update_btn.setText("Update") # Reset button text + self.update_btn.setText(_('buttons.update')) # Reset button text if success: # Show success briefly then auto-check version diff --git a/src/gui/ytsage_gui_format_table.py b/src/gui/ytsage_gui_format_table.py index 012c14e..8c4cee5 100644 --- a/src/gui/ytsage_gui_format_table.py +++ b/src/gui/ytsage_gui_format_table.py @@ -4,6 +4,8 @@ from PySide6.QtCore import QObject, Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget +from src.utils.ytsage_localization import _ + if TYPE_CHECKING: from src.gui.ytsage_gui_main import YTSageApp @@ -22,14 +24,14 @@ class FormatTableMixin: self.format_table.setColumnCount(8) self.format_table.setHorizontalHeaderLabels( [ - "Select", - "Quality", - "Extension", - "Resolution", - "File Size", - "Codec", - "Audio", - "Notes", + _("formats.select"), + _("formats.quality"), + _("formats.extension"), + _("formats.resolution"), + _("formats.file_size"), + _("formats.codec"), + _("formats.audio"), + _("formats.notes"), ] ) @@ -183,7 +185,7 @@ class FormatTableMixin: # Configure columns based on mode if is_playlist_mode: self.format_table.setColumnCount(5) - self.format_table.setHorizontalHeaderLabels(["Select", "Quality", "Resolution", "Notes", "Audio"]) + self.format_table.setHorizontalHeaderLabels([_("formats.select"), _("formats.quality"), _("formats.resolution"), _("formats.notes"), _("formats.audio")]) # Configure column visibility and resizing for playlist mode self.format_table.setColumnHidden(5, True) @@ -202,14 +204,14 @@ class FormatTableMixin: self.format_table.setColumnCount(8) self.format_table.setHorizontalHeaderLabels( [ - "Select", - "Quality", - "Extension", - "Resolution", - "File Size", - "Codec", - "Audio", - "Notes", + _("formats.select"), + _("formats.quality"), + _("formats.extension"), + _("formats.resolution"), + _("formats.file_size"), + _("formats.codec"), + _("formats.audio"), + _("formats.notes"), ] ) # Ensure all columns are visible @@ -262,14 +264,14 @@ class FormatTableMixin: # Column 1: Quality (Always shown) quality_text = self.get_quality_label(f) quality_item = QTableWidgetItem(quality_text) - # Set color based on quality - if "Best" in quality_text: + # Set color based on quality (check both English and Spanish terms) + if any(term in quality_text for term in ["Best", "Óptima", "Mejor"]): quality_item.setForeground(QColor("#00ff00")) # Green for best quality - elif "High" in quality_text: + elif any(term in quality_text for term in ["High", "Alta", "Alto"]): quality_item.setForeground(QColor("#00cc00")) # Light green for high quality - elif "Medium" in quality_text: + elif any(term in quality_text for term in ["Medium", "Media", "Medio"]): quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality - elif "Low" in quality_text: + elif any(term in quality_text for term in ["Low", "Baja", "Bajo"]): quality_item.setForeground(QColor("#ff5555")) # Red for low quality self.format_table.setItem(row, 1, quality_item) @@ -278,7 +280,7 @@ class FormatTableMixin: # Column 2: Resolution (Always shown) resolution = f.get("resolution", "N/A") if f.get("vcodec") == "none": - resolution = "Audio only" + resolution = _("formats.audio_only_resolution") self.format_table.setItem(row, 2, QTableWidgetItem(resolution)) # Column 3: Notes for playlist mode, Extension for normal mode @@ -299,11 +301,11 @@ class FormatTableMixin: # Column 4 in playlist mode, Column 6 in normal mode: Audio Status needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none" # Only mark video-only as needing merge - audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get("vcodec") != "none" else "Audio Only") + audio_status = _("formats.will_merge_audio") if needs_audio else (_("formats.has_audio") if f.get("vcodec") != "none" else _("formats.audio_only")) audio_item = QTableWidgetItem(audio_status) if needs_audio: audio_item.setForeground(QColor("#ffa500")) - elif audio_status == "Audio Only": + elif audio_status == _("formats.audio_only"): audio_item.setForeground(QColor("#cccccc")) # Neutral color for audio only else: # Has Audio (Video+Audio) audio_item.setForeground(QColor("#00cc00")) # Green for included audio @@ -369,13 +371,13 @@ class FormatTableMixin: # Audio quality abr = format_info.get("abr", 0) if abr >= 256: - return "Best Audio" + return _("formats.best_audio") elif abr >= 192: - return "High Audio" + return _("formats.high_audio") elif abr >= 128: - return "Medium Audio" + return _("formats.medium_audio") else: - return "Low Audio" + return _("formats.low_audio") else: # Video quality height = 0 @@ -387,17 +389,17 @@ class FormatTableMixin: pass if height >= 2160: - return "Best (4K)" + return _("formats.best_4k") elif height >= 1440: - return "Best (2K)" + return _("formats.best_2k") elif height >= 1080: - return "High (1080p)" + return _("formats.high_1080p") elif height >= 720: - return "High (720p)" + return _("formats.high_720p") elif height >= 480: - return "Medium (480p)" + return _("formats.medium_480p") else: - return "Low Quality" + return _("formats.low_quality") def _get_format_notes(self, format_info) -> str: """Generate helpful format notes based on format info.""" @@ -410,11 +412,11 @@ class FormatTableMixin: # Better file size categories if file_size > 50 * 1024 * 1024: # Over 50MB - notes.append("Large size") + notes.append(_("formats.large_size")) elif file_size > 15 * 1024 * 1024: # 15-50MB - notes.append("Medium size") + notes.append(_("formats.medium_size")) elif file_size > 5 * 1024 * 1024: # 5-15MB - notes.append("Standard size") + notes.append(_("formats.standard_size")) else: # Under 5MB notes.append("Small size") @@ -422,11 +424,11 @@ class FormatTableMixin: vcodec = format_info.get("vcodec", "") if vcodec != "none": if "avc1" in vcodec: # H.264 - notes.append("Compatible") + notes.append(_("formats.compatible")) elif "av01" in vcodec: # AV1 - notes.append("Efficient") + notes.append(_("formats.efficient")) elif "vp9" in vcodec: # VP9 - notes.append("High quality") + notes.append(_("formats.high_quality")) # Add quick mobile compatibility check if "avc1" in vcodec and file_size < 8 * 1024 * 1024: diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py index 63e449c..6d58965 100644 --- a/src/gui/ytsage_gui_main.py +++ b/src/gui/ytsage_gui_main.py @@ -46,6 +46,7 @@ from src.gui.ytsage_gui_video_info import VideoInfoMixin from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS from src.utils.ytsage_logger import logger from src.utils.ytsage_config_manager import ConfigManager +from src.utils.ytsage_localization import LocalizationManager, _ try: import yt_dlp @@ -60,6 +61,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from def __init__(self) -> None: super().__init__() + # Initialize localization system + saved_language = ConfigManager.get("language") or "en" + LocalizationManager.initialize(saved_language) + # Log startup warnings for missing dependencies if not YT_DLP_AVAILABLE: logger.warning("yt-dlp not available at startup, will be downloaded at runtime") @@ -316,7 +321,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from logger.exception(f"Error playing notification sound: {e}") def init_ui(self) -> None: - self.setWindowTitle(f"YTSage v{self.version}") + self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}") self.setMinimumSize(900, 750) # Main widget and layout @@ -329,13 +334,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # URL input section url_layout = QHBoxLayout() self.url_input = QLineEdit() - self.url_input.setPlaceholderText("Enter YouTube video or playlist URL") + self.url_input.setPlaceholderText(_("main_ui.url_placeholder")) self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key - self.analyze_button = QPushButton("Analyze") + self.analyze_button = QPushButton(_("buttons.analyze")) self.analyze_button.clicked.connect(self.analyze_url) - self.paste_button = QPushButton("Paste URL") + self.paste_button = QPushButton(_("buttons.paste_url")) self.paste_button.clicked.connect(self.paste_url) url_layout.addWidget(self.url_input, 1) @@ -363,7 +368,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from layout.addWidget(self.playlist_info_label) # Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit - self.playlist_select_btn = QPushButton("Select Videos...") + self.playlist_select_btn = QPushButton(_("buttons.select_videos")) self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog) self.playlist_select_btn.setVisible(False) self.playlist_select_btn.setStyleSheet( @@ -394,7 +399,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout = QHBoxLayout() # Show formats label - self.show_formats_label = QLabel("Show formats:") + self.show_formats_label = QLabel(_("formats.show_formats")) self.show_formats_label.setStyleSheet("color: white;") self.format_layout.addWidget(self.show_formats_label) @@ -403,7 +408,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_buttons.setExclusive(True) # Video button - self.video_button = QPushButton("Video") + self.video_button = QPushButton(_("buttons.video")) self.video_button.setCheckable(True) self.video_button.setChecked(True) # Set video as default self.video_button.setStyleSheet( @@ -431,7 +436,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout.addWidget(self.video_button) # Audio button - self.audio_button = QPushButton("Audio Only") + self.audio_button = QPushButton(_("buttons.audio_only")) self.audio_button.setCheckable(True) self.audio_button.setStyleSheet( """ @@ -458,7 +463,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout.addWidget(self.audio_button) # Add Merge Subtitles checkbox (Moved here) - self.merge_subs_checkbox = QCheckBox("Merge Subtitles") + self.merge_subs_checkbox = QCheckBox(_("main_ui.merge_subtitles")) self.merge_subs_checkbox.setStyleSheet( """ QCheckBox { @@ -491,7 +496,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout.addWidget(self.merge_subs_checkbox) # Add Save Thumbnail Checkbox (Moved here) - self.save_thumbnail_checkbox = QCheckBox("Save Thumbnail") + self.save_thumbnail_checkbox = QCheckBox(_("main_ui.save_thumbnail")) self.save_thumbnail_checkbox.setChecked(False) self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_save_thumbnail) self.save_thumbnail_checkbox.setStyleSheet( @@ -511,7 +516,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout.addWidget(self.save_thumbnail_checkbox) # Add Save Description Checkbox (Moved here) - self.save_description_checkbox = QCheckBox("Save Description") + self.save_description_checkbox = QCheckBox(_("main_ui.save_description")) self.save_description_checkbox.setChecked(False) self.save_description_checkbox.stateChanged.connect(self.toggle_save_description) self.save_description_checkbox.setStyleSheet( @@ -531,7 +536,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.format_layout.addWidget(self.save_description_checkbox) # Add Embed Chapters Checkbox - self.embed_chapters_checkbox = QCheckBox("Embed Chapters") + self.embed_chapters_checkbox = QCheckBox(_("main_ui.embed_chapters")) self.embed_chapters_checkbox.setChecked(False) self.embed_chapters_checkbox.stateChanged.connect(self.toggle_embed_chapters) self.embed_chapters_checkbox.setStyleSheet( @@ -562,34 +567,34 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from download_layout = QHBoxLayout() # Replace the two separate buttons with a single Custom Options button - self.custom_options_btn = QPushButton("Custom Options") + self.custom_options_btn = QPushButton(_("buttons.custom_options")) self.custom_options_btn.clicked.connect(self.show_custom_options) - self.about_btn = QPushButton("About") + self.about_btn = QPushButton(_("buttons.about")) self.about_btn.clicked.connect(self.show_about_dialog) # Add new Time Range button - self.time_range_btn = QPushButton("Trim Video") + self.time_range_btn = QPushButton(_("buttons.trim_video")) self.time_range_btn.clicked.connect(self.show_time_range_dialog) - self.update_ytdlp_btn = QPushButton("Update yt-dlp") + self.update_ytdlp_btn = QPushButton(_("buttons.update")) self.update_ytdlp_btn.clicked.connect(self.update_ytdlp) # --- Rename Path Button to Settings Button --- - self.settings_button = QPushButton("Download Settings") # Renamed button + self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method self.settings_button.setToolTip(f"Current Path: {self.last_path}\nSpeed Limit: None") # Update initial tooltip # --- End Settings Button --- - self.download_btn = QPushButton("Download") + self.download_btn = QPushButton(_("buttons.download")) self.download_btn.clicked.connect(self.start_download) # Add pause and cancel buttons - self.pause_btn = QPushButton("Pause") + self.pause_btn = QPushButton(_("buttons.pause")) self.pause_btn.clicked.connect(self.toggle_pause) self.pause_btn.setVisible(False) # Hidden initially - self.cancel_btn = QPushButton("Cancel") + self.cancel_btn = QPushButton(_("buttons.cancel")) self.cancel_btn.clicked.connect(self.cancel_download) self.cancel_btn.setVisible(False) # Hidden initially @@ -640,7 +645,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from ) progress_layout.addWidget(self.download_details_label) - self.status_label = QLabel("Ready") + self.status_label = QLabel(_("app.ready")) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setStyleSheet( """ @@ -673,12 +678,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.signals.update_status.emit("Invalid URL or please enter a URL.") return - self.signals.update_status.emit("Analyzing (0%)... Preparing request") + self.signals.update_status.emit(_("main_ui.analyzing_preparing")) threading.Thread(target=self._analyze_url_thread, args=(url,), daemon=True).start() def _analyze_url_thread(self, url) -> None: try: - self.signals.update_status.emit("Analyzing (15%)... Extracting basic info") + self.signals.update_status.emit(_("main_ui.analyzing_extracting_basic")) # Clean up the URL to handle both playlist and video URLs if "list=" in url and "watch?v=" in url: @@ -719,7 +724,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from if not basic_info: logger.error("Could not extract basic video information") self.signals.update_status.emit( - "Error: Could not extract basic video information. Please check your link." + _("main_ui.error_extract_info") ) # Hide playlist UI on error self.signals.playlist_info_label_visible.emit(False) @@ -733,7 +738,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.signals.update_status.emit(user_friendly_error) return - self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info") + self.signals.update_status.emit(_("main_ui.analyzing_extracting_detailed")) # Configure options for detailed extraction (keep other options) # Add no_warnings here as well, as this is where detailed info is fetched ydl_opts_detail = { @@ -762,7 +767,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # Use a separate options dict for the detailed extraction with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail: try: - self.signals.update_status.emit("Analyzing (45%)... Processing video data") + self.signals.update_status.emit(_("main_ui.analyzing_processing_video")) if basic_info.get("_type") == "playlist": self.is_playlist = True self.playlist_info = basic_info @@ -806,7 +811,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # Show playlist selection BUTTON # update signal method from QMetaObject.invokeMethod to signals - self.signals.playlist_select_btn_text.emit("Select Videos... (All selected)") + self.signals.playlist_select_btn_text.emit(_("main_ui.select_videos_all")) self.signals.playlist_select_btn_visible.emit(True) else: # Single video @@ -824,19 +829,19 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # Verify we have format information if not self.video_info or "formats" not in self.video_info: logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}") - self.signals.update_status.emit("Error: No format information available.") + self.signals.update_status.emit(_("main_ui.error_no_format_info")) self.signals.playlist_info_label_visible.emit(False) self.signals.playlist_select_btn_visible.emit(False) return - self.signals.update_status.emit("Analyzing (60%)... Processing formats") + self.signals.update_status.emit(_("main_ui.analyzing_processing_formats")) self.all_formats = self.video_info["formats"] # Update UI self.update_video_info(self.video_info) # Update thumbnail - self.signals.update_status.emit("Analyzing (75%)... Loading thumbnail") + self.signals.update_status.emit(_("main_ui.analyzing_loading_thumbnail")) # Try to get thumbnail from playlist info first # Fallback to video thumbnail if playlist thumbnail not found or not a playlist thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail") @@ -849,14 +854,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from ) # --- Subtitle Handling --- - self.signals.update_status.emit("Analyzing (85%)... Processing subtitles") + self.signals.update_status.emit(_("main_ui.analyzing_processing_subtitles")) # Clear previous selections when analyzing a new video self.selected_subtitles = [] self.available_subtitles = self.video_info.get("subtitles", {}) self.available_automatic_subtitles = self.video_info.get("automatic_captions", {}) # Update the UI elements related to subtitle selection state # update signal method from QMetaObject.invokeMethod to signals - self.signals.selected_subs_label_text.emit("0 selected") + self.signals.selected_subs_label_text.emit(_("main_ui.subtitles_selected", count=0)) # QMetaObject.invokeMethod( # self.subtitle_select_btn, @@ -875,11 +880,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # ) # Update format table - self.signals.update_status.emit("Analyzing (95%)... Updating format table") + self.signals.update_status.emit(_("main_ui.analyzing_updating_table")) self.video_button.setChecked(True) self.audio_button.setChecked(False) self.filter_formats() - self.signals.update_status.emit("Analysis complete!") + self.signals.update_status.emit(_("main_ui.analysis_complete")) except Exception as e: logger.exception(f"Detailed extraction failed: {e}") @@ -948,22 +953,22 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from if not url or not path: # More specific error message if path is missing if not path: - self.status_label.setText("Please set a download path using 'Change Path'") + self.status_label.setText(_('download.please_set_path')) elif not url: - self.status_label.setText("Please enter a URL") + self.status_label.setText(_('download.please_enter_url')) else: - self.status_label.setText("Please enter URL and set download path") + self.status_label.setText(_('download.please_enter_url_and_path')) return # --- End Path Change --- # Get selected format format_id = self.get_selected_format() if not format_id: - self.status_label.setText("Please select a format") + self.status_label.setText(_('download.please_select_format')) return # Show preparation message - self.status_label.setText("🚀 Preparing your download...") + self.status_label.setText(_('download.preparing')) self.progress_bar.setValue(0) # Get resolution for filename @@ -1044,7 +1049,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.download_cancelled = False # Show pause/cancel buttons - self.pause_btn.setText("Pause") + self.pause_btn.setText(_("buttons.pause")) self.pause_btn.setVisible(True) self.cancel_btn.setVisible(True) @@ -1066,16 +1071,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from # Video file extensions if ext in [".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"]: - self.status_label.setText(f"✅ Video download completed!") + self.status_label.setText(_('download.video_completed')) # Audio file extensions elif ext in [".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"]: - self.status_label.setText(f"✅ Audio download completed!") + self.status_label.setText(_('download.audio_completed')) # Subtitle file extensions elif ext in [".vtt", ".srt", ".ass", ".ssa"]: - self.status_label.setText(f"✅ Subtitle download completed!") + self.status_label.setText(_('download.subtitle_completed')) # Default case else: - self.status_label.setText("✅ Download completed!") + self.status_label.setText(_('download.completed')) # Play notification sound when download completes self.play_notification_sound() @@ -1102,7 +1107,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.pause_btn.setText("Resume") self.signals.update_status.emit("Download paused") else: - self.pause_btn.setText("Pause") + self.pause_btn.setText(_("buttons.pause")) self.signals.update_status.emit("Download resumed") def check_for_updates(self) -> None: @@ -1735,7 +1740,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.signals.playlist_select_btn_visible.emit(False) return - self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable") + self.signals.update_status.emit(_("main_ui.analyzing_extracting_ytdlp")) # Clean up the URL to handle both playlist and video URLs if "list=" in url and "watch?v=" in url: @@ -1787,7 +1792,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.signals.playlist_select_btn_visible.emit(False) return - self.signals.update_status.emit("Analyzing (60%)... Processing data") + self.signals.update_status.emit(_("main_ui.analyzing_processing_data")) if first_info.get("_type") == "playlist" or len(json_lines) > 1: # Handle playlist @@ -1852,14 +1857,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.signals.playlist_select_btn_visible.emit(False) return - self.signals.update_status.emit("Analyzing (75%)... Processing formats") + self.signals.update_status.emit(_("main_ui.analyzing_processing_formats_ytdlp")) self.all_formats = self.video_info["formats"] # Update UI self.update_video_info(self.video_info) # Update thumbnail - self.signals.update_status.emit("Analyzing (85%)... Loading thumbnail") + self.signals.update_status.emit(_("main_ui.analyzing_loading_thumbnail_ytdlp")) # Try to get thumbnail from playlist info first # Fallback to video thumbnail if playlist thumbnail not found or not a playlist thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail") @@ -1871,7 +1876,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from self.download_thumbnail_file(self.video_url, self.path_input.text()) # type: ignore[reportAttributeAccessIssue] # Handle subtitles - self.signals.update_status.emit("Analyzing (90%)... Processing subtitles") + self.signals.update_status.emit(_("main_ui.analyzing_processing_subtitles_ytdlp")) self.selected_subtitles = [] self.available_subtitles = self.video_info.get("subtitles", {}) self.available_automatic_subtitles = self.video_info.get("automatic_captions", {}) diff --git a/src/gui/ytsage_gui_video_info.py b/src/gui/ytsage_gui_video_info.py index 56353ca..60270dc 100644 --- a/src/gui/ytsage_gui_video_info.py +++ b/src/gui/ytsage_gui_video_info.py @@ -14,6 +14,7 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__ SponsorBlockCategoryDialog, SubtitleSelectionDialog, ) +from src.utils.ytsage_localization import _ from src.utils.ytsage_logger import logger if TYPE_CHECKING: @@ -95,7 +96,7 @@ class VideoInfoMixin: subtitle_layout.setSpacing(10) # Subtitle selection button - self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text + self.subtitle_select_btn = QPushButton(_("main_ui.select_subtitles")) # Renamed & changed text self.subtitle_select_btn.setFixedHeight(30) # self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog) @@ -124,7 +125,7 @@ class VideoInfoMixin: subtitle_layout.addWidget(self.subtitle_select_btn) # Label to show number of selected subtitles - self.selected_subs_label = QLabel("0 selected") + self.selected_subs_label = QLabel(_("selection.none_selected")) self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;") subtitle_layout.addWidget(self.selected_subs_label) @@ -138,7 +139,7 @@ class VideoInfoMixin: # --- SponsorBlock Section --- sponsorblock_layout = QHBoxLayout() - self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...") + self.sponsorblock_select_btn = QPushButton(_("main_ui.sponsorblock_categories")) self.sponsorblock_select_btn.setFixedHeight(30) self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog) self.sponsorblock_select_btn.setStyleSheet( @@ -167,7 +168,7 @@ class VideoInfoMixin: sponsorblock_layout.addWidget(self.sponsorblock_select_btn) # Label to show selection count - self.selected_sponsorblock_label = QLabel("0 selected") + self.selected_sponsorblock_label = QLabel(_("selection.none_selected")) self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;") sponsorblock_layout.addWidget(self.selected_sponsorblock_label) @@ -253,7 +254,7 @@ class VideoInfoMixin: date_obj = datetime.strptime(upload_date, "%Y%m%d") formatted_date = date_obj.strftime("%B %d, %Y") else: - formatted_date = "Unknown date" + formatted_date = _("video_info.unknown_date") # Format duration duration = info.get("duration", 0) @@ -261,13 +262,13 @@ class VideoInfoMixin: seconds = duration % 60 duration_str = f"{minutes}:{seconds:02d}" - # Update labels - self.title_label.setText(info.get("title", "Unknown title")) - self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}") - self.views_label.setText(f"Views: {formatted_views}") - self.like_count_label.setText(f"Likes: {formatted_likes}") - self.date_label.setText(f"Upload date: {formatted_date}") - self.duration_label.setText(f"Duration: {duration_str}") + # Update labels with localized text + self.title_label.setText(info.get("title", _("video_info.unknown_title"))) + self.channel_label.setText(f"{_("video_info.channel")}: {info.get('uploader', _("video_info.unknown_channel"))}") + self.views_label.setText(f"{_("video_info.views")}: {formatted_views}") + self.like_count_label.setText(f"{_("video_info.likes")}: {formatted_likes}") + self.date_label.setText(f"{_("video_info.upload_date")}: {formatted_date}") + self.duration_label.setText(f"{_("video_info.duration")}: {duration_str}") def open_subtitle_dialog(self) -> None: self = cast("YTSageApp", self) # for autocompletion and type inference. @@ -341,11 +342,11 @@ class VideoInfoMixin: # Update label text if count == 0: - self.selected_sponsorblock_label.setText("0 selected") + self.selected_sponsorblock_label.setText(_("selection.none_selected")) elif count == 1: - self.selected_sponsorblock_label.setText("1 category selected") + self.selected_sponsorblock_label.setText(_("selection.one_selected")) else: - self.selected_sponsorblock_label.setText(f"{count} categories selected") + self.selected_sponsorblock_label.setText(_("selection.count_selected", count=count)) # Update button property for styling self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0) diff --git a/src/utils/ytsage_config_manager.py b/src/utils/ytsage_config_manager.py index bf83ace..ff3a04b 100644 --- a/src/utils/ytsage_config_manager.py +++ b/src/utils/ytsage_config_manager.py @@ -79,6 +79,7 @@ class ConfigManager: "auto_update_ytdlp": True, "auto_update_frequency": "daily", "last_update_check": 0, + "language": "en", "cached_versions": { "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, diff --git a/src/utils/ytsage_localization.py b/src/utils/ytsage_localization.py new file mode 100644 index 0000000..b9d0756 --- /dev/null +++ b/src/utils/ytsage_localization.py @@ -0,0 +1,266 @@ +""" +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 src.utils.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 src.utils.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.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" + }, + "tabs": { + "cookies": "Login with Cookies", + "custom_command": "Custom Command", + "proxy": "Proxy", + "language": "Language" + }, + "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)" + }, + "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:" + } + } + + @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) \ No newline at end of file