Files
TwitchDropsMiner/translate.py
2025-08-27 17:19:32 +02:00

481 lines
14 KiB
Python

from __future__ import annotations
from collections import abc
from typing import Any, TypedDict, TYPE_CHECKING
from exceptions import MinerException
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):
terminated: str
watching: str
goes_online: str
goes_offline: str
claimed_drop: str
no_channel: str
no_campaign: str
class ChromeMessages(TypedDict):
startup: str
login_to_complete: str
no_token: str
closed_window: str
class LoginMessages(TypedDict):
chrome: ChromeMessages
error_code: str
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
exiting: str
terminated: str
cleanup: str
gathering: str
switching: 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
game: str
campaign: str
remaining: str
drop_progress: str
campaign_progress: str
class GUIChannelHeadings(TypedDict):
channel: str
status: str
game: str
viewers: str
class GUIChannels(TypedDict):
name: str
switch: str
online: str
pending: str
offline: str
headings: GUIChannelHeadings
class GUIInvFilter(TypedDict):
name: str
show: str
not_linked: str
upcoming: str
expired: str
excluded: str
finished: str
refresh: str
class GUIInvStatus(TypedDict):
linked: str
not_linked: str
active: str
expired: str
upcoming: str
claimed: str
ready_to_claim: 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
tray_notifications: str
dark_mode: str
priority_mode: str
proxy: str
class GUIPriorityModes(TypedDict):
priority_only: str
ending_soonest: str
low_availability: str
class GUISettings(TypedDict):
general: GUISettingsGeneral
priority_modes: GUIPriorityModes
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]
english_name: str
status: StatusMessages
login: LoginMessages
error: ErrorMessages
gui: GUIMessages
default_translation: Translation = {
"english_name": "English",
"status": {
"terminated": "\nApplication Terminated.\nClose the window to exit the application.",
"watching": "Watching: {channel}",
"goes_online": "{channel} goes ONLINE, switching...",
"goes_offline": "{channel} goes OFFLINE, switching...",
"claimed_drop": "Claimed drop: {drop}",
"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, usually due to being redirected. "
"Do you need to login for internet access?"
),
"chrome": {
"startup": "Opening Chrome...",
"login_to_complete": (
"Complete the login procedure manually by pressing the Login button again."
),
"no_token": "No authorization token could be found.",
"closed_window": (
"The Chrome window was closed before the login procedure could be completed."
),
},
"error_code": "Login error code: {error_code}",
"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",
"exiting": "Exiting...",
"terminated": "Terminated",
"cleanup": "Cleaning up channels...",
"gathering": "Gathering channels...",
"switching": "Switching the channel...",
"fetching_inventory": "Fetching inventory...",
"fetching_campaigns": "Fetching campaigns...",
"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:",
"game": "Game:",
"campaign": "Campaign:",
"remaining": "{time} remaining",
"drop_progress": "Progress:",
"campaign_progress": "Progress:",
},
"channels": {
"name": "Channels",
"switch": "Switch",
"online": "ONLINE ✔",
"pending": "OFFLINE ⏳",
"offline": "OFFLINE ❌",
"headings": {
"channel": "Channel",
"status": "Status",
"game": "Game",
"viewers": "Viewers",
},
},
"inventory": {
"filter": {
"name": "Filter",
"show": "Show:",
"not_linked": "Not linked",
"upcoming": "Upcoming",
"expired": "Expired",
"excluded": "Excluded",
"finished": "Finished",
"refresh": "Refresh",
},
"status": {
"linked": "Linked ✔",
"not_linked": "Not Linked ❌",
"active": "Active ✔",
"upcoming": "Upcoming ⏳",
"expired": "Expired ❌",
"claimed": "Claimed ✔",
"ready_to_claim": "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: ",
"tray_notifications": "Tray notifications: ",
"dark_mode": "Dark mode: ",
"priority_mode": "Priority mode: ",
"proxy": "Proxy (requires restart):",
},
"priority_modes": {
"priority_only": "Priority list only",
"ending_soonest": "Ending soonest",
"low_availability": "Low availability first",
},
"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 several seconds, the application pretends to watch a particular stream "
"by fetching stream metadata - this is enough to advance the drops. "
"Note that this completely bypasses the need to download "
"any actual stream of video and sound. "
"To keep the status (ONLINE or OFFLINE) of the channels up-to-date, "
"there's a websocket connection established that receives events about streams "
"going up or down, or updates regarding the current number of viewers."
),
"getting_started": "Getting Started",
"getting_started_text": (
"1. Login to 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 mining everything possible, "
"change the Priority Mode to anything other than \"Priority list only\" "
"and press on \"Reload\".\n"
"4. If you want to mine specific games first, use the \"Priority\" list "
"to set up 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 mode\" selected as \"Priority list only\", "
"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 \"Priority mode\", 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")
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
try:
for key in path:
v = v[key]
except KeyError:
# this can only really happen for the default translation
raise MinerException(
f"{self.current} translation is missing the '{' -> '.join(path)}' translation key"
)
return v
_ = Translator()