Implement the translation feature
This commit is contained in:
1 parent
3c81268501
commit
05466364d1
14 files changed
+1041
-220
No files matched your search
+2
-4
@@ -1,10 +1,8 @@
|
||||
# Cache files
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
# Build files
|
||||
# Build and dist files
|
||||
/build
|
||||
/*.spec
|
||||
# Dist files
|
||||
/dist
|
||||
/7z.exe
|
||||
/*.zip
|
||||
@@ -12,7 +10,7 @@ __pycache__
|
||||
/Twitch Drops Miner
|
||||
# Dev files
|
||||
/env
|
||||
/lang
|
||||
/cache
|
||||
cookies.jar
|
||||
settings.json
|
||||
/lang/English.json
|
||||
@@ -57,3 +57,8 @@ Every ~60 seconds, the application sends a "minute watched" event to the channel
|
||||
- Run `setup_env.bat` to setup the virtual environment for the application. Activate it by running `env/Scripts/activate.bat`, or just use `python`, `pythonw` and `pip` executables from the `env/Scripts` directly going forward.
|
||||
- Run `pythonw main.py` to start the application without a console. `python main.py` can be used to start with a console, in case you'd expect errors to be printed out to the console - may help with debugging problems.
|
||||
- If you'd like to build the executable yourself, you'll need to `pip install pyinstaller` into the virtual environment, and then simply run `build.bat`. The end result can be found inside the `dist` folder. If you supply a `7z.exe` ([or `7za.exe` from 7z Extras](https://www.7-zip.org/download.html)) executable inside the project's folder, a packaged zip with the whole release should be created next to the `dist` folder as well.
|
||||
|
||||
|
||||
### Credits:
|
||||
|
||||
@Suz1e - For the entirety of the Chinese (简体中文) translation.
|
||||
@@ -1,5 +1,4 @@
|
||||
@echo off
|
||||
cls
|
||||
%CD%/env/scripts/pyinstaller --onefile --noconsole --name "Twitch Drops Miner (by DevilXD)" ^
|
||||
--icon pickaxe.ico --add-data pickaxe.ico;. main.py
|
||||
%CD%/env/scripts/pyinstaller build.spec
|
||||
if %ERRORLEVEL% == 0 call pack.bat
|
||||
+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:
|
||||
self._root = manager._root
|
||||
CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
self._hashes: Hashes = json_load(CACHE_DB, default_database)
|
||||
self._hashes: Hashes = json_load(CACHE_DB, default_database, merge=False)
|
||||
self._images: dict[ImageHash, Image] = {}
|
||||
self._photos: dict[tuple[ImageHash, ImageSize], PhotoImage] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
@@ -58,12 +58,18 @@ class ImageCache:
|
||||
for img_hash, count in hash_counts.items():
|
||||
if count == 0:
|
||||
# hashes come with an extension already
|
||||
for file in CACHE_PATH.glob(img_hash):
|
||||
file.unlink(missing_ok=True)
|
||||
CACHE_PATH.joinpath(img_hash).unlink(missing_ok=True)
|
||||
# NOTE: The hashes are deleted from self._hashes above
|
||||
# NOTE: This cleanups the cache folder from unused PNG files
|
||||
# orphans = [
|
||||
# file.name for file in CACHE_PATH.glob("*.png") if file.name not in hash_counts
|
||||
# ]
|
||||
# for filename in orphans:
|
||||
# CACHE_PATH.joinpath(filename).unlink(missing_ok=True)
|
||||
|
||||
def save(self) -> None:
|
||||
if self._altered:
|
||||
json_save(CACHE_DB, self._hashes)
|
||||
def save(self, *, force: bool = False) -> None:
|
||||
if self._altered or force:
|
||||
json_save(CACHE_DB, self._hashes, sort=True)
|
||||
|
||||
def _new_expires(self) -> datetime:
|
||||
return datetime.now(timezone.utc) + self.LIFETIME
|
||||
|
||||
+28
-1
@@ -15,9 +15,35 @@ if TYPE_CHECKING:
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
|
||||
# True if we're running from a built EXE, False inside a dev build
|
||||
IS_PACKAGED = hasattr(sys, "_MEIPASS")
|
||||
|
||||
|
||||
def _resource_path(relative_path: Path | str) -> Path:
|
||||
"""
|
||||
Get an absolute path to a bundled resource.
|
||||
|
||||
Works for dev and for PyInstaller.
|
||||
"""
|
||||
if IS_PACKAGED:
|
||||
# PyInstaller's folder where the one-file app is unpacked
|
||||
meipass: str = getattr(sys, "_MEIPASS")
|
||||
base_path = Path(meipass)
|
||||
else:
|
||||
base_path = WORKING_DIR
|
||||
return base_path.joinpath(relative_path)
|
||||
|
||||
|
||||
# Base Paths
|
||||
SELF_PATH = Path(sys.argv[0])
|
||||
# NOTE: pyinstaller will set this to its own executable when building,
|
||||
# detect this to use __file__ and main.py redirection instead
|
||||
SELF_PATH = Path(sys.argv[0]).absolute()
|
||||
if SELF_PATH.stem == "pyinstaller":
|
||||
SELF_PATH = Path(__file__).with_name("main.py").absolute()
|
||||
WORKING_DIR = SELF_PATH.absolute().parent
|
||||
# Translations path
|
||||
# NOTE: These don't have to be available to the end-user, so the path points to the internal dir
|
||||
LANG_PATH = _resource_path("lang")
|
||||
# Other Paths
|
||||
LOG_PATH = Path(WORKING_DIR, "log.txt")
|
||||
CACHE_PATH = Path(WORKING_DIR, "cache")
|
||||
@@ -32,6 +58,7 @@ TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]"
|
||||
MAX_WEBSOCKETS = 8
|
||||
WS_TOPICS_LIMIT = 50
|
||||
# Misc
|
||||
DEFAULT_LANG = "English"
|
||||
BASE_URL = "https://twitch.tv"
|
||||
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
USER_AGENT = (
|
||||
|
||||
@@ -9,16 +9,17 @@ from functools import partial
|
||||
from collections import abc, namedtuple
|
||||
from tkinter.font import Font, nametofont
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, TypedDict, NoReturn, TYPE_CHECKING
|
||||
from typing import Any, TypedDict, NoReturn, Generic, TYPE_CHECKING
|
||||
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage
|
||||
|
||||
import pystray
|
||||
from yarl import URL
|
||||
from PIL import Image as Image_module
|
||||
|
||||
from translate import _
|
||||
from cache import ImageCache
|
||||
from exceptions import ExitRequest
|
||||
from utils import resource_path, Game
|
||||
from utils import resource_path, Game, _T
|
||||
from registry import RegistryKey, ValueType
|
||||
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
|
||||
|
||||
@@ -268,9 +269,47 @@ class LinkLabel(ttk.Label):
|
||||
return lambda e: webbrowser.open_new_tab(url)
|
||||
|
||||
|
||||
class SelectMenu(tk.Menubutton, Generic[_T]):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
*args: Any,
|
||||
tearoff: bool = False,
|
||||
options: dict[str, _T],
|
||||
command: abc.Callable[[_T], Any] | None = None,
|
||||
default: str | None = None,
|
||||
relief: tk._Relief = "solid",
|
||||
background: tk._Color = "white",
|
||||
**kwargs: Any,
|
||||
):
|
||||
width = max((len(k) for k in options.keys()), default=20)
|
||||
super().__init__(
|
||||
master, *args, background=background, relief=relief, width=width, **kwargs
|
||||
)
|
||||
self._menu_options: dict[str, _T] = options
|
||||
self._command = command
|
||||
self.menu = tk.Menu(self, tearoff=tearoff)
|
||||
self.config(menu=self.menu)
|
||||
for name in options.keys():
|
||||
self.menu.add_command(label=name, command=partial(self._select, name))
|
||||
if default is not None and default in self._menu_options:
|
||||
self.config(text=default)
|
||||
|
||||
def _select(self, option: str) -> None:
|
||||
self.config(text=option)
|
||||
if self._command is not None:
|
||||
self._command(self._menu_options[option])
|
||||
|
||||
def get(self) -> _T:
|
||||
return self._menu_options[self.cget("text")]
|
||||
|
||||
|
||||
# GUI ELEMENTS END / GUI DEFINITION START
|
||||
|
||||
|
||||
class StatusBar:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
frame = ttk.LabelFrame(master, text="Status", padding=(4, 0, 4, 4))
|
||||
frame = ttk.LabelFrame(master, text=_("gui", "status", "name"), padding=(4, 0, 4, 4))
|
||||
frame.grid(column=0, row=0, columnspan=3, sticky="nsew", padx=2)
|
||||
self._label = ttk.Label(frame)
|
||||
self._label.grid(column=0, row=0, sticky="nsew")
|
||||
@@ -289,13 +328,16 @@ class _WSEntry(TypedDict):
|
||||
|
||||
class WebsocketStatus:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4))
|
||||
frame = ttk.LabelFrame(master, text=_("gui", "websocket", "name"), padding=(4, 0, 4, 4))
|
||||
frame.grid(column=0, row=1, sticky="nsew", padx=2)
|
||||
self._status_var = StringVar(frame)
|
||||
self._topics_var = StringVar(frame)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text='\n'.join(f"Websocket #{i}:" for i in range(1, MAX_WEBSOCKETS + 1)),
|
||||
text='\n'.join(
|
||||
_("gui", "websocket", "websocket").format(id=i)
|
||||
for i in range(1, MAX_WEBSOCKETS + 1)
|
||||
),
|
||||
style="MS.TLabel",
|
||||
).grid(column=0, row=0)
|
||||
ttk.Label(
|
||||
@@ -320,7 +362,9 @@ class WebsocketStatus:
|
||||
raise TypeError("You need to provide at least one of: status, topics")
|
||||
entry = self._items.get(idx)
|
||||
if entry is None:
|
||||
entry = self._items[idx] = _WSEntry(status="Disconnected", topics=0)
|
||||
entry = self._items[idx] = _WSEntry(
|
||||
status=_("gui", "websocket", "disconnected"), topics=0
|
||||
)
|
||||
if status is not None:
|
||||
entry["status"] = status
|
||||
if topics is not None:
|
||||
@@ -353,23 +397,27 @@ class LoginForm:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
self._manager = manager
|
||||
self._var = StringVar(master)
|
||||
frame = ttk.LabelFrame(master, text="Login Form", padding=(4, 0, 4, 4))
|
||||
frame = ttk.LabelFrame(master, text=_("gui", "login", "name"), padding=(4, 0, 4, 4))
|
||||
frame.grid(column=1, row=1, sticky="nsew", padx=2)
|
||||
frame.columnconfigure(0, weight=2)
|
||||
frame.columnconfigure(1, weight=1)
|
||||
frame.rowconfigure(4, weight=1)
|
||||
ttk.Label(frame, text="Status:\nUser ID:").grid(column=0, row=0)
|
||||
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
|
||||
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
|
||||
self._login_entry = PlaceholderEntry(frame, placeholder="Username")
|
||||
self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
|
||||
self._login_entry.grid(column=0, row=1, columnspan=2)
|
||||
self._pass_entry = PlaceholderEntry(frame, placeholder="Password", show='•')
|
||||
self._pass_entry = PlaceholderEntry(
|
||||
frame, placeholder=_("gui", "login", "password"), show='•'
|
||||
)
|
||||
self._pass_entry.grid(column=0, row=2, columnspan=2)
|
||||
self._token_entry = PlaceholderEntry(frame, placeholder="2FA Code (optional)")
|
||||
self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
|
||||
self._token_entry.grid(column=0, row=3, columnspan=2)
|
||||
self._confirm = asyncio.Event()
|
||||
self._button = ttk.Button(frame, text="Login", command=self._confirm.set, state="disabled")
|
||||
self._button = ttk.Button(
|
||||
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
|
||||
)
|
||||
self._button.grid(column=0, row=4, columnspan=2)
|
||||
self.update("Logged out", None)
|
||||
self.update(_("gui", "login", "logged_out"), None)
|
||||
|
||||
def clear(self, login: bool = False, password: bool = False, token: bool = False):
|
||||
clear_all = not login and not password and not token
|
||||
@@ -381,8 +429,8 @@ class LoginForm:
|
||||
self._token_entry.clear()
|
||||
|
||||
async def ask_login(self) -> LoginData:
|
||||
self.update("Login required", None)
|
||||
self._manager.print("Please log in to continue.")
|
||||
self.update(_("gui", "login", "required"), None)
|
||||
self._manager.print(_("gui", "login", "request"))
|
||||
self._confirm.clear()
|
||||
self._button.config(state="normal")
|
||||
# NOTE: we need this to allow for the closing window event to break the waiting here
|
||||
@@ -443,16 +491,18 @@ class CampaignProgress:
|
||||
},
|
||||
}
|
||||
self._frame = frame = ttk.LabelFrame(
|
||||
master, text="Campaign Progress", padding=(4, 0, 4, 4)
|
||||
master, text=_("gui", "progress", "name"), padding=(4, 0, 4, 4)
|
||||
)
|
||||
frame.grid(column=0, row=2, columnspan=2, sticky="nsew", padx=2)
|
||||
frame.columnconfigure(0, weight=2)
|
||||
frame.columnconfigure(1, weight=1)
|
||||
ttk.Label(frame, text="Campaign:").grid(column=0, row=0, columnspan=2)
|
||||
ttk.Label(frame, text=_("gui", "progress", "campaign")).grid(column=0, row=0, columnspan=2)
|
||||
ttk.Label(
|
||||
frame, textvariable=self._vars["campaign"]["name"]
|
||||
).grid(column=0, row=1, columnspan=2)
|
||||
ttk.Label(frame, text="Progress:").grid(column=0, row=2, rowspan=2)
|
||||
ttk.Label(
|
||||
frame, text=_("gui", "progress", "campaign_progress")
|
||||
).grid(column=0, row=2, rowspan=2)
|
||||
ttk.Label(frame, textvariable=self._vars["campaign"]["percentage"]).grid(column=1, row=2)
|
||||
ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3)
|
||||
ttk.Progressbar(
|
||||
@@ -465,11 +515,13 @@ class CampaignProgress:
|
||||
ttk.Separator(
|
||||
frame, orient="horizontal"
|
||||
).grid(row=5, columnspan=2, sticky="ew", pady=(4, 0))
|
||||
ttk.Label(frame, text="Drop:").grid(column=0, row=6, columnspan=2)
|
||||
ttk.Label(frame, text=_("gui", "progress", "drop")).grid(column=0, row=6, columnspan=2)
|
||||
ttk.Label(
|
||||
frame, textvariable=self._vars["drop"]["rewards"]
|
||||
).grid(column=0, row=7, columnspan=2)
|
||||
ttk.Label(frame, text="Progress:").grid(column=0, row=8, rowspan=2)
|
||||
ttk.Label(
|
||||
frame, text=_("gui", "progress", "drop_progress")
|
||||
).grid(column=0, row=8, rowspan=2)
|
||||
ttk.Label(frame, textvariable=self._vars["drop"]["percentage"]).grid(column=1, row=8)
|
||||
ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9)
|
||||
ttk.Progressbar(
|
||||
@@ -502,9 +554,13 @@ class CampaignProgress:
|
||||
campaign_vars: _CampaignVars = self._vars["campaign"]
|
||||
dseconds = seconds % 60
|
||||
hours, minutes = self._divmod(drop_minutes, seconds)
|
||||
drop_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{dseconds:02} remaining")
|
||||
drop_vars["remaining"].set(
|
||||
_("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}")
|
||||
)
|
||||
hours, minutes = self._divmod(campaign_minutes, seconds)
|
||||
campaign_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{dseconds:02} remaining")
|
||||
campaign_vars["remaining"].set(
|
||||
_("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}")
|
||||
)
|
||||
|
||||
async def _timer_loop(self):
|
||||
seconds = 60
|
||||
@@ -561,7 +617,7 @@ class CampaignProgress:
|
||||
|
||||
class ConsoleOutput:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
frame = ttk.LabelFrame(master, text="Output", padding=(4, 0, 4, 4))
|
||||
frame = ttk.LabelFrame(master, text=_("gui", "output"), padding=(4, 0, 4, 4))
|
||||
frame.grid(column=0, row=3, columnspan=2, sticky="nsew", padx=2)
|
||||
# tell master frame that the containing row can expand
|
||||
master.rowconfigure(3, weight=1)
|
||||
@@ -601,7 +657,7 @@ class _Buttons(TypedDict):
|
||||
class ChannelList:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
self._manager = manager
|
||||
frame = ttk.LabelFrame(master, text="Channels", padding=(4, 0, 4, 4))
|
||||
frame = ttk.LabelFrame(master, text=_("gui", "channels", "name"), padding=(4, 0, 4, 4))
|
||||
frame.grid(column=2, row=1, rowspan=3, sticky="nsew", padx=2)
|
||||
# tell master frame that the containing column can expand
|
||||
master.columnconfigure(2, weight=1)
|
||||
@@ -612,12 +668,12 @@ class ChannelList:
|
||||
"frame": buttons_frame,
|
||||
"switch": ttk.Button(
|
||||
buttons_frame,
|
||||
text="Switch",
|
||||
text=_("gui", "channels", "switch"),
|
||||
state="disabled",
|
||||
command=manager._twitch.state_change(State.CHANNEL_SWITCH),
|
||||
),
|
||||
"load_points": ttk.Button(
|
||||
buttons_frame, text="Load Points", command=self._load_points
|
||||
buttons_frame, text=_("gui", "channels", "load_points"), command=self._load_points
|
||||
),
|
||||
}
|
||||
buttons_frame.grid(column=0, row=0, columnspan=2)
|
||||
@@ -638,12 +694,26 @@ class ChannelList:
|
||||
table.bind("<Button-1>", self._disable_column_resize)
|
||||
table.bind("<<TreeviewSelect>>", self._selected)
|
||||
self._add_column("#0", '', width=0)
|
||||
self._add_column("channel", "Channel", width=100, anchor='w')
|
||||
self._add_column("status", "Status", width_template="OFFLINE ❌")
|
||||
self._add_column("game", "Game", width=50)
|
||||
self._add_column(
|
||||
"channel", _("gui", "channels", "headings", "channel"), width=100, anchor='w'
|
||||
)
|
||||
self._add_column(
|
||||
"status",
|
||||
_("gui", "channels", "headings", "status"),
|
||||
width_template=[
|
||||
_("gui", "channels", "online"),
|
||||
_("gui", "channels", "pending"),
|
||||
_("gui", "channels", "offline"),
|
||||
],
|
||||
)
|
||||
self._add_column("game", _("gui", "channels", "headings", "game"), width=50)
|
||||
self._add_column("drops", "🎁", width_template="✔")
|
||||
self._add_column("viewers", "Viewers", width_template="1234567")
|
||||
self._add_column("points", "Points", width_template="1234567")
|
||||
self._add_column(
|
||||
"viewers", _("gui", "channels", "headings", "viewers"), width_template="1234567"
|
||||
)
|
||||
self._add_column(
|
||||
"points", _("gui", "channels", "headings", "points"), width_template="1234567"
|
||||
)
|
||||
self._add_column("priority", "❗", width_template="✔")
|
||||
self._channel_map: dict[str, Channel] = {}
|
||||
|
||||
@@ -654,7 +724,7 @@ class ChannelList:
|
||||
*,
|
||||
anchor: tk._Anchor = "center",
|
||||
width: int | None = None,
|
||||
width_template: str | None = None,
|
||||
width_template: str | list[str] | None = None,
|
||||
):
|
||||
table = self._table
|
||||
# NOTE: we don't do this for the icon column
|
||||
@@ -678,7 +748,10 @@ class ChannelList:
|
||||
table.column(s_cid, minwidth=s_minwidth, width=s_width, stretch=False)
|
||||
# set heading and column settings for the new column
|
||||
if width_template is not None:
|
||||
width = self._measure(width_template)
|
||||
if isinstance(width_template, str):
|
||||
width = self._measure(width_template)
|
||||
else:
|
||||
width = max((self._measure(template) for template in width_template), default=20)
|
||||
self._const_width.add(cid)
|
||||
assert width is not None
|
||||
table.heading(cid, text=name, anchor=anchor)
|
||||
@@ -779,11 +852,11 @@ class ChannelList:
|
||||
priority = "✔" if channel.priority else "❌"
|
||||
# status
|
||||
if channel.online:
|
||||
status = "ONLINE ✔"
|
||||
status = _("gui", "channels", "online")
|
||||
elif channel.pending_online:
|
||||
status = "OFFLINE ⏳"
|
||||
status = _("gui", "channels", "pending")
|
||||
else:
|
||||
status = "OFFLINE ❌"
|
||||
status = _("gui", "channels", "offline")
|
||||
# game
|
||||
game = str(channel.game or '')
|
||||
# drops
|
||||
@@ -832,7 +905,7 @@ class TrayIcon:
|
||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||
self._manager = manager
|
||||
self.icon: pystray.Icon | None = None
|
||||
self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray")
|
||||
self._button = ttk.Button(master, command=self.minimize, text=_("gui", "tray", "minimize"))
|
||||
self._button.grid(column=0, row=0, sticky="ne")
|
||||
|
||||
def is_tray(self) -> bool:
|
||||
@@ -857,9 +930,9 @@ class TrayIcon:
|
||||
return lambda: loop.call_soon_threadsafe(func)
|
||||
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Show", bridge(self.restore), default=True),
|
||||
pystray.MenuItem(_("gui", "tray", "show"), bridge(self.restore), default=True),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Quit", bridge(self.quit)),
|
||||
pystray.MenuItem(_("gui", "tray", "quit"), bridge(self.quit)),
|
||||
)
|
||||
self.icon = pystray.Icon(
|
||||
"twitch_miner",
|
||||
@@ -933,37 +1006,49 @@ class InventoryOverview:
|
||||
"finished": IntVar(master, 0),
|
||||
}
|
||||
# Filtering options
|
||||
filter_frame = ttk.LabelFrame(master, text="Filter", padding=(4, 0, 4, 4))
|
||||
filter_frame = ttk.LabelFrame(
|
||||
master, text=_("gui", "inventory", "filter", "name"), padding=(4, 0, 4, 4)
|
||||
)
|
||||
LABEL_SPACING = 20
|
||||
filter_frame.grid(column=0, row=0, columnspan=2, sticky="nsew")
|
||||
ttk.Label(filter_frame, text="Show:", padding=(0, 0, 10, 0)).grid(column=0, row=0)
|
||||
ttk.Label(
|
||||
filter_frame, text=_("gui", "inventory", "filter", "show"), padding=(0, 0, 10, 0)
|
||||
).grid(column=0, row=0)
|
||||
icolumn = 0
|
||||
ttk.Checkbutton(
|
||||
filter_frame, variable=self._filters["linked"]
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Label(
|
||||
filter_frame, text="Linked only", padding=(0, 0, LABEL_SPACING, 0)
|
||||
filter_frame,
|
||||
text=_("gui", "inventory", "filter", "linked"),
|
||||
padding=(0, 0, LABEL_SPACING, 0),
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Checkbutton(
|
||||
filter_frame, variable=self._filters["upcoming"]
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Label(
|
||||
filter_frame, text="Upcoming", padding=(0, 0, LABEL_SPACING, 0)
|
||||
filter_frame,
|
||||
text=_("gui", "inventory", "filter", "upcoming"),
|
||||
padding=(0, 0, LABEL_SPACING, 0),
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Checkbutton(
|
||||
filter_frame, variable=self._filters["expired"]
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Label(
|
||||
filter_frame, text="Expired", padding=(0, 0, LABEL_SPACING, 0)
|
||||
filter_frame,
|
||||
text=_("gui", "inventory", "filter", "expired"),
|
||||
padding=(0, 0, LABEL_SPACING, 0),
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Checkbutton(
|
||||
filter_frame, variable=self._filters["finished"]
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Label(
|
||||
filter_frame, text="Finished", padding=(0, 0, LABEL_SPACING, 0)
|
||||
filter_frame,
|
||||
text=_("gui", "inventory", "filter", "finished"),
|
||||
padding=(0, 0, LABEL_SPACING, 0),
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
ttk.Button(
|
||||
filter_frame, text="Refresh", command=self.refresh
|
||||
filter_frame, text=_("gui", "inventory", "filter", "refresh"), command=self.refresh
|
||||
).grid(column=(icolumn := icolumn + 1), row=0)
|
||||
# Inventory view
|
||||
self._canvas = tk.Canvas(master, scrollregion=(0, 0, 0, 0))
|
||||
@@ -1033,13 +1118,13 @@ class InventoryOverview:
|
||||
).grid(column=0, row=0, columnspan=2, sticky="w")
|
||||
# Status
|
||||
if campaign.active:
|
||||
status_text: str = "Active ✔"
|
||||
status_text: str = _("gui", "inventory", "status", "active")
|
||||
status_color: tk._Color = "green"
|
||||
elif campaign.upcoming:
|
||||
status_text = "Upcoming ⏳"
|
||||
status_text = _("gui", "inventory", "status", "upcoming")
|
||||
status_color = "goldenrod"
|
||||
else:
|
||||
status_text = "Expired ❌"
|
||||
status_text = _("gui", "inventory", "status", "expired")
|
||||
status_color = "red"
|
||||
ttk.Label(
|
||||
campaign_frame, text=status_text, takefocus=False, foreground=status_color
|
||||
@@ -1047,9 +1132,11 @@ class InventoryOverview:
|
||||
# Starts / Ends
|
||||
MouseOverLabel(
|
||||
campaign_frame,
|
||||
text=f"Ends: {campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)}",
|
||||
alt_text=(
|
||||
f"Starts: {campaign.starts_at.astimezone().replace(microsecond=0, tzinfo=None)}"
|
||||
text=_("gui", "inventory", "ends").format(
|
||||
time=campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None)
|
||||
),
|
||||
alt_text=_("gui", "inventory", "starts").format(
|
||||
time=campaign.starts_at.astimezone().replace(microsecond=0, tzinfo=None)
|
||||
),
|
||||
reverse=campaign.upcoming,
|
||||
takefocus=False,
|
||||
@@ -1058,12 +1145,12 @@ class InventoryOverview:
|
||||
if campaign.linked:
|
||||
link_kwargs = {
|
||||
"style": '',
|
||||
"text": "Linked ✔",
|
||||
"text": _("gui", "inventory", "status", "linked"),
|
||||
"foreground": "green",
|
||||
}
|
||||
else:
|
||||
link_kwargs = {
|
||||
"text": "Not linked ❌",
|
||||
"text": _("gui", "inventory", "status", "not_linked"),
|
||||
"foreground": "red",
|
||||
}
|
||||
LinkLabel(
|
||||
@@ -1080,11 +1167,15 @@ class InventoryOverview:
|
||||
allowed_text: str = '\n'.join(ch.name for ch in acl)
|
||||
else:
|
||||
allowed_text = '\n'.join(ch.name for ch in acl[:4])
|
||||
allowed_text += f"\nand {len(acl) - 4} more..."
|
||||
allowed_text += (
|
||||
f"\n{_('gui', 'inventory', 'and_more').format(amount=len(acl) - 4)}"
|
||||
)
|
||||
else:
|
||||
allowed_text = "All"
|
||||
allowed_text = _("gui", "inventory", "all_channels")
|
||||
ttk.Label(
|
||||
campaign_frame, text=f"Allowed channels:\n{allowed_text}", takefocus=False
|
||||
campaign_frame,
|
||||
text=f"{_('gui', 'inventory', 'allowed_channels')}\n{allowed_text}",
|
||||
takefocus=False,
|
||||
).grid(column=1, row=4, sticky="nw", padx=4)
|
||||
# Image
|
||||
campaign_image = await self._cache.get(campaign.image_url, size=(108, 144))
|
||||
@@ -1123,15 +1214,20 @@ class InventoryOverview:
|
||||
|
||||
def get_progress(self, drop: TimedDrop) -> tuple[str, tk._Color]:
|
||||
progress_color: tk._Color = ''
|
||||
progress_text: str = f"{drop.required_minutes} minutes"
|
||||
progress_text: str = _("gui", "inventory", "minutes_progress").format(
|
||||
minutes=drop.required_minutes
|
||||
)
|
||||
if drop.is_claimed:
|
||||
progress_color = "green"
|
||||
progress_text = "Claimed ✔"
|
||||
progress_text = _("gui", "inventory", "status", "claimed")
|
||||
elif drop.can_claim:
|
||||
progress_color = "goldenrod"
|
||||
progress_text = "Ready to claim ⏳"
|
||||
progress_text = _("gui", "inventory", "status", "ready")
|
||||
elif drop.can_earn():
|
||||
progress_text = f"{drop.progress:3.1%} of {drop.required_minutes} minutes"
|
||||
progress_text = _("gui", "inventory", "percent_progress").format(
|
||||
percent=f"{drop.progress:3.1%}",
|
||||
minutes=drop.required_minutes,
|
||||
)
|
||||
return (progress_text, progress_color)
|
||||
|
||||
def update_drop(self, drop: TimedDrop) -> None:
|
||||
@@ -1180,7 +1276,9 @@ class SettingsPanel:
|
||||
center_frame = ttk.Frame(master)
|
||||
center_frame.grid(column=0, row=0)
|
||||
# General section
|
||||
general_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="General")
|
||||
general_frame = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "general", "name")
|
||||
)
|
||||
general_frame.grid(column=0, row=0, sticky="nsew")
|
||||
# use another frame to center the options within the section
|
||||
# NOTE: this can be adjusted or removed later on if more options were to be added
|
||||
@@ -1188,23 +1286,43 @@ class SettingsPanel:
|
||||
general_frame.columnconfigure(0, weight=1)
|
||||
center_frame2 = ttk.Frame(general_frame)
|
||||
center_frame2.grid(column=0, row=0)
|
||||
ttk.Label(center_frame2, text="Autostart: ").grid(column=0, row=0, sticky="e")
|
||||
ttk.Checkbutton(
|
||||
center_frame2, variable=self._vars["autostart"], command=self.update_autostart
|
||||
).grid(column=1, row=0, sticky="w")
|
||||
ttk.Label(center_frame2, text="Autostart into tray: ").grid(column=0, row=1, sticky="e")
|
||||
ttk.Checkbutton(
|
||||
center_frame2, variable=self._vars["tray"], command=self.update_autostart
|
||||
).grid(column=1, row=1, sticky="w")
|
||||
ttk.Label(center_frame2, text="Priority only: ").grid(column=0, row=2, sticky="e")
|
||||
ttk.Checkbutton(
|
||||
center_frame2, variable=self._vars["priority_only"], command=self.priority_only
|
||||
).grid(column=1, row=2, sticky="w")
|
||||
# language frame
|
||||
language_frame = ttk.Frame(center_frame2)
|
||||
language_frame.grid(column=0, row=0)
|
||||
ttk.Label(language_frame, text="Language 🌐 (requires restart): ").grid(column=0, row=0)
|
||||
SelectMenu(
|
||||
language_frame,
|
||||
default=_.current,
|
||||
options={k: k for k in _.languages},
|
||||
command=lambda lang: setattr(self._settings, "language", lang),
|
||||
).grid(column=1, row=0)
|
||||
# checkboxes frame
|
||||
checkboxes_frame = ttk.Frame(center_frame2)
|
||||
checkboxes_frame.grid(column=0, row=1)
|
||||
ttk.Label(
|
||||
center_frame2, text="Proxy (requires restart):"
|
||||
).grid(column=0, row=3, columnspan=2)
|
||||
checkboxes_frame, text=_("gui", "settings", "general", "autostart")
|
||||
).grid(column=0, row=(irow := 0), sticky="e")
|
||||
ttk.Checkbutton(
|
||||
checkboxes_frame, variable=self._vars["autostart"], command=self.update_autostart
|
||||
).grid(column=1, row=irow, sticky="w")
|
||||
ttk.Label(
|
||||
checkboxes_frame, text=_("gui", "settings", "general", "tray")
|
||||
).grid(column=0, row=(irow := irow + 1), sticky="e")
|
||||
ttk.Checkbutton(
|
||||
checkboxes_frame, variable=self._vars["tray"], command=self.update_autostart
|
||||
).grid(column=1, row=irow, sticky="w")
|
||||
ttk.Label(
|
||||
checkboxes_frame, text=_("gui", "settings", "general", "priority_only")
|
||||
).grid(column=0, row=(irow := irow + 1), sticky="e")
|
||||
ttk.Checkbutton(
|
||||
checkboxes_frame, variable=self._vars["priority_only"], command=self.priority_only
|
||||
).grid(column=1, row=irow, sticky="w")
|
||||
# proxy frame
|
||||
proxy_frame = ttk.Frame(center_frame2)
|
||||
proxy_frame.grid(column=0, row=2)
|
||||
ttk.Label(proxy_frame, text=_("gui", "settings", "general", "proxy")).grid(column=0, row=0)
|
||||
self._proxy = PlaceholderEntry(
|
||||
center_frame2,
|
||||
proxy_frame,
|
||||
width=37,
|
||||
validate="focusout",
|
||||
prefill="http://",
|
||||
@@ -1212,12 +1330,14 @@ class SettingsPanel:
|
||||
placeholder="http://username:password@address:port",
|
||||
)
|
||||
self._proxy.config(validatecommand=partial(proxy_validate, self._proxy, self._settings))
|
||||
self._proxy.grid(column=0, row=4, columnspan=2)
|
||||
self._proxy.grid(column=0, row=1)
|
||||
# Priority section
|
||||
priority_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Priority")
|
||||
priority_frame = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "priority")
|
||||
)
|
||||
priority_frame.grid(column=1, row=0, sticky="nsew")
|
||||
self._priority_entry = PlaceholderCombobox(
|
||||
priority_frame, placeholder="Game name", width=30
|
||||
priority_frame, placeholder=_("gui", "settings", "game_name"), width=30
|
||||
)
|
||||
self._priority_entry.grid(column=0, row=0, sticky="ew")
|
||||
priority_frame.columnconfigure(0, weight=1)
|
||||
@@ -1256,9 +1376,13 @@ class SettingsPanel:
|
||||
).grid(column=1, row=3, sticky="ns")
|
||||
priority_frame.rowconfigure(3, weight=1)
|
||||
# Exclude section
|
||||
exclude_frame = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Exclude")
|
||||
exclude_frame = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "exclude")
|
||||
)
|
||||
exclude_frame.grid(column=2, row=0, sticky="nsew")
|
||||
self._exclude_entry = PlaceholderCombobox(exclude_frame, placeholder="Game name", width=26)
|
||||
self._exclude_entry = PlaceholderCombobox(
|
||||
exclude_frame, placeholder=_("gui", "settings", "game_name"), width=26
|
||||
)
|
||||
self._exclude_entry.grid(column=0, row=0, sticky="ew")
|
||||
ttk.Button(
|
||||
exclude_frame, text="+", command=self.exclude_add, width=2, style="Large.TButton"
|
||||
@@ -1282,12 +1406,11 @@ class SettingsPanel:
|
||||
# Reload button
|
||||
reload_frame = ttk.Frame(center_frame)
|
||||
reload_frame.grid(column=0, row=1, columnspan=3, pady=4)
|
||||
ttk.Label(
|
||||
reload_frame,
|
||||
text="Most changes require a reload to take an immediate effect: "
|
||||
).grid(column=0, row=0)
|
||||
ttk.Label(reload_frame, text=_("gui", "settings", "reload_text")).grid(column=0, row=0)
|
||||
ttk.Button(
|
||||
reload_frame, text="Reload", command=self._twitch.state_change(State.INVENTORY_FETCH)
|
||||
reload_frame,
|
||||
text=_("gui", "settings", "reload"),
|
||||
command=self._twitch.state_change(State.INVENTORY_FETCH),
|
||||
).grid(column=1, row=0)
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
@@ -1463,52 +1586,34 @@ class HelpTab:
|
||||
wraplength=self.WIDTH,
|
||||
).grid(column=1, row=3, sticky="nsew")
|
||||
# Useful links
|
||||
links = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Useful links")
|
||||
links = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "links", "name")
|
||||
)
|
||||
links.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||
LinkLabel(
|
||||
links, link="https://www.twitch.tv/drops/inventory", text="See Twitch Inventory"
|
||||
links,
|
||||
link="https://www.twitch.tv/drops/inventory",
|
||||
text=_("gui", "help", "links", "inventory"),
|
||||
).grid(column=0, row=0, sticky="nsew")
|
||||
LinkLabel(
|
||||
links, link="https://www.twitch.tv/drops/campaigns", text="Manage account links"
|
||||
links,
|
||||
link="https://www.twitch.tv/drops/campaigns",
|
||||
text=_("gui", "help", "links", "campaigns"),
|
||||
).grid(column=0, row=1, sticky="nsew")
|
||||
# How It Works
|
||||
howitworks = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="How It Works")
|
||||
howitworks = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "how_it_works")
|
||||
)
|
||||
howitworks.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||
ttk.Label(
|
||||
howitworks,
|
||||
text=(
|
||||
"Every ~60 seconds, the application sends a \"minute watched\" event "
|
||||
"to the channel that's currently being watched - this is enough "
|
||||
"to advance the drops. Note that this completely bypasses the need to download "
|
||||
"any actual stream video and sound. "
|
||||
"To keep the status (ONLINE or OFFLINE) of the channels up-to-date, "
|
||||
"there's a websocket connection estabilished that receives events about streams "
|
||||
"going up or down, or updates regarding the current amount of viewers."
|
||||
),
|
||||
wraplength=self.WIDTH,
|
||||
howitworks, text=_("gui", "help", "how_it_works_text"), wraplength=self.WIDTH
|
||||
).grid(sticky="nsew")
|
||||
getstarted = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="Getting Started")
|
||||
getstarted = ttk.LabelFrame(
|
||||
center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "getting_started")
|
||||
)
|
||||
getstarted.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2)
|
||||
ttk.Label(
|
||||
getstarted,
|
||||
text=(
|
||||
"1. Login into the application.\n"
|
||||
"2. Ensure your Twitch account is linked to all campaigns "
|
||||
"you're interested in mining.\n"
|
||||
"3. If you're interested in just mining everything, "
|
||||
"uncheck \"Priority only\" and press on \"Reload\".\n"
|
||||
"4. If you want to mine specific games first, use the \"Priority\" list "
|
||||
"to setup an ordered list of games of your choice. Games from the top of the list "
|
||||
"will be attempted to be mined first, before the ones lower down the list.\n"
|
||||
"5. Keep the \"Priority only\" option checked, to avoid mining games "
|
||||
"that are not on the priority list. Or not - it's up to you.\n"
|
||||
"6. Use the \"Exclude\" list to tell the application "
|
||||
"which games should never be mined.\n"
|
||||
"7. Changing the contents of either of the lists, or changing the state "
|
||||
"of the \"Priority only\" option, requires you to press on \"Reload\" "
|
||||
"for the changes to take an effect."
|
||||
),
|
||||
wraplength=self.WIDTH,
|
||||
getstarted, text=_("gui", "help", "getting_started_text"), wraplength=self.WIDTH
|
||||
).grid(sticky="nsew")
|
||||
|
||||
|
||||
@@ -1585,7 +1690,7 @@ class GUIManager:
|
||||
self.tray = TrayIcon(self, root_frame)
|
||||
# Main tab
|
||||
main_frame = ttk.Frame(root_frame, padding=8)
|
||||
self.tabs.add_tab(main_frame, name="Main")
|
||||
self.tabs.add_tab(main_frame, name=_("gui", "tabs", "main"))
|
||||
self.status = StatusBar(self, main_frame)
|
||||
self.websockets = WebsocketStatus(self, main_frame)
|
||||
self.login = LoginForm(self, main_frame)
|
||||
@@ -1595,15 +1700,15 @@ class GUIManager:
|
||||
# Inventory tab
|
||||
inv_frame = ttk.Frame(root_frame, padding=8)
|
||||
self.inv = InventoryOverview(self, inv_frame)
|
||||
self.tabs.add_tab(inv_frame, name="Inventory")
|
||||
self.tabs.add_tab(inv_frame, name=_("gui", "tabs", "inventory"))
|
||||
# Settings tab
|
||||
settings_frame = ttk.Frame(root_frame, padding=8)
|
||||
self.settings = SettingsPanel(self, settings_frame)
|
||||
self.tabs.add_tab(settings_frame, name="Settings")
|
||||
self.tabs.add_tab(settings_frame, name=_("gui", "tabs", "settings"))
|
||||
# Help tab
|
||||
help_frame = ttk.Frame(root_frame, padding=8)
|
||||
self.help = HelpTab(self, help_frame)
|
||||
self.tabs.add_tab(help_frame, name="Help")
|
||||
self.tabs.add_tab(help_frame, name=_("gui", "tabs", "help"))
|
||||
# clamp minimum window size (update geometry first)
|
||||
root.update_idletasks()
|
||||
root.minsize(width=root.winfo_reqwidth(), height=root.winfo_reqheight())
|
||||
@@ -1699,8 +1804,8 @@ class GUIManager:
|
||||
self.settings.clear_selection()
|
||||
|
||||
# these are here to interface with underlaying GUI components
|
||||
def save(self) -> None:
|
||||
self._cache.save()
|
||||
def save(self, *, force: bool = False) -> None:
|
||||
self._cache.save(force=force)
|
||||
|
||||
def set_games(self, games: abc.Iterable[Game]) -> None:
|
||||
self.settings.set_games(games)
|
||||
@@ -1831,6 +1936,7 @@ if __name__ == "__main__":
|
||||
priority=[],
|
||||
proxy=URL(),
|
||||
autostart=False,
|
||||
language="English",
|
||||
priority_only=False,
|
||||
autostart_tray=False,
|
||||
exclude={"Lit Game"},
|
||||
|
||||
+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:
|
||||
raise ImportError("You have to run 'pip install pillow' first") from exc
|
||||
|
||||
from translate import _
|
||||
from twitch import Twitch
|
||||
from settings import Settings
|
||||
from version import __version__
|
||||
@@ -133,6 +134,13 @@ if exists and not settings.no_run_check:
|
||||
# already running - exit
|
||||
sys.exit(3)
|
||||
|
||||
# set language
|
||||
try:
|
||||
_.set_language(settings.language)
|
||||
except ValueError:
|
||||
# this language doesn't exist - stick to English
|
||||
pass
|
||||
|
||||
# handle logging stuff
|
||||
if settings.logging_level > logging.DEBUG:
|
||||
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
||||
@@ -158,14 +166,12 @@ try:
|
||||
loop.run_until_complete(client.run())
|
||||
except CaptchaRequired:
|
||||
exit_status = 1
|
||||
msg = "Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours."
|
||||
logger.exception(msg)
|
||||
msg = _("error", "captcha")
|
||||
client.prevent_close()
|
||||
client.print(msg)
|
||||
except Exception:
|
||||
exit_status = 1
|
||||
msg = "Fatal error encountered:\n"
|
||||
logger.exception(msg)
|
||||
client.prevent_close()
|
||||
client.print(msg)
|
||||
client.print(traceback.format_exc())
|
||||
@@ -174,14 +180,12 @@ finally:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||
loop.run_until_complete(client.shutdown())
|
||||
if not client.gui.close_requested:
|
||||
client.print(
|
||||
"\nApplication Terminated.\nClose the window to exit the application."
|
||||
)
|
||||
client.print(_("status", "terminated"))
|
||||
loop.run_until_complete(client.gui.wait_until_closed())
|
||||
# save settings
|
||||
# save the application state
|
||||
# NOTE: we have to do it after wait_until_closed,
|
||||
# because the user can alter settings between shutdown and closing the window
|
||||
client.settings.save(force=True)
|
||||
# because the user can alter some settings between app termination and closing the window
|
||||
client.save(force=True)
|
||||
client.gui.stop()
|
||||
client.gui.close_window()
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
|
||||
+11
-3
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
|
||||
class SettingsFile(TypedDict):
|
||||
proxy: URL
|
||||
language: str
|
||||
autostart: bool
|
||||
exclude: set[str]
|
||||
priority: list[str]
|
||||
@@ -25,6 +26,7 @@ default_settings: SettingsFile = {
|
||||
"priority": [],
|
||||
"exclude": set(),
|
||||
"autostart": False,
|
||||
"language": "English",
|
||||
"priority_only": True,
|
||||
"autostart_tray": False,
|
||||
}
|
||||
@@ -41,12 +43,15 @@ class Settings:
|
||||
logging_level: int
|
||||
# from settings file
|
||||
proxy: URL
|
||||
language: str
|
||||
autostart: bool
|
||||
exclude: set[str]
|
||||
priority: list[str]
|
||||
priority_only: bool
|
||||
autostart_tray: bool
|
||||
|
||||
PASSTHROUGH = ("_settings", "_args", "_altered")
|
||||
|
||||
def __init__(self, args: ParsedArgs):
|
||||
self._settings: SettingsFile = json_load(SETTINGS_PATH, default_settings)
|
||||
self._args: ParsedArgs = args
|
||||
@@ -54,14 +59,17 @@ class Settings:
|
||||
|
||||
# default logic of reading settings is to check args first, then the settings file
|
||||
def __getattr__(self, name: str, /) -> Any:
|
||||
if hasattr(self._args, name):
|
||||
if name in self.PASSTHROUGH:
|
||||
# passthrough
|
||||
return getattr(super(), name)
|
||||
elif hasattr(self._args, name):
|
||||
return getattr(self._args, name)
|
||||
elif name in self._settings:
|
||||
return self._settings[name] # type: ignore[literal-required]
|
||||
return getattr(super(), name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any, /) -> None:
|
||||
if name in ("_settings", "_args", "_altered"):
|
||||
if name in self.PASSTHROUGH:
|
||||
# passthrough
|
||||
return super().__setattr__(name, value)
|
||||
elif name in self._settings:
|
||||
@@ -78,4 +86,4 @@ class Settings:
|
||||
|
||||
def save(self, *, force: bool = False) -> None:
|
||||
if self._altered or force:
|
||||
json_save(SETTINGS_PATH, self._settings)
|
||||
json_save(SETTINGS_PATH, self._settings, sort=True)
|
||||
+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
|
||||
from yarl import URL
|
||||
|
||||
from translate import _
|
||||
from gui import GUIManager
|
||||
from channel import Channel
|
||||
from websocket import WebsocketPool
|
||||
@@ -87,7 +88,7 @@ class Twitch:
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
start_time = time()
|
||||
self.gui.print("Exiting...")
|
||||
self.gui.print(_("status", "exiting"))
|
||||
self.stop_watching()
|
||||
if self._watching_task is not None:
|
||||
self._watching_task.cancel()
|
||||
@@ -102,8 +103,6 @@ class Twitch:
|
||||
session, self._session = self._session, None
|
||||
await session.close()
|
||||
await self.websocket.stop()
|
||||
# save image cache
|
||||
self.gui.save()
|
||||
# wait at least half a second + whatever it takes to complete the closing
|
||||
# this allows aiohttp to safely close the session
|
||||
await asyncio.sleep(start_time + 0.5 - time())
|
||||
@@ -149,12 +148,12 @@ class Twitch:
|
||||
"""
|
||||
self.gui.print(*args, **kwargs)
|
||||
|
||||
def save(self) -> None:
|
||||
def save(self, *, force: bool = False) -> None:
|
||||
"""
|
||||
Saves the application state.
|
||||
"""
|
||||
self.gui.save()
|
||||
self.settings.save()
|
||||
self.gui.save(force=force)
|
||||
self.settings.save(force=force)
|
||||
|
||||
@staticmethod
|
||||
def _viewers_key(channel: Channel) -> int:
|
||||
@@ -198,7 +197,7 @@ class Twitch:
|
||||
self.change_state(State.INVENTORY_FETCH)
|
||||
while True:
|
||||
if self._state is State.IDLE:
|
||||
self.gui.status.update("Idle")
|
||||
self.gui.status.update(_("gui", "status", "idle"))
|
||||
self.stop_watching()
|
||||
# clear the flag and wait until it's set again
|
||||
self._state_change.clear()
|
||||
@@ -243,7 +242,7 @@ class Twitch:
|
||||
self.restart_watching()
|
||||
self.change_state(State.CHANNELS_CLEANUP)
|
||||
elif self._state is State.CHANNELS_CLEANUP:
|
||||
self.gui.status.update("Cleaning up channels...")
|
||||
self.gui.status.update(_("gui", "status", "cleanup"))
|
||||
if not self.games or full_cleanup:
|
||||
# no games selected or we're doing full cleanup: remove everything
|
||||
to_remove: list[Channel] = list(channels.values())
|
||||
@@ -275,12 +274,10 @@ class Twitch:
|
||||
self.change_state(State.CHANNELS_FETCH)
|
||||
else:
|
||||
# with no games available, we switch to IDLE after cleanup
|
||||
self.gui.print(
|
||||
"No active campaigns to mine drops for. Waiting for an active campaign..."
|
||||
)
|
||||
self.gui.print(_("gui", "status", "no_campaigns"))
|
||||
self.change_state(State.IDLE)
|
||||
elif self._state is State.CHANNELS_FETCH:
|
||||
self.gui.status.update("Gathering channels...")
|
||||
self.gui.status.update(_("gui", "status", "gathering"))
|
||||
# start with all current channels
|
||||
new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values())
|
||||
# gather and add ACL channels from campaigns
|
||||
@@ -361,7 +358,7 @@ class Twitch:
|
||||
break
|
||||
self.change_state(State.CHANNEL_SWITCH)
|
||||
elif self._state is State.CHANNEL_SWITCH:
|
||||
self.gui.status.update("Switching the channel...")
|
||||
self.gui.status.update(_("gui", "status", "switching"))
|
||||
# Change into the selected channel, stay in the watching channel,
|
||||
# or select a new channel that meets the required conditions
|
||||
new_watching = None
|
||||
@@ -381,9 +378,7 @@ class Twitch:
|
||||
watching_channel = self.watching_channel.get_with_default(None)
|
||||
if watching_channel is None and new_watching is None:
|
||||
# not watching anything and there isn't anything to watch either
|
||||
self.gui.print(
|
||||
"No available channels to watch. Waiting for an ONLINE channel..."
|
||||
)
|
||||
self.gui.print(_("status", "no_channel"))
|
||||
self.change_state(State.IDLE)
|
||||
else:
|
||||
if new_watching is not None:
|
||||
@@ -393,11 +388,13 @@ class Twitch:
|
||||
# should immediately complete, since we'll definitely
|
||||
# have a channel we're watching here
|
||||
watching_channel = await self.watching_channel.get()
|
||||
self.gui.status.update(f"Watching {watching_channel.name}")
|
||||
self.gui.status.update(
|
||||
_("gui", "status", "watching").format(channel=watching_channel.name)
|
||||
)
|
||||
# break the state change chain by clearing the flag
|
||||
self._state_change.clear()
|
||||
elif self._state is State.EXIT:
|
||||
self.gui.status.update("Exiting...")
|
||||
self.gui.status.update(_("status", "exiting"))
|
||||
# we've been requested to exit the application
|
||||
break
|
||||
await self._state_change.wait()
|
||||
@@ -422,8 +419,7 @@ class Twitch:
|
||||
channel = await self.watching_channel.get()
|
||||
succeeded = await channel.send_watch()
|
||||
if not succeeded:
|
||||
# this usually means there are connection problems
|
||||
self.gui.print("Connection problems, retrying in 60 seconds...")
|
||||
# this usually means the campaign expired in the middle of mining
|
||||
await self._watch_sleep(60)
|
||||
continue
|
||||
last_watch = time()
|
||||
@@ -484,10 +480,11 @@ class Twitch:
|
||||
period = timedelta.max
|
||||
for campaign in self.inventory:
|
||||
if not campaign.linked:
|
||||
# this relies on the linked campaigns being first due to sorting
|
||||
break
|
||||
if campaign.starts_at > now and (test_period := campaign.starts_at - now) < period:
|
||||
if campaign.starts_at >= now and (test_period := campaign.starts_at - now) < period:
|
||||
period = test_period
|
||||
elif campaign.ends_at > now and (test_period := campaign.ends_at - now) < period:
|
||||
elif campaign.ends_at >= now and (test_period := campaign.ends_at - now) < period:
|
||||
period = test_period
|
||||
if period > one_hour:
|
||||
period = one_hour
|
||||
@@ -514,7 +511,7 @@ class Twitch:
|
||||
if not self.games:
|
||||
return False
|
||||
return (
|
||||
channel.online # steam online
|
||||
channel.online # stream online
|
||||
and channel.drops_enabled # drops are enabled
|
||||
# it's one of the games we've selected
|
||||
and channel.game is not None and channel.game in self.games
|
||||
@@ -588,7 +585,7 @@ class Twitch:
|
||||
self.can_watch(channel) # we can watch the channel that just got ONLINE
|
||||
and self.should_switch(channel) # and we should!
|
||||
):
|
||||
self.gui.print(f"{channel.name} goes ONLINE, switching...")
|
||||
self.gui.print(_("status", "goes_online").format(channel=channel.name))
|
||||
self.watch(channel)
|
||||
|
||||
def on_offline(self, channel: Channel):
|
||||
@@ -598,7 +595,7 @@ class Twitch:
|
||||
# change the channel if we're currently watching it
|
||||
watching_channel = self.watching_channel.get_with_default(None)
|
||||
if watching_channel is not None and watching_channel == channel:
|
||||
self.gui.print(f"{channel.name} goes OFFLINE, switching...")
|
||||
self.gui.print(_("status", "goes_offline").format(channel=channel.name))
|
||||
self.change_state(State.CHANNEL_SWITCH)
|
||||
else:
|
||||
logger.debug(f"{channel.name} goes OFFLINE")
|
||||
@@ -629,8 +626,8 @@ class Twitch:
|
||||
f"{drop.rewards_text()} "
|
||||
f"({campaign.claimed_drops}/{campaign.total_drops})"
|
||||
)
|
||||
self.gui.print(f"Claimed drop: {claim_text}")
|
||||
self.gui.tray.notify(claim_text, "Mined Drop")
|
||||
self.gui.print(_("status", "claimed_drop").format(drop=claim_text))
|
||||
self.gui.tray.notify(claim_text, _("gui", "tray", "notification_title"))
|
||||
else:
|
||||
logger.error(f"Drop claim failed! Drop ID: {drop_id}")
|
||||
# About 4-20s after claiming the drop, next drop can be started
|
||||
@@ -720,12 +717,14 @@ class Twitch:
|
||||
if channel is not None:
|
||||
channel.points = balance
|
||||
channel.display()
|
||||
self.gui.print(f"Earned points for watching: {points:3}, total: {balance}")
|
||||
self.gui.print(
|
||||
_("status", "earned_points").format(points=f"{points:3}", balance=balance)
|
||||
)
|
||||
elif msg_type == "claim-available":
|
||||
claim_data = message["data"]["claim"]
|
||||
points = claim_data["point_gain"]["total_points"]
|
||||
await self.claim_points(claim_data["channel_id"], claim_data["id"])
|
||||
self.gui.print(f"Claimed bonus points: {points}")
|
||||
self.gui.print(_("status", "claimed_points").format(points=points))
|
||||
|
||||
async def _login(self) -> str:
|
||||
logger.debug("Login flow started")
|
||||
@@ -822,7 +821,7 @@ class Twitch:
|
||||
# looks like we're missing something
|
||||
login_form: LoginForm = self.gui.login
|
||||
logger.debug("Checking login")
|
||||
login_form.update("Logging in...", None)
|
||||
login_form.update(_("gui", "login", "logging_in"), None)
|
||||
# NOTE: We need this here because of the jar being accessed
|
||||
session = await self.get_session()
|
||||
url = URL(BASE_URL)
|
||||
@@ -860,7 +859,7 @@ class Twitch:
|
||||
cookie["persistent"] = str(self._user_id)
|
||||
self._is_logged_in.set()
|
||||
logger.debug(f"Login successful, user ID: {self._user_id}")
|
||||
login_form.update("Logged in", self._user_id)
|
||||
login_form.update(_("gui", "login", "logged_in"), self._user_id)
|
||||
# update our cookie and save it
|
||||
jar.update_cookies(cookie, url)
|
||||
jar.save(COOKIES_PATH)
|
||||
@@ -881,11 +880,11 @@ class Twitch:
|
||||
if response.status < 500:
|
||||
yield response
|
||||
return
|
||||
self.print(f"Twitch is down, retrying in {round(delay)} seconds...")
|
||||
self.print(_("error", "site_down").format(seconds=round(delay)))
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
# just so that quick 2nd retries that often happen, aren't shown
|
||||
if delay > 1:
|
||||
self.print(f"Cannot connect to Twitch, retrying in {round(delay)} seconds...")
|
||||
self.print(_("error", "no_connection").format(seconds=round(delay)))
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def gql_request(self, op: GQLOperation) -> JsonType:
|
||||
@@ -922,7 +921,7 @@ class Twitch:
|
||||
|
||||
async def fetch_inventory(self) -> None:
|
||||
status_update = self.gui.status.update
|
||||
status_update("Fetching inventory...")
|
||||
status_update(_("gui", "status", "fetching_inventory"))
|
||||
# fetch in-progress campaigns (inventory)
|
||||
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
|
||||
if self.gui.close_requested:
|
||||
@@ -953,7 +952,11 @@ class Twitch:
|
||||
)
|
||||
}
|
||||
# add campaigns that remained, that can be earned but are not in-progress yet
|
||||
status_update(f"Fetching campaigns... (0/{len(available_campaigns)})")
|
||||
status_update(
|
||||
_("gui", "status", "fetching_campaigns").format(
|
||||
counter=f"(0/{len(available_campaigns)})"
|
||||
)
|
||||
)
|
||||
fetched_campaigns: list[DropsCampaign] = []
|
||||
for i, coro in enumerate(
|
||||
# specifically use an intermediate list per a Python bug
|
||||
@@ -965,7 +968,11 @@ class Twitch:
|
||||
start=1,
|
||||
):
|
||||
fetched_campaigns.append(await coro)
|
||||
status_update(f"Fetching campaigns... ({i}/{len(available_campaigns)})")
|
||||
status_update(
|
||||
_("gui", "status", "fetching_campaigns").format(
|
||||
counter=f"({i}/{len(available_campaigns)})"
|
||||
)
|
||||
)
|
||||
if self.gui.close_requested:
|
||||
raise ExitRequest()
|
||||
campaigns.extend(fetched_campaigns)
|
||||
@@ -976,7 +983,9 @@ class Twitch:
|
||||
self.gui.inv.clear()
|
||||
self.inventory.clear()
|
||||
for i, campaign in enumerate(campaigns, start=1):
|
||||
status_update(f"Adding campaigns to inventory... ({i}/{len(campaigns)})")
|
||||
status_update(
|
||||
_("gui", "status", "adding_campaigns").format(counter=f"({i}/{len(campaigns)})")
|
||||
)
|
||||
self._drops.update({drop.id: drop for drop in campaign.drops})
|
||||
await self.gui.inv.add_campaign(campaign)
|
||||
self.inventory.append(campaign)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
@@ -19,7 +18,8 @@ from typing import (
|
||||
|
||||
import yarl
|
||||
|
||||
from constants import WORKING_DIR, JsonType
|
||||
from constants import JsonType
|
||||
from constants import _resource_path as resource_path # noqa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import ParamSpec
|
||||
@@ -52,21 +52,6 @@ def json_minify(data: JsonType | list[JsonType]) -> str:
|
||||
return json.dumps(data, separators=(',', ':'))
|
||||
|
||||
|
||||
def resource_path(relative_path: Path | str) -> Path:
|
||||
"""
|
||||
Get an absolute path to a bundled resource.
|
||||
|
||||
Works for dev and for PyInstaller.
|
||||
"""
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# PyInstaller's folder where the one-file app is unpacked
|
||||
meipass: str = getattr(sys, "_MEIPASS")
|
||||
base_path = Path(meipass)
|
||||
else:
|
||||
base_path = WORKING_DIR
|
||||
return base_path.joinpath(relative_path)
|
||||
|
||||
|
||||
def timestamp(string: str) -> datetime:
|
||||
return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
|
||||
@@ -142,6 +127,9 @@ def _remove_missing(obj: JsonType) -> JsonType:
|
||||
del obj[key]
|
||||
elif isinstance(value, dict):
|
||||
_remove_missing(value)
|
||||
if not value:
|
||||
# the dict is empty now, so remove it's key entirely
|
||||
del obj[key]
|
||||
return obj
|
||||
|
||||
|
||||
@@ -155,17 +143,41 @@ def _deserialize(obj: JsonType) -> Any:
|
||||
return obj
|
||||
|
||||
|
||||
def json_load(path: Path, defaults: _JSON_T) -> _JSON_T:
|
||||
combined: JsonType = dict(defaults)
|
||||
def merge_json(obj: JsonType, template: Mapping[Any, Any]) -> None:
|
||||
# NOTE: This modifies object in place
|
||||
for k, v in list(obj.items()):
|
||||
if k not in template:
|
||||
del obj[k]
|
||||
elif isinstance(v, dict):
|
||||
if isinstance(template[k], dict):
|
||||
merge_json(v, template[k])
|
||||
else:
|
||||
# object is a dict, template is not: overwrite from template
|
||||
obj[k] = template[k]
|
||||
elif isinstance(template[k], dict):
|
||||
# template is a dict, object is not: overwrite from template
|
||||
obj[k] = template[k]
|
||||
# ensure the object is not missing any keys
|
||||
for k in template.keys():
|
||||
if k not in obj:
|
||||
obj[k] = template[k]
|
||||
|
||||
|
||||
def json_load(path: Path, defaults: _JSON_T, *, merge: bool = True) -> _JSON_T:
|
||||
defaults_dict: JsonType = dict(defaults)
|
||||
if path.exists():
|
||||
with open(path, 'r') as file:
|
||||
combined.update(_remove_missing(json.load(file, object_hook=_deserialize)))
|
||||
with open(path, 'r', encoding="utf8") as file:
|
||||
combined: JsonType = _remove_missing(json.load(file, object_hook=_deserialize))
|
||||
if merge:
|
||||
merge_json(combined, defaults_dict)
|
||||
else:
|
||||
combined = defaults_dict
|
||||
return cast(_JSON_T, combined)
|
||||
|
||||
|
||||
def json_save(path: Path, contents: Mapping[Any, Any]) -> None:
|
||||
with open(path, 'w') as file:
|
||||
json.dump(contents, file, default=_serialize, sort_keys=True, indent=4)
|
||||
def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None:
|
||||
with open(path, 'w', encoding="utf8") as file:
|
||||
json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4)
|
||||
|
||||
|
||||
class ExponentialBackoff:
|
||||
|
||||
+7
-6
@@ -9,6 +9,7 @@ from typing import Any, Literal, TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
|
||||
from translate import _
|
||||
from exceptions import MinerException, WebsocketClosed
|
||||
from constants import PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT
|
||||
from utils import (
|
||||
@@ -91,7 +92,7 @@ class Websocket:
|
||||
self._closed.set()
|
||||
ws = self._ws.get_with_default(None)
|
||||
if ws is not None:
|
||||
self.set_status("Disconnecting...")
|
||||
self.set_status(_("gui", "websocket", "disconnecting"))
|
||||
await ws.close()
|
||||
|
||||
def stop_nowait(self):
|
||||
@@ -141,9 +142,9 @@ class Websocket:
|
||||
@task_wrapper
|
||||
async def _handle(self):
|
||||
# ensure we're logged in before connecting
|
||||
self.set_status("Initializing...")
|
||||
self.set_status(_("gui", "websocket", "initializing"))
|
||||
await self._twitch.wait_until_login()
|
||||
self.set_status("Connecting...")
|
||||
self.set_status(_("gui", "websocket", "connecting"))
|
||||
ws_logger.info(f"Websocket[{self._idx}] connecting...")
|
||||
self._closed.clear()
|
||||
# Connect/Reconnect loop
|
||||
@@ -154,7 +155,7 @@ class Websocket:
|
||||
self._reconnect_requested.clear()
|
||||
# NOTE: _topics_changed doesn't start set,
|
||||
# because there's no initial topics we can sub to right away
|
||||
self.set_status("Connected")
|
||||
self.set_status(_("gui", "websocket", "connected"))
|
||||
try:
|
||||
try:
|
||||
while not self._reconnect_requested.is_set():
|
||||
@@ -176,11 +177,11 @@ class Websocket:
|
||||
elif self._closed.is_set():
|
||||
# we closed it - exit
|
||||
ws_logger.info(f"Websocket[{self._idx}] stopped.")
|
||||
self.set_status("Disconnected")
|
||||
self.set_status(_("gui", "websocket", "disconnected"))
|
||||
return
|
||||
except Exception:
|
||||
ws_logger.exception(f"Exception in Websocket[{self._idx}]")
|
||||
self.set_status("Reconnecting...")
|
||||
self.set_status(_("gui", "websocket", "reconnecting"))
|
||||
ws_logger.warning(f"Websocket[{self._idx}] reconnecting...")
|
||||
|
||||
async def _handle_ping(self):
|
||||
|
||||
Reference in new issue
Block a user