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
+1040 -219

No files matched your search

+2 -4
View File
@@ -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
+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 `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 -2
View File
@@ -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
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:
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
View File
@@ -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 = (
+233 -127
View File
@@ -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:
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
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:
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
View File
@@ -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
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
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)
+36 -24
View File
@@ -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
View File
@@ -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):