From 05466364d1aa7b2ccedb51c6d2e0d70c9739461c Mon Sep 17 00:00:00 2001 From: DevilXD Date: Thu, 18 Aug 2022 22:28:30 +0200 Subject: [PATCH] Implement the translation feature --- .gitignore | 6 +- README.md | 5 + build.bat | 3 +- build.spec | 61 +++++++ cache.py | 18 +- constants.py | 29 ++- gui.py | 362 ++++++++++++++++++++++++------------- lang/简体中文.json | 150 ++++++++++++++++ main.py | 22 ++- settings.py | 14 +- translate.py | 435 +++++++++++++++++++++++++++++++++++++++++++++ twitch.py | 83 +++++---- utils.py | 60 ++++--- websocket.py | 13 +- 14 files changed, 1041 insertions(+), 220 deletions(-) create mode 100644 build.spec create mode 100644 lang/简体中文.json create mode 100644 translate.py diff --git a/.gitignore b/.gitignore index 5b9e13d..436d5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ # Cache files __pycache__ .mypy_cache -# Build files +# Build and dist files /build -/*.spec -# Dist files /dist /7z.exe /*.zip @@ -12,7 +10,7 @@ __pycache__ /Twitch Drops Miner # Dev files /env -/lang /cache cookies.jar settings.json +/lang/English.json diff --git a/README.md b/README.md index 3e11172..476e9c6 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,8 @@ Every ~60 seconds, the application sends a "minute watched" event to the channel - Run `setup_env.bat` to setup the virtual environment for the application. Activate it by running `env/Scripts/activate.bat`, or just use `python`, `pythonw` and `pip` executables from the `env/Scripts` directly going forward. - Run `pythonw main.py` to start the application without a console. `python main.py` can be used to start with a console, in case you'd expect errors to be printed out to the console - may help with debugging problems. - If you'd like to build the executable yourself, you'll need to `pip install pyinstaller` into the virtual environment, and then simply run `build.bat`. The end result can be found inside the `dist` folder. If you supply a `7z.exe` ([or `7za.exe` from 7z Extras](https://www.7-zip.org/download.html)) executable inside the project's folder, a packaged zip with the whole release should be created next to the `dist` folder as well. + + +### Credits: + +@Suz1e - For the entirety of the Chinese (简体中文) translation. diff --git a/build.bat b/build.bat index 8363247..f36c0d6 100644 --- a/build.bat +++ b/build.bat @@ -1,5 +1,4 @@ @echo off cls -%CD%/env/scripts/pyinstaller --onefile --noconsole --name "Twitch Drops Miner (by DevilXD)" ^ ---icon pickaxe.ico --add-data pickaxe.ico;. main.py +%CD%/env/scripts/pyinstaller build.spec if %ERRORLEVEL% == 0 call pack.bat diff --git a/build.spec b/build.spec new file mode 100644 index 0000000..a42dd53 --- /dev/null +++ b/build.spec @@ -0,0 +1,61 @@ +# -*- mode: python ; coding: utf-8 -*- +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +self_path = str(Path(".").absolute()) +if self_path not in sys.path: + sys.path.insert(0, self_path) + +from constants import WORKING_DIR, DEFAULT_LANG + +if TYPE_CHECKING: + from PyInstaller.building.api import PYZ, EXE + from PyInstaller.building.build_main import Analysis + + +datas: list[tuple[str | Path, str]] = [("pickaxe.ico", '.')] +for lang_filepath in WORKING_DIR.joinpath("lang").glob("*.json"): + if lang_filepath.stem != DEFAULT_LANG: + datas.append((lang_filepath, "lang")) + +block_cipher = None +a = Analysis( + ["main.py"], + pathex=[], + datas=datas, + binaries=[], + excludes=[], + hookspath=[], + hooksconfig={}, + noarchive=False, + hiddenimports=[], + runtime_hooks=[], + cipher=block_cipher, + win_no_prefer_redirects=False, + win_private_assemblies=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + upx=True, + debug=False, + strip=False, + console=False, + upx_exclude=[], + target_arch=None, + icon="pickaxe.ico", + runtime_tmpdir=None, + codesign_identity=None, + entitlements_file=None, + bootloader_ignore_signals=False, + disable_windowed_traceback=False, + name="Twitch Drops Miner (by DevilXD)", +) diff --git a/cache.py b/cache.py index 1e52130..951170a 100644 --- a/cache.py +++ b/cache.py @@ -38,7 +38,7 @@ class ImageCache: def __init__(self, manager: GUIManager) -> None: self._root = manager._root CACHE_PATH.mkdir(parents=True, exist_ok=True) - self._hashes: Hashes = json_load(CACHE_DB, default_database) + self._hashes: Hashes = json_load(CACHE_DB, default_database, merge=False) self._images: dict[ImageHash, Image] = {} self._photos: dict[tuple[ImageHash, ImageSize], PhotoImage] = {} self._lock = asyncio.Lock() @@ -58,12 +58,18 @@ class ImageCache: for img_hash, count in hash_counts.items(): if count == 0: # hashes come with an extension already - for file in CACHE_PATH.glob(img_hash): - file.unlink(missing_ok=True) + CACHE_PATH.joinpath(img_hash).unlink(missing_ok=True) + # NOTE: The hashes are deleted from self._hashes above + # NOTE: This cleanups the cache folder from unused PNG files + # orphans = [ + # file.name for file in CACHE_PATH.glob("*.png") if file.name not in hash_counts + # ] + # for filename in orphans: + # CACHE_PATH.joinpath(filename).unlink(missing_ok=True) - def save(self) -> None: - if self._altered: - json_save(CACHE_DB, self._hashes) + def save(self, *, force: bool = False) -> None: + if self._altered or force: + json_save(CACHE_DB, self._hashes, sort=True) def _new_expires(self) -> datetime: return datetime.now(timezone.utc) + self.LIFETIME diff --git a/constants.py b/constants.py index 2f08901..c13590c 100644 --- a/constants.py +++ b/constants.py @@ -15,9 +15,35 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias +# True if we're running from a built EXE, False inside a dev build +IS_PACKAGED = hasattr(sys, "_MEIPASS") + + +def _resource_path(relative_path: Path | str) -> Path: + """ + Get an absolute path to a bundled resource. + + Works for dev and for PyInstaller. + """ + if IS_PACKAGED: + # PyInstaller's folder where the one-file app is unpacked + meipass: str = getattr(sys, "_MEIPASS") + base_path = Path(meipass) + else: + base_path = WORKING_DIR + return base_path.joinpath(relative_path) + + # Base Paths -SELF_PATH = Path(sys.argv[0]) +# NOTE: pyinstaller will set this to its own executable when building, +# detect this to use __file__ and main.py redirection instead +SELF_PATH = Path(sys.argv[0]).absolute() +if SELF_PATH.stem == "pyinstaller": + SELF_PATH = Path(__file__).with_name("main.py").absolute() WORKING_DIR = SELF_PATH.absolute().parent +# Translations path +# NOTE: These don't have to be available to the end-user, so the path points to the internal dir +LANG_PATH = _resource_path("lang") # Other Paths LOG_PATH = Path(WORKING_DIR, "log.txt") CACHE_PATH = Path(WORKING_DIR, "cache") @@ -32,6 +58,7 @@ TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]" MAX_WEBSOCKETS = 8 WS_TOPICS_LIMIT = 50 # Misc +DEFAULT_LANG = "English" BASE_URL = "https://twitch.tv" CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" USER_AGENT = ( diff --git a/gui.py b/gui.py index 99fc211..bc63655 100644 --- a/gui.py +++ b/gui.py @@ -9,16 +9,17 @@ from functools import partial from collections import abc, namedtuple from tkinter.font import Font, nametofont from datetime import datetime, timedelta, timezone -from typing import Any, TypedDict, NoReturn, TYPE_CHECKING +from typing import Any, TypedDict, NoReturn, Generic, TYPE_CHECKING from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage import pystray from yarl import URL from PIL import Image as Image_module +from translate import _ from cache import ImageCache from exceptions import ExitRequest -from utils import resource_path, Game +from utils import resource_path, Game, _T from registry import RegistryKey, ValueType from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State @@ -268,9 +269,47 @@ class LinkLabel(ttk.Label): return lambda e: webbrowser.open_new_tab(url) +class SelectMenu(tk.Menubutton, Generic[_T]): + def __init__( + self, + master: tk.Misc, + *args: Any, + tearoff: bool = False, + options: dict[str, _T], + command: abc.Callable[[_T], Any] | None = None, + default: str | None = None, + relief: tk._Relief = "solid", + background: tk._Color = "white", + **kwargs: Any, + ): + width = max((len(k) for k in options.keys()), default=20) + super().__init__( + master, *args, background=background, relief=relief, width=width, **kwargs + ) + self._menu_options: dict[str, _T] = options + self._command = command + self.menu = tk.Menu(self, tearoff=tearoff) + self.config(menu=self.menu) + for name in options.keys(): + self.menu.add_command(label=name, command=partial(self._select, name)) + if default is not None and default in self._menu_options: + self.config(text=default) + + def _select(self, option: str) -> None: + self.config(text=option) + if self._command is not None: + self._command(self._menu_options[option]) + + def get(self) -> _T: + return self._menu_options[self.cget("text")] + + +# GUI ELEMENTS END / GUI DEFINITION START + + class StatusBar: def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text="Status", padding=(4, 0, 4, 4)) + frame = ttk.LabelFrame(master, text=_("gui", "status", "name"), padding=(4, 0, 4, 4)) frame.grid(column=0, row=0, columnspan=3, sticky="nsew", padx=2) self._label = ttk.Label(frame) self._label.grid(column=0, row=0, sticky="nsew") @@ -289,13 +328,16 @@ class _WSEntry(TypedDict): class WebsocketStatus: def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4)) + frame = ttk.LabelFrame(master, text=_("gui", "websocket", "name"), padding=(4, 0, 4, 4)) frame.grid(column=0, row=1, sticky="nsew", padx=2) self._status_var = StringVar(frame) self._topics_var = StringVar(frame) ttk.Label( frame, - text='\n'.join(f"Websocket #{i}:" for i in range(1, MAX_WEBSOCKETS + 1)), + text='\n'.join( + _("gui", "websocket", "websocket").format(id=i) + for i in range(1, MAX_WEBSOCKETS + 1) + ), style="MS.TLabel", ).grid(column=0, row=0) ttk.Label( @@ -320,7 +362,9 @@ class WebsocketStatus: raise TypeError("You need to provide at least one of: status, topics") entry = self._items.get(idx) if entry is None: - entry = self._items[idx] = _WSEntry(status="Disconnected", topics=0) + entry = self._items[idx] = _WSEntry( + status=_("gui", "websocket", "disconnected"), topics=0 + ) if status is not None: entry["status"] = status if topics is not None: @@ -353,23 +397,27 @@ class LoginForm: def __init__(self, manager: GUIManager, master: ttk.Widget): self._manager = manager self._var = StringVar(master) - frame = ttk.LabelFrame(master, text="Login Form", padding=(4, 0, 4, 4)) + frame = ttk.LabelFrame(master, text=_("gui", "login", "name"), padding=(4, 0, 4, 4)) frame.grid(column=1, row=1, sticky="nsew", padx=2) frame.columnconfigure(0, weight=2) frame.columnconfigure(1, weight=1) frame.rowconfigure(4, weight=1) - ttk.Label(frame, text="Status:\nUser ID:").grid(column=0, row=0) + ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0) ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0) - self._login_entry = PlaceholderEntry(frame, placeholder="Username") + self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username")) self._login_entry.grid(column=0, row=1, columnspan=2) - self._pass_entry = PlaceholderEntry(frame, placeholder="Password", show='•') + self._pass_entry = PlaceholderEntry( + frame, placeholder=_("gui", "login", "password"), show='•' + ) self._pass_entry.grid(column=0, row=2, columnspan=2) - self._token_entry = PlaceholderEntry(frame, placeholder="2FA Code (optional)") + self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code")) self._token_entry.grid(column=0, row=3, columnspan=2) self._confirm = asyncio.Event() - self._button = ttk.Button(frame, text="Login", command=self._confirm.set, state="disabled") + self._button = ttk.Button( + frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled" + ) self._button.grid(column=0, row=4, columnspan=2) - self.update("Logged out", None) + self.update(_("gui", "login", "logged_out"), None) def clear(self, login: bool = False, password: bool = False, token: bool = False): clear_all = not login and not password and not token @@ -381,8 +429,8 @@ class LoginForm: self._token_entry.clear() async def ask_login(self) -> LoginData: - self.update("Login required", None) - self._manager.print("Please log in to continue.") + self.update(_("gui", "login", "required"), None) + self._manager.print(_("gui", "login", "request")) self._confirm.clear() self._button.config(state="normal") # NOTE: we need this to allow for the closing window event to break the waiting here @@ -443,16 +491,18 @@ class CampaignProgress: }, } self._frame = frame = ttk.LabelFrame( - master, text="Campaign Progress", padding=(4, 0, 4, 4) + master, text=_("gui", "progress", "name"), padding=(4, 0, 4, 4) ) frame.grid(column=0, row=2, columnspan=2, sticky="nsew", padx=2) frame.columnconfigure(0, weight=2) frame.columnconfigure(1, weight=1) - ttk.Label(frame, text="Campaign:").grid(column=0, row=0, columnspan=2) + ttk.Label(frame, text=_("gui", "progress", "campaign")).grid(column=0, row=0, columnspan=2) ttk.Label( frame, textvariable=self._vars["campaign"]["name"] ).grid(column=0, row=1, columnspan=2) - ttk.Label(frame, text="Progress:").grid(column=0, row=2, rowspan=2) + ttk.Label( + frame, text=_("gui", "progress", "campaign_progress") + ).grid(column=0, row=2, rowspan=2) ttk.Label(frame, textvariable=self._vars["campaign"]["percentage"]).grid(column=1, row=2) ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3) ttk.Progressbar( @@ -465,11 +515,13 @@ class CampaignProgress: ttk.Separator( frame, orient="horizontal" ).grid(row=5, columnspan=2, sticky="ew", pady=(4, 0)) - ttk.Label(frame, text="Drop:").grid(column=0, row=6, columnspan=2) + ttk.Label(frame, text=_("gui", "progress", "drop")).grid(column=0, row=6, columnspan=2) ttk.Label( frame, textvariable=self._vars["drop"]["rewards"] ).grid(column=0, row=7, columnspan=2) - ttk.Label(frame, text="Progress:").grid(column=0, row=8, rowspan=2) + ttk.Label( + frame, text=_("gui", "progress", "drop_progress") + ).grid(column=0, row=8, rowspan=2) ttk.Label(frame, textvariable=self._vars["drop"]["percentage"]).grid(column=1, row=8) ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9) ttk.Progressbar( @@ -502,9 +554,13 @@ class CampaignProgress: campaign_vars: _CampaignVars = self._vars["campaign"] dseconds = seconds % 60 hours, minutes = self._divmod(drop_minutes, seconds) - drop_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{dseconds:02} remaining") + drop_vars["remaining"].set( + _("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}") + ) hours, minutes = self._divmod(campaign_minutes, seconds) - campaign_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{dseconds:02} remaining") + campaign_vars["remaining"].set( + _("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}") + ) async def _timer_loop(self): seconds = 60 @@ -561,7 +617,7 @@ class CampaignProgress: class ConsoleOutput: def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text="Output", padding=(4, 0, 4, 4)) + frame = ttk.LabelFrame(master, text=_("gui", "output"), padding=(4, 0, 4, 4)) frame.grid(column=0, row=3, columnspan=2, sticky="nsew", padx=2) # tell master frame that the containing row can expand master.rowconfigure(3, weight=1) @@ -601,7 +657,7 @@ class _Buttons(TypedDict): class ChannelList: def __init__(self, manager: GUIManager, master: ttk.Widget): self._manager = manager - frame = ttk.LabelFrame(master, text="Channels", padding=(4, 0, 4, 4)) + frame = ttk.LabelFrame(master, text=_("gui", "channels", "name"), padding=(4, 0, 4, 4)) frame.grid(column=2, row=1, rowspan=3, sticky="nsew", padx=2) # tell master frame that the containing column can expand master.columnconfigure(2, weight=1) @@ -612,12 +668,12 @@ class ChannelList: "frame": buttons_frame, "switch": ttk.Button( buttons_frame, - text="Switch", + text=_("gui", "channels", "switch"), state="disabled", command=manager._twitch.state_change(State.CHANNEL_SWITCH), ), "load_points": ttk.Button( - buttons_frame, text="Load Points", command=self._load_points + buttons_frame, text=_("gui", "channels", "load_points"), command=self._load_points ), } buttons_frame.grid(column=0, row=0, columnspan=2) @@ -638,12 +694,26 @@ class ChannelList: table.bind("", self._disable_column_resize) table.bind("<>", self._selected) self._add_column("#0", '', width=0) - self._add_column("channel", "Channel", width=100, anchor='w') - self._add_column("status", "Status", width_template="OFFLINE ❌") - self._add_column("game", "Game", width=50) + self._add_column( + "channel", _("gui", "channels", "headings", "channel"), width=100, anchor='w' + ) + self._add_column( + "status", + _("gui", "channels", "headings", "status"), + width_template=[ + _("gui", "channels", "online"), + _("gui", "channels", "pending"), + _("gui", "channels", "offline"), + ], + ) + self._add_column("game", _("gui", "channels", "headings", "game"), width=50) self._add_column("drops", "🎁", width_template="✔") - self._add_column("viewers", "Viewers", width_template="1234567") - self._add_column("points", "Points", width_template="1234567") + self._add_column( + "viewers", _("gui", "channels", "headings", "viewers"), width_template="1234567" + ) + self._add_column( + "points", _("gui", "channels", "headings", "points"), width_template="1234567" + ) self._add_column("priority", "❗", width_template="✔") self._channel_map: dict[str, Channel] = {} @@ -654,7 +724,7 @@ class ChannelList: *, anchor: tk._Anchor = "center", width: int | None = None, - width_template: str | None = None, + width_template: str | list[str] | None = None, ): table = self._table # NOTE: we don't do this for the icon column @@ -678,7 +748,10 @@ class ChannelList: table.column(s_cid, minwidth=s_minwidth, width=s_width, stretch=False) # set heading and column settings for the new column if width_template is not None: - width = self._measure(width_template) + if isinstance(width_template, str): + width = self._measure(width_template) + else: + width = max((self._measure(template) for template in width_template), default=20) self._const_width.add(cid) assert width is not None table.heading(cid, text=name, anchor=anchor) @@ -779,11 +852,11 @@ class ChannelList: priority = "✔" if channel.priority else "❌" # status if channel.online: - status = "ONLINE ✔" + status = _("gui", "channels", "online") elif channel.pending_online: - status = "OFFLINE ⏳" + status = _("gui", "channels", "pending") else: - status = "OFFLINE ❌" + status = _("gui", "channels", "offline") # game game = str(channel.game or '') # drops @@ -832,7 +905,7 @@ class TrayIcon: def __init__(self, manager: GUIManager, master: ttk.Widget): self._manager = manager self.icon: pystray.Icon | None = None - self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray") + self._button = ttk.Button(master, command=self.minimize, text=_("gui", "tray", "minimize")) self._button.grid(column=0, row=0, sticky="ne") def is_tray(self) -> bool: @@ -857,9 +930,9 @@ class TrayIcon: return lambda: loop.call_soon_threadsafe(func) menu = pystray.Menu( - pystray.MenuItem("Show", bridge(self.restore), default=True), + pystray.MenuItem(_("gui", "tray", "show"), bridge(self.restore), default=True), pystray.Menu.SEPARATOR, - pystray.MenuItem("Quit", bridge(self.quit)), + pystray.MenuItem(_("gui", "tray", "quit"), bridge(self.quit)), ) self.icon = pystray.Icon( "twitch_miner", @@ -933,37 +1006,49 @@ class InventoryOverview: "finished": IntVar(master, 0), } # Filtering options - filter_frame = ttk.LabelFrame(master, text="Filter", padding=(4, 0, 4, 4)) + filter_frame = ttk.LabelFrame( + master, text=_("gui", "inventory", "filter", "name"), padding=(4, 0, 4, 4) + ) LABEL_SPACING = 20 filter_frame.grid(column=0, row=0, columnspan=2, sticky="nsew") - ttk.Label(filter_frame, text="Show:", padding=(0, 0, 10, 0)).grid(column=0, row=0) + ttk.Label( + filter_frame, text=_("gui", "inventory", "filter", "show"), padding=(0, 0, 10, 0) + ).grid(column=0, row=0) icolumn = 0 ttk.Checkbutton( filter_frame, variable=self._filters["linked"] ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Label( - filter_frame, text="Linked only", padding=(0, 0, LABEL_SPACING, 0) + filter_frame, + text=_("gui", "inventory", "filter", "linked"), + padding=(0, 0, LABEL_SPACING, 0), ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Checkbutton( filter_frame, variable=self._filters["upcoming"] ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Label( - filter_frame, text="Upcoming", padding=(0, 0, LABEL_SPACING, 0) + filter_frame, + text=_("gui", "inventory", "filter", "upcoming"), + padding=(0, 0, LABEL_SPACING, 0), ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Checkbutton( filter_frame, variable=self._filters["expired"] ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Label( - filter_frame, text="Expired", padding=(0, 0, LABEL_SPACING, 0) + filter_frame, + text=_("gui", "inventory", "filter", "expired"), + padding=(0, 0, LABEL_SPACING, 0), ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Checkbutton( filter_frame, variable=self._filters["finished"] ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Label( - filter_frame, text="Finished", padding=(0, 0, LABEL_SPACING, 0) + filter_frame, + text=_("gui", "inventory", "filter", "finished"), + padding=(0, 0, LABEL_SPACING, 0), ).grid(column=(icolumn := icolumn + 1), row=0) ttk.Button( - filter_frame, text="Refresh", command=self.refresh + filter_frame, text=_("gui", "inventory", "filter", "refresh"), command=self.refresh ).grid(column=(icolumn := icolumn + 1), row=0) # Inventory view self._canvas = tk.Canvas(master, scrollregion=(0, 0, 0, 0)) @@ -1033,13 +1118,13 @@ class InventoryOverview: ).grid(column=0, row=0, columnspan=2, sticky="w") # Status if campaign.active: - status_text: str = "Active ✔" + status_text: str = _("gui", "inventory", "status", "active") status_color: tk._Color = "green" elif campaign.upcoming: - status_text = "Upcoming ⏳" + status_text = _("gui", "inventory", "status", "upcoming") status_color = "goldenrod" else: - status_text = "Expired ❌" + status_text = _("gui", "inventory", "status", "expired") status_color = "red" ttk.Label( campaign_frame, text=status_text, takefocus=False, foreground=status_color @@ -1047,9 +1132,11 @@ class InventoryOverview: # Starts / Ends MouseOverLabel( campaign_frame, - text=f"Ends: {campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)}", - alt_text=( - f"Starts: {campaign.starts_at.astimezone().replace(microsecond=0, tzinfo=None)}" + text=_("gui", "inventory", "ends").format( + time=campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None) + ), + alt_text=_("gui", "inventory", "starts").format( + time=campaign.starts_at.astimezone().replace(microsecond=0, tzinfo=None) ), reverse=campaign.upcoming, takefocus=False, @@ -1058,12 +1145,12 @@ class InventoryOverview: if campaign.linked: link_kwargs = { "style": '', - "text": "Linked ✔", + "text": _("gui", "inventory", "status", "linked"), "foreground": "green", } else: link_kwargs = { - "text": "Not linked ❌", + "text": _("gui", "inventory", "status", "not_linked"), "foreground": "red", } LinkLabel( @@ -1080,11 +1167,15 @@ class InventoryOverview: allowed_text: str = '\n'.join(ch.name for ch in acl) else: allowed_text = '\n'.join(ch.name for ch in acl[:4]) - allowed_text += f"\nand {len(acl) - 4} more..." + allowed_text += ( + f"\n{_('gui', 'inventory', 'and_more').format(amount=len(acl) - 4)}" + ) else: - allowed_text = "All" + allowed_text = _("gui", "inventory", "all_channels") ttk.Label( - campaign_frame, text=f"Allowed channels:\n{allowed_text}", takefocus=False + campaign_frame, + text=f"{_('gui', 'inventory', 'allowed_channels')}\n{allowed_text}", + takefocus=False, ).grid(column=1, row=4, sticky="nw", padx=4) # Image campaign_image = await self._cache.get(campaign.image_url, size=(108, 144)) @@ -1123,15 +1214,20 @@ class InventoryOverview: def get_progress(self, drop: TimedDrop) -> tuple[str, tk._Color]: progress_color: tk._Color = '' - progress_text: str = f"{drop.required_minutes} minutes" + progress_text: str = _("gui", "inventory", "minutes_progress").format( + minutes=drop.required_minutes + ) if drop.is_claimed: progress_color = "green" - progress_text = "Claimed ✔" + progress_text = _("gui", "inventory", "status", "claimed") elif drop.can_claim: progress_color = "goldenrod" - progress_text = "Ready to claim ⏳" + progress_text = _("gui", "inventory", "status", "ready") elif drop.can_earn(): - progress_text = f"{drop.progress:3.1%} of {drop.required_minutes} minutes" + progress_text = _("gui", "inventory", "percent_progress").format( + percent=f"{drop.progress:3.1%}", + minutes=drop.required_minutes, + ) return (progress_text, progress_color) def update_drop(self, drop: TimedDrop) -> None: @@ -1180,7 +1276,9 @@ class SettingsPanel: center_frame = ttk.Frame(master) center_frame.grid(column=0, row=0) # General section - general_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="General") + general_frame = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "general", "name") + ) general_frame.grid(column=0, row=0, sticky="nsew") # use another frame to center the options within the section # NOTE: this can be adjusted or removed later on if more options were to be added @@ -1188,23 +1286,43 @@ class SettingsPanel: general_frame.columnconfigure(0, weight=1) center_frame2 = ttk.Frame(general_frame) center_frame2.grid(column=0, row=0) - ttk.Label(center_frame2, text="Autostart: ").grid(column=0, row=0, sticky="e") - ttk.Checkbutton( - center_frame2, variable=self._vars["autostart"], command=self.update_autostart - ).grid(column=1, row=0, sticky="w") - ttk.Label(center_frame2, text="Autostart into tray: ").grid(column=0, row=1, sticky="e") - ttk.Checkbutton( - center_frame2, variable=self._vars["tray"], command=self.update_autostart - ).grid(column=1, row=1, sticky="w") - ttk.Label(center_frame2, text="Priority only: ").grid(column=0, row=2, sticky="e") - ttk.Checkbutton( - center_frame2, variable=self._vars["priority_only"], command=self.priority_only - ).grid(column=1, row=2, sticky="w") + # language frame + language_frame = ttk.Frame(center_frame2) + language_frame.grid(column=0, row=0) + ttk.Label(language_frame, text="Language 🌐 (requires restart): ").grid(column=0, row=0) + SelectMenu( + language_frame, + default=_.current, + options={k: k for k in _.languages}, + command=lambda lang: setattr(self._settings, "language", lang), + ).grid(column=1, row=0) + # checkboxes frame + checkboxes_frame = ttk.Frame(center_frame2) + checkboxes_frame.grid(column=0, row=1) ttk.Label( - center_frame2, text="Proxy (requires restart):" - ).grid(column=0, row=3, columnspan=2) + checkboxes_frame, text=_("gui", "settings", "general", "autostart") + ).grid(column=0, row=(irow := 0), sticky="e") + ttk.Checkbutton( + checkboxes_frame, variable=self._vars["autostart"], command=self.update_autostart + ).grid(column=1, row=irow, sticky="w") + ttk.Label( + checkboxes_frame, text=_("gui", "settings", "general", "tray") + ).grid(column=0, row=(irow := irow + 1), sticky="e") + ttk.Checkbutton( + checkboxes_frame, variable=self._vars["tray"], command=self.update_autostart + ).grid(column=1, row=irow, sticky="w") + ttk.Label( + checkboxes_frame, text=_("gui", "settings", "general", "priority_only") + ).grid(column=0, row=(irow := irow + 1), sticky="e") + ttk.Checkbutton( + checkboxes_frame, variable=self._vars["priority_only"], command=self.priority_only + ).grid(column=1, row=irow, sticky="w") + # proxy frame + proxy_frame = ttk.Frame(center_frame2) + proxy_frame.grid(column=0, row=2) + ttk.Label(proxy_frame, text=_("gui", "settings", "general", "proxy")).grid(column=0, row=0) self._proxy = PlaceholderEntry( - center_frame2, + proxy_frame, width=37, validate="focusout", prefill="http://", @@ -1212,12 +1330,14 @@ class SettingsPanel: placeholder="http://username:password@address:port", ) self._proxy.config(validatecommand=partial(proxy_validate, self._proxy, self._settings)) - self._proxy.grid(column=0, row=4, columnspan=2) + self._proxy.grid(column=0, row=1) # Priority section - priority_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Priority") + priority_frame = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "priority") + ) priority_frame.grid(column=1, row=0, sticky="nsew") self._priority_entry = PlaceholderCombobox( - priority_frame, placeholder="Game name", width=30 + priority_frame, placeholder=_("gui", "settings", "game_name"), width=30 ) self._priority_entry.grid(column=0, row=0, sticky="ew") priority_frame.columnconfigure(0, weight=1) @@ -1256,9 +1376,13 @@ class SettingsPanel: ).grid(column=1, row=3, sticky="ns") priority_frame.rowconfigure(3, weight=1) # Exclude section - exclude_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Exclude") + exclude_frame = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "exclude") + ) exclude_frame.grid(column=2, row=0, sticky="nsew") - self._exclude_entry = PlaceholderCombobox(exclude_frame, placeholder="Game name", width=26) + self._exclude_entry = PlaceholderCombobox( + exclude_frame, placeholder=_("gui", "settings", "game_name"), width=26 + ) self._exclude_entry.grid(column=0, row=0, sticky="ew") ttk.Button( exclude_frame, text="+", command=self.exclude_add, width=2, style="Large.TButton" @@ -1282,12 +1406,11 @@ class SettingsPanel: # Reload button reload_frame = ttk.Frame(center_frame) reload_frame.grid(column=0, row=1, columnspan=3, pady=4) - ttk.Label( - reload_frame, - text="Most changes require a reload to take an immediate effect: " - ).grid(column=0, row=0) + ttk.Label(reload_frame, text=_("gui", "settings", "reload_text")).grid(column=0, row=0) ttk.Button( - reload_frame, text="Reload", command=self._twitch.state_change(State.INVENTORY_FETCH) + reload_frame, + text=_("gui", "settings", "reload"), + command=self._twitch.state_change(State.INVENTORY_FETCH), ).grid(column=1, row=0) def clear_selection(self) -> None: @@ -1463,52 +1586,34 @@ class HelpTab: wraplength=self.WIDTH, ).grid(column=1, row=3, sticky="nsew") # Useful links - links = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Useful links") + links = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "links", "name") + ) links.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) LinkLabel( - links, link="https://www.twitch.tv/drops/inventory", text="See Twitch Inventory" + links, + link="https://www.twitch.tv/drops/inventory", + text=_("gui", "help", "links", "inventory"), ).grid(column=0, row=0, sticky="nsew") LinkLabel( - links, link="https://www.twitch.tv/drops/campaigns", text="Manage account links" + links, + link="https://www.twitch.tv/drops/campaigns", + text=_("gui", "help", "links", "campaigns"), ).grid(column=0, row=1, sticky="nsew") # How It Works - howitworks = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="How It Works") + howitworks = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "how_it_works") + ) howitworks.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) ttk.Label( - howitworks, - text=( - "Every ~60 seconds, the application sends a \"minute watched\" event " - "to the channel that's currently being watched - this is enough " - "to advance the drops. Note that this completely bypasses the need to download " - "any actual stream video and sound. " - "To keep the status (ONLINE or OFFLINE) of the channels up-to-date, " - "there's a websocket connection estabilished that receives events about streams " - "going up or down, or updates regarding the current amount of viewers." - ), - wraplength=self.WIDTH, + howitworks, text=_("gui", "help", "how_it_works_text"), wraplength=self.WIDTH ).grid(sticky="nsew") - getstarted = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Getting Started") + getstarted = ttk.LabelFrame( + center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "getting_started") + ) getstarted.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) ttk.Label( - getstarted, - text=( - "1. Login into the application.\n" - "2. Ensure your Twitch account is linked to all campaigns " - "you're interested in mining.\n" - "3. If you're interested in just mining everything, " - "uncheck \"Priority only\" and press on \"Reload\".\n" - "4. If you want to mine specific games first, use the \"Priority\" list " - "to setup an ordered list of games of your choice. Games from the top of the list " - "will be attempted to be mined first, before the ones lower down the list.\n" - "5. Keep the \"Priority only\" option checked, to avoid mining games " - "that are not on the priority list. Or not - it's up to you.\n" - "6. Use the \"Exclude\" list to tell the application " - "which games should never be mined.\n" - "7. Changing the contents of either of the lists, or changing the state " - "of the \"Priority only\" option, requires you to press on \"Reload\" " - "for the changes to take an effect." - ), - wraplength=self.WIDTH, + getstarted, text=_("gui", "help", "getting_started_text"), wraplength=self.WIDTH ).grid(sticky="nsew") @@ -1585,7 +1690,7 @@ class GUIManager: self.tray = TrayIcon(self, root_frame) # Main tab main_frame = ttk.Frame(root_frame, padding=8) - self.tabs.add_tab(main_frame, name="Main") + self.tabs.add_tab(main_frame, name=_("gui", "tabs", "main")) self.status = StatusBar(self, main_frame) self.websockets = WebsocketStatus(self, main_frame) self.login = LoginForm(self, main_frame) @@ -1595,15 +1700,15 @@ class GUIManager: # Inventory tab inv_frame = ttk.Frame(root_frame, padding=8) self.inv = InventoryOverview(self, inv_frame) - self.tabs.add_tab(inv_frame, name="Inventory") + self.tabs.add_tab(inv_frame, name=_("gui", "tabs", "inventory")) # Settings tab settings_frame = ttk.Frame(root_frame, padding=8) self.settings = SettingsPanel(self, settings_frame) - self.tabs.add_tab(settings_frame, name="Settings") + self.tabs.add_tab(settings_frame, name=_("gui", "tabs", "settings")) # Help tab help_frame = ttk.Frame(root_frame, padding=8) self.help = HelpTab(self, help_frame) - self.tabs.add_tab(help_frame, name="Help") + self.tabs.add_tab(help_frame, name=_("gui", "tabs", "help")) # clamp minimum window size (update geometry first) root.update_idletasks() root.minsize(width=root.winfo_reqwidth(), height=root.winfo_reqheight()) @@ -1699,8 +1804,8 @@ class GUIManager: self.settings.clear_selection() # these are here to interface with underlaying GUI components - def save(self) -> None: - self._cache.save() + def save(self, *, force: bool = False) -> None: + self._cache.save(force=force) def set_games(self, games: abc.Iterable[Game]) -> None: self.settings.set_games(games) @@ -1831,6 +1936,7 @@ if __name__ == "__main__": priority=[], proxy=URL(), autostart=False, + language="English", priority_only=False, autostart_tray=False, exclude={"Lit Game"}, diff --git a/lang/简体中文.json b/lang/简体中文.json new file mode 100644 index 0000000..0220c2c --- /dev/null +++ b/lang/简体中文.json @@ -0,0 +1,150 @@ +{ + "status": { + "exiting": "正在退出...", + "terminated": "\n应用程序已终止,请关闭窗口以退出应用程序。", + "goes_online": "{channel} 该频道已上线, 正在切换...", + "goes_offline": "{channel} 该频道已离线, 正在切换...", + "claimed_drop": "声明的掉宝: {drop}", + "claimed_points": "声明的积分奖励: {points}", + "earned_points": "观看直播获得积分: {points}, 总积分: {balance}", + "no_channel": "没有可观看的频道,等待一个在线直播频道...", + "no_campaign": "没有可用于掉宝的活动,等待一个有效的掉宝活动..." + }, + "login": { + "unexpected_content": "返回的内容类型有误通常是由于被重定向.您需要VPN才能登录吗?", + "incorrect_login_pass": "用户名或密码错误.", + "incorrect_email_code": "电子邮箱错误.", + "incorrect_twofa_code": "邮箱令牌错误.", + "email_code_required": "需要电子邮件代码 查看你的邮件.", + "twofa_code_required": "需要邮箱2FA令牌." + }, + "error": { + "captcha": "您的登录存在异常,Twitch官方已进行限制,请在12小时后重试.", + "site_down": "Twitch无法连接,{seconds} 秒后重试...", + "no_connection": "无法连接到Twitch,请确保已经开启VPN并添加了代理,{seconds} 秒后重试..." + }, + "gui": { + "output": "输出日志", + "status": { + "name": "状态", + "idle": "已完成获取", + "cleanup": "清除频道中...", + "gathering": "搜索直播频道...", + "switching": "切换直播频道中...", + "watching": "正在观看 {channel}", + "fetching_inventory": "获取库存中...", + "fetching_campaigns": "获取掉宝活动... {counter}", + "adding_campaigns": "将掉宝活动列表添加至库存... {counter}" + }, + "tabs": { + "main": "主面板", + "inventory": "掉宝库存", + "settings": "设置", + "help": "帮助" + }, + "tray": { + "notification_title": "开始掉宝活动", + "minimize": "窗口最小化", + "show": "掉宝活动", + "quit": "退出" + }, + "login": { + "name": "登录Twitch账号", + "labels": "状态:\n用户ID:", + "logged_in": "已登录", + "logged_out": "未登录", + "logging_in": "正在登陆中...", + "required": "请先登录", + "request": "请先登录以访问更多内容.", + "username": "用户名", + "password": "密码", + "twofa_code": "邮箱2FA令牌", + "button": "登录" + }, + "websocket": { + "name": "网络协议连接状态", + "websocket": "Websocket #{id}:", + "initializing": "初始化...", + "connected": "已连接", + "disconnected": "断开连接", + "connecting": "连接中...", + "disconnecting": "断开连接中...", + "reconnecting": "重新连接中..." + }, + "progress": { + "name": "掉宝活动进度", + "drop": "掉宝奖励:", + "remaining": "剩余 {time}", + "campaign": "掉宝活动名称:", + "drop_progress": "掉宝进度:", + "campaign_progress": "活动进度:" + }, + "channels": { + "name": "活动渠道", + "switch": "连接", + "load_points": "加载积分", + "online": "ONLINE \u2714", + "pending": "OFFLINE \u23f3", + "offline": "OFFLINE \u274c", + "headings": { + "channel": "直播频道", + "game": "游戏", + "points": "积分", + "status": "在线状态", + "viewers": "观众" + } + }, + "inventory": { + "filter": { + "name": "筛选", + "show": "掉宝活动:", + "linked": "仅直播中", + "expired": "已结束", + "upcoming": "即将上线", + "finished": "已完成", + "refresh": "刷新" + }, + "status": { + "linked": "直播中 \u2714", + "not_linked": "暂无直播中的频道 \u274c", + "active": "已上线 \u2714", + "upcoming": "即将上线 \u23f3", + "expired": "已结束 \u274c", + "claimed": "声明 \u2714", + "ready": "准备掉宝 \u23f3" + }, + "starts": "开始时间: {time}", + "ends": "结束时间: {time}", + "allowed_channels": "允许的频道:", + "all_channels": "全部", + "and_more": "还有 {amount} 个...", + "percent_progress": "{minutes} 分钟的 {percent}", + "minutes_progress": "{minutes} 分钟" + }, + "settings": { + "general": { + "name": "功能设置", + "autostart": "开机自启动: ", + "tray": "开机自启动并最小化: ", + "priority_only": "仅掉宝游戏模式: ", + "proxy": "代理:" + }, + "game_name": "游戏名称", + "priority": "添加掉宝游戏", + "exclude": "添加不参与掉宝游戏", + "reload": "刷新", + "reload_text": "大多数设置更改后需要重新启动软件或刷新才能生效: " + }, + "help": { + "links": { + "name": "帮助", + "inventory": "查看Twitch库存", + "campaigns": "查看所有掉宝活动并管理账户关联" + }, + "how_it_works": "使用帮助", + "how_it_works_text": "每隔约 60 秒,应用程序会向当前正在观看的频道发送一个“分钟观看”事件 - 这足以推进投放。请注意,这完全绕过了下载任何实际流视频和声音的需要。为了使频道的状态(在线或离线)保持最新,建立了一个 websocket 连接,用于接收有关流上升或下降的事件,或有关当前观众数量的更新。", + "getting_started": "入门设置", + "getting_started_text": "1. 登录应用程序。\n2.确保您的 Twitch 帐户与您有兴趣挖掘的所有广告系列相关联。\n3.如果您只想挖掘所有内容,请取消选中“仅优先级”,然后按“重新加载”。\n4.如果您想先挖掘特定游戏,请使用“优先级”列表设置您选择的游戏的有序列表。将首先尝试挖掘列表顶部的游戏,然后再尝试挖掘列表较低的游戏。\n5.保持“仅限优先”选项处于选中状态,以避免挖掘不在优先列表中的游戏。或不 - 这取决于你。\n6。使用“排除”列表告诉应用程序哪些游戏不应该被挖掘。\n7.更改任一列表的内容,或更改“仅优先级”选项的状态,需要您按“重新加载”才能使更改生效." + } + } +} \ No newline at end of file diff --git a/main.py b/main.py index 009d0c5..9ad05c2 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ try: except ModuleNotFoundError as exc: raise ImportError("You have to run 'pip install pillow' first") from exc +from translate import _ from twitch import Twitch from settings import Settings from version import __version__ @@ -133,6 +134,13 @@ if exists and not settings.no_run_check: # already running - exit sys.exit(3) +# set language +try: + _.set_language(settings.language) +except ValueError: + # this language doesn't exist - stick to English + pass + # handle logging stuff if settings.logging_level > logging.DEBUG: # redirect the root logger into a NullHandler, effectively ignoring all logging calls @@ -158,14 +166,12 @@ try: loop.run_until_complete(client.run()) except CaptchaRequired: exit_status = 1 - msg = "Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours." - logger.exception(msg) + msg = _("error", "captcha") client.prevent_close() client.print(msg) except Exception: exit_status = 1 msg = "Fatal error encountered:\n" - logger.exception(msg) client.prevent_close() client.print(msg) client.print(traceback.format_exc()) @@ -174,14 +180,12 @@ finally: signal.signal(signal.SIGTERM, signal.SIG_DFL) loop.run_until_complete(client.shutdown()) if not client.gui.close_requested: - client.print( - "\nApplication Terminated.\nClose the window to exit the application." - ) + client.print(_("status", "terminated")) loop.run_until_complete(client.gui.wait_until_closed()) -# save settings +# save the application state # NOTE: we have to do it after wait_until_closed, -# because the user can alter settings between shutdown and closing the window -client.settings.save(force=True) +# because the user can alter some settings between app termination and closing the window +client.save(force=True) client.gui.stop() client.gui.close_window() loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/settings.py b/settings.py index d3b0dab..822cbfa 100644 --- a/settings.py +++ b/settings.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: class SettingsFile(TypedDict): proxy: URL + language: str autostart: bool exclude: set[str] priority: list[str] @@ -25,6 +26,7 @@ default_settings: SettingsFile = { "priority": [], "exclude": set(), "autostart": False, + "language": "English", "priority_only": True, "autostart_tray": False, } @@ -41,12 +43,15 @@ class Settings: logging_level: int # from settings file proxy: URL + language: str autostart: bool exclude: set[str] priority: list[str] priority_only: bool autostart_tray: bool + PASSTHROUGH = ("_settings", "_args", "_altered") + def __init__(self, args: ParsedArgs): self._settings: SettingsFile = json_load(SETTINGS_PATH, default_settings) self._args: ParsedArgs = args @@ -54,14 +59,17 @@ class Settings: # default logic of reading settings is to check args first, then the settings file def __getattr__(self, name: str, /) -> Any: - if hasattr(self._args, name): + if name in self.PASSTHROUGH: + # passthrough + return getattr(super(), name) + elif hasattr(self._args, name): return getattr(self._args, name) elif name in self._settings: return self._settings[name] # type: ignore[literal-required] return getattr(super(), name) def __setattr__(self, name: str, value: Any, /) -> None: - if name in ("_settings", "_args", "_altered"): + if name in self.PASSTHROUGH: # passthrough return super().__setattr__(name, value) elif name in self._settings: @@ -78,4 +86,4 @@ class Settings: def save(self, *, force: bool = False) -> None: if self._altered or force: - json_save(SETTINGS_PATH, self._settings) + json_save(SETTINGS_PATH, self._settings, sort=True) diff --git a/translate.py b/translate.py new file mode 100644 index 0000000..0b2f3ed --- /dev/null +++ b/translate.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +from collections import abc +from typing import Any, TypedDict, TYPE_CHECKING + +from utils import json_load, json_save +from constants import IS_PACKAGED, LANG_PATH, DEFAULT_LANG + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +class StatusMessages(TypedDict): + exiting: str + terminated: str + goes_online: str + goes_offline: str + claimed_drop: str + claimed_points: str + earned_points: str + no_channel: str + no_campaign: str + + +class LoginMessages(TypedDict): + unexpected_content: str + email_code_required: str + twofa_code_required: str + incorrect_login_pass: str + incorrect_email_code: str + incorrect_twofa_code: str + + +class ErrorMessages(TypedDict): + captcha: str + no_connection: str + site_down: str + + +class GUIStatus(TypedDict): + name: str + idle: str + cleanup: str + gathering: str + switching: str + watching: str + fetching_inventory: str + fetching_campaigns: str + adding_campaigns: str + + +class GUITabs(TypedDict): + main: str + inventory: str + settings: str + help: str + + +class GUITray(TypedDict): + notification_title: str + minimize: str + show: str + quit: str + + +class GUILoginForm(TypedDict): + name: str + labels: str + logging_in: str + logged_in: str + logged_out: str + request: str + required: str + username: str + password: str + twofa_code: str + button: str + + +class GUIWebsocket(TypedDict): + name: str + websocket: str + initializing: str + connected: str + disconnected: str + connecting: str + disconnecting: str + reconnecting: str + + +class GUIProgress(TypedDict): + name: str + drop: str + campaign: str + remaining: str + drop_progress: str + campaign_progress: str + + +class GUIChannelHeadings(TypedDict): + channel: str + status: str + game: str + points: str + viewers: str + + +class GUIChannels(TypedDict): + name: str + switch: str + load_points: str + online: str + pending: str + offline: str + headings: GUIChannelHeadings + + +class GUIInvFilter(TypedDict): + name: str + show: str + linked: str + expired: str + upcoming: str + finished: str + refresh: str + + +class GUIInvStatus(TypedDict): + linked: str + not_linked: str + active: str + expired: str + upcoming: str + claimed: str + ready: str + + +class GUIInventory(TypedDict): + filter: GUIInvFilter + status: GUIInvStatus + starts: str + ends: str + allowed_channels: str + all_channels: str + and_more: str + percent_progress: str + minutes_progress: str + + +class GUISettingsGeneral(TypedDict): + name: str + autostart: str + tray: str + priority_only: str + proxy: str + + +class GUISettings(TypedDict): + general: GUISettingsGeneral + game_name: str + priority: str + exclude: str + reload: str + reload_text: str + + +class GUIHelpLinks(TypedDict): + name: str + inventory: str + campaigns: str + + +class GUIHelp(TypedDict): + links: GUIHelpLinks + how_it_works: str + how_it_works_text: str + getting_started: str + getting_started_text: str + + +class GUIMessages(TypedDict): + output: str + status: GUIStatus + tabs: GUITabs + tray: GUITray + login: GUILoginForm + websocket: GUIWebsocket + progress: GUIProgress + channels: GUIChannels + inventory: GUIInventory + settings: GUISettings + help: GUIHelp + + +class Translation(TypedDict): + language_name: NotRequired[str] + status: StatusMessages + login: LoginMessages + error: ErrorMessages + gui: GUIMessages + + +default_translation: Translation = { + "status": { + "exiting": "Exiting...", + "terminated": "\nApplication Terminated.\nClose the window to exit the application.", + "goes_online": "{channel} goes ONLINE, switching...", + "goes_offline": "{channel} goes OFFLINE, switching...", + "claimed_drop": "Claimed drop: {drop}", + "claimed_points": "Claimed bonus points: {points}", + "earned_points": "Earned points for watching: {points}, total: {balance}", + "no_channel": "No available channels to watch. Waiting for an ONLINE channel...", + "no_campaign": "No active campaigns to mine drops for. Waiting for an active campaign...", + }, + "login": { + "unexpected_content": ( + "Unexpected content type returned instead of JSON, usually due to being redirected. " + "Do you need to login for internet access?" + ), + "incorrect_login_pass": "Incorrect username or password.", + "incorrect_email_code": "Incorrect email code.", + "incorrect_twofa_code": "Incorrect 2FA code.", + "email_code_required": "Email code required. Check your email.", + "twofa_code_required": "2FA token required.", + }, + "error": { + "captcha": "Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours.", + "site_down": "Twitch is down, retrying in {seconds} seconds...", + "no_connection": "Cannot connect to Twitch, retrying in {seconds} seconds...", + }, + "gui": { + "output": "Output", + "status": { + "name": "Status", + "idle": "Idle", + "watching": "Watching {channel}", + "cleanup": "Cleaning up channels...", + "gathering": "Gathering channels...", + "switching": "Switching the channel...", + "fetching_inventory": "Fetching inventory...", + "fetching_campaigns": "Fetching campaigns... {counter}", + "adding_campaigns": "Adding campaigns to inventory... {counter}", + }, + "tabs": { + "main": "Main", + "inventory": "Inventory", + "settings": "Settings", + "help": "Help", + }, + "tray": { + "notification_title": "Mined Drop", + "minimize": "Minimize to Tray", + "show": "Show", + "quit": "Quit", + }, + "login": { + "name": "Login Form", + "labels": "Status:\nUser ID:", + "logged_in": "Logged in", + "logged_out": "Logged out", + "logging_in": "Logging in...", + "required": "Login required", + "request": "Please log in to continue.", + "username": "Username", + "password": "Password", + "twofa_code": "2FA code (optional)", + "button": "Login", + }, + "websocket": { + "name": "Websocket Status", + "websocket": "Websocket #{id}:", + "initializing": "Initializing...", + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting...", + "disconnecting": "Disconnecting...", + "reconnecting": "Reconnecting...", + }, + "progress": { + "name": "Campaign Progress", + "drop": "Drop:", + "campaign": "Campaign:", + "remaining": "{time} remaining", + "drop_progress": "Progress:", + "campaign_progress": "Progress:", + }, + "channels": { + "name": "Channels", + "switch": "Switch", + "load_points": "Load Points", + "online": "ONLINE ✔", + "pending": "OFFLINE ⏳", + "offline": "OFFLINE ❌", + "headings": { + "channel": "Channel", + "status": "Status", + "game": "Game", + "viewers": "Viewers", + "points": "Points", + }, + }, + "inventory": { + "filter": { + "name": "Filter", + "show": "Show:", + "linked": "Linked only", + "expired": "Expired", + "upcoming": "Upcoming", + "finished": "Finished", + "refresh": "Refresh", + }, + "status": { + "linked": "Linked ✔", + "not_linked": "Not Linked ❌", + "active": "Active ✔", + "upcoming": "Upcoming ⏳", + "expired": "Expired ❌", + "claimed": "Claimed ✔", + "ready": "Ready to claim ⏳", + }, + "starts": "Starts: {time}", + "ends": "Ends: {time}", + "allowed_channels": "Allowed Channels:", + "all_channels": "All", + "and_more": "and {amount} more...", + "percent_progress": "{percent} of {minutes} minutes", + "minutes_progress": "{minutes} minutes", + }, + "settings": { + "general": { + "name": "General", + "autostart": "Autostart: ", + "tray": "Autostart into tray: ", + "priority_only": "Priority Only: ", + "proxy": "Proxy (requires restart):", + }, + "game_name": "Game name", + "priority": "Priority", + "exclude": "Exclude", + "reload": "Reload", + "reload_text": "Most changes require a reload to take an immediate effect: ", + }, + "help": { + "links": { + "name": "Useful Links", + "inventory": "See Twitch inventory", + "campaigns": "See all campaigns and manage account links", + }, + "how_it_works": "How It Works", + "how_it_works_text": ( + "Every ~60 seconds, the application sends a \"minute watched\" event " + "to the channel that's currently being watched - this is enough " + "to advance the drops. Note that this completely bypasses the need to download " + "any actual stream video and sound. " + "To keep the status (ONLINE or OFFLINE) of the channels up-to-date, " + "there's a websocket connection estabilished that receives events about streams " + "going up or down, or updates regarding the current amount of viewers." + ), + "getting_started": "Getting Started", + "getting_started_text": ( + "1. Login into the application.\n" + "2. Ensure your Twitch account is linked to all campaigns " + "you're interested in mining.\n" + "3. If you're interested in just mining everything, " + "uncheck \"Priority only\" and press on \"Reload\".\n" + "4. If you want to mine specific games first, use the \"Priority\" list " + "to setup an ordered list of games of your choice. Games from the top of the list " + "will be attempted to be mined first, before the ones lower down the list.\n" + "5. Keep the \"Priority only\" option checked, to avoid mining games " + "that are not on the priority list. Or not - it's up to you.\n" + "6. Use the \"Exclude\" list to tell the application " + "which games should never be mined.\n" + "7. Changing the contents of either of the lists, or changing the state " + "of the \"Priority only\" option, requires you to press on \"Reload\" " + "for the changes to take an effect." + ), + }, + }, +} + + +class Translator: + def __init__(self) -> None: + self._langs: list[str] = [] + # start with (and always copy) the default translation + self._translation: Translation = default_translation.copy() + # if we're in dev, update the template English.json file + if not IS_PACKAGED: + default_langpath = LANG_PATH.joinpath(f"{DEFAULT_LANG}.json") + if not default_langpath.exists(): + json_save(default_langpath, default_translation) + self._translation["language_name"] = DEFAULT_LANG + # load available translation names + for filepath in LANG_PATH.glob("*.json"): + self._langs.append(filepath.stem) + self._langs.sort() + if DEFAULT_LANG in self._langs: + self._langs.remove(DEFAULT_LANG) + self._langs.insert(0, DEFAULT_LANG) + + @property + def languages(self) -> abc.Iterable[str]: + return iter(self._langs) + + @property + def current(self) -> str: + return self._translation["language_name"] + + def set_language(self, language: str): + if language not in self._langs: + raise ValueError("Unrecognized language") + elif self._translation["language_name"] == language: + # same language as loaded selected + return + elif language == DEFAULT_LANG: + # default language selected - use the memory value + self._translation = default_translation.copy() + else: + self._translation = json_load( + LANG_PATH.joinpath(f"{language}.json"), default_translation + ) + if "language_name" in self._translation: + raise ValueError("Translations cannot define 'language_name'") + self._translation["language_name"] = language + + def __call__(self, *path: str) -> str: + if not path: + raise ValueError("Language path expected") + v: Any = self._translation + for key in path: + v = v[key] + return v + + +_ = Translator() diff --git a/twitch.py b/twitch.py index 1204861..52b94e5 100644 --- a/twitch.py +++ b/twitch.py @@ -13,6 +13,7 @@ from typing import Any, Literal, Final, NoReturn, cast, TYPE_CHECKING import aiohttp from yarl import URL +from translate import _ from gui import GUIManager from channel import Channel from websocket import WebsocketPool @@ -87,7 +88,7 @@ class Twitch: async def shutdown(self) -> None: start_time = time() - self.gui.print("Exiting...") + self.gui.print(_("status", "exiting")) self.stop_watching() if self._watching_task is not None: self._watching_task.cancel() @@ -102,8 +103,6 @@ class Twitch: session, self._session = self._session, None await session.close() await self.websocket.stop() - # save image cache - self.gui.save() # wait at least half a second + whatever it takes to complete the closing # this allows aiohttp to safely close the session await asyncio.sleep(start_time + 0.5 - time()) @@ -149,12 +148,12 @@ class Twitch: """ self.gui.print(*args, **kwargs) - def save(self) -> None: + def save(self, *, force: bool = False) -> None: """ Saves the application state. """ - self.gui.save() - self.settings.save() + self.gui.save(force=force) + self.settings.save(force=force) @staticmethod def _viewers_key(channel: Channel) -> int: @@ -198,7 +197,7 @@ class Twitch: self.change_state(State.INVENTORY_FETCH) while True: if self._state is State.IDLE: - self.gui.status.update("Idle") + self.gui.status.update(_("gui", "status", "idle")) self.stop_watching() # clear the flag and wait until it's set again self._state_change.clear() @@ -243,7 +242,7 @@ class Twitch: self.restart_watching() self.change_state(State.CHANNELS_CLEANUP) elif self._state is State.CHANNELS_CLEANUP: - self.gui.status.update("Cleaning up channels...") + self.gui.status.update(_("gui", "status", "cleanup")) if not self.games or full_cleanup: # no games selected or we're doing full cleanup: remove everything to_remove: list[Channel] = list(channels.values()) @@ -275,12 +274,10 @@ class Twitch: self.change_state(State.CHANNELS_FETCH) else: # with no games available, we switch to IDLE after cleanup - self.gui.print( - "No active campaigns to mine drops for. Waiting for an active campaign..." - ) + self.gui.print(_("gui", "status", "no_campaigns")) self.change_state(State.IDLE) elif self._state is State.CHANNELS_FETCH: - self.gui.status.update("Gathering channels...") + self.gui.status.update(_("gui", "status", "gathering")) # start with all current channels new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values()) # gather and add ACL channels from campaigns @@ -361,7 +358,7 @@ class Twitch: break self.change_state(State.CHANNEL_SWITCH) elif self._state is State.CHANNEL_SWITCH: - self.gui.status.update("Switching the channel...") + self.gui.status.update(_("gui", "status", "switching")) # Change into the selected channel, stay in the watching channel, # or select a new channel that meets the required conditions new_watching = None @@ -381,9 +378,7 @@ class Twitch: watching_channel = self.watching_channel.get_with_default(None) if watching_channel is None and new_watching is None: # not watching anything and there isn't anything to watch either - self.gui.print( - "No available channels to watch. Waiting for an ONLINE channel..." - ) + self.gui.print(_("status", "no_channel")) self.change_state(State.IDLE) else: if new_watching is not None: @@ -393,11 +388,13 @@ class Twitch: # should immediately complete, since we'll definitely # have a channel we're watching here watching_channel = await self.watching_channel.get() - self.gui.status.update(f"Watching {watching_channel.name}") + self.gui.status.update( + _("gui", "status", "watching").format(channel=watching_channel.name) + ) # break the state change chain by clearing the flag self._state_change.clear() elif self._state is State.EXIT: - self.gui.status.update("Exiting...") + self.gui.status.update(_("status", "exiting")) # we've been requested to exit the application break await self._state_change.wait() @@ -422,8 +419,7 @@ class Twitch: channel = await self.watching_channel.get() succeeded = await channel.send_watch() if not succeeded: - # this usually means there are connection problems - self.gui.print("Connection problems, retrying in 60 seconds...") + # this usually means the campaign expired in the middle of mining await self._watch_sleep(60) continue last_watch = time() @@ -484,10 +480,11 @@ class Twitch: period = timedelta.max for campaign in self.inventory: if not campaign.linked: + # this relies on the linked campaigns being first due to sorting break - if campaign.starts_at > now and (test_period := campaign.starts_at - now) < period: + if campaign.starts_at >= now and (test_period := campaign.starts_at - now) < period: period = test_period - elif campaign.ends_at > now and (test_period := campaign.ends_at - now) < period: + elif campaign.ends_at >= now and (test_period := campaign.ends_at - now) < period: period = test_period if period > one_hour: period = one_hour @@ -514,7 +511,7 @@ class Twitch: if not self.games: return False return ( - channel.online # steam online + channel.online # stream online and channel.drops_enabled # drops are enabled # it's one of the games we've selected and channel.game is not None and channel.game in self.games @@ -588,7 +585,7 @@ class Twitch: self.can_watch(channel) # we can watch the channel that just got ONLINE and self.should_switch(channel) # and we should! ): - self.gui.print(f"{channel.name} goes ONLINE, switching...") + self.gui.print(_("status", "goes_online").format(channel=channel.name)) self.watch(channel) def on_offline(self, channel: Channel): @@ -598,7 +595,7 @@ class Twitch: # change the channel if we're currently watching it watching_channel = self.watching_channel.get_with_default(None) if watching_channel is not None and watching_channel == channel: - self.gui.print(f"{channel.name} goes OFFLINE, switching...") + self.gui.print(_("status", "goes_offline").format(channel=channel.name)) self.change_state(State.CHANNEL_SWITCH) else: logger.debug(f"{channel.name} goes OFFLINE") @@ -629,8 +626,8 @@ class Twitch: f"{drop.rewards_text()} " f"({campaign.claimed_drops}/{campaign.total_drops})" ) - self.gui.print(f"Claimed drop: {claim_text}") - self.gui.tray.notify(claim_text, "Mined Drop") + self.gui.print(_("status", "claimed_drop").format(drop=claim_text)) + self.gui.tray.notify(claim_text, _("gui", "tray", "notification_title")) else: logger.error(f"Drop claim failed! Drop ID: {drop_id}") # About 4-20s after claiming the drop, next drop can be started @@ -720,12 +717,14 @@ class Twitch: if channel is not None: channel.points = balance channel.display() - self.gui.print(f"Earned points for watching: {points:3}, total: {balance}") + self.gui.print( + _("status", "earned_points").format(points=f"{points:3}", balance=balance) + ) elif msg_type == "claim-available": claim_data = message["data"]["claim"] points = claim_data["point_gain"]["total_points"] await self.claim_points(claim_data["channel_id"], claim_data["id"]) - self.gui.print(f"Claimed bonus points: {points}") + self.gui.print(_("status", "claimed_points").format(points=points)) async def _login(self) -> str: logger.debug("Login flow started") @@ -822,7 +821,7 @@ class Twitch: # looks like we're missing something login_form: LoginForm = self.gui.login logger.debug("Checking login") - login_form.update("Logging in...", None) + login_form.update(_("gui", "login", "logging_in"), None) # NOTE: We need this here because of the jar being accessed session = await self.get_session() url = URL(BASE_URL) @@ -860,7 +859,7 @@ class Twitch: cookie["persistent"] = str(self._user_id) self._is_logged_in.set() logger.debug(f"Login successful, user ID: {self._user_id}") - login_form.update("Logged in", self._user_id) + login_form.update(_("gui", "login", "logged_in"), self._user_id) # update our cookie and save it jar.update_cookies(cookie, url) jar.save(COOKIES_PATH) @@ -881,11 +880,11 @@ class Twitch: if response.status < 500: yield response return - self.print(f"Twitch is down, retrying in {round(delay)} seconds...") + self.print(_("error", "site_down").format(seconds=round(delay))) except (aiohttp.ClientConnectionError, asyncio.TimeoutError): # just so that quick 2nd retries that often happen, aren't shown if delay > 1: - self.print(f"Cannot connect to Twitch, retrying in {round(delay)} seconds...") + self.print(_("error", "no_connection").format(seconds=round(delay))) await asyncio.sleep(delay) async def gql_request(self, op: GQLOperation) -> JsonType: @@ -922,7 +921,7 @@ class Twitch: async def fetch_inventory(self) -> None: status_update = self.gui.status.update - status_update("Fetching inventory...") + status_update(_("gui", "status", "fetching_inventory")) # fetch in-progress campaigns (inventory) response = await self.gql_request(GQL_OPERATIONS["Inventory"]) if self.gui.close_requested: @@ -953,7 +952,11 @@ class Twitch: ) } # add campaigns that remained, that can be earned but are not in-progress yet - status_update(f"Fetching campaigns... (0/{len(available_campaigns)})") + status_update( + _("gui", "status", "fetching_campaigns").format( + counter=f"(0/{len(available_campaigns)})" + ) + ) fetched_campaigns: list[DropsCampaign] = [] for i, coro in enumerate( # specifically use an intermediate list per a Python bug @@ -965,7 +968,11 @@ class Twitch: start=1, ): fetched_campaigns.append(await coro) - status_update(f"Fetching campaigns... ({i}/{len(available_campaigns)})") + status_update( + _("gui", "status", "fetching_campaigns").format( + counter=f"({i}/{len(available_campaigns)})" + ) + ) if self.gui.close_requested: raise ExitRequest() campaigns.extend(fetched_campaigns) @@ -976,7 +983,9 @@ class Twitch: self.gui.inv.clear() self.inventory.clear() for i, campaign in enumerate(campaigns, start=1): - status_update(f"Adding campaigns to inventory... ({i}/{len(campaigns)})") + status_update( + _("gui", "status", "adding_campaigns").format(counter=f"({i}/{len(campaigns)})") + ) self._drops.update({drop.id: drop for drop in campaign.drops}) await self.gui.inv.add_campaign(campaign) self.inventory.append(campaign) diff --git a/utils.py b/utils.py index 36aa3bf..1f500a8 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys import json import random import string @@ -19,7 +18,8 @@ from typing import ( import yarl -from constants import WORKING_DIR, JsonType +from constants import JsonType +from constants import _resource_path as resource_path # noqa if TYPE_CHECKING: from typing_extensions import ParamSpec @@ -52,21 +52,6 @@ def json_minify(data: JsonType | list[JsonType]) -> str: return json.dumps(data, separators=(',', ':')) -def resource_path(relative_path: Path | str) -> Path: - """ - Get an absolute path to a bundled resource. - - Works for dev and for PyInstaller. - """ - if hasattr(sys, "_MEIPASS"): - # PyInstaller's folder where the one-file app is unpacked - meipass: str = getattr(sys, "_MEIPASS") - base_path = Path(meipass) - else: - base_path = WORKING_DIR - return base_path.joinpath(relative_path) - - def timestamp(string: str) -> datetime: return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) @@ -142,6 +127,9 @@ def _remove_missing(obj: JsonType) -> JsonType: del obj[key] elif isinstance(value, dict): _remove_missing(value) + if not value: + # the dict is empty now, so remove it's key entirely + del obj[key] return obj @@ -155,17 +143,41 @@ def _deserialize(obj: JsonType) -> Any: return obj -def json_load(path: Path, defaults: _JSON_T) -> _JSON_T: - combined: JsonType = dict(defaults) +def merge_json(obj: JsonType, template: Mapping[Any, Any]) -> None: + # NOTE: This modifies object in place + for k, v in list(obj.items()): + if k not in template: + del obj[k] + elif isinstance(v, dict): + if isinstance(template[k], dict): + merge_json(v, template[k]) + else: + # object is a dict, template is not: overwrite from template + obj[k] = template[k] + elif isinstance(template[k], dict): + # template is a dict, object is not: overwrite from template + obj[k] = template[k] + # ensure the object is not missing any keys + for k in template.keys(): + if k not in obj: + obj[k] = template[k] + + +def json_load(path: Path, defaults: _JSON_T, *, merge: bool = True) -> _JSON_T: + defaults_dict: JsonType = dict(defaults) if path.exists(): - with open(path, 'r') as file: - combined.update(_remove_missing(json.load(file, object_hook=_deserialize))) + with open(path, 'r', encoding="utf8") as file: + combined: JsonType = _remove_missing(json.load(file, object_hook=_deserialize)) + if merge: + merge_json(combined, defaults_dict) + else: + combined = defaults_dict return cast(_JSON_T, combined) -def json_save(path: Path, contents: Mapping[Any, Any]) -> None: - with open(path, 'w') as file: - json.dump(contents, file, default=_serialize, sort_keys=True, indent=4) +def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None: + with open(path, 'w', encoding="utf8") as file: + json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4) class ExponentialBackoff: diff --git a/websocket.py b/websocket.py index fe06fe3..ede0059 100644 --- a/websocket.py +++ b/websocket.py @@ -9,6 +9,7 @@ from typing import Any, Literal, TYPE_CHECKING import aiohttp +from translate import _ from exceptions import MinerException, WebsocketClosed from constants import PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT from utils import ( @@ -91,7 +92,7 @@ class Websocket: self._closed.set() ws = self._ws.get_with_default(None) if ws is not None: - self.set_status("Disconnecting...") + self.set_status(_("gui", "websocket", "disconnecting")) await ws.close() def stop_nowait(self): @@ -141,9 +142,9 @@ class Websocket: @task_wrapper async def _handle(self): # ensure we're logged in before connecting - self.set_status("Initializing...") + self.set_status(_("gui", "websocket", "initializing")) await self._twitch.wait_until_login() - self.set_status("Connecting...") + self.set_status(_("gui", "websocket", "connecting")) ws_logger.info(f"Websocket[{self._idx}] connecting...") self._closed.clear() # Connect/Reconnect loop @@ -154,7 +155,7 @@ class Websocket: self._reconnect_requested.clear() # NOTE: _topics_changed doesn't start set, # because there's no initial topics we can sub to right away - self.set_status("Connected") + self.set_status(_("gui", "websocket", "connected")) try: try: while not self._reconnect_requested.is_set(): @@ -176,11 +177,11 @@ class Websocket: elif self._closed.is_set(): # we closed it - exit ws_logger.info(f"Websocket[{self._idx}] stopped.") - self.set_status("Disconnected") + self.set_status(_("gui", "websocket", "disconnected")) return except Exception: ws_logger.exception(f"Exception in Websocket[{self._idx}]") - self.set_status("Reconnecting...") + self.set_status(_("gui", "websocket", "reconnecting")) ws_logger.warning(f"Websocket[{self._idx}] reconnecting...") async def _handle_ping(self):