Implement the translation feature

This commit is contained in:
DevilXD committed 2022-08-18 22:28:30 +02:00
1 parent 3c81268501
commit 05466364d1
14 files changed
+1041 -220

No files matched your search

+2 -4
View File
@@ -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
+5
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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)",
)
+12 -6
View File
@@ -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
View File
@@ -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 = (
+234 -128
View File
@@ -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:
width = self._measure(width_template) if isinstance(width_template, str):
width = self._measure(width_template)
else:
width = max((self._measure(template) for template in width_template), default=20)
self._const_width.add(cid) 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
View File
@@ -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.更改任一列表的内容,或更改“仅优先级”选项的状态,需要您按“重新加载”才能使更改生效."
}
}
}
+13 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+46 -37
View File
@@ -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)
+36 -24
View File
@@ -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
View File
@@ -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):