Implement the translation feature
This commit is contained in:
1 parent
3c81268501
commit
05466364d1
14 files changed
+1040
-219
No files matched your search
+2
-4
@@ -1,10 +1,8 @@
|
|||||||
# Cache files
|
# Cache files
|
||||||
__pycache__
|
__pycache__
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
# Build files
|
# Build and dist files
|
||||||
/build
|
/build
|
||||||
/*.spec
|
|
||||||
# Dist files
|
|
||||||
/dist
|
/dist
|
||||||
/7z.exe
|
/7z.exe
|
||||||
/*.zip
|
/*.zip
|
||||||
@@ -12,7 +10,7 @@ __pycache__
|
|||||||
/Twitch Drops Miner
|
/Twitch Drops Miner
|
||||||
# Dev files
|
# Dev files
|
||||||
/env
|
/env
|
||||||
/lang
|
|
||||||
/cache
|
/cache
|
||||||
cookies.jar
|
cookies.jar
|
||||||
settings.json
|
settings.json
|
||||||
|
/lang/English.json
|
||||||
@@ -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 `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.
|
- 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.
|
- 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.
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
cls
|
cls
|
||||||
%CD%/env/scripts/pyinstaller --onefile --noconsole --name "Twitch Drops Miner (by DevilXD)" ^
|
%CD%/env/scripts/pyinstaller build.spec
|
||||||
--icon pickaxe.ico --add-data pickaxe.ico;. main.py
|
|
||||||
if %ERRORLEVEL% == 0 call pack.bat
|
if %ERRORLEVEL% == 0 call pack.bat
|
||||||
+61
@@ -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)",
|
||||||
|
)
|
||||||
@@ -38,7 +38,7 @@ class ImageCache:
|
|||||||
def __init__(self, manager: GUIManager) -> None:
|
def __init__(self, manager: GUIManager) -> None:
|
||||||
self._root = manager._root
|
self._root = manager._root
|
||||||
CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
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._images: dict[ImageHash, Image] = {}
|
||||||
self._photos: dict[tuple[ImageHash, ImageSize], PhotoImage] = {}
|
self._photos: dict[tuple[ImageHash, ImageSize], PhotoImage] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
@@ -58,12 +58,18 @@ class ImageCache:
|
|||||||
for img_hash, count in hash_counts.items():
|
for img_hash, count in hash_counts.items():
|
||||||
if count == 0:
|
if count == 0:
|
||||||
# hashes come with an extension already
|
# hashes come with an extension already
|
||||||
for file in CACHE_PATH.glob(img_hash):
|
CACHE_PATH.joinpath(img_hash).unlink(missing_ok=True)
|
||||||
file.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:
|
def save(self, *, force: bool = False) -> None:
|
||||||
if self._altered:
|
if self._altered or force:
|
||||||
json_save(CACHE_DB, self._hashes)
|
json_save(CACHE_DB, self._hashes, sort=True)
|
||||||
|
|
||||||
def _new_expires(self) -> datetime:
|
def _new_expires(self) -> datetime:
|
||||||
return datetime.now(timezone.utc) + self.LIFETIME
|
return datetime.now(timezone.utc) + self.LIFETIME
|
||||||
|
|||||||
+28
-1
@@ -15,9 +15,35 @@ if TYPE_CHECKING:
|
|||||||
from typing_extensions import TypeAlias
|
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
|
# 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
|
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
|
# Other Paths
|
||||||
LOG_PATH = Path(WORKING_DIR, "log.txt")
|
LOG_PATH = Path(WORKING_DIR, "log.txt")
|
||||||
CACHE_PATH = Path(WORKING_DIR, "cache")
|
CACHE_PATH = Path(WORKING_DIR, "cache")
|
||||||
@@ -32,6 +58,7 @@ TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]"
|
|||||||
MAX_WEBSOCKETS = 8
|
MAX_WEBSOCKETS = 8
|
||||||
WS_TOPICS_LIMIT = 50
|
WS_TOPICS_LIMIT = 50
|
||||||
# Misc
|
# Misc
|
||||||
|
DEFAULT_LANG = "English"
|
||||||
BASE_URL = "https://twitch.tv"
|
BASE_URL = "https://twitch.tv"
|
||||||
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||||
USER_AGENT = (
|
USER_AGENT = (
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ from functools import partial
|
|||||||
from collections import abc, namedtuple
|
from collections import abc, namedtuple
|
||||||
from tkinter.font import Font, nametofont
|
from tkinter.font import Font, nametofont
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage
|
||||||
|
|
||||||
import pystray
|
import pystray
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
from PIL import Image as Image_module
|
from PIL import Image as Image_module
|
||||||
|
|
||||||
|
from translate import _
|
||||||
from cache import ImageCache
|
from cache import ImageCache
|
||||||
from exceptions import ExitRequest
|
from exceptions import ExitRequest
|
||||||
from utils import resource_path, Game
|
from utils import resource_path, Game, _T
|
||||||
from registry import RegistryKey, ValueType
|
from registry import RegistryKey, ValueType
|
||||||
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
|
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)
|
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:
|
class StatusBar:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
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)
|
frame.grid(column=0, row=0, columnspan=3, sticky="nsew", padx=2)
|
||||||
self._label = ttk.Label(frame)
|
self._label = ttk.Label(frame)
|
||||||
self._label.grid(column=0, row=0, sticky="nsew")
|
self._label.grid(column=0, row=0, sticky="nsew")
|
||||||
@@ -289,13 +328,16 @@ class _WSEntry(TypedDict):
|
|||||||
|
|
||||||
class WebsocketStatus:
|
class WebsocketStatus:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
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)
|
frame.grid(column=0, row=1, sticky="nsew", padx=2)
|
||||||
self._status_var = StringVar(frame)
|
self._status_var = StringVar(frame)
|
||||||
self._topics_var = StringVar(frame)
|
self._topics_var = StringVar(frame)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
frame,
|
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",
|
style="MS.TLabel",
|
||||||
).grid(column=0, row=0)
|
).grid(column=0, row=0)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
@@ -320,7 +362,9 @@ class WebsocketStatus:
|
|||||||
raise TypeError("You need to provide at least one of: status, topics")
|
raise TypeError("You need to provide at least one of: status, topics")
|
||||||
entry = self._items.get(idx)
|
entry = self._items.get(idx)
|
||||||
if entry is None:
|
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:
|
if status is not None:
|
||||||
entry["status"] = status
|
entry["status"] = status
|
||||||
if topics is not None:
|
if topics is not None:
|
||||||
@@ -353,23 +397,27 @@ class LoginForm:
|
|||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
self._var = StringVar(master)
|
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.grid(column=1, row=1, sticky="nsew", padx=2)
|
||||||
frame.columnconfigure(0, weight=2)
|
frame.columnconfigure(0, weight=2)
|
||||||
frame.columnconfigure(1, weight=1)
|
frame.columnconfigure(1, weight=1)
|
||||||
frame.rowconfigure(4, 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)
|
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._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._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._token_entry.grid(column=0, row=3, columnspan=2)
|
||||||
self._confirm = asyncio.Event()
|
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._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):
|
def clear(self, login: bool = False, password: bool = False, token: bool = False):
|
||||||
clear_all = not login and not password and not token
|
clear_all = not login and not password and not token
|
||||||
@@ -381,8 +429,8 @@ class LoginForm:
|
|||||||
self._token_entry.clear()
|
self._token_entry.clear()
|
||||||
|
|
||||||
async def ask_login(self) -> LoginData:
|
async def ask_login(self) -> LoginData:
|
||||||
self.update("Login required", None)
|
self.update(_("gui", "login", "required"), None)
|
||||||
self._manager.print("Please log in to continue.")
|
self._manager.print(_("gui", "login", "request"))
|
||||||
self._confirm.clear()
|
self._confirm.clear()
|
||||||
self._button.config(state="normal")
|
self._button.config(state="normal")
|
||||||
# NOTE: we need this to allow for the closing window event to break the waiting here
|
# 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(
|
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.grid(column=0, row=2, columnspan=2, sticky="nsew", padx=2)
|
||||||
frame.columnconfigure(0, weight=2)
|
frame.columnconfigure(0, weight=2)
|
||||||
frame.columnconfigure(1, weight=1)
|
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(
|
ttk.Label(
|
||||||
frame, textvariable=self._vars["campaign"]["name"]
|
frame, textvariable=self._vars["campaign"]["name"]
|
||||||
).grid(column=0, row=1, columnspan=2)
|
).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"]["percentage"]).grid(column=1, row=2)
|
||||||
ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3)
|
ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3)
|
||||||
ttk.Progressbar(
|
ttk.Progressbar(
|
||||||
@@ -465,11 +515,13 @@ class CampaignProgress:
|
|||||||
ttk.Separator(
|
ttk.Separator(
|
||||||
frame, orient="horizontal"
|
frame, orient="horizontal"
|
||||||
).grid(row=5, columnspan=2, sticky="ew", pady=(4, 0))
|
).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(
|
ttk.Label(
|
||||||
frame, textvariable=self._vars["drop"]["rewards"]
|
frame, textvariable=self._vars["drop"]["rewards"]
|
||||||
).grid(column=0, row=7, columnspan=2)
|
).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"]["percentage"]).grid(column=1, row=8)
|
||||||
ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9)
|
ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9)
|
||||||
ttk.Progressbar(
|
ttk.Progressbar(
|
||||||
@@ -502,9 +554,13 @@ class CampaignProgress:
|
|||||||
campaign_vars: _CampaignVars = self._vars["campaign"]
|
campaign_vars: _CampaignVars = self._vars["campaign"]
|
||||||
dseconds = seconds % 60
|
dseconds = seconds % 60
|
||||||
hours, minutes = self._divmod(drop_minutes, seconds)
|
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)
|
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):
|
async def _timer_loop(self):
|
||||||
seconds = 60
|
seconds = 60
|
||||||
@@ -561,7 +617,7 @@ class CampaignProgress:
|
|||||||
|
|
||||||
class ConsoleOutput:
|
class ConsoleOutput:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
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)
|
frame.grid(column=0, row=3, columnspan=2, sticky="nsew", padx=2)
|
||||||
# tell master frame that the containing row can expand
|
# tell master frame that the containing row can expand
|
||||||
master.rowconfigure(3, weight=1)
|
master.rowconfigure(3, weight=1)
|
||||||
@@ -601,7 +657,7 @@ class _Buttons(TypedDict):
|
|||||||
class ChannelList:
|
class ChannelList:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
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)
|
frame.grid(column=2, row=1, rowspan=3, sticky="nsew", padx=2)
|
||||||
# tell master frame that the containing column can expand
|
# tell master frame that the containing column can expand
|
||||||
master.columnconfigure(2, weight=1)
|
master.columnconfigure(2, weight=1)
|
||||||
@@ -612,12 +668,12 @@ class ChannelList:
|
|||||||
"frame": buttons_frame,
|
"frame": buttons_frame,
|
||||||
"switch": ttk.Button(
|
"switch": ttk.Button(
|
||||||
buttons_frame,
|
buttons_frame,
|
||||||
text="Switch",
|
text=_("gui", "channels", "switch"),
|
||||||
state="disabled",
|
state="disabled",
|
||||||
command=manager._twitch.state_change(State.CHANNEL_SWITCH),
|
command=manager._twitch.state_change(State.CHANNEL_SWITCH),
|
||||||
),
|
),
|
||||||
"load_points": ttk.Button(
|
"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)
|
buttons_frame.grid(column=0, row=0, columnspan=2)
|
||||||
@@ -638,12 +694,26 @@ class ChannelList:
|
|||||||
table.bind("<Button-1>", self._disable_column_resize)
|
table.bind("<Button-1>", self._disable_column_resize)
|
||||||
table.bind("<<TreeviewSelect>>", self._selected)
|
table.bind("<<TreeviewSelect>>", self._selected)
|
||||||
self._add_column("#0", '', width=0)
|
self._add_column("#0", '', width=0)
|
||||||
self._add_column("channel", "Channel", width=100, anchor='w')
|
self._add_column(
|
||||||
self._add_column("status", "Status", width_template="OFFLINE ❌")
|
"channel", _("gui", "channels", "headings", "channel"), width=100, anchor='w'
|
||||||
self._add_column("game", "Game", width=50)
|
)
|
||||||
|
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("drops", "🎁", width_template="✔")
|
||||||
self._add_column("viewers", "Viewers", width_template="1234567")
|
self._add_column(
|
||||||
self._add_column("points", "Points", width_template="1234567")
|
"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._add_column("priority", "❗", width_template="✔")
|
||||||
self._channel_map: dict[str, Channel] = {}
|
self._channel_map: dict[str, Channel] = {}
|
||||||
|
|
||||||
@@ -654,7 +724,7 @@ class ChannelList:
|
|||||||
*,
|
*,
|
||||||
anchor: tk._Anchor = "center",
|
anchor: tk._Anchor = "center",
|
||||||
width: int | None = None,
|
width: int | None = None,
|
||||||
width_template: str | None = None,
|
width_template: str | list[str] | None = None,
|
||||||
):
|
):
|
||||||
table = self._table
|
table = self._table
|
||||||
# NOTE: we don't do this for the icon column
|
# 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)
|
table.column(s_cid, minwidth=s_minwidth, width=s_width, stretch=False)
|
||||||
# set heading and column settings for the new column
|
# set heading and column settings for the new column
|
||||||
if width_template is not None:
|
if width_template is not None:
|
||||||
|
if isinstance(width_template, str):
|
||||||
width = self._measure(width_template)
|
width = self._measure(width_template)
|
||||||
|
else:
|
||||||
|
width = max((self._measure(template) for template in width_template), default=20)
|
||||||
self._const_width.add(cid)
|
self._const_width.add(cid)
|
||||||
assert width is not None
|
assert width is not None
|
||||||
table.heading(cid, text=name, anchor=anchor)
|
table.heading(cid, text=name, anchor=anchor)
|
||||||
@@ -779,11 +852,11 @@ class ChannelList:
|
|||||||
priority = "✔" if channel.priority else "❌"
|
priority = "✔" if channel.priority else "❌"
|
||||||
# status
|
# status
|
||||||
if channel.online:
|
if channel.online:
|
||||||
status = "ONLINE ✔"
|
status = _("gui", "channels", "online")
|
||||||
elif channel.pending_online:
|
elif channel.pending_online:
|
||||||
status = "OFFLINE ⏳"
|
status = _("gui", "channels", "pending")
|
||||||
else:
|
else:
|
||||||
status = "OFFLINE ❌"
|
status = _("gui", "channels", "offline")
|
||||||
# game
|
# game
|
||||||
game = str(channel.game or '')
|
game = str(channel.game or '')
|
||||||
# drops
|
# drops
|
||||||
@@ -832,7 +905,7 @@ class TrayIcon:
|
|||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
self.icon: pystray.Icon | None = None
|
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")
|
self._button.grid(column=0, row=0, sticky="ne")
|
||||||
|
|
||||||
def is_tray(self) -> bool:
|
def is_tray(self) -> bool:
|
||||||
@@ -857,9 +930,9 @@ class TrayIcon:
|
|||||||
return lambda: loop.call_soon_threadsafe(func)
|
return lambda: loop.call_soon_threadsafe(func)
|
||||||
|
|
||||||
menu = pystray.Menu(
|
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.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Quit", bridge(self.quit)),
|
pystray.MenuItem(_("gui", "tray", "quit"), bridge(self.quit)),
|
||||||
)
|
)
|
||||||
self.icon = pystray.Icon(
|
self.icon = pystray.Icon(
|
||||||
"twitch_miner",
|
"twitch_miner",
|
||||||
@@ -933,37 +1006,49 @@ class InventoryOverview:
|
|||||||
"finished": IntVar(master, 0),
|
"finished": IntVar(master, 0),
|
||||||
}
|
}
|
||||||
# Filtering options
|
# 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
|
LABEL_SPACING = 20
|
||||||
filter_frame.grid(column=0, row=0, columnspan=2, sticky="nsew")
|
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
|
icolumn = 0
|
||||||
ttk.Checkbutton(
|
ttk.Checkbutton(
|
||||||
filter_frame, variable=self._filters["linked"]
|
filter_frame, variable=self._filters["linked"]
|
||||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Label(
|
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)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Checkbutton(
|
ttk.Checkbutton(
|
||||||
filter_frame, variable=self._filters["upcoming"]
|
filter_frame, variable=self._filters["upcoming"]
|
||||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Label(
|
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)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Checkbutton(
|
ttk.Checkbutton(
|
||||||
filter_frame, variable=self._filters["expired"]
|
filter_frame, variable=self._filters["expired"]
|
||||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Label(
|
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)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Checkbutton(
|
ttk.Checkbutton(
|
||||||
filter_frame, variable=self._filters["finished"]
|
filter_frame, variable=self._filters["finished"]
|
||||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Label(
|
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)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
ttk.Button(
|
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)
|
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||||
# Inventory view
|
# Inventory view
|
||||||
self._canvas = tk.Canvas(master, scrollregion=(0, 0, 0, 0))
|
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")
|
).grid(column=0, row=0, columnspan=2, sticky="w")
|
||||||
# Status
|
# Status
|
||||||
if campaign.active:
|
if campaign.active:
|
||||||
status_text: str = "Active ✔"
|
status_text: str = _("gui", "inventory", "status", "active")
|
||||||
status_color: tk._Color = "green"
|
status_color: tk._Color = "green"
|
||||||
elif campaign.upcoming:
|
elif campaign.upcoming:
|
||||||
status_text = "Upcoming ⏳"
|
status_text = _("gui", "inventory", "status", "upcoming")
|
||||||
status_color = "goldenrod"
|
status_color = "goldenrod"
|
||||||
else:
|
else:
|
||||||
status_text = "Expired ❌"
|
status_text = _("gui", "inventory", "status", "expired")
|
||||||
status_color = "red"
|
status_color = "red"
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
campaign_frame, text=status_text, takefocus=False, foreground=status_color
|
campaign_frame, text=status_text, takefocus=False, foreground=status_color
|
||||||
@@ -1047,9 +1132,11 @@ class InventoryOverview:
|
|||||||
# Starts / Ends
|
# Starts / Ends
|
||||||
MouseOverLabel(
|
MouseOverLabel(
|
||||||
campaign_frame,
|
campaign_frame,
|
||||||
text=f"Ends: {campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)}",
|
text=_("gui", "inventory", "ends").format(
|
||||||
alt_text=(
|
time=campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)
|
||||||
f"Starts: {campaign.starts_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,
|
reverse=campaign.upcoming,
|
||||||
takefocus=False,
|
takefocus=False,
|
||||||
@@ -1058,12 +1145,12 @@ class InventoryOverview:
|
|||||||
if campaign.linked:
|
if campaign.linked:
|
||||||
link_kwargs = {
|
link_kwargs = {
|
||||||
"style": '',
|
"style": '',
|
||||||
"text": "Linked ✔",
|
"text": _("gui", "inventory", "status", "linked"),
|
||||||
"foreground": "green",
|
"foreground": "green",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
link_kwargs = {
|
link_kwargs = {
|
||||||
"text": "Not linked ❌",
|
"text": _("gui", "inventory", "status", "not_linked"),
|
||||||
"foreground": "red",
|
"foreground": "red",
|
||||||
}
|
}
|
||||||
LinkLabel(
|
LinkLabel(
|
||||||
@@ -1080,11 +1167,15 @@ class InventoryOverview:
|
|||||||
allowed_text: str = '\n'.join(ch.name for ch in acl)
|
allowed_text: str = '\n'.join(ch.name for ch in acl)
|
||||||
else:
|
else:
|
||||||
allowed_text = '\n'.join(ch.name for ch in acl[:4])
|
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:
|
else:
|
||||||
allowed_text = "All"
|
allowed_text = _("gui", "inventory", "all_channels")
|
||||||
ttk.Label(
|
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)
|
).grid(column=1, row=4, sticky="nw", padx=4)
|
||||||
# Image
|
# Image
|
||||||
campaign_image = await self._cache.get(campaign.image_url, size=(108, 144))
|
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]:
|
def get_progress(self, drop: TimedDrop) -> tuple[str, tk._Color]:
|
||||||
progress_color: 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:
|
if drop.is_claimed:
|
||||||
progress_color = "green"
|
progress_color = "green"
|
||||||
progress_text = "Claimed ✔"
|
progress_text = _("gui", "inventory", "status", "claimed")
|
||||||
elif drop.can_claim:
|
elif drop.can_claim:
|
||||||
progress_color = "goldenrod"
|
progress_color = "goldenrod"
|
||||||
progress_text = "Ready to claim ⏳"
|
progress_text = _("gui", "inventory", "status", "ready")
|
||||||
elif drop.can_earn():
|
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)
|
return (progress_text, progress_color)
|
||||||
|
|
||||||
def update_drop(self, drop: TimedDrop) -> None:
|
def update_drop(self, drop: TimedDrop) -> None:
|
||||||
@@ -1180,7 +1276,9 @@ class SettingsPanel:
|
|||||||
center_frame = ttk.Frame(master)
|
center_frame = ttk.Frame(master)
|
||||||
center_frame.grid(column=0, row=0)
|
center_frame.grid(column=0, row=0)
|
||||||
# General section
|
# 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")
|
general_frame.grid(column=0, row=0, sticky="nsew")
|
||||||
# use another frame to center the options within the section
|
# 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
|
# 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)
|
general_frame.columnconfigure(0, weight=1)
|
||||||
center_frame2 = ttk.Frame(general_frame)
|
center_frame2 = ttk.Frame(general_frame)
|
||||||
center_frame2.grid(column=0, row=0)
|
center_frame2.grid(column=0, row=0)
|
||||||
ttk.Label(center_frame2, text="Autostart: ").grid(column=0, row=0, sticky="e")
|
# language frame
|
||||||
ttk.Checkbutton(
|
language_frame = ttk.Frame(center_frame2)
|
||||||
center_frame2, variable=self._vars["autostart"], command=self.update_autostart
|
language_frame.grid(column=0, row=0)
|
||||||
).grid(column=1, row=0, sticky="w")
|
ttk.Label(language_frame, text="Language 🌐 (requires restart): ").grid(column=0, row=0)
|
||||||
ttk.Label(center_frame2, text="Autostart into tray: ").grid(column=0, row=1, sticky="e")
|
SelectMenu(
|
||||||
ttk.Checkbutton(
|
language_frame,
|
||||||
center_frame2, variable=self._vars["tray"], command=self.update_autostart
|
default=_.current,
|
||||||
).grid(column=1, row=1, sticky="w")
|
options={k: k for k in _.languages},
|
||||||
ttk.Label(center_frame2, text="Priority only: ").grid(column=0, row=2, sticky="e")
|
command=lambda lang: setattr(self._settings, "language", lang),
|
||||||
ttk.Checkbutton(
|
).grid(column=1, row=0)
|
||||||
center_frame2, variable=self._vars["priority_only"], command=self.priority_only
|
# checkboxes frame
|
||||||
).grid(column=1, row=2, sticky="w")
|
checkboxes_frame = ttk.Frame(center_frame2)
|
||||||
|
checkboxes_frame.grid(column=0, row=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
center_frame2, text="Proxy (requires restart):"
|
checkboxes_frame, text=_("gui", "settings", "general", "autostart")
|
||||||
).grid(column=0, row=3, columnspan=2)
|
).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(
|
self._proxy = PlaceholderEntry(
|
||||||
center_frame2,
|
proxy_frame,
|
||||||
width=37,
|
width=37,
|
||||||
validate="focusout",
|
validate="focusout",
|
||||||
prefill="http://",
|
prefill="http://",
|
||||||
@@ -1212,12 +1330,14 @@ class SettingsPanel:
|
|||||||
placeholder="http://username:password@address:port",
|
placeholder="http://username:password@address:port",
|
||||||
)
|
)
|
||||||
self._proxy.config(validatecommand=partial(proxy_validate, self._proxy, self._settings))
|
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 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")
|
priority_frame.grid(column=1, row=0, sticky="nsew")
|
||||||
self._priority_entry = PlaceholderCombobox(
|
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")
|
self._priority_entry.grid(column=0, row=0, sticky="ew")
|
||||||
priority_frame.columnconfigure(0, weight=1)
|
priority_frame.columnconfigure(0, weight=1)
|
||||||
@@ -1256,9 +1376,13 @@ class SettingsPanel:
|
|||||||
).grid(column=1, row=3, sticky="ns")
|
).grid(column=1, row=3, sticky="ns")
|
||||||
priority_frame.rowconfigure(3, weight=1)
|
priority_frame.rowconfigure(3, weight=1)
|
||||||
# Exclude section
|
# 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")
|
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")
|
self._exclude_entry.grid(column=0, row=0, sticky="ew")
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
exclude_frame, text="+", command=self.exclude_add, width=2, style="Large.TButton"
|
exclude_frame, text="+", command=self.exclude_add, width=2, style="Large.TButton"
|
||||||
@@ -1282,12 +1406,11 @@ class SettingsPanel:
|
|||||||
# Reload button
|
# Reload button
|
||||||
reload_frame = ttk.Frame(center_frame)
|
reload_frame = ttk.Frame(center_frame)
|
||||||
reload_frame.grid(column=0, row=1, columnspan=3, pady=4)
|
reload_frame.grid(column=0, row=1, columnspan=3, pady=4)
|
||||||
ttk.Label(
|
ttk.Label(reload_frame, text=_("gui", "settings", "reload_text")).grid(column=0, row=0)
|
||||||
reload_frame,
|
|
||||||
text="Most changes require a reload to take an immediate effect: "
|
|
||||||
).grid(column=0, row=0)
|
|
||||||
ttk.Button(
|
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)
|
).grid(column=1, row=0)
|
||||||
|
|
||||||
def clear_selection(self) -> None:
|
def clear_selection(self) -> None:
|
||||||
@@ -1463,52 +1586,34 @@ class HelpTab:
|
|||||||
wraplength=self.WIDTH,
|
wraplength=self.WIDTH,
|
||||||
).grid(column=1, row=3, sticky="nsew")
|
).grid(column=1, row=3, sticky="nsew")
|
||||||
# Useful links
|
# 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)
|
links.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||||
LinkLabel(
|
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")
|
).grid(column=0, row=0, sticky="nsew")
|
||||||
LinkLabel(
|
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")
|
).grid(column=0, row=1, sticky="nsew")
|
||||||
# How It Works
|
# 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)
|
howitworks.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
howitworks,
|
howitworks, text=_("gui", "help", "how_it_works_text"), wraplength=self.WIDTH
|
||||||
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,
|
|
||||||
).grid(sticky="nsew")
|
).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)
|
getstarted.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
getstarted,
|
getstarted, text=_("gui", "help", "getting_started_text"), wraplength=self.WIDTH
|
||||||
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,
|
|
||||||
).grid(sticky="nsew")
|
).grid(sticky="nsew")
|
||||||
|
|
||||||
|
|
||||||
@@ -1585,7 +1690,7 @@ class GUIManager:
|
|||||||
self.tray = TrayIcon(self, root_frame)
|
self.tray = TrayIcon(self, root_frame)
|
||||||
# Main tab
|
# Main tab
|
||||||
main_frame = ttk.Frame(root_frame, padding=8)
|
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.status = StatusBar(self, main_frame)
|
||||||
self.websockets = WebsocketStatus(self, main_frame)
|
self.websockets = WebsocketStatus(self, main_frame)
|
||||||
self.login = LoginForm(self, main_frame)
|
self.login = LoginForm(self, main_frame)
|
||||||
@@ -1595,15 +1700,15 @@ class GUIManager:
|
|||||||
# Inventory tab
|
# Inventory tab
|
||||||
inv_frame = ttk.Frame(root_frame, padding=8)
|
inv_frame = ttk.Frame(root_frame, padding=8)
|
||||||
self.inv = InventoryOverview(self, inv_frame)
|
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 tab
|
||||||
settings_frame = ttk.Frame(root_frame, padding=8)
|
settings_frame = ttk.Frame(root_frame, padding=8)
|
||||||
self.settings = SettingsPanel(self, settings_frame)
|
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 tab
|
||||||
help_frame = ttk.Frame(root_frame, padding=8)
|
help_frame = ttk.Frame(root_frame, padding=8)
|
||||||
self.help = HelpTab(self, help_frame)
|
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)
|
# clamp minimum window size (update geometry first)
|
||||||
root.update_idletasks()
|
root.update_idletasks()
|
||||||
root.minsize(width=root.winfo_reqwidth(), height=root.winfo_reqheight())
|
root.minsize(width=root.winfo_reqwidth(), height=root.winfo_reqheight())
|
||||||
@@ -1699,8 +1804,8 @@ class GUIManager:
|
|||||||
self.settings.clear_selection()
|
self.settings.clear_selection()
|
||||||
|
|
||||||
# these are here to interface with underlaying GUI components
|
# these are here to interface with underlaying GUI components
|
||||||
def save(self) -> None:
|
def save(self, *, force: bool = False) -> None:
|
||||||
self._cache.save()
|
self._cache.save(force=force)
|
||||||
|
|
||||||
def set_games(self, games: abc.Iterable[Game]) -> None:
|
def set_games(self, games: abc.Iterable[Game]) -> None:
|
||||||
self.settings.set_games(games)
|
self.settings.set_games(games)
|
||||||
@@ -1831,6 +1936,7 @@ if __name__ == "__main__":
|
|||||||
priority=[],
|
priority=[],
|
||||||
proxy=URL(),
|
proxy=URL(),
|
||||||
autostart=False,
|
autostart=False,
|
||||||
|
language="English",
|
||||||
priority_only=False,
|
priority_only=False,
|
||||||
autostart_tray=False,
|
autostart_tray=False,
|
||||||
exclude={"Lit Game"},
|
exclude={"Lit Game"},
|
||||||
|
|||||||
+150
@@ -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.更改任一列表的内容,或更改“仅优先级”选项的状态,需要您按“重新加载”才能使更改生效."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ try:
|
|||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
raise ImportError("You have to run 'pip install pillow' first") from exc
|
raise ImportError("You have to run 'pip install pillow' first") from exc
|
||||||
|
|
||||||
|
from translate import _
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
from version import __version__
|
from version import __version__
|
||||||
@@ -133,6 +134,13 @@ if exists and not settings.no_run_check:
|
|||||||
# already running - exit
|
# already running - exit
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
|
# set language
|
||||||
|
try:
|
||||||
|
_.set_language(settings.language)
|
||||||
|
except ValueError:
|
||||||
|
# this language doesn't exist - stick to English
|
||||||
|
pass
|
||||||
|
|
||||||
# handle logging stuff
|
# handle logging stuff
|
||||||
if settings.logging_level > logging.DEBUG:
|
if settings.logging_level > logging.DEBUG:
|
||||||
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
||||||
@@ -158,14 +166,12 @@ try:
|
|||||||
loop.run_until_complete(client.run())
|
loop.run_until_complete(client.run())
|
||||||
except CaptchaRequired:
|
except CaptchaRequired:
|
||||||
exit_status = 1
|
exit_status = 1
|
||||||
msg = "Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours."
|
msg = _("error", "captcha")
|
||||||
logger.exception(msg)
|
|
||||||
client.prevent_close()
|
client.prevent_close()
|
||||||
client.print(msg)
|
client.print(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
exit_status = 1
|
exit_status = 1
|
||||||
msg = "Fatal error encountered:\n"
|
msg = "Fatal error encountered:\n"
|
||||||
logger.exception(msg)
|
|
||||||
client.prevent_close()
|
client.prevent_close()
|
||||||
client.print(msg)
|
client.print(msg)
|
||||||
client.print(traceback.format_exc())
|
client.print(traceback.format_exc())
|
||||||
@@ -174,14 +180,12 @@ finally:
|
|||||||
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||||
loop.run_until_complete(client.shutdown())
|
loop.run_until_complete(client.shutdown())
|
||||||
if not client.gui.close_requested:
|
if not client.gui.close_requested:
|
||||||
client.print(
|
client.print(_("status", "terminated"))
|
||||||
"\nApplication Terminated.\nClose the window to exit the application."
|
|
||||||
)
|
|
||||||
loop.run_until_complete(client.gui.wait_until_closed())
|
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,
|
# NOTE: we have to do it after wait_until_closed,
|
||||||
# because the user can alter settings between shutdown and closing the window
|
# because the user can alter some settings between app termination and closing the window
|
||||||
client.settings.save(force=True)
|
client.save(force=True)
|
||||||
client.gui.stop()
|
client.gui.stop()
|
||||||
client.gui.close_window()
|
client.gui.close_window()
|
||||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
|
|||||||
+11
-3
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class SettingsFile(TypedDict):
|
class SettingsFile(TypedDict):
|
||||||
proxy: URL
|
proxy: URL
|
||||||
|
language: str
|
||||||
autostart: bool
|
autostart: bool
|
||||||
exclude: set[str]
|
exclude: set[str]
|
||||||
priority: list[str]
|
priority: list[str]
|
||||||
@@ -25,6 +26,7 @@ default_settings: SettingsFile = {
|
|||||||
"priority": [],
|
"priority": [],
|
||||||
"exclude": set(),
|
"exclude": set(),
|
||||||
"autostart": False,
|
"autostart": False,
|
||||||
|
"language": "English",
|
||||||
"priority_only": True,
|
"priority_only": True,
|
||||||
"autostart_tray": False,
|
"autostart_tray": False,
|
||||||
}
|
}
|
||||||
@@ -41,12 +43,15 @@ class Settings:
|
|||||||
logging_level: int
|
logging_level: int
|
||||||
# from settings file
|
# from settings file
|
||||||
proxy: URL
|
proxy: URL
|
||||||
|
language: str
|
||||||
autostart: bool
|
autostart: bool
|
||||||
exclude: set[str]
|
exclude: set[str]
|
||||||
priority: list[str]
|
priority: list[str]
|
||||||
priority_only: bool
|
priority_only: bool
|
||||||
autostart_tray: bool
|
autostart_tray: bool
|
||||||
|
|
||||||
|
PASSTHROUGH = ("_settings", "_args", "_altered")
|
||||||
|
|
||||||
def __init__(self, args: ParsedArgs):
|
def __init__(self, args: ParsedArgs):
|
||||||
self._settings: SettingsFile = json_load(SETTINGS_PATH, default_settings)
|
self._settings: SettingsFile = json_load(SETTINGS_PATH, default_settings)
|
||||||
self._args: ParsedArgs = args
|
self._args: ParsedArgs = args
|
||||||
@@ -54,14 +59,17 @@ class Settings:
|
|||||||
|
|
||||||
# default logic of reading settings is to check args first, then the settings file
|
# default logic of reading settings is to check args first, then the settings file
|
||||||
def __getattr__(self, name: str, /) -> Any:
|
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)
|
return getattr(self._args, name)
|
||||||
elif name in self._settings:
|
elif name in self._settings:
|
||||||
return self._settings[name] # type: ignore[literal-required]
|
return self._settings[name] # type: ignore[literal-required]
|
||||||
return getattr(super(), name)
|
return getattr(super(), name)
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: Any, /) -> None:
|
def __setattr__(self, name: str, value: Any, /) -> None:
|
||||||
if name in ("_settings", "_args", "_altered"):
|
if name in self.PASSTHROUGH:
|
||||||
# passthrough
|
# passthrough
|
||||||
return super().__setattr__(name, value)
|
return super().__setattr__(name, value)
|
||||||
elif name in self._settings:
|
elif name in self._settings:
|
||||||
@@ -78,4 +86,4 @@ class Settings:
|
|||||||
|
|
||||||
def save(self, *, force: bool = False) -> None:
|
def save(self, *, force: bool = False) -> None:
|
||||||
if self._altered or force:
|
if self._altered or force:
|
||||||
json_save(SETTINGS_PATH, self._settings)
|
json_save(SETTINGS_PATH, self._settings, sort=True)
|
||||||
+435
@@ -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()
|
||||||
@@ -13,6 +13,7 @@ from typing import Any, Literal, Final, NoReturn, cast, TYPE_CHECKING
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
|
from translate import _
|
||||||
from gui import GUIManager
|
from gui import GUIManager
|
||||||
from channel import Channel
|
from channel import Channel
|
||||||
from websocket import WebsocketPool
|
from websocket import WebsocketPool
|
||||||
@@ -87,7 +88,7 @@ class Twitch:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
start_time = time()
|
start_time = time()
|
||||||
self.gui.print("Exiting...")
|
self.gui.print(_("status", "exiting"))
|
||||||
self.stop_watching()
|
self.stop_watching()
|
||||||
if self._watching_task is not None:
|
if self._watching_task is not None:
|
||||||
self._watching_task.cancel()
|
self._watching_task.cancel()
|
||||||
@@ -102,8 +103,6 @@ class Twitch:
|
|||||||
session, self._session = self._session, None
|
session, self._session = self._session, None
|
||||||
await session.close()
|
await session.close()
|
||||||
await self.websocket.stop()
|
await self.websocket.stop()
|
||||||
# save image cache
|
|
||||||
self.gui.save()
|
|
||||||
# wait at least half a second + whatever it takes to complete the closing
|
# wait at least half a second + whatever it takes to complete the closing
|
||||||
# this allows aiohttp to safely close the session
|
# this allows aiohttp to safely close the session
|
||||||
await asyncio.sleep(start_time + 0.5 - time())
|
await asyncio.sleep(start_time + 0.5 - time())
|
||||||
@@ -149,12 +148,12 @@ class Twitch:
|
|||||||
"""
|
"""
|
||||||
self.gui.print(*args, **kwargs)
|
self.gui.print(*args, **kwargs)
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self, *, force: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Saves the application state.
|
Saves the application state.
|
||||||
"""
|
"""
|
||||||
self.gui.save()
|
self.gui.save(force=force)
|
||||||
self.settings.save()
|
self.settings.save(force=force)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _viewers_key(channel: Channel) -> int:
|
def _viewers_key(channel: Channel) -> int:
|
||||||
@@ -198,7 +197,7 @@ class Twitch:
|
|||||||
self.change_state(State.INVENTORY_FETCH)
|
self.change_state(State.INVENTORY_FETCH)
|
||||||
while True:
|
while True:
|
||||||
if self._state is State.IDLE:
|
if self._state is State.IDLE:
|
||||||
self.gui.status.update("Idle")
|
self.gui.status.update(_("gui", "status", "idle"))
|
||||||
self.stop_watching()
|
self.stop_watching()
|
||||||
# clear the flag and wait until it's set again
|
# clear the flag and wait until it's set again
|
||||||
self._state_change.clear()
|
self._state_change.clear()
|
||||||
@@ -243,7 +242,7 @@ class Twitch:
|
|||||||
self.restart_watching()
|
self.restart_watching()
|
||||||
self.change_state(State.CHANNELS_CLEANUP)
|
self.change_state(State.CHANNELS_CLEANUP)
|
||||||
elif self._state is 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:
|
if not self.games or full_cleanup:
|
||||||
# no games selected or we're doing full cleanup: remove everything
|
# no games selected or we're doing full cleanup: remove everything
|
||||||
to_remove: list[Channel] = list(channels.values())
|
to_remove: list[Channel] = list(channels.values())
|
||||||
@@ -275,12 +274,10 @@ class Twitch:
|
|||||||
self.change_state(State.CHANNELS_FETCH)
|
self.change_state(State.CHANNELS_FETCH)
|
||||||
else:
|
else:
|
||||||
# with no games available, we switch to IDLE after cleanup
|
# with no games available, we switch to IDLE after cleanup
|
||||||
self.gui.print(
|
self.gui.print(_("gui", "status", "no_campaigns"))
|
||||||
"No active campaigns to mine drops for. Waiting for an active campaign..."
|
|
||||||
)
|
|
||||||
self.change_state(State.IDLE)
|
self.change_state(State.IDLE)
|
||||||
elif self._state is State.CHANNELS_FETCH:
|
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
|
# start with all current channels
|
||||||
new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values())
|
new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values())
|
||||||
# gather and add ACL channels from campaigns
|
# gather and add ACL channels from campaigns
|
||||||
@@ -361,7 +358,7 @@ class Twitch:
|
|||||||
break
|
break
|
||||||
self.change_state(State.CHANNEL_SWITCH)
|
self.change_state(State.CHANNEL_SWITCH)
|
||||||
elif self._state is 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,
|
# Change into the selected channel, stay in the watching channel,
|
||||||
# or select a new channel that meets the required conditions
|
# or select a new channel that meets the required conditions
|
||||||
new_watching = None
|
new_watching = None
|
||||||
@@ -381,9 +378,7 @@ class Twitch:
|
|||||||
watching_channel = self.watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
if watching_channel is None and new_watching is None:
|
if watching_channel is None and new_watching is None:
|
||||||
# not watching anything and there isn't anything to watch either
|
# not watching anything and there isn't anything to watch either
|
||||||
self.gui.print(
|
self.gui.print(_("status", "no_channel"))
|
||||||
"No available channels to watch. Waiting for an ONLINE channel..."
|
|
||||||
)
|
|
||||||
self.change_state(State.IDLE)
|
self.change_state(State.IDLE)
|
||||||
else:
|
else:
|
||||||
if new_watching is not None:
|
if new_watching is not None:
|
||||||
@@ -393,11 +388,13 @@ class Twitch:
|
|||||||
# should immediately complete, since we'll definitely
|
# should immediately complete, since we'll definitely
|
||||||
# have a channel we're watching here
|
# have a channel we're watching here
|
||||||
watching_channel = await self.watching_channel.get()
|
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
|
# break the state change chain by clearing the flag
|
||||||
self._state_change.clear()
|
self._state_change.clear()
|
||||||
elif self._state is State.EXIT:
|
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
|
# we've been requested to exit the application
|
||||||
break
|
break
|
||||||
await self._state_change.wait()
|
await self._state_change.wait()
|
||||||
@@ -422,8 +419,7 @@ class Twitch:
|
|||||||
channel = await self.watching_channel.get()
|
channel = await self.watching_channel.get()
|
||||||
succeeded = await channel.send_watch()
|
succeeded = await channel.send_watch()
|
||||||
if not succeeded:
|
if not succeeded:
|
||||||
# this usually means there are connection problems
|
# this usually means the campaign expired in the middle of mining
|
||||||
self.gui.print("Connection problems, retrying in 60 seconds...")
|
|
||||||
await self._watch_sleep(60)
|
await self._watch_sleep(60)
|
||||||
continue
|
continue
|
||||||
last_watch = time()
|
last_watch = time()
|
||||||
@@ -484,10 +480,11 @@ class Twitch:
|
|||||||
period = timedelta.max
|
period = timedelta.max
|
||||||
for campaign in self.inventory:
|
for campaign in self.inventory:
|
||||||
if not campaign.linked:
|
if not campaign.linked:
|
||||||
|
# this relies on the linked campaigns being first due to sorting
|
||||||
break
|
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
|
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
|
period = test_period
|
||||||
if period > one_hour:
|
if period > one_hour:
|
||||||
period = one_hour
|
period = one_hour
|
||||||
@@ -514,7 +511,7 @@ class Twitch:
|
|||||||
if not self.games:
|
if not self.games:
|
||||||
return False
|
return False
|
||||||
return (
|
return (
|
||||||
channel.online # steam online
|
channel.online # stream online
|
||||||
and channel.drops_enabled # drops are enabled
|
and channel.drops_enabled # drops are enabled
|
||||||
# it's one of the games we've selected
|
# it's one of the games we've selected
|
||||||
and channel.game is not None and channel.game in self.games
|
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
|
self.can_watch(channel) # we can watch the channel that just got ONLINE
|
||||||
and self.should_switch(channel) # and we should!
|
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)
|
self.watch(channel)
|
||||||
|
|
||||||
def on_offline(self, channel: Channel):
|
def on_offline(self, channel: Channel):
|
||||||
@@ -598,7 +595,7 @@ class Twitch:
|
|||||||
# change the channel if we're currently watching it
|
# change the channel if we're currently watching it
|
||||||
watching_channel = self.watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
if watching_channel is not None and watching_channel == channel:
|
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)
|
self.change_state(State.CHANNEL_SWITCH)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"{channel.name} goes OFFLINE")
|
logger.debug(f"{channel.name} goes OFFLINE")
|
||||||
@@ -629,8 +626,8 @@ class Twitch:
|
|||||||
f"{drop.rewards_text()} "
|
f"{drop.rewards_text()} "
|
||||||
f"({campaign.claimed_drops}/{campaign.total_drops})"
|
f"({campaign.claimed_drops}/{campaign.total_drops})"
|
||||||
)
|
)
|
||||||
self.gui.print(f"Claimed drop: {claim_text}")
|
self.gui.print(_("status", "claimed_drop").format(drop=claim_text))
|
||||||
self.gui.tray.notify(claim_text, "Mined Drop")
|
self.gui.tray.notify(claim_text, _("gui", "tray", "notification_title"))
|
||||||
else:
|
else:
|
||||||
logger.error(f"Drop claim failed! Drop ID: {drop_id}")
|
logger.error(f"Drop claim failed! Drop ID: {drop_id}")
|
||||||
# About 4-20s after claiming the drop, next drop can be started
|
# About 4-20s after claiming the drop, next drop can be started
|
||||||
@@ -720,12 +717,14 @@ class Twitch:
|
|||||||
if channel is not None:
|
if channel is not None:
|
||||||
channel.points = balance
|
channel.points = balance
|
||||||
channel.display()
|
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":
|
elif msg_type == "claim-available":
|
||||||
claim_data = message["data"]["claim"]
|
claim_data = message["data"]["claim"]
|
||||||
points = claim_data["point_gain"]["total_points"]
|
points = claim_data["point_gain"]["total_points"]
|
||||||
await self.claim_points(claim_data["channel_id"], claim_data["id"])
|
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:
|
async def _login(self) -> str:
|
||||||
logger.debug("Login flow started")
|
logger.debug("Login flow started")
|
||||||
@@ -822,7 +821,7 @@ class Twitch:
|
|||||||
# looks like we're missing something
|
# looks like we're missing something
|
||||||
login_form: LoginForm = self.gui.login
|
login_form: LoginForm = self.gui.login
|
||||||
logger.debug("Checking 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
|
# NOTE: We need this here because of the jar being accessed
|
||||||
session = await self.get_session()
|
session = await self.get_session()
|
||||||
url = URL(BASE_URL)
|
url = URL(BASE_URL)
|
||||||
@@ -860,7 +859,7 @@ class Twitch:
|
|||||||
cookie["persistent"] = str(self._user_id)
|
cookie["persistent"] = str(self._user_id)
|
||||||
self._is_logged_in.set()
|
self._is_logged_in.set()
|
||||||
logger.debug(f"Login successful, user ID: {self._user_id}")
|
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
|
# update our cookie and save it
|
||||||
jar.update_cookies(cookie, url)
|
jar.update_cookies(cookie, url)
|
||||||
jar.save(COOKIES_PATH)
|
jar.save(COOKIES_PATH)
|
||||||
@@ -881,11 +880,11 @@ class Twitch:
|
|||||||
if response.status < 500:
|
if response.status < 500:
|
||||||
yield response
|
yield response
|
||||||
return
|
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):
|
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||||
# just so that quick 2nd retries that often happen, aren't shown
|
# just so that quick 2nd retries that often happen, aren't shown
|
||||||
if delay > 1:
|
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)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
async def gql_request(self, op: GQLOperation) -> JsonType:
|
async def gql_request(self, op: GQLOperation) -> JsonType:
|
||||||
@@ -922,7 +921,7 @@ class Twitch:
|
|||||||
|
|
||||||
async def fetch_inventory(self) -> None:
|
async def fetch_inventory(self) -> None:
|
||||||
status_update = self.gui.status.update
|
status_update = self.gui.status.update
|
||||||
status_update("Fetching inventory...")
|
status_update(_("gui", "status", "fetching_inventory"))
|
||||||
# fetch in-progress campaigns (inventory)
|
# fetch in-progress campaigns (inventory)
|
||||||
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
|
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
|
||||||
if self.gui.close_requested:
|
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
|
# 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] = []
|
fetched_campaigns: list[DropsCampaign] = []
|
||||||
for i, coro in enumerate(
|
for i, coro in enumerate(
|
||||||
# specifically use an intermediate list per a Python bug
|
# specifically use an intermediate list per a Python bug
|
||||||
@@ -965,7 +968,11 @@ class Twitch:
|
|||||||
start=1,
|
start=1,
|
||||||
):
|
):
|
||||||
fetched_campaigns.append(await coro)
|
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:
|
if self.gui.close_requested:
|
||||||
raise ExitRequest()
|
raise ExitRequest()
|
||||||
campaigns.extend(fetched_campaigns)
|
campaigns.extend(fetched_campaigns)
|
||||||
@@ -976,7 +983,9 @@ class Twitch:
|
|||||||
self.gui.inv.clear()
|
self.gui.inv.clear()
|
||||||
self.inventory.clear()
|
self.inventory.clear()
|
||||||
for i, campaign in enumerate(campaigns, start=1):
|
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})
|
self._drops.update({drop.id: drop for drop in campaign.drops})
|
||||||
await self.gui.inv.add_campaign(campaign)
|
await self.gui.inv.add_campaign(campaign)
|
||||||
self.inventory.append(campaign)
|
self.inventory.append(campaign)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
@@ -19,7 +18,8 @@ from typing import (
|
|||||||
|
|
||||||
import yarl
|
import yarl
|
||||||
|
|
||||||
from constants import WORKING_DIR, JsonType
|
from constants import JsonType
|
||||||
|
from constants import _resource_path as resource_path # noqa
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import ParamSpec
|
from typing_extensions import ParamSpec
|
||||||
@@ -52,21 +52,6 @@ def json_minify(data: JsonType | list[JsonType]) -> str:
|
|||||||
return json.dumps(data, separators=(',', ':'))
|
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:
|
def timestamp(string: str) -> datetime:
|
||||||
return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
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]
|
del obj[key]
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
_remove_missing(value)
|
_remove_missing(value)
|
||||||
|
if not value:
|
||||||
|
# the dict is empty now, so remove it's key entirely
|
||||||
|
del obj[key]
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -155,17 +143,41 @@ def _deserialize(obj: JsonType) -> Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def json_load(path: Path, defaults: _JSON_T) -> _JSON_T:
|
def merge_json(obj: JsonType, template: Mapping[Any, Any]) -> None:
|
||||||
combined: JsonType = dict(defaults)
|
# 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():
|
if path.exists():
|
||||||
with open(path, 'r') as file:
|
with open(path, 'r', encoding="utf8") as file:
|
||||||
combined.update(_remove_missing(json.load(file, object_hook=_deserialize)))
|
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)
|
return cast(_JSON_T, combined)
|
||||||
|
|
||||||
|
|
||||||
def json_save(path: Path, contents: Mapping[Any, Any]) -> None:
|
def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None:
|
||||||
with open(path, 'w') as file:
|
with open(path, 'w', encoding="utf8") as file:
|
||||||
json.dump(contents, file, default=_serialize, sort_keys=True, indent=4)
|
json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4)
|
||||||
|
|
||||||
|
|
||||||
class ExponentialBackoff:
|
class ExponentialBackoff:
|
||||||
|
|||||||
+7
-6
@@ -9,6 +9,7 @@ from typing import Any, Literal, TYPE_CHECKING
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from translate import _
|
||||||
from exceptions import MinerException, WebsocketClosed
|
from exceptions import MinerException, WebsocketClosed
|
||||||
from constants import PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT
|
from constants import PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT
|
||||||
from utils import (
|
from utils import (
|
||||||
@@ -91,7 +92,7 @@ class Websocket:
|
|||||||
self._closed.set()
|
self._closed.set()
|
||||||
ws = self._ws.get_with_default(None)
|
ws = self._ws.get_with_default(None)
|
||||||
if ws is not None:
|
if ws is not None:
|
||||||
self.set_status("Disconnecting...")
|
self.set_status(_("gui", "websocket", "disconnecting"))
|
||||||
await ws.close()
|
await ws.close()
|
||||||
|
|
||||||
def stop_nowait(self):
|
def stop_nowait(self):
|
||||||
@@ -141,9 +142,9 @@ class Websocket:
|
|||||||
@task_wrapper
|
@task_wrapper
|
||||||
async def _handle(self):
|
async def _handle(self):
|
||||||
# ensure we're logged in before connecting
|
# ensure we're logged in before connecting
|
||||||
self.set_status("Initializing...")
|
self.set_status(_("gui", "websocket", "initializing"))
|
||||||
await self._twitch.wait_until_login()
|
await self._twitch.wait_until_login()
|
||||||
self.set_status("Connecting...")
|
self.set_status(_("gui", "websocket", "connecting"))
|
||||||
ws_logger.info(f"Websocket[{self._idx}] connecting...")
|
ws_logger.info(f"Websocket[{self._idx}] connecting...")
|
||||||
self._closed.clear()
|
self._closed.clear()
|
||||||
# Connect/Reconnect loop
|
# Connect/Reconnect loop
|
||||||
@@ -154,7 +155,7 @@ class Websocket:
|
|||||||
self._reconnect_requested.clear()
|
self._reconnect_requested.clear()
|
||||||
# NOTE: _topics_changed doesn't start set,
|
# NOTE: _topics_changed doesn't start set,
|
||||||
# because there's no initial topics we can sub to right away
|
# because there's no initial topics we can sub to right away
|
||||||
self.set_status("Connected")
|
self.set_status(_("gui", "websocket", "connected"))
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
while not self._reconnect_requested.is_set():
|
while not self._reconnect_requested.is_set():
|
||||||
@@ -176,11 +177,11 @@ class Websocket:
|
|||||||
elif self._closed.is_set():
|
elif self._closed.is_set():
|
||||||
# we closed it - exit
|
# we closed it - exit
|
||||||
ws_logger.info(f"Websocket[{self._idx}] stopped.")
|
ws_logger.info(f"Websocket[{self._idx}] stopped.")
|
||||||
self.set_status("Disconnected")
|
self.set_status(_("gui", "websocket", "disconnected"))
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
ws_logger.exception(f"Exception in Websocket[{self._idx}]")
|
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...")
|
ws_logger.warning(f"Websocket[{self._idx}] reconnecting...")
|
||||||
|
|
||||||
async def _handle_ping(self):
|
async def _handle_ping(self):
|
||||||
|
|||||||
Reference in new issue
Block a user