feat: replace analytics-only UI with full miner config dashboard
GitHub Traffic Count Update Everyday / build (push) Has been cancelled
GitHub Clone Count Update Everyday / build (push) Has been cancelled

add JSON-backed miner configuration
make new config UI the root web interface
move analytics view to /analytics
add follower import with selectable and sortable streamer priority list
add notifier, logger, streamer default, and prediction settings controls
add prediction presets and per-streamer overrides
preserve mounted cookies/logs/analytics for Docker deployment
mask secrets in config API responses
This commit is contained in:
2026-07-19 21:32:08 +02:00
parent 8a35f87cd5
commit 72fe2fb4f4
11 changed files with 1905 additions and 6 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ cython_debug/
.idea/
# Custom files
run.py
config/miner.json
chromedriver*
# Folders
+2
View File
@@ -44,4 +44,6 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --fix-missing --no-ins
&& rm -rf /usr/share/doc/*
ADD ./TwitchChannelPointsMiner ./TwitchChannelPointsMiner
ADD ./assets ./assets
ADD ./run.py ./run.py
ENTRYPOINT [ "python", "run.py" ]
@@ -194,6 +194,7 @@ class TwitchChannelPointsMiner:
refresh=refresh,
days_ago=days_ago,
username=self.username,
miner=self,
)
http_server.daemon = True
http_server.name = "Analytics Thread"
@@ -510,4 +511,4 @@ class TwitchChannelPointsMiner:
logger.info(
f"{streamer_gain}\n{streamer_history}",
extra={"emoji": ":moneybag:"},
)
)
@@ -1,13 +1,16 @@
import json
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
from threading import Thread
import pandas as pd
from flask import Flask, Response, cli, render_template, request
from flask import Flask, Response, cli, jsonify, render_template, request
from TwitchChannelPointsMiner.classes import ConfigManager
from TwitchChannelPointsMiner.classes.Settings import FollowersOrder
from TwitchChannelPointsMiner.classes.Settings import Settings
from TwitchChannelPointsMiner.utils import download_file
@@ -207,6 +210,9 @@ def check_assets():
required_files = [
"banner.png",
"charts.html",
"config.css",
"config.html",
"config.js",
"script.js",
"style.css",
"dark-theme.css",
@@ -231,7 +237,8 @@ class AnalyticsServer(Thread):
port: int = 5000,
refresh: int = 5,
days_ago: int = 7,
username: str = None
username: str = None,
miner=None
):
super(AnalyticsServer, self).__init__()
@@ -242,6 +249,7 @@ class AnalyticsServer(Thread):
self.refresh = refresh
self.days_ago = days_ago
self.username = username
self.miner = miner
def generate_log():
global last_sent_log_index # Use the global variable
@@ -269,8 +277,9 @@ class AnalyticsServer(Thread):
template_folder=os.path.join(Path().absolute(), "assets"),
static_folder=os.path.join(Path().absolute(), "assets"),
)
self.app.add_url_rule("/", "config", self.config_page, methods=["GET"])
self.app.add_url_rule(
"/",
"/analytics",
"index",
index,
defaults={"refresh": refresh, "days_ago": days_ago},
@@ -285,6 +294,50 @@ class AnalyticsServer(Thread):
json_all, methods=["GET"])
self.app.add_url_rule(
"/log", "log", generate_log, methods=["GET"])
self.app.add_url_rule("/config", "config_alias", self.config_page, methods=["GET"])
self.app.add_url_rule("/api/config", "api_config_get", self.get_config, methods=["GET"])
self.app.add_url_rule("/api/config", "api_config_put", self.put_config, methods=["PUT"])
self.app.add_url_rule("/api/meta", "api_meta", self.get_meta, methods=["GET"])
self.app.add_url_rule(
"/api/followers/import",
"api_followers_import",
self.import_followers,
methods=["POST"],
)
self.app.add_url_rule("/api/restart", "api_restart", self.restart, methods=["POST"])
def config_page(self):
return render_template("config.html")
def get_meta(self):
return jsonify(ConfigManager.metadata())
def get_config(self):
return jsonify(ConfigManager.client_config(ConfigManager.load_config()))
def put_config(self):
config = request.get_json(force=True)
saved = ConfigManager.save_config(config)
return jsonify(ConfigManager.client_config(saved))
def import_followers(self):
if self.miner is None or self.miner.twitch is None:
return jsonify({"error": "Miner is not ready yet."}), 503
payload = request.get_json(silent=True) or {}
order = ConfigManager.enum_member(
FollowersOrder,
payload.get("followers_order", "ASC"),
FollowersOrder.ASC,
)
limit = int(payload.get("limit", 100))
return jsonify({"followers": self.miner.twitch.get_followers(limit=limit, order=order)})
def restart(self):
def exit_process():
os._exit(0)
threading.Timer(0.5, exit_process).start()
return jsonify({"status": "restarting"})
def run(self):
logger.info(
@@ -0,0 +1,462 @@
import copy
import json
import logging
import os
from pathlib import Path
from TwitchChannelPointsMiner import TwitchChannelPointsMiner
from TwitchChannelPointsMiner.classes.Chat import ChatPresence
from TwitchChannelPointsMiner.classes.Discord import Discord
from TwitchChannelPointsMiner.classes.Gotify import Gotify
from TwitchChannelPointsMiner.classes.Matrix import Matrix
from TwitchChannelPointsMiner.classes.Pushover import Pushover
from TwitchChannelPointsMiner.classes.Settings import Events, FollowersOrder, Priority
from TwitchChannelPointsMiner.classes.Telegram import Telegram
from TwitchChannelPointsMiner.classes.Webhook import Webhook
from TwitchChannelPointsMiner.classes.entities.Bet import (
BetSettings,
Condition,
DelayMode,
FilterCondition,
OutcomeKeys,
Strategy,
)
from TwitchChannelPointsMiner.classes.entities.Streamer import (
Streamer,
StreamerSettings,
)
from TwitchChannelPointsMiner.logger import ColorPalette, LoggerSettings
logger = logging.getLogger(__name__)
CONFIG_PATH = Path(os.environ.get("MINER_CONFIG_PATH", "config/miner.json"))
MASKED_SECRET = "********"
SECRET_PATHS = [
"account.password",
"notifications.telegram.token",
"notifications.discord.webhook_api",
"notifications.webhook.endpoint",
"notifications.matrix.password",
"notifications.pushover.userkey",
"notifications.pushover.token",
"notifications.gotify.endpoint",
]
OUTCOME_KEYS = [
OutcomeKeys.PERCENTAGE_USERS,
OutcomeKeys.ODDS_PERCENTAGE,
OutcomeKeys.ODDS,
OutcomeKeys.TOP_POINTS,
OutcomeKeys.TOTAL_USERS,
OutcomeKeys.TOTAL_POINTS,
OutcomeKeys.DECISION_USERS,
OutcomeKeys.DECISION_POINTS,
]
DEFAULT_CONFIG = {
"account": {
"username": "",
"password": "",
},
"miner": {
"claim_drops_startup": False,
"enable_analytics": True,
"disable_ssl_cert_verification": False,
"disable_at_in_nickname": False,
"priority": ["STREAK", "DROPS", "ORDER"],
"followers": False,
"followers_order": "ASC",
"blacklist": [],
},
"analytics": {
"host": "0.0.0.0",
"port": 5000,
"refresh": 5,
"days_ago": 7,
},
"logger": {
"save": True,
"console_level": "INFO",
"console_username": False,
"auto_clear": True,
"time_zone": "",
"file_level": "INFO",
"emoji": True,
"less": True,
"colored": True,
"color_palette": {
"STREAMER_ONLINE": "GREEN",
"STREAMER_OFFLINE": "RED",
"BET_WIN": "MAGENTA",
},
},
"notifications": {
"telegram": {
"enabled": False,
"chat_id": "",
"token": "",
"events": [],
"disable_notification": True,
},
"discord": {"enabled": False, "webhook_api": "", "events": []},
"webhook": {
"enabled": False,
"endpoint": "",
"method": "GET",
"events": [],
},
"matrix": {
"enabled": False,
"username": "",
"password": "",
"homeserver": "matrix.org",
"room_id": "",
"events": [],
},
"pushover": {
"enabled": False,
"userkey": "",
"token": "",
"priority": 0,
"sound": "pushover",
"events": [],
},
"gotify": {"enabled": False, "endpoint": "", "priority": 8, "events": []},
},
"streamer_defaults": {
"make_predictions": True,
"follow_raid": True,
"claim_drops": True,
"claim_moments": True,
"watch_streak": True,
"community_goals": False,
"chat": "ONLINE",
"bet": {
"strategy": "SMART",
"percentage": 5,
"percentage_gap": 20,
"max_points": 50000,
"minimum_points": 0,
"stealth_mode": False,
"delay_mode": "FROM_END",
"delay": 6,
"filter_condition": {
"enabled": False,
"by": "total_users",
"where": "GTE",
"value": 1,
},
},
},
"streamers": [],
}
def metadata():
return {
"events": enum_names(Events),
"priorities": enum_names(Priority),
"followers_orders": enum_names(FollowersOrder),
"chat_presences": enum_names(ChatPresence),
"bet_strategies": enum_names(Strategy),
"delay_modes": enum_names(DelayMode),
"filter_conditions": enum_names(Condition),
"outcome_keys": OUTCOME_KEYS,
"log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
"colors": [
"BLACK",
"RED",
"GREEN",
"YELLOW",
"BLUE",
"MAGENTA",
"CYAN",
"WHITE",
"RESET",
],
}
def enum_names(enum_class):
return [item.name for item in enum_class]
def load_config(path=CONFIG_PATH):
config = copy.deepcopy(DEFAULT_CONFIG)
if Path(path).is_file():
with open(path, "r", encoding="utf-8") as file:
config = deep_merge(config, json.load(file))
return config
def save_config(config, path=CONFIG_PATH, preserve_secrets=True):
if preserve_secrets and Path(path).is_file():
config = preserve_existing_secrets(config, load_config(path))
normalized = deep_merge(copy.deepcopy(DEFAULT_CONFIG), config)
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temp = target.with_suffix(target.suffix + ".tmp")
with open(temp, "w", encoding="utf-8") as file:
json.dump(normalized, file, indent=2)
file.write("\n")
os.replace(temp, target)
return normalized
def client_config(config):
sanitized = copy.deepcopy(config)
for path in SECRET_PATHS:
value = get_path(sanitized, path)
if value not in (None, ""):
set_path(sanitized, path, MASKED_SECRET)
return sanitized
def preserve_existing_secrets(incoming, existing):
merged = copy.deepcopy(incoming)
for path in SECRET_PATHS:
value = get_path(merged, path)
if value in ("", None, MASKED_SECRET):
set_path(merged, path, get_path(existing, path))
return merged
def deep_merge(base, override):
for key, value in override.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = deep_merge(base[key], value)
else:
base[key] = value
return base
def get_path(config, path):
current = config
for part in path.split("."):
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def set_path(config, path, value):
parts = path.split(".")
current = config
for part in parts[:-1]:
current = current.setdefault(part, {})
current[parts[-1]] = value
def build_runtime(config):
config = deep_merge(copy.deepcopy(DEFAULT_CONFIG), config)
logger_settings = build_logger_settings(config)
streamer_defaults = build_streamer_settings(config["streamer_defaults"])
miner = TwitchChannelPointsMiner(
username=config["account"]["username"],
password=config["account"].get("password") or None,
claim_drops_startup=as_bool(config["miner"]["claim_drops_startup"]),
enable_analytics=as_bool(config["miner"]["enable_analytics"]),
disable_ssl_cert_verification=as_bool(
config["miner"]["disable_ssl_cert_verification"]
),
disable_at_in_nickname=as_bool(config["miner"]["disable_at_in_nickname"]),
priority=[
enum_member(Priority, item, Priority.ORDER)
for item in config["miner"].get("priority", [])
],
logger_settings=logger_settings,
streamer_settings=streamer_defaults,
)
streamers = [
build_streamer(item)
for item in config.get("streamers", [])
if item.get("enabled", True) and item.get("username", "").strip()
]
return {
"miner": miner,
"analytics": config["analytics"],
"mine": {
"streamers": streamers,
"blacklist": config["miner"].get("blacklist", []),
"followers": as_bool(config["miner"].get("followers", False)),
"followers_order": enum_member(
FollowersOrder,
config["miner"].get("followers_order"),
FollowersOrder.ASC,
),
},
}
def build_logger_settings(config):
logger_config = config["logger"]
notifications = config["notifications"]
return LoggerSettings(
save=as_bool(logger_config["save"]),
console_level=log_level(logger_config["console_level"]),
console_username=as_bool(logger_config["console_username"]),
auto_clear=as_bool(logger_config["auto_clear"]),
time_zone=logger_config.get("time_zone") or "",
file_level=log_level(logger_config["file_level"]),
emoji=as_bool(logger_config["emoji"]),
less=as_bool(logger_config["less"]),
colored=as_bool(logger_config["colored"]),
color_palette=ColorPalette(**logger_config.get("color_palette", {})),
telegram=build_telegram(notifications["telegram"]),
discord=build_discord(notifications["discord"]),
webhook=build_webhook(notifications["webhook"]),
matrix=build_matrix(notifications["matrix"]),
pushover=build_pushover(notifications["pushover"]),
gotify=build_gotify(notifications["gotify"]),
)
def build_telegram(config):
if not as_bool(config.get("enabled")):
return None
chat_id = config.get("chat_id")
return Telegram(
chat_id=int(chat_id) if str(chat_id).strip() else 123456789,
token=config.get("token", ""),
events=event_list(config.get("events", [])),
disable_notification=as_bool(config.get("disable_notification", False)),
)
def build_discord(config):
if not as_bool(config.get("enabled")):
return None
return Discord(config.get("webhook_api", ""), event_list(config.get("events", [])))
def build_webhook(config):
if not as_bool(config.get("enabled")):
return None
return Webhook(
config.get("endpoint", ""),
config.get("method", "GET"),
event_list(config.get("events", [])),
)
def build_matrix(config):
if not as_bool(config.get("enabled")):
return None
return Matrix(
config.get("username", ""),
config.get("password", ""),
config.get("homeserver", "matrix.org"),
config.get("room_id", ""),
event_list(config.get("events", [])),
)
def build_pushover(config):
if not as_bool(config.get("enabled")):
return None
return Pushover(
config.get("userkey", ""),
config.get("token", ""),
int(config.get("priority", 0)),
config.get("sound", "pushover"),
event_list(config.get("events", [])),
)
def build_gotify(config):
if not as_bool(config.get("enabled")):
return None
return Gotify(
config.get("endpoint", ""),
int(config.get("priority", 8)),
event_list(config.get("events", [])),
)
def build_streamer(config):
settings_config = config.get("settings")
settings = build_streamer_settings(settings_config) if settings_config else None
return Streamer(config["username"], settings=settings)
def build_streamer_settings(config):
return StreamerSettings(
make_predictions=nullable_bool(config.get("make_predictions")),
follow_raid=nullable_bool(config.get("follow_raid")),
claim_drops=nullable_bool(config.get("claim_drops")),
claim_moments=nullable_bool(config.get("claim_moments")),
watch_streak=nullable_bool(config.get("watch_streak")),
community_goals=nullable_bool(config.get("community_goals")),
chat=enum_member(ChatPresence, config.get("chat"), None),
bet=build_bet_settings(config.get("bet", {})),
)
def build_bet_settings(config):
return BetSettings(
strategy=enum_member(Strategy, config.get("strategy"), None),
percentage=nullable_int(config.get("percentage")),
percentage_gap=nullable_int(config.get("percentage_gap")),
max_points=nullable_int(config.get("max_points")),
minimum_points=nullable_int(config.get("minimum_points")),
stealth_mode=nullable_bool(config.get("stealth_mode")),
delay=nullable_float(config.get("delay")),
delay_mode=enum_member(DelayMode, config.get("delay_mode"), None),
filter_condition=build_filter_condition(config.get("filter_condition", {})),
)
def build_filter_condition(config):
if not as_bool(config.get("enabled")):
return None
return FilterCondition(
by=config.get("by", OutcomeKeys.TOTAL_USERS),
where=enum_member(Condition, config.get("where"), Condition.GTE),
value=nullable_float(config.get("value")) or 0,
)
def event_list(values):
return [enum_member(Events, value, None) for value in values if enum_member(Events, value, None)]
def enum_member(enum_class, value, default):
if value is None:
return default
if isinstance(value, enum_class):
return value
name = str(value).upper()
return getattr(enum_class, name, default)
def log_level(value):
if isinstance(value, int):
return value
return getattr(logging, str(value).upper(), logging.INFO)
def nullable_int(value):
if value in (None, ""):
return None
return int(value)
def nullable_float(value):
if value in (None, ""):
return None
return float(value)
def nullable_bool(value):
if value is None:
return None
return as_bool(value)
def as_bool(value):
if isinstance(value, bool):
return value
return str(value).lower() in ["1", "true", "yes", "on"]
+2 -1
View File
@@ -145,6 +145,7 @@
<input class="input" type="date" id="endDate">
</div>
<div class="column has-text-right" style="margin: auto">
<a class="button" href="/">Config</a>
<button id="auto-update-log">⏸️</button>
<label class="checkbox checkbox-label">
Log
@@ -206,4 +207,4 @@
</script>
<script type="text/javascript" src="{{url_for('static', filename='script.js')}}"></script>
</html>
</html>
+445
View File
@@ -0,0 +1,445 @@
:root {
color-scheme: dark;
--bg: #101114;
--panel: #181a20;
--panel-2: #20232b;
--line: #343844;
--text: #f0f2f5;
--muted: #9ca3af;
--accent: #00c7a7;
--danger: #ff5c77;
--focus: #8b5cf6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 238px;
border-right: 1px solid var(--line);
background: #15171d;
padding: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 26px;
}
.brand-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 8px;
background: var(--accent);
color: #07110f;
font-weight: 800;
}
.brand span:last-child {
display: block;
color: var(--muted);
font-size: 12px;
}
nav {
display: grid;
gap: 6px;
}
nav a {
color: var(--muted);
text-decoration: none;
padding: 9px 10px;
border-radius: 6px;
}
nav a:hover {
background: var(--panel-2);
color: var(--text);
}
main {
margin-left: 238px;
padding: 22px;
}
.topbar,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 4px;
font-size: 28px;
}
h2 {
margin-bottom: 18px;
font-size: 20px;
}
h3 {
font-size: 15px;
}
p {
color: var(--muted);
}
section {
border-top: 1px solid var(--line);
padding: 26px 0;
}
.grid {
display: grid;
gap: 14px;
}
.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
padding: 8px 10px;
}
input,
select {
min-height: 38px;
}
textarea {
min-height: 420px;
resize: vertical;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.5;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--focus);
outline: none;
}
.checks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
margin: 16px 0;
}
.checks label,
.checkline {
display: flex;
align-items: center;
gap: 9px;
min-height: 38px;
color: var(--text);
}
input[type="checkbox"] {
width: 17px;
min-height: 17px;
}
button {
min-height: 38px;
border: 0;
border-radius: 6px;
padding: 8px 12px;
background: var(--accent);
color: #06110f;
font-weight: 750;
cursor: pointer;
}
button.secondary {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
}
button.danger {
background: var(--danger);
color: white;
}
.actions,
.inline-actions,
.tabs,
.pill-list {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.inline-actions input {
width: 210px;
}
.notice {
margin: 16px 0;
padding: 11px 12px;
border-radius: 6px;
background: #123229;
color: #c6fff3;
border: 1px solid #1d6b59;
}
.hidden {
display: none;
}
.pill,
.streamer-row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 8px;
}
.pill button,
.streamer-row button {
min-height: 30px;
padding: 5px 8px;
}
.streamer-list {
display: grid;
gap: 8px;
}
.streamer-board {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1.1fr);
gap: 16px;
}
.streamer-panel {
min-height: 420px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 14px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel-head h3 {
margin-bottom: 0;
}
.panel-head span,
.summary-line {
color: var(--muted);
}
.inline-actions.full {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
margin-bottom: 10px;
}
.inline-actions.full input {
width: 100%;
}
.available-list,
.selected-list {
display: grid;
gap: 8px;
max-height: 540px;
overflow: auto;
padding-right: 4px;
}
.available-row,
.selected-row {
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel-2);
color: var(--text);
padding: 8px;
}
.available-row {
display: flex;
align-items: center;
gap: 9px;
}
.selected-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.selected-row.dragging {
opacity: 0.6;
border-color: var(--accent);
}
.drag-handle {
width: 42px;
color: var(--muted);
cursor: grab;
}
.streamer-settings {
flex-basis: 100%;
display: grid;
gap: 12px;
border-top: 1px solid var(--line);
padding-top: 10px;
}
.compact {
margin: 0;
}
.prediction-presets {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.summary-line {
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
}
.streamer-row input[type="text"] {
width: 260px;
}
.streamer-row .spacer {
flex: 1;
}
.tabs {
margin-bottom: 14px;
}
.tabs button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
}
.tabs button.active {
background: var(--accent);
color: #06110f;
}
.notifier-box {
display: grid;
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
background: var(--panel);
}
.events {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px;
}
@media (max-width: 900px) {
.sidebar {
position: static;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
main {
margin-left: 0;
}
.topbar,
.section-head {
align-items: stretch;
flex-direction: column;
}
.two,
.three,
.four,
.streamer-board {
grid-template-columns: 1fr;
}
}
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Twitch Miner Konfiguration</title>
<link rel="icon" type="image/png" sizes="32x32"
href="https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png">
<link href="{{url_for('static', filename='config.css')}}" rel="stylesheet" />
</head>
<body>
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">CP</span>
<div>
<strong>Twitch Miner</strong>
<span>Konfiguration</span>
</div>
</div>
<nav>
<a href="#account">Account</a>
<a href="#streamers">Kanale</a>
<a href="#defaults">Defaults</a>
<a href="#bets">Predictions</a>
<a href="#notifications">Notifier</a>
<a href="#logger">Logging</a>
<a href="#advanced">JSON</a>
<a href="/analytics">Analytics</a>
</nav>
</aside>
<main>
<header class="topbar">
<div>
<h1>Bot steuern</h1>
<p>Speichern schreibt die JSON-Konfiguration. Ein Neustart aktiviert die neue Laufzeit.</p>
</div>
<div class="actions">
<button id="save">Speichern</button>
<button id="restart" class="danger">Neustart</button>
</div>
</header>
<div id="notice" class="notice hidden"></div>
<section id="account">
<h2>Account und Miner</h2>
<div class="grid two">
<label>Twitch Username<input data-path="account.username" autocomplete="username"></label>
<label>Twitch Passwort<input data-path="account.password" type="password" autocomplete="current-password"></label>
<label>Analytics Host<input data-path="analytics.host"></label>
<label>Analytics Port<input data-path="analytics.port" type="number" min="1" max="65535"></label>
<label>Refresh Minuten<input data-path="analytics.refresh" type="number" min="1"></label>
<label>Zeitraum Tage<input data-path="analytics.days_ago" type="number" min="1"></label>
</div>
<div class="checks">
<label><input data-path="miner.claim_drops_startup" type="checkbox"> Drops beim Start claimen</label>
<label><input data-path="miner.enable_analytics" type="checkbox"> Analytics aktivieren</label>
<label><input data-path="miner.disable_ssl_cert_verification" type="checkbox"> SSL-Prufung deaktivieren</label>
<label><input data-path="miner.disable_at_in_nickname" type="checkbox"> Chat-Mention ohne @ erkennen</label>
<label><input data-path="miner.followers" type="checkbox"> Twitch-Follows automatisch minen</label>
</div>
<div class="grid two">
<label>Follower-Reihenfolge<select data-path="miner.followers_order" data-options="followers_orders"></select></label>
<label>Blacklist, komma-getrennt<input id="blacklist"></label>
</div>
<div class="priority">
<h3>Globale Watch-Prioritat</h3>
<div id="priority-list" class="pill-list"></div>
<select id="priority-add" data-options="priorities"></select>
<button id="add-priority" type="button">Hinzufugen</button>
</div>
</section>
<section id="streamers">
<div class="section-head">
<div>
<h2>Kanale</h2>
<p>Links stehen importierte oder manuelle Kanale. Rechts steht die aktive Watch-Reihenfolge.</p>
</div>
<div class="inline-actions">
<button id="import-followers" type="button">Follows importieren</button>
<button id="select-all-visible" type="button" class="secondary">Sichtbare auswahlen</button>
<button id="clear-selected" type="button" class="secondary">Auswahl leeren</button>
</div>
</div>
<div class="streamer-board">
<div class="streamer-panel">
<div class="panel-head">
<h3>Verfugbare Kanale</h3>
<span id="available-count"></span>
</div>
<div class="inline-actions full">
<input id="streamer-name" placeholder="channelname">
<button id="add-streamer" type="button">Hinzufugen</button>
</div>
<input id="streamer-search" placeholder="Suchen">
<div id="available-streamers" class="available-list"></div>
</div>
<div class="streamer-panel">
<div class="panel-head">
<h3>Aktive Watch-Prioritat</h3>
<span id="selected-count"></span>
</div>
<div id="selected-streamers" class="selected-list"></div>
</div>
</div>
</section>
<section id="defaults">
<h2>Streamer Defaults</h2>
<div class="checks">
<label><input data-path="streamer_defaults.make_predictions" type="checkbox"> Predictions setzen</label>
<label><input data-path="streamer_defaults.follow_raid" type="checkbox"> Raids folgen</label>
<label><input data-path="streamer_defaults.claim_drops" type="checkbox"> Drops claimen</label>
<label><input data-path="streamer_defaults.claim_moments" type="checkbox"> Moments claimen</label>
<label><input data-path="streamer_defaults.watch_streak" type="checkbox"> Watch Streak priorisieren</label>
<label><input data-path="streamer_defaults.community_goals" type="checkbox"> Community Goals beitragen</label>
</div>
<label>Chat Presence<select data-path="streamer_defaults.chat" data-options="chat_presences"></select></label>
</section>
<section id="bets">
<h2>Prediction Settings</h2>
<div class="prediction-presets">
<button type="button" data-preset="off" class="secondary">Aus</button>
<button type="button" data-preset="conservative" class="secondary">Konservativ</button>
<button type="button" data-preset="balanced" class="secondary">Balanced</button>
<button type="button" data-preset="aggressive" class="secondary">Aggressiv</button>
</div>
<div id="prediction-summary" class="summary-line"></div>
<div class="grid four">
<label>Strategie<select data-path="streamer_defaults.bet.strategy" data-options="bet_strategies"></select></label>
<label>Prozent<input data-path="streamer_defaults.bet.percentage" type="number" min="0"></label>
<label>Smart Gap<input data-path="streamer_defaults.bet.percentage_gap" type="number" min="0"></label>
<label>Max Points<input data-path="streamer_defaults.bet.max_points" type="number" min="0"></label>
<label>Minimum Points<input data-path="streamer_defaults.bet.minimum_points" type="number" min="0"></label>
<label>Delay Mode<select data-path="streamer_defaults.bet.delay_mode" data-options="delay_modes"></select></label>
<label>Delay<input data-path="streamer_defaults.bet.delay" type="number" min="0" step="0.1"></label>
<label class="checkline"><input data-path="streamer_defaults.bet.stealth_mode" type="checkbox"> Stealth Mode</label>
</div>
<div class="filter">
<label class="checkline"><input data-path="streamer_defaults.bet.filter_condition.enabled" type="checkbox"> Filterbedingung aktiv</label>
<div class="grid three">
<label>Feld<select data-path="streamer_defaults.bet.filter_condition.by" data-options="outcome_keys"></select></label>
<label>Bedingung<select data-path="streamer_defaults.bet.filter_condition.where" data-options="filter_conditions"></select></label>
<label>Wert<input data-path="streamer_defaults.bet.filter_condition.value" type="number" step="0.1"></label>
</div>
</div>
</section>
<section id="notifications">
<h2>Notifier</h2>
<div id="notifier-tabs" class="tabs"></div>
<div id="notifier-panel"></div>
</section>
<section id="logger">
<h2>Logging</h2>
<div class="grid four">
<label>Console Level<select data-path="logger.console_level" data-options="log_levels"></select></label>
<label>File Level<select data-path="logger.file_level" data-options="log_levels"></select></label>
<label>Timezone<input data-path="logger.time_zone" placeholder="Europe/Berlin"></label>
<label>Online Farbe<select data-path="logger.color_palette.STREAMER_ONLINE" data-options="colors"></select></label>
<label>Offline Farbe<select data-path="logger.color_palette.STREAMER_OFFLINE" data-options="colors"></select></label>
<label>Bet-Win Farbe<select data-path="logger.color_palette.BET_WIN" data-options="colors"></select></label>
</div>
<div class="checks">
<label><input data-path="logger.save" type="checkbox"> Logs speichern</label>
<label><input data-path="logger.console_username" type="checkbox"> Username in Console Logs</label>
<label><input data-path="logger.auto_clear" type="checkbox"> Rotation 7 Tage</label>
<label><input data-path="logger.emoji" type="checkbox"> Emoji</label>
<label><input data-path="logger.less" type="checkbox"> Kurze Logs</label>
<label><input data-path="logger.colored" type="checkbox"> Farben</label>
</div>
</section>
<section id="advanced">
<h2>Advanced JSON</h2>
<p>Vollstandige Konfiguration fur seltene Optionen und pro-Kanal Overrides.</p>
<textarea id="raw-config" spellcheck="false"></textarea>
</section>
</main>
<script src="{{url_for('static', filename='config.js')}}"></script>
</body>
</html>
+615
View File
@@ -0,0 +1,615 @@
let config = null;
let meta = null;
let activeNotifier = "telegram";
let availableStreamers = [];
let draggedStreamerIndex = null;
const notifierFields = {
telegram: [
["chat_id", "Chat ID", "number"],
["token", "Bot Token", "password"],
["disable_notification", "Silent", "checkbox"],
],
discord: [["webhook_api", "Webhook URL", "text"]],
webhook: [
["endpoint", "Endpoint", "text"],
["method", "Methode", "select:GET,POST"],
],
matrix: [
["username", "Username", "text"],
["password", "Passwort", "password"],
["homeserver", "Homeserver", "text"],
["room_id", "Room ID", "text"],
],
pushover: [
["userkey", "User Key", "text"],
["token", "App Token", "password"],
["priority", "Priority", "number"],
["sound", "Sound", "text"],
],
gotify: [
["endpoint", "Endpoint", "text"],
["priority", "Priority", "number"],
],
};
const predictionPresets = {
off: { make_predictions: false },
conservative: {
make_predictions: true,
bet: {
strategy: "SMART",
percentage: 3,
percentage_gap: 25,
max_points: 2500,
minimum_points: 25000,
stealth_mode: true,
delay_mode: "FROM_END",
delay: 4,
filter_condition: { enabled: true, by: "total_users", where: "GTE", value: 25 },
},
},
balanced: {
make_predictions: true,
bet: {
strategy: "SMART_MONEY",
percentage: 7,
percentage_gap: 18,
max_points: 10000,
minimum_points: 20000,
stealth_mode: true,
delay_mode: "FROM_END",
delay: 3,
filter_condition: { enabled: true, by: "total_users", where: "GTE", value: 10 },
},
},
aggressive: {
make_predictions: true,
bet: {
strategy: "HIGH_ODDS",
percentage: 12,
percentage_gap: 10,
max_points: 50000,
minimum_points: 5000,
stealth_mode: false,
delay_mode: "FROM_END",
delay: 1,
filter_condition: { enabled: false, by: "total_users", where: "GTE", value: 1 },
},
},
};
document.addEventListener("DOMContentLoaded", async () => {
const [metaResponse, configResponse] = await Promise.all([
fetch("/api/meta"),
fetch("/api/config"),
]);
meta = await metaResponse.json();
config = await configResponse.json();
normalizeConfig();
availableStreamers = uniqueNames(config.streamers.map((streamer) => streamer.username));
hydrateOptions();
bindStaticInputs();
renderPriority();
renderStreamerBoard();
renderPredictionSummary();
renderNotifierTabs();
renderNotifierPanel();
syncRawConfig();
bindActions();
});
function normalizeConfig() {
config.streamers = (config.streamers || [])
.map((streamer) => typeof streamer === "string" ? { username: cleanName(streamer), enabled: true } : streamer)
.filter((streamer) => cleanName(streamer.username))
.map((streamer) => ({ ...streamer, username: cleanName(streamer.username), enabled: streamer.enabled !== false }));
}
function hydrateOptions() {
document.querySelectorAll("select[data-options]").forEach((select) => {
const values = meta[select.dataset.options] || [];
select.replaceChildren(...values.map((value) => option(value, value)));
});
}
function bindStaticInputs() {
document.querySelectorAll("[data-path]").forEach((inputElement) => {
setInputValue(inputElement, getPath(config, inputElement.dataset.path));
inputElement.addEventListener("input", () => {
setPath(config, inputElement.dataset.path, readInputValue(inputElement));
renderPredictionSummary();
syncRawConfig();
});
inputElement.addEventListener("change", () => {
setPath(config, inputElement.dataset.path, readInputValue(inputElement));
renderPredictionSummary();
syncRawConfig();
});
});
document.querySelector("#blacklist").value = (config.miner.blacklist || []).join(", ");
document.querySelector("#blacklist").addEventListener("input", (event) => {
config.miner.blacklist = splitNames(event.target.value);
syncRawConfig();
});
}
function bindActions() {
document.querySelector("#save").addEventListener("click", saveConfig);
document.querySelector("#restart").addEventListener("click", restartMiner);
document.querySelector("#add-streamer").addEventListener("click", () => {
addAvailableStreamer(document.querySelector("#streamer-name").value, true);
document.querySelector("#streamer-name").value = "";
});
document.querySelector("#streamer-name").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
addAvailableStreamer(event.target.value, true);
event.target.value = "";
}
});
document.querySelector("#streamer-search").addEventListener("input", renderAvailableStreamers);
document.querySelector("#import-followers").addEventListener("click", importFollowers);
document.querySelector("#select-all-visible").addEventListener("click", selectAllVisible);
document.querySelector("#clear-selected").addEventListener("click", () => {
config.streamers = [];
renderStreamerBoard();
syncRawConfig();
});
document.querySelector("#add-priority").addEventListener("click", () => {
config.miner.priority.push(document.querySelector("#priority-add").value);
renderPriority();
syncRawConfig();
});
document.querySelectorAll("[data-preset]").forEach((button) => {
button.addEventListener("click", () => applyPredictionPreset(button.dataset.preset));
});
}
function renderPriority() {
const target = document.querySelector("#priority-list");
target.replaceChildren(...config.miner.priority.map((name, index) => {
const row = element("div", "pill");
row.append(
text("strong", name),
smallButton("Up", () => move(config.miner.priority, index, index - 1, renderPriority)),
smallButton("Down", () => move(config.miner.priority, index, index + 1, renderPriority)),
smallButton("Remove", () => {
config.miner.priority.splice(index, 1);
renderPriority();
syncRawConfig();
}, "danger")
);
return row;
}));
}
function renderStreamerBoard() {
renderAvailableStreamers();
renderSelectedStreamers();
}
function renderAvailableStreamers() {
const target = document.querySelector("#available-streamers");
const selected = new Set(config.streamers.map((streamer) => streamer.username));
const search = cleanName(document.querySelector("#streamer-search").value);
const names = availableStreamers
.filter((name) => !search || name.includes(search))
.sort((a, b) => a.localeCompare(b));
document.querySelector("#available-count").textContent = `${names.length} sichtbar`;
target.replaceChildren(...names.map((name) => {
const checkbox = input("checkbox", selected.has(name));
checkbox.addEventListener("change", () => {
if (checkbox.checked) selectStreamer(name);
else removeStreamer(name);
});
const row = element("label", "available-row");
row.append(checkbox, text("span", name));
return row;
}));
}
function renderSelectedStreamers() {
const target = document.querySelector("#selected-streamers");
document.querySelector("#selected-count").textContent = `${config.streamers.length} aktiv`;
target.replaceChildren(...config.streamers.map((streamer, index) => selectedStreamerRow(streamer, index)));
}
function selectedStreamerRow(streamer, index) {
const row = element("div", "selected-row");
row.draggable = true;
row.dataset.index = index;
row.addEventListener("dragstart", () => {
draggedStreamerIndex = index;
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
draggedStreamerIndex = null;
row.classList.remove("dragging");
});
row.addEventListener("dragover", (event) => event.preventDefault());
row.addEventListener("drop", () => {
move(config.streamers, draggedStreamerIndex, index, renderStreamerBoard);
});
const handle = text("span", "Drag");
handle.className = "drag-handle";
const enabled = input("checkbox", streamer.enabled !== false);
enabled.addEventListener("change", () => {
streamer.enabled = enabled.checked;
syncRawConfig();
});
const title = text("strong", streamer.username);
const override = input("checkbox", Boolean(streamer.settings));
override.addEventListener("change", () => {
streamer.settings = override.checked ? deepClone(config.streamer_defaults) : undefined;
renderSelectedStreamers();
syncRawConfig();
});
row.append(
handle,
enabled,
title,
element("span", "spacer"),
checkLabel(override, "eigene Settings"),
smallButton("Up", () => move(config.streamers, index, index - 1, renderStreamerBoard)),
smallButton("Down", () => move(config.streamers, index, index + 1, renderStreamerBoard)),
smallButton("Remove", () => removeStreamer(streamer.username), "danger")
);
if (streamer.settings) row.append(streamerSettingsPanel(streamer));
return row;
}
function streamerSettingsPanel(streamer) {
const panel = element("div", "streamer-settings");
const flags = element("div", "checks compact");
[
["make_predictions", "Predictions"],
["follow_raid", "Raids"],
["claim_drops", "Drops"],
["claim_moments", "Moments"],
["watch_streak", "Watch Streak"],
["community_goals", "Community Goals"],
].forEach(([key, label]) => flags.append(objectCheckbox(streamer.settings, key, label)));
const bet = streamer.settings.bet || {};
streamer.settings.bet = bet;
const betGrid = element("div", "grid four");
betGrid.append(
objectSelect(bet, "strategy", "Strategie", meta.bet_strategies),
objectNumber(bet, "percentage", "Prozent"),
objectNumber(bet, "max_points", "Max Points"),
objectNumber(bet, "minimum_points", "Min Points"),
objectCheckbox(bet, "stealth_mode", "Stealth"),
objectSelect(bet, "delay_mode", "Delay Mode", meta.delay_modes),
objectNumber(bet, "delay", "Delay"),
objectNumber(bet, "percentage_gap", "Smart Gap")
);
panel.append(flags, betGrid);
return panel;
}
function renderPredictionSummary() {
const defaults = config.streamer_defaults;
const bet = defaults.bet;
const summary = defaults.make_predictions
? `Aktiv: ${bet.strategy}, ${bet.percentage}% bis max ${bet.max_points}, Minimum ${bet.minimum_points}, Delay ${bet.delay_mode}/${bet.delay}s`
: "Predictions sind global deaktiviert.";
document.querySelector("#prediction-summary").textContent = summary;
}
function applyPredictionPreset(name) {
const preset = predictionPresets[name];
config.streamer_defaults = deepMerge(config.streamer_defaults, deepClone(preset));
document.querySelectorAll("[data-path]").forEach((inputElement) => {
setInputValue(inputElement, getPath(config, inputElement.dataset.path));
});
renderPredictionSummary();
syncRawConfig();
}
function renderNotifierTabs() {
const target = document.querySelector("#notifier-tabs");
target.replaceChildren(...Object.keys(notifierFields).map((name) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = labelize(name);
button.className = name === activeNotifier ? "active" : "";
button.addEventListener("click", () => {
activeNotifier = name;
renderNotifierTabs();
renderNotifierPanel();
});
return button;
}));
}
function renderNotifierPanel() {
const notifier = config.notifications[activeNotifier];
const panel = document.querySelector("#notifier-panel");
const box = element("div", "notifier-box");
const enabled = input("checkbox", notifier.enabled);
enabled.addEventListener("change", () => {
notifier.enabled = enabled.checked;
syncRawConfig();
});
box.append(checkLabel(enabled, `${labelize(activeNotifier)} aktiv`));
const fields = element("div", "grid two");
notifierFields[activeNotifier].forEach(([key, label, type]) => fields.append(fieldFor(notifier, key, label, type)));
box.append(fields, eventPicker(notifier));
panel.replaceChildren(box);
}
function fieldFor(object, key, label, type) {
const wrapper = document.createElement("label");
wrapper.append(document.createTextNode(label));
if (type.startsWith("select:")) {
const select = document.createElement("select");
type.slice(7).split(",").forEach((value) => select.append(option(value, value)));
select.value = object[key] || "";
select.addEventListener("change", () => {
object[key] = select.value;
syncRawConfig();
});
wrapper.append(select);
return wrapper;
}
const control = input(type, object[key]);
control.addEventListener("input", () => {
object[key] = readInputValue(control);
syncRawConfig();
});
control.addEventListener("change", () => {
object[key] = readInputValue(control);
syncRawConfig();
});
wrapper.append(control);
return wrapper;
}
function eventPicker(notifier) {
const wrap = element("div", "events");
const selected = new Set(notifier.events || []);
meta.events.forEach((eventName) => {
const checkbox = input("checkbox", selected.has(eventName));
checkbox.addEventListener("change", () => {
if (checkbox.checked) selected.add(eventName);
else selected.delete(eventName);
notifier.events = [...selected];
syncRawConfig();
});
wrap.append(checkLabel(checkbox, eventName));
});
return wrap;
}
function addAvailableStreamer(value, selectAfterAdd = false) {
const username = cleanName(value);
if (!username) return;
if (!availableStreamers.includes(username)) availableStreamers.push(username);
if (selectAfterAdd) selectStreamer(username);
renderStreamerBoard();
syncRawConfig();
}
function selectStreamer(name) {
const username = cleanName(name);
if (!config.streamers.some((streamer) => streamer.username === username)) {
config.streamers.push({ username, enabled: true });
}
renderStreamerBoard();
syncRawConfig();
}
function removeStreamer(name) {
const username = cleanName(name);
config.streamers = config.streamers.filter((streamer) => streamer.username !== username);
renderStreamerBoard();
syncRawConfig();
}
function selectAllVisible() {
document.querySelectorAll("#available-streamers input[type='checkbox']").forEach((checkbox) => {
if (!checkbox.checked) checkbox.click();
});
}
async function importFollowers() {
showNotice("Import lauft...");
const response = await fetch("/api/followers/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ followers_order: config.miner.followers_order, limit: 100 }),
});
const data = await response.json();
if (!response.ok) {
showNotice(data.error || "Follower konnten nicht importiert werden.");
return;
}
const before = availableStreamers.length;
availableStreamers = uniqueNames([...availableStreamers, ...data.followers]);
renderStreamerBoard();
syncRawConfig();
showNotice(`${availableStreamers.length - before} neue Kanale importiert. Hak sie links an und sortiere sie rechts.`);
}
async function saveConfig() {
try {
config = JSON.parse(document.querySelector("#raw-config").value);
normalizeConfig();
} catch (error) {
showNotice(`JSON ist ungultig: ${error.message}`);
return;
}
const response = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
config = await response.json();
normalizeConfig();
availableStreamers = uniqueNames([...availableStreamers, ...config.streamers.map((streamer) => streamer.username)]);
renderStreamerBoard();
renderPredictionSummary();
syncRawConfig();
showNotice("Gespeichert. Neustart aktiviert die Anderungen.");
}
async function restartMiner() {
await fetch("/api/restart", { method: "POST" });
showNotice("Neustart angefordert. Die Seite ist gleich wieder erreichbar.");
}
function objectCheckbox(object, key, label) {
const checkbox = input("checkbox", object[key]);
checkbox.addEventListener("change", () => {
object[key] = checkbox.checked;
syncRawConfig();
});
return checkLabel(checkbox, label);
}
function objectNumber(object, key, label) {
return objectField(object, key, label, "number");
}
function objectSelect(object, key, label, values) {
const wrapper = document.createElement("label");
wrapper.append(document.createTextNode(label));
const select = document.createElement("select");
values.forEach((value) => select.append(option(value, value)));
select.value = object[key] || values[0];
select.addEventListener("change", () => {
object[key] = select.value;
syncRawConfig();
});
wrapper.append(select);
return wrapper;
}
function objectField(object, key, label, type) {
const wrapper = document.createElement("label");
wrapper.append(document.createTextNode(label));
const control = input(type, object[key]);
control.addEventListener("input", () => {
object[key] = readInputValue(control);
syncRawConfig();
});
wrapper.append(control);
return wrapper;
}
function getPath(object, path) {
return path.split(".").reduce((current, key) => current?.[key], object);
}
function setPath(object, path, value) {
const keys = path.split(".");
const last = keys.pop();
const target = keys.reduce((current, key) => current[key], object);
target[last] = value;
}
function setInputValue(inputElement, value) {
if (inputElement.type === "checkbox") inputElement.checked = Boolean(value);
else inputElement.value = value ?? "";
}
function readInputValue(inputElement) {
if (inputElement.type === "checkbox") return inputElement.checked;
if (inputElement.type === "number") return inputElement.value === "" ? "" : Number(inputElement.value);
return inputElement.value;
}
function splitNames(value) {
return value.split(",").map(cleanName).filter(Boolean);
}
function cleanName(value) {
return String(value || "").trim().toLowerCase().replace(/^@/, "");
}
function uniqueNames(values) {
return [...new Set(values.map(cleanName).filter(Boolean))];
}
function move(array, from, to, callback) {
if (from === null || from === undefined || to < 0 || to >= array.length) return;
const [item] = array.splice(from, 1);
array.splice(to, 0, item);
callback();
syncRawConfig();
}
function option(value, label) {
const item = document.createElement("option");
item.value = value;
item.textContent = label;
return item;
}
function input(type, value) {
const control = document.createElement("input");
control.type = type;
setInputValue(control, value);
return control;
}
function smallButton(label, onClick, className = "secondary") {
const button = document.createElement("button");
button.type = "button";
button.className = className;
button.textContent = label;
button.addEventListener("click", onClick);
return button;
}
function element(tag, className) {
const node = document.createElement(tag);
if (className) node.className = className;
return node;
}
function text(tag, content) {
const node = document.createElement(tag);
node.textContent = content;
return node;
}
function checkLabel(control, label) {
const wrapper = document.createElement("label");
wrapper.className = "checkline";
wrapper.append(control, document.createTextNode(label));
return wrapper;
}
function labelize(value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function showNotice(message) {
const notice = document.querySelector("#notice");
notice.textContent = message;
notice.classList.remove("hidden");
}
function syncRawConfig() {
const raw = document.querySelector("#raw-config");
if (raw) raw.value = JSON.stringify(config, null, 2);
}
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function deepMerge(base, override) {
const result = deepClone(base);
Object.entries(override).forEach(([key, value]) => {
if (value && typeof value === "object" && !Array.isArray(value) && result[key]) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
});
return result;
}
+111
View File
@@ -0,0 +1,111 @@
{
"account": {
"username": "your-twitch-username",
"password": ""
},
"miner": {
"claim_drops_startup": false,
"enable_analytics": true,
"disable_ssl_cert_verification": false,
"disable_at_in_nickname": false,
"priority": ["STREAK", "DROPS", "ORDER"],
"followers": false,
"followers_order": "ASC",
"blacklist": []
},
"analytics": {
"host": "0.0.0.0",
"port": 5000,
"refresh": 5,
"days_ago": 7
},
"logger": {
"save": true,
"console_level": "INFO",
"console_username": false,
"auto_clear": true,
"time_zone": "",
"file_level": "INFO",
"emoji": true,
"less": true,
"colored": true,
"color_palette": {
"STREAMER_ONLINE": "GREEN",
"STREAMER_OFFLINE": "RED",
"BET_WIN": "MAGENTA"
}
},
"notifications": {
"telegram": {
"enabled": false,
"chat_id": "",
"token": "",
"events": ["STREAMER_ONLINE", "STREAMER_OFFLINE", "CHAT_MENTION"],
"disable_notification": true
},
"discord": {
"enabled": false,
"webhook_api": "",
"events": ["STREAMER_ONLINE", "STREAMER_OFFLINE", "CHAT_MENTION"]
},
"webhook": {
"enabled": false,
"endpoint": "",
"method": "GET",
"events": []
},
"matrix": {
"enabled": false,
"username": "",
"password": "",
"homeserver": "matrix.org",
"room_id": "",
"events": []
},
"pushover": {
"enabled": false,
"userkey": "",
"token": "",
"priority": 0,
"sound": "pushover",
"events": []
},
"gotify": {
"enabled": false,
"endpoint": "",
"priority": 8,
"events": []
}
},
"streamer_defaults": {
"make_predictions": true,
"follow_raid": true,
"claim_drops": true,
"claim_moments": true,
"watch_streak": true,
"community_goals": false,
"chat": "ONLINE",
"bet": {
"strategy": "SMART",
"percentage": 5,
"percentage_gap": 20,
"max_points": 50000,
"minimum_points": 0,
"stealth_mode": false,
"delay_mode": "FROM_END",
"delay": 6,
"filter_condition": {
"enabled": false,
"by": "total_users",
"where": "GTE",
"value": 1
}
}
},
"streamers": [
{
"username": "streamer-username",
"enabled": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from TwitchChannelPointsMiner.classes.ConfigManager import build_runtime, load_config
config = load_config()
runtime = build_runtime(config)
twitch_miner = runtime["miner"]
if config["miner"]["enable_analytics"]:
analytics = runtime["analytics"]
twitch_miner.analytics(
host=analytics["host"],
port=int(analytics["port"]),
refresh=int(analytics["refresh"]),
days_ago=int(analytics["days_ago"]),
)
twitch_miner.mine(**runtime["mine"])