├── .dockerignore
├── .github
└── workflows
│ ├── frontend.yml
│ ├── image.yml
│ └── rust.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── app.dockerfile
├── app
├── Cargo.toml
├── diesel.toml
├── migrations
│ ├── .keep
│ ├── 2024-04-01-111509_create_streamers
│ │ ├── down.sql
│ │ └── up.sql
│ ├── 2024-04-01-111659_create_points
│ │ ├── down.sql
│ │ └── up.sql
│ └── 2024-04-06-102702_create_predictions
│ │ ├── down.sql
│ │ └── up.sql
└── src
│ ├── analytics
│ ├── mod.rs
│ ├── model.rs
│ └── schema.rs
│ ├── default_config.rs
│ ├── main.rs
│ ├── pubsub.rs
│ └── web_api
│ ├── analytics.rs
│ ├── config.rs
│ ├── mod.rs
│ ├── predictions.rs
│ └── streamer.rs
├── assets
├── tpm-ui-edit-config.png
├── tpm-ui-landing.png
├── tpm-ui-make-prediction.png
└── tpm-ui-setup.png
├── common
├── Cargo.toml
└── src
│ ├── config
│ ├── filters.rs
│ ├── mod.rs
│ └── strategy.rs
│ ├── lib.rs
│ ├── twitch
│ ├── api.rs
│ ├── auth.rs
│ ├── gql.rs
│ ├── mod.rs
│ └── ws.rs
│ └── types.rs
├── example.config.yaml
├── frontend
├── .gitignore
├── .prettierrc
├── bun.lockb
├── components.json
├── index.html
├── package.json
├── postcss.config.cjs
├── src
│ ├── App.svelte
│ ├── api.d.ts
│ ├── app.pcss
│ ├── common.ts
│ ├── lib
│ │ ├── components
│ │ │ └── ui
│ │ │ │ ├── Config.svelte
│ │ │ │ ├── ErrorAlert.svelte
│ │ │ │ ├── SortableList.svelte
│ │ │ │ ├── WatchPriority.svelte
│ │ │ │ ├── alert-dialog
│ │ │ │ ├── alert-dialog-action.svelte
│ │ │ │ ├── alert-dialog-cancel.svelte
│ │ │ │ ├── alert-dialog-content.svelte
│ │ │ │ ├── alert-dialog-description.svelte
│ │ │ │ ├── alert-dialog-footer.svelte
│ │ │ │ ├── alert-dialog-header.svelte
│ │ │ │ ├── alert-dialog-overlay.svelte
│ │ │ │ ├── alert-dialog-portal.svelte
│ │ │ │ ├── alert-dialog-title.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── alert
│ │ │ │ ├── alert-description.svelte
│ │ │ │ ├── alert-title.svelte
│ │ │ │ ├── alert.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── button
│ │ │ │ ├── button.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── card
│ │ │ │ ├── card-content.svelte
│ │ │ │ ├── card-description.svelte
│ │ │ │ ├── card-footer.svelte
│ │ │ │ ├── card-header.svelte
│ │ │ │ ├── card-title.svelte
│ │ │ │ ├── card.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── checkbox
│ │ │ │ ├── checkbox.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── dialog
│ │ │ │ ├── dialog-content.svelte
│ │ │ │ ├── dialog-description.svelte
│ │ │ │ ├── dialog-footer.svelte
│ │ │ │ ├── dialog-header.svelte
│ │ │ │ ├── dialog-overlay.svelte
│ │ │ │ ├── dialog-portal.svelte
│ │ │ │ ├── dialog-title.svelte
│ │ │ │ └── index.ts
│ │ │ │ ├── input
│ │ │ │ ├── index.ts
│ │ │ │ └── input.svelte
│ │ │ │ ├── label
│ │ │ │ ├── index.ts
│ │ │ │ └── label.svelte
│ │ │ │ ├── menubar
│ │ │ │ ├── index.ts
│ │ │ │ ├── menubar-checkbox-item.svelte
│ │ │ │ ├── menubar-content.svelte
│ │ │ │ ├── menubar-item.svelte
│ │ │ │ ├── menubar-label.svelte
│ │ │ │ ├── menubar-radio-item.svelte
│ │ │ │ ├── menubar-separator.svelte
│ │ │ │ ├── menubar-shortcut.svelte
│ │ │ │ ├── menubar-sub-content.svelte
│ │ │ │ ├── menubar-sub-trigger.svelte
│ │ │ │ ├── menubar-trigger.svelte
│ │ │ │ └── menubar.svelte
│ │ │ │ ├── popover
│ │ │ │ ├── index.ts
│ │ │ │ └── popover-content.svelte
│ │ │ │ ├── range-calendar
│ │ │ │ ├── index.ts
│ │ │ │ ├── range-calendar-cell.svelte
│ │ │ │ ├── range-calendar-day.svelte
│ │ │ │ ├── range-calendar-grid-body.svelte
│ │ │ │ ├── range-calendar-grid-head.svelte
│ │ │ │ ├── range-calendar-grid-row.svelte
│ │ │ │ ├── range-calendar-grid.svelte
│ │ │ │ ├── range-calendar-head-cell.svelte
│ │ │ │ ├── range-calendar-header.svelte
│ │ │ │ ├── range-calendar-heading.svelte
│ │ │ │ ├── range-calendar-months.svelte
│ │ │ │ ├── range-calendar-next-button.svelte
│ │ │ │ ├── range-calendar-prev-button.svelte
│ │ │ │ └── range-calendar.svelte
│ │ │ │ ├── scroll-area
│ │ │ │ ├── index.ts
│ │ │ │ ├── scroll-area-scrollbar.svelte
│ │ │ │ └── scroll-area.svelte
│ │ │ │ ├── select
│ │ │ │ ├── index.ts
│ │ │ │ ├── select-content.svelte
│ │ │ │ ├── select-item.svelte
│ │ │ │ ├── select-label.svelte
│ │ │ │ ├── select-separator.svelte
│ │ │ │ └── select-trigger.svelte
│ │ │ │ ├── separator
│ │ │ │ ├── index.ts
│ │ │ │ └── separator.svelte
│ │ │ │ ├── sonner
│ │ │ │ ├── index.ts
│ │ │ │ └── sonner.svelte
│ │ │ │ ├── switch
│ │ │ │ ├── index.ts
│ │ │ │ └── switch.svelte
│ │ │ │ └── table
│ │ │ │ ├── index.ts
│ │ │ │ ├── table-body.svelte
│ │ │ │ ├── table-caption.svelte
│ │ │ │ ├── table-cell.svelte
│ │ │ │ ├── table-checkbox.svelte
│ │ │ │ ├── table-footer.svelte
│ │ │ │ ├── table-head.svelte
│ │ │ │ ├── table-header.svelte
│ │ │ │ ├── table-row.svelte
│ │ │ │ └── table.svelte
│ │ └── utils.ts
│ ├── main.ts
│ ├── routes
│ │ ├── Logs.svelte
│ │ ├── Points.svelte
│ │ ├── Predictions.svelte
│ │ └── Setup.svelte
│ ├── strategy
│ │ ├── DetailedStrategy.svelte
│ │ └── strategy.ts
│ └── vite-env.d.ts
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── mock.dockerfile
└── mock
├── Cargo.toml
└── src
└── main.rs
/.dockerignore:
--------------------------------------------------------------------------------
1 | target
2 | *.env
3 | .cargo
4 | .vscode
5 | config.yaml
6 | tokens.json
7 | *.db
8 | node_modules
9 | dist
10 | tailscale
11 | twitch-points-miner.log
--------------------------------------------------------------------------------
/.github/workflows/frontend.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | pull_request:
5 | branches: [ "master" ]
6 | defaults:
7 | run:
8 | working-directory: ./frontend
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: oven-sh/setup-bun@v1
15 | with:
16 | bun-version: latest
17 | - run: bun install
18 | working-directory: frontend
19 | - run: bun run build
20 | working-directory: frontend
21 |
--------------------------------------------------------------------------------
/.github/workflows/image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | - uses: SebRollen/toml-action@v1.2.0
13 | id: read_version
14 | with:
15 | file: 'app/Cargo.toml'
16 | field: 'package.version'
17 | - name: Build the Docker image
18 | run: docker build . --file app.dockerfile --tag t348575/twitch-points-miner:${{ steps.read_version.outputs.value }}
19 | - name: Tag latest
20 | run: docker tag t348575/twitch-points-miner:${{ steps.read_version.outputs.value }} t348575/twitch-points-miner:latest
21 | - name: Login to docker
22 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
23 | with:
24 | username: ${{ secrets.DOCKER_USERNAME }}
25 | password: ${{ secrets.DOCKER_PASSWORD }}
26 | - name: Push image version
27 | run: docker push t348575/twitch-points-miner:${{ steps.read_version.outputs.value }}
28 | - name: Push latest image
29 | run: docker push t348575/twitch-points-miner:latest
--------------------------------------------------------------------------------
/.github/workflows/rust.yml:
--------------------------------------------------------------------------------
1 | name: Rust
2 |
3 | on:
4 | pull_request:
5 | branches: [ "master" ]
6 |
7 | env:
8 | CARGO_TERM_COLOR: always
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - name: Build
16 | run: cargo build
17 | - name: Run tests for common
18 | run: cargo test -p common --features testing
19 | - name: Run tests for app
20 | run: cargo test -p twitch-points-miner
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target
2 | *.env
3 | .cargo
4 | .vscode
5 | config.yaml
6 | tokens.json
7 | *.db
8 | node_modules
9 | dist
10 | tailscale
11 | twitch-points-miner.log
12 | temp
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # v0.1.11
2 | * Fix various UI bugs on prediction page
3 | * Fix runtime blocking bug
4 |
5 | # v0.1.10
6 | * Removed `analytics_db` from config file to argument `analytics_db`
7 | * Rename `log_to_file` argument to `log_file` and accepts path to the log file
8 | * Add websocket pooling, increasing maximum streamers to around 250 (suggested, not definite)
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | members = [
3 | "app",
4 | "common",
5 | "mock"
6 | ]
7 | resolver = "2"
8 |
9 | [profile.release]
10 | opt-level = 3
11 | strip = true
12 | codegen-units = 1
13 | lto = true
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
twitch-points-miner
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | A very lightweight twitch points miner, using only a few MB of ram, inspired by Twitch-Channel-Points-Miner-v2.
13 |
14 |
15 | 
16 |
17 | ## Features
18 | * Web UI to interact with the app, and change configurations at runtime [screenshots](#Web-UI-screenshots)
19 | * Auto place bets on predictions
20 | * Watch stream to collect view points
21 | * Claim view point bonuses
22 | * Follow raids
23 | * REST API to manage app (Swagger docs at /docs)
24 | * Analytics logging all actions
25 |
26 | ## Configuration
27 | Check [example.config.yaml](example.config.yaml) for an example configuration.
28 |
29 | For a complete list of all configuration possibilities, check [common/src/config](common/src/config).
30 |
31 | Use the log level `info` for adequate information. Use `debug` for detailed logs, or if you feel a bug is present.
32 |
33 | ## Docker image
34 | This is the suggested way of using twitch-points-miner.
35 |
36 | Pull [t348575/twitch-points-miner](https://hub.docker.com/r/t348575/twitch-points-miner), be sure to pass your config file, and a volume for your `tokens.json`, as well as appropriate CLI arguments.
37 |
38 | Run with stdin attached the first time, in order to authenticate your twitch account.
39 | ```
40 | docker run -i -t -v ./data:/data t348575/twitch-points-miner --token /data/tokens.json
41 | ```
42 | Once it is running and the login flow is complete, CTRL+C then just attach the tokens file in subsequent runs.
43 |
44 | ## Docker compose
45 | An example docker compose file
46 | ```yaml
47 | services:
48 | twitch-points-miner:
49 | container_name: twitch-points-miner
50 | image: t348575/twitch-points-miner:latest
51 | volumes:
52 | - ./data:/data
53 | - ./config.yaml:/config.yaml # change this if needed to your config file
54 | command:
55 | - -t
56 | - /data/tokens.json
57 | - --analytics-db
58 | - /data/analytics.db
59 | - --log-file
60 | - /data/twitch-points-miner.log
61 | ports:
62 | - 3000:3000 # Web UI port
63 | environment:
64 | - LOG=info
65 | ```
66 |
67 | ## Windows
68 | Has not been tested on windows, but should work fine
69 |
70 | ## Building
71 | ```
72 | cargo build --release
73 | ```
74 |
75 | ## Web UI screenshots
76 | 
77 | 
78 | 
79 | 
80 |
--------------------------------------------------------------------------------
/app.dockerfile:
--------------------------------------------------------------------------------
1 | FROM oven/bun:slim as frontend
2 | WORKDIR /frontend
3 | COPY frontend /frontend
4 | RUN bun install
5 | RUN bun run build
6 |
7 | FROM t348575/muslrust-chef:1.77.1-stable as chef
8 | WORKDIR /tpm
9 |
10 | FROM chef as planner
11 | ADD app app
12 | ADD common common
13 | COPY ["Cargo.toml", "Cargo.lock", "."]
14 | RUN perl -0777 -i -pe 's/members = \[[^\]]+\]/members = ["app", "common"]/igs' Cargo.toml
15 | RUN cargo chef prepare --recipe-path recipe.json
16 |
17 | FROM chef as builder
18 | COPY --from=planner /tpm/recipe.json recipe.json
19 | ARG RUSTFLAGS='-C strip=symbols -C linker=clang -C link-arg=-fuse-ld=/usr/local/bin/mold'
20 | RUN RUSTFLAGS="$RUSTFLAGS" cargo chef cook --release --recipe-path recipe.json
21 | ADD app app
22 | ADD common common
23 | COPY ["Cargo.toml", "Cargo.lock", "."]
24 | RUN perl -0777 -i -pe 's/members = \[[^\]]+\]/members = ["app", "common"]/igs' Cargo.toml
25 | RUN RUSTFLAGS="$RUSTFLAGS" cargo build --release --target x86_64-unknown-linux-musl
26 |
27 | FROM scratch AS runtime
28 | COPY --from=frontend /dist /dist
29 | WORKDIR /
30 | ENV LOG=info
31 | COPY --from=builder /tpm/target/x86_64-unknown-linux-musl/release/twitch-points-miner /app
32 | EXPOSE 3000
33 | ENTRYPOINT ["/app"]
--------------------------------------------------------------------------------
/app/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "twitch-points-miner"
3 | version = "0.1.11"
4 | edition = "2021"
5 | resolver = "2"
6 |
7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 |
9 | [dependencies]
10 | serde = { version = "1", features = ["derive"] }
11 | serde_json = "1"
12 | tokio = { version = "1", features = ["full"] }
13 | indexmap = { version = "2.2", features = ["serde"] }
14 | tracing = "0.1"
15 | tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] }
16 | twitch_api = { features = ["tpm", "utoipa"], default-features = false, git = "https://github.com/t348575/twitch_api", branch = "hidden_pubsubs" }
17 | clap = { version = "4.5", features = ["derive", "std", "help"], default-features = false }
18 | eyre = "0.6"
19 | rand = "0.8"
20 | serde_yaml = "0.9"
21 | chrono = { version = "0.4", features = ["serde"] }
22 | axum = "0.7"
23 | libsqlite3-sys = { version = "0.28", features = ["bundled"], default-features = false }
24 | diesel = { version = "2", features = ["sqlite", "chrono"] }
25 | diesel_migrations = { version = "2", features = ["sqlite"] }
26 | thiserror = "1"
27 | utoipa = { version = "4", features = ["axum_extras", "chrono"] }
28 | utoipa-swagger-ui = { version = "6", features = ["axum"] }
29 | tower-http = { version = "0.5", features = ["trace", "cors", "fs"] }
30 | tracing-appender = "0.2"
31 | flume = "0.11"
32 | common = { path = "../common", features = ["web_api"] }
33 | http = "1.1.0"
34 | ansi-to-html = "0.2"
35 |
36 | [dev-dependencies]
37 | common = { path = "../common", features = ["web_api", "testing"] }
38 | rstest = "0.19"
39 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
40 |
41 | [[bin]]
42 | name = "twitch-points-miner"
43 | path = "src/main.rs"
44 |
45 | # [[bin]]
46 | # name = "default-config"
47 | # path = "src/default_config.rs"
48 |
--------------------------------------------------------------------------------
/app/diesel.toml:
--------------------------------------------------------------------------------
1 | # For documentation on how to configure this file,
2 | # see https://diesel.rs/guides/configuring-diesel-cli
3 |
4 | [print_schema]
5 | file = "src/analytics/schema.rs"
6 | custom_type_derives = ["diesel::query_builder::QueryId"]
7 |
8 | [migrations_directory]
9 | dir = "migrations"
10 |
--------------------------------------------------------------------------------
/app/migrations/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/app/migrations/.keep
--------------------------------------------------------------------------------
/app/migrations/2024-04-01-111509_create_streamers/down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE streamers;
--------------------------------------------------------------------------------
/app/migrations/2024-04-01-111509_create_streamers/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE streamers (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
3 | name TEXT NOT NULL
4 | );
--------------------------------------------------------------------------------
/app/migrations/2024-04-01-111659_create_points/down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE points;
--------------------------------------------------------------------------------
/app/migrations/2024-04-01-111659_create_points/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE points (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
3 | channel_id INTEGER NOT NULL,
4 | points_value INTEGER NOT NULL,
5 | points_info TEXT NOT NULL,
6 | created_at TIMESTAMP NOT NULL,
7 | FOREIGN KEY (channel_id)
8 | REFERENCES streamers (id)
9 | );
--------------------------------------------------------------------------------
/app/migrations/2024-04-06-102702_create_predictions/down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE predictions;
--------------------------------------------------------------------------------
/app/migrations/2024-04-06-102702_create_predictions/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE predictions (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
3 | channel_id INTEGER NOT NULL,
4 | prediction_id TEXT NOT NULL,
5 | title TEXT NOT NULL,
6 | prediction_window BIGINT NOT NULL,
7 | outcomes TEXT NOT NULL,
8 | winning_outcome_id TEXT,
9 | placed_bet TEXT NOT NULL,
10 | created_at TIMESTAMP NOT NULL,
11 | closed_at TIMESTAMP,
12 | FOREIGN KEY (channel_id)
13 | REFERENCES streamers (id)
14 | )
--------------------------------------------------------------------------------
/app/src/analytics/model.rs:
--------------------------------------------------------------------------------
1 | use chrono::NaiveDateTime;
2 | use diesel::{
3 | deserialize::FromSql,
4 | prelude::*,
5 | serialize::{IsNull, ToSql},
6 | sql_types::Text,
7 | sqlite::{Sqlite, SqliteValue},
8 | AsExpression, FromSqlRow,
9 | };
10 | use serde::{de::DeserializeOwned, Deserialize, Serialize};
11 |
12 | #[derive(
13 | Queryable, Identifiable, Selectable, Insertable, Debug, PartialEq, Clone, Serialize, Deserialize,
14 | )]
15 | #[diesel(table_name = super::schema::streamers, primary_key(id))]
16 | pub struct Streamer {
17 | pub id: i32,
18 | pub name: String,
19 | }
20 |
21 | #[derive(
22 | Queryable,
23 | Selectable,
24 | Insertable,
25 | Debug,
26 | PartialEq,
27 | Clone,
28 | Serialize,
29 | Deserialize,
30 | QueryableByName,
31 | utoipa::ToSchema,
32 | )]
33 | #[diesel(table_name = super::schema::points)]
34 | pub struct Point {
35 | pub channel_id: i32,
36 | pub points_value: i32,
37 | #[diesel(sql_type = Text)]
38 | pub points_info: PointsInfo,
39 | pub created_at: NaiveDateTime,
40 | }
41 |
42 | #[derive(
43 | Debug, Clone, Deserialize, Serialize, PartialEq, FromSqlRow, AsExpression, utoipa::ToSchema,
44 | )]
45 | #[diesel(sql_type = Text)]
46 | pub enum PointsInfo {
47 | FirstEntry,
48 | Watching,
49 | CommunityPointsClaimed,
50 | /// prediction event id
51 | Prediction(String, i32),
52 | }
53 |
54 | #[derive(
55 | Debug, Clone, Deserialize, Serialize, PartialEq, FromSqlRow, AsExpression, utoipa::ToSchema,
56 | )]
57 | #[diesel(sql_type = Text)]
58 | pub struct Outcomes(pub Vec);
59 |
60 | #[derive(
61 | Debug, Clone, Deserialize, Serialize, PartialEq, FromSqlRow, AsExpression, utoipa::ToSchema,
62 | )]
63 | #[diesel(sql_type = Text)]
64 | pub struct Outcome {
65 | pub id: String,
66 | pub title: String,
67 | pub total_points: i64,
68 | pub total_users: i64,
69 | }
70 |
71 | #[derive(
72 | Debug, Clone, Deserialize, Serialize, PartialEq, FromSqlRow, AsExpression, utoipa::ToSchema,
73 | )]
74 | #[diesel(sql_type = Text)]
75 | pub enum PredictionBetWrapper {
76 | None,
77 | Some(PredictionBet),
78 | }
79 |
80 | #[derive(
81 | Debug, Clone, Deserialize, Serialize, PartialEq, FromSqlRow, AsExpression, utoipa::ToSchema,
82 | )]
83 | #[diesel(sql_type = Text)]
84 | pub struct PredictionBet {
85 | pub outcome_id: String,
86 | pub points: u32,
87 | }
88 |
89 | #[derive(
90 | Queryable,
91 | Selectable,
92 | Insertable,
93 | Debug,
94 | PartialEq,
95 | Clone,
96 | Serialize,
97 | Deserialize,
98 | QueryableByName,
99 | utoipa::ToSchema,
100 | )]
101 | #[diesel(table_name = super::schema::predictions, primary_key(id))]
102 | pub struct Prediction {
103 | pub channel_id: i32,
104 | pub prediction_id: String,
105 | pub title: String,
106 | pub prediction_window: i64,
107 | #[diesel(sql_type = Text)]
108 | pub outcomes: Outcomes,
109 | pub winning_outcome_id: Option,
110 | #[diesel(sql_type = Text)]
111 | pub placed_bet: PredictionBetWrapper,
112 | pub created_at: NaiveDateTime,
113 | pub closed_at: Option,
114 | }
115 |
116 | impl From> for Outcomes {
117 | fn from(value: Vec) -> Self {
118 | Self(
119 | value
120 | .into_iter()
121 | .map(|x| Outcome {
122 | id: x.id,
123 | title: x.title,
124 | total_points: x.total_points,
125 | total_users: x.total_users,
126 | })
127 | .collect(),
128 | )
129 | }
130 | }
131 |
132 | pub fn from_sql(
133 | bytes: SqliteValue<'_, '_, '_>,
134 | ) -> diesel::deserialize::Result {
135 | let s: String = FromSql::::from_sql(bytes)?;
136 | Ok(serde_json::from_str(&s)?)
137 | }
138 |
139 | pub fn to_sql(
140 | data: &T,
141 | out: &mut diesel::serialize::Output<'_, '_, Sqlite>,
142 | ) -> diesel::serialize::Result {
143 | out.set_value(serde_json::to_string(&data)?);
144 | Ok(IsNull::No)
145 | }
146 |
147 | impl FromSql for PointsInfo {
148 | fn from_sql(bytes: SqliteValue<'_, '_, '_>) -> diesel::deserialize::Result {
149 | from_sql(bytes)
150 | }
151 | }
152 |
153 | impl ToSql for PointsInfo {
154 | fn to_sql<'b>(
155 | &'b self,
156 | out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
157 | ) -> diesel::serialize::Result {
158 | to_sql(self, out)
159 | }
160 | }
161 |
162 | impl FromSql for Outcomes {
163 | fn from_sql(bytes: SqliteValue<'_, '_, '_>) -> diesel::deserialize::Result {
164 | from_sql(bytes)
165 | }
166 | }
167 |
168 | impl ToSql for Outcomes {
169 | fn to_sql<'b>(
170 | &'b self,
171 | out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
172 | ) -> diesel::serialize::Result {
173 | to_sql(self, out)
174 | }
175 | }
176 |
177 | impl FromSql for PredictionBetWrapper {
178 | fn from_sql(bytes: SqliteValue<'_, '_, '_>) -> diesel::deserialize::Result {
179 | from_sql(bytes)
180 | }
181 | }
182 |
183 | impl ToSql for PredictionBetWrapper {
184 | fn to_sql<'b>(
185 | &'b self,
186 | out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
187 | ) -> diesel::serialize::Result {
188 | to_sql(self, out)
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/app/src/analytics/schema.rs:
--------------------------------------------------------------------------------
1 | // @generated automatically by Diesel CLI.
2 |
3 | diesel::table! {
4 | points (id) {
5 | id -> Integer,
6 | channel_id -> Integer,
7 | points_value -> Integer,
8 | points_info -> Text,
9 | created_at -> Timestamp,
10 | }
11 | }
12 |
13 | diesel::table! {
14 | predictions (id) {
15 | id -> Integer,
16 | channel_id -> Integer,
17 | prediction_id -> Text,
18 | title -> Text,
19 | prediction_window -> BigInt,
20 | outcomes -> Text,
21 | winning_outcome_id -> Nullable,
22 | placed_bet -> Text,
23 | created_at -> Timestamp,
24 | closed_at -> Nullable,
25 | }
26 | }
27 |
28 | diesel::table! {
29 | streamers (id) {
30 | id -> Integer,
31 | name -> Text,
32 | }
33 | }
34 |
35 | diesel::joinable!(points -> streamers (channel_id));
36 | diesel::joinable!(predictions -> streamers (channel_id));
37 |
38 | diesel::allow_tables_to_appear_in_same_query!(points, predictions, streamers,);
39 |
--------------------------------------------------------------------------------
/app/src/default_config.rs:
--------------------------------------------------------------------------------
1 | use std::fs;
2 |
3 | use config::{
4 | filters::Filter, strategy::*, Config, ConfigType, StreamerConfig
5 | };
6 | use indexmap::IndexMap;
7 |
8 | mod config;
9 | mod types;
10 |
11 | fn main() {
12 | let config = Config {
13 | watch_priority: Some(vec!["streamer_b".to_owned()]),
14 | analytics_db: "/my/custom/path/to/analytics".to_owned(),
15 | streamers: IndexMap::from([
16 | (
17 | "streamer_a".to_owned(),
18 | ConfigType::Specific(StreamerConfig {
19 | strategy: Strategy::Detailed(Detailed {
20 | detailed: Some(vec![
21 | DetailedOdds {
22 | _type: OddsComparisonType::Ge,
23 | threshold: 90.0,
24 | attempt_rate: 100.0,
25 | points: Points {
26 | max_value: 1000,
27 | percent: 1.0,
28 | },
29 | },
30 | DetailedOdds {
31 | _type: OddsComparisonType::Le,
32 | threshold: 10.0,
33 | attempt_rate: 1.0,
34 | points: Points {
35 | max_value: 1000,
36 | percent: 1.0,
37 | },
38 | },
39 | DetailedOdds {
40 | _type: OddsComparisonType::Ge,
41 | threshold: 70.0,
42 | attempt_rate: 100.0,
43 | points: Points {
44 | max_value: 5000,
45 | percent: 5.0,
46 | },
47 | },
48 | DetailedOdds {
49 | _type: OddsComparisonType::Le,
50 | threshold: 30.0,
51 | attempt_rate: 3.0,
52 | points: Points {
53 | max_value: 5000,
54 | percent: 5.0,
55 | },
56 | },
57 | ]),
58 | default: DefaultPrediction {
59 | max_percentage: 55.0,
60 | min_percentage: 45.0,
61 | points: Points {
62 | max_value: 100000,
63 | percent: 25.0,
64 | },
65 | },
66 | }),
67 | filters: vec![Filter::DelayPercentage(50.0), Filter::TotalUsers(300)],
68 | }),
69 | ),
70 | (
71 | "streamer_b".to_owned(),
72 | ConfigType::Preset("small".to_owned())
73 | )
74 | ]),
75 | presets: Some(IndexMap::from([
76 | (
77 | "small".to_owned(),
78 | StreamerConfig {
79 | strategy: Strategy::Detailed(Detailed::default()),
80 | filters: vec![],
81 | }
82 | )
83 | ])),
84 | };
85 |
86 | fs::write(
87 | "example.config.yaml",
88 | &serde_yaml::to_string(&config).unwrap(),
89 | )
90 | .unwrap();
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::path::Path;
2 | use std::sync::Arc;
3 |
4 | use clap::Parser;
5 | use common::twitch::ws::{Request, WsPool};
6 | use eyre::{eyre, Context, Result};
7 | use tokio::sync::RwLock;
8 | use tokio::{fs, spawn};
9 | use tracing::info;
10 | use tracing_subscriber::fmt::format::{Compact, DefaultFields};
11 | use tracing_subscriber::fmt::time::ChronoLocal;
12 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
13 | use twitch_api::pubsub::community_points::CommunityPointsUserV1;
14 | use twitch_api::pubsub::video_playback::{VideoPlaybackById, VideoPlaybackReply};
15 | use twitch_api::pubsub::{TopicData, Topics};
16 |
17 | use crate::analytics::{Analytics, AnalyticsWrapper};
18 |
19 | mod analytics;
20 | // mod live;
21 | mod pubsub;
22 | mod web_api;
23 |
24 | #[derive(Parser, Debug)]
25 | #[command(version, about, long_about = None)]
26 | struct Args {
27 | /// Config file
28 | #[arg(short, long, default_value_t = String::from("config.yaml"))]
29 | config: String,
30 | /// API address to bind
31 | #[arg(short, long, default_value_t = String::from("0.0.0.0:3000"))]
32 | address: String,
33 | /// Simulate predictions, don't actually make them
34 | #[arg(short, long, default_value_t = false)]
35 | simulate: bool,
36 | /// Token file
37 | #[arg(short, long, default_value_t = String::from("tokens.json"))]
38 | token: String,
39 | /// Log to file
40 | #[arg(short, long)]
41 | log_file: Option,
42 | /// Analytics database path
43 | #[arg(long, default_value_t = String::from("analytics.db"))]
44 | analytics_db: String,
45 | }
46 |
47 | const BASE_URL: &str = "https://twitch.tv";
48 |
49 | fn get_layer(
50 | layer: tracing_subscriber::fmt::Layer,
51 | ) -> tracing_subscriber::fmt::Layer<
52 | S,
53 | DefaultFields,
54 | tracing_subscriber::fmt::format::Format,
55 | > {
56 | layer
57 | .with_timer(ChronoLocal::new("%v %k:%M:%S %z".to_owned()))
58 | .compact()
59 | }
60 |
61 | #[tokio::main]
62 | async fn main() -> Result<()> {
63 | let args = Args::parse();
64 |
65 | let log_level = std::env::var("LOG").unwrap_or("warn".to_owned());
66 | let tracing_opts = tracing_subscriber::registry()
67 | .with(
68 | EnvFilter::new(format!("twitch_points_miner={log_level}"))
69 | .add_directive(format!("common={log_level}").parse()?)
70 | .add_directive(format!("tower_http::trace={log_level}").parse()?),
71 | )
72 | .with(get_layer(tracing_subscriber::fmt::layer()));
73 |
74 | let file_appender = tracing_appender::rolling::never(
75 | ".",
76 | args.log_file.clone().unwrap_or("log.log".to_owned()),
77 | );
78 | let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
79 | if args.log_file.is_some() {
80 | tracing_opts
81 | .with(get_layer(tracing_subscriber::fmt::layer()).with_writer(non_blocking))
82 | .init();
83 | } else {
84 | tracing_opts.init();
85 | }
86 |
87 | tracing::trace!("{args:#?}");
88 |
89 | if !Path::new(&args.token).exists() {
90 | info!("Starting login sequence");
91 | common::twitch::auth::login(&args.token).await?;
92 | }
93 |
94 | let mut c: common::config::Config = serde_yaml::from_str(
95 | &fs::read_to_string(&args.config)
96 | .await
97 | .context("Reading config file")?,
98 | )
99 | .context("Parsing config file")?;
100 | info!("Parsed config file");
101 |
102 | if c.streamers.is_empty() {
103 | return Err(eyre!("No streamers in config file"));
104 | }
105 |
106 | let c_original = c.clone();
107 | c.parse_and_validate()?;
108 |
109 | for item in c.watch_priority.clone().unwrap_or_default() {
110 | if !c.streamers.contains_key(&item) {
111 | return Err(eyre!(format!(
112 | "Channel in watch_priority not found in streamers list {item}"
113 | )));
114 | }
115 | }
116 |
117 | let token: common::twitch::auth::Token = serde_json::from_str(
118 | &fs::read_to_string(args.token)
119 | .await
120 | .context("Reading tokens file")?,
121 | )
122 | .context("Parsing tokens file")?;
123 | info!("Parsed tokens file");
124 |
125 | let gql = common::twitch::gql::Client::new(
126 | token.access_token.clone(),
127 | "https://gql.twitch.tv/gql".to_owned(),
128 | );
129 | let user_info = gql.get_user_id().await?;
130 | let streamer_names = c.streamers.keys().map(|s| s.as_str()).collect::>();
131 | let channels = gql
132 | .streamer_metadata(&streamer_names)
133 | .await
134 | .wrap_err_with(|| "Could not get streamer list. Is your token valid?")?;
135 | info!("Got streamer list");
136 |
137 | for (idx, id) in channels.iter().enumerate() {
138 | if id.is_none() {
139 | return Err(eyre!(format!("Channel not found {}", streamer_names[idx])));
140 | }
141 | }
142 |
143 | let (mut analytics, analytics_tx) = Analytics::new(&args.analytics_db)?;
144 |
145 | let channels = channels.into_iter().flatten().collect::>();
146 | let points = gql
147 | .get_channel_points(
148 | &channels
149 | .iter()
150 | .map(|x| x.1.channel_name.as_str())
151 | .collect::>(),
152 | )
153 | .await?;
154 |
155 | for (c, p) in channels.iter().zip(&points) {
156 | let id = c.0.as_str().parse::()?;
157 | let inserted = analytics.insert_streamer(id, c.1.channel_name.clone())?;
158 | if inserted {
159 | analytics.insert_points(id, p.0 as i32, analytics::model::PointsInfo::FirstEntry)?;
160 | }
161 | }
162 |
163 | let active_predictions = gql
164 | .channel_points_context(
165 | &channels
166 | .iter()
167 | .map(|x| x.1.channel_name.as_str())
168 | .collect::>(),
169 | )
170 | .await?;
171 |
172 | info!("Config OK!");
173 | let (ws_pool, ws_tx, (ws_data_tx, ws_rx)) = WsPool::start(
174 | &token.access_token,
175 | #[cfg(test)]
176 | String::new(),
177 | )
178 | .await;
179 |
180 | channels.iter().for_each(|x| {
181 | let channel_id = x.0.as_str().parse().unwrap();
182 |
183 | if x.1.live {
184 | // send initial live messages
185 | _ = ws_data_tx.send(TopicData::VideoPlaybackById {
186 | topic: VideoPlaybackById { channel_id },
187 | reply: Box::new(VideoPlaybackReply::StreamUp {
188 | server_time: 0.0,
189 | play_delay: 0,
190 | }),
191 | });
192 | }
193 |
194 | ws_tx
195 | .send(Request::Listen(Topics::VideoPlaybackById(
196 | VideoPlaybackById { channel_id },
197 | )))
198 | .expect("Could not add streamer to pubsub");
199 | });
200 | ws_tx
201 | .send_async(Request::Listen(Topics::CommunityPointsUserV1(
202 | CommunityPointsUserV1 {
203 | channel_id: user_info.0.parse().unwrap(),
204 | },
205 | )))
206 | .await
207 | .context("Could not add user to pubsub")?;
208 | // we definitely do not want to keep this in scope
209 | drop(ws_data_tx);
210 |
211 | let pubsub_data = Arc::new(RwLock::new(pubsub::PubSub::new(
212 | c_original,
213 | args.config,
214 | channels
215 | .clone()
216 | .into_iter()
217 | .zip(c.streamers.values())
218 | .collect(),
219 | points,
220 | active_predictions,
221 | c.presets.unwrap_or_default(),
222 | args.simulate,
223 | user_info,
224 | gql.clone(),
225 | BASE_URL,
226 | ws_tx,
227 | Arc::new(AnalyticsWrapper::new(analytics)),
228 | analytics_tx,
229 | )?));
230 |
231 | let pubsub = spawn(pubsub::PubSub::run(ws_rx, pubsub_data.clone(), gql));
232 |
233 | info!("Starting web api!");
234 |
235 | let axum_server = web_api::get_api_server(
236 | args.address,
237 | pubsub_data,
238 | Arc::new(token),
239 | &args.analytics_db,
240 | args.log_file,
241 | )
242 | .await?;
243 |
244 | axum_server.await?;
245 | pubsub.await??;
246 | ws_pool.await?;
247 |
248 | Ok(())
249 | }
250 |
--------------------------------------------------------------------------------
/app/src/web_api/analytics.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | use axum::{extract::State, routing::post, Json, Router};
4 | use chrono::{DateTime, FixedOffset};
5 | use serde::Deserialize;
6 | use utoipa::ToSchema;
7 |
8 | use crate::{
9 | analytics::{model::Outcome, AnalyticsWrapper, TimelineResult},
10 | make_paths,
11 | };
12 |
13 | use super::{ApiError, RouterBuild};
14 |
15 | pub fn build(analytics: Arc) -> RouterBuild {
16 | let routes = Router::new()
17 | .route("/timeline", post(points_timeline))
18 | .with_state(analytics);
19 |
20 | let schemas = vec![Outcome::schema(), Timeline::schema()];
21 |
22 | let paths = make_paths!(__path_points_timeline);
23 |
24 | (routes, schemas, paths)
25 | }
26 |
27 | #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
28 | /// Timeline information, RFC3339 strings
29 | struct Timeline {
30 | /// GE time
31 | from: String,
32 | /// LE time
33 | to: String,
34 | /// Channels
35 | channels: Vec,
36 | }
37 |
38 | #[utoipa::path(
39 | post,
40 | path = "/api/analytics/timeline",
41 | responses(
42 | (status = 200, description = "Timeline of point information in the specified range", body = Vec),
43 | ),
44 | request_body = Timeline
45 | )]
46 | async fn points_timeline(
47 | State(analytics): State>,
48 | axum::extract::Json(timeline): axum::extract::Json,
49 | ) -> Result>, ApiError> {
50 | let from = DateTime::from(DateTime::::parse_from_rfc3339(&timeline.from)?);
51 | let to = DateTime::from(DateTime::::parse_from_rfc3339(&timeline.to)?);
52 |
53 | let res = analytics
54 | .execute(|analytics| analytics.timeline(from, to, &timeline.channels))
55 | .await?;
56 | Ok(Json(res))
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/web_api/predictions.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | use axum::{
4 | extract::{Path, State},
5 | response::IntoResponse,
6 | routing::{get, post},
7 | Json, Router,
8 | };
9 | use common::twitch::gql;
10 | use eyre::{eyre, Context, ContextCompat};
11 | use flume::Sender;
12 | use http::StatusCode;
13 | use serde::Deserialize;
14 | use thiserror::Error;
15 | use tokio::sync::RwLockWriteGuard;
16 | use tracing::info;
17 | use utoipa::ToSchema;
18 |
19 | use crate::{
20 | analytics::{self, model::*, Analytics, AnalyticsError, AnalyticsWrapper, TimelineResult},
21 | pubsub::PubSub,
22 | };
23 | use crate::{make_paths, pubsub::prediction_logic, sub_error};
24 |
25 | use super::{ApiError, ApiState, RouterBuild, WebApiError};
26 |
27 | pub fn build(
28 | state: ApiState,
29 | analytics: Arc,
30 | tx: Sender,
31 | ) -> RouterBuild {
32 | let routes = Router::new()
33 | .route("/live", get(get_live_prediction))
34 | .route("/bet/:streamer", post(make_prediction))
35 | .with_state((state, analytics, tx));
36 |
37 | #[allow(unused_mut)]
38 | let mut schemas = vec![MakePrediction::schema()];
39 |
40 | schemas.extend(vec![
41 | Prediction::schema(),
42 | TimelineResult::schema(),
43 | Point::schema(),
44 | Outcomes::schema(),
45 | PointsInfo::schema(),
46 | PredictionBetWrapper::schema(),
47 | PredictionBet::schema(),
48 | ]);
49 |
50 | #[allow(unused_mut)]
51 | let mut paths = make_paths!(__path_make_prediction);
52 | paths.extend(make_paths!(__path_get_live_prediction));
53 |
54 | (routes, schemas, paths)
55 | }
56 |
57 | #[derive(Debug, Error)]
58 | pub enum PredictionError {
59 | #[error("Prediction does not exist")]
60 | PredictionNotFound,
61 | #[error("Outcome does not exist")]
62 | OutcomeNotFound,
63 | }
64 |
65 | impl WebApiError for PredictionError {
66 | fn make_response(&self) -> axum::response::Response {
67 | use PredictionError::*;
68 | let status_code = match self {
69 | OutcomeNotFound | PredictionNotFound => StatusCode::BAD_REQUEST,
70 | };
71 |
72 | (status_code, self.to_string()).into_response()
73 | }
74 | }
75 |
76 | #[derive(Debug, Deserialize, ToSchema)]
77 | struct MakePrediction {
78 | /// ID of the prediction
79 | event_id: String,
80 | /// If specified, a bet is forcefully placed, otherwise the prediction logic specified in the configuration is used
81 | points: Option,
82 | /// The outcome to place the bet on
83 | outcome_id: String,
84 | }
85 |
86 | #[utoipa::path(
87 | post,
88 | path = "/api/predictions/bet/{streamer}",
89 | responses(
90 | (status = 201, description = "Placed a bet", body = Points),
91 | (status = 202, description = "Did not place a bet, but no error occurred"),
92 | (status = 404, description = "Could not find streamer or event ID")
93 | ),
94 | params(
95 | ("streamer" = String, Path, description = "Name of streamer to get state for"),
96 | ),
97 | request_body = MakePrediction
98 | )]
99 | async fn make_prediction(
100 | State((data, _analytics, tx)): State<(
101 | ApiState,
102 | Arc,
103 | Sender,
104 | )>,
105 | Path(streamer): Path,
106 | Json(payload): Json,
107 | ) -> Result {
108 | let mut state = data.write().await;
109 | let simulate = state.simulate;
110 |
111 | let gql = state.gql.clone();
112 | let s = state.get_by_name(&streamer);
113 | if s.is_none() {
114 | return Err(ApiError::StreamerDoesNotExist);
115 | }
116 |
117 | let s_id = state.get_id_by_name(&streamer).unwrap().to_owned();
118 | let s = state.get_by_name_mut(&streamer).unwrap().clone();
119 |
120 | let prediction = s.predictions.get(&payload.event_id);
121 | if prediction.is_none() {
122 | return sub_error!(PredictionError::PredictionNotFound);
123 | }
124 |
125 | let (event, _) = prediction.unwrap().clone();
126 | if !event.outcomes.iter().any(|o| o.id == payload.outcome_id) {
127 | return sub_error!(PredictionError::OutcomeNotFound);
128 | }
129 | drop(state);
130 |
131 | let update_placed_state = |mut state: RwLockWriteGuard| {
132 | state
133 | .get_by_name_mut(&streamer)
134 | .context("Streamer not found")
135 | .unwrap()
136 | .predictions
137 | .get_mut(&payload.event_id)
138 | .unwrap()
139 | .1 = true;
140 | };
141 |
142 | if payload.points.is_some() && *payload.points.as_ref().unwrap() > 0 {
143 | place_bet(
144 | payload.event_id.clone(),
145 | payload.outcome_id,
146 | *payload.points.as_ref().unwrap(),
147 | simulate,
148 | &streamer,
149 | &gql,
150 | &s_id,
151 | tx,
152 | )
153 | .await?;
154 | update_placed_state(data.write().await);
155 | Ok(StatusCode::CREATED)
156 | } else {
157 | match prediction_logic(&s, &payload.event_id) {
158 | Ok(Some((o, p))) => {
159 | place_bet(
160 | payload.event_id.clone(),
161 | o,
162 | p,
163 | simulate,
164 | &streamer,
165 | &gql,
166 | &s_id,
167 | tx,
168 | )
169 | .await?;
170 | update_placed_state(data.write().await);
171 | Ok(StatusCode::CREATED)
172 | }
173 | Ok(None) => Ok(StatusCode::ACCEPTED),
174 | Err(err) => Err(ApiError::internal_error(err)),
175 | }
176 | }
177 | }
178 |
179 | async fn place_bet(
180 | event_id: String,
181 | outcome_id: String,
182 | points: u32,
183 | simulate: bool,
184 | streamer_name: &str,
185 | gql: &gql::Client,
186 | streamer_id: &str,
187 | tx: Sender,
188 | ) -> Result<(), ApiError> {
189 | info!(
190 | "{}: predicting {}, with points {}",
191 | streamer_name, event_id, points
192 | );
193 |
194 | gql.make_prediction(points, &event_id, &outcome_id, simulate)
195 | .await
196 | .map_err(ApiError::twitch_api_error)?;
197 |
198 | let channel_id = streamer_id
199 | .parse::()
200 | .context("Could not parse streamer ID")?;
201 | let channel_points = gql
202 | .get_channel_points(&[streamer_name])
203 | .await
204 | .map_err(ApiError::twitch_api_error)?;
205 |
206 | tx.send_async(Box::new(
207 | move |analytics: &mut Analytics| -> Result<(), AnalyticsError> {
208 | let entry_id = analytics.last_prediction_id(channel_id, &event_id)?;
209 | analytics.insert_points(
210 | channel_id,
211 | channel_points[0].0 as i32,
212 | PointsInfo::Prediction(event_id.clone(), entry_id),
213 | )?;
214 | analytics.place_bet(&event_id, channel_id, &outcome_id, points)
215 | },
216 | ))
217 | .await
218 | .map_err(|_| eyre!("Could not send analytics request"))?;
219 | Ok(())
220 | }
221 |
222 | #[derive(Deserialize, ToSchema, utoipa::IntoParams)]
223 | struct GetPredictionQuery {
224 | prediction_id: String,
225 | channel_id: i32,
226 | }
227 |
228 | #[utoipa::path(
229 | get,
230 | path = "/api/predictions/live",
231 | responses(
232 | (status = 200, description = "Get live prediction", body = Option),
233 | ),
234 | params(GetPredictionQuery)
235 | )]
236 | async fn get_live_prediction(
237 | axum::extract::Query(query): axum::extract::Query,
238 | State(state): State<(ApiState, Arc, Sender)>,
239 | ) -> Result>, ApiError> {
240 | let res = state
241 | .1
242 | .execute(|analytics| analytics.get_live_prediction(query.channel_id, &query.prediction_id))
243 | .await?;
244 | Ok(Json(res))
245 | }
246 |
--------------------------------------------------------------------------------
/app/src/web_api/streamer.rs:
--------------------------------------------------------------------------------
1 | use std::{collections::HashMap, sync::Arc, time::Instant};
2 |
3 | use axum::{
4 | extract::{Path, State},
5 | response::IntoResponse,
6 | routing::{delete, get, put},
7 | Extension, Json, Router,
8 | };
9 |
10 | use common::{
11 | config::ConfigType,
12 | twitch::{auth::Token, gql, ws},
13 | types::*,
14 | };
15 | use eyre::Context;
16 | use http::StatusCode;
17 | use serde::{Deserialize, Serialize};
18 | use thiserror::Error;
19 | use twitch_api::{pubsub::predictions::Event, types::UserId};
20 | use utoipa::ToSchema;
21 |
22 | use crate::{make_paths, sub_error};
23 |
24 | use super::{ApiError, ApiState, RouterBuild, WebApiError};
25 |
26 | pub fn build(state: ApiState, token: Arc) -> RouterBuild {
27 | let routes = Router::new()
28 | .route("/live", get(live_streamers))
29 | .route("/mine/:streamer", put(mine_streamer))
30 | .route("/mine/:streamer/", delete(remove_streamer))
31 | .route("/:streamer", get(streamer))
32 | .layer(Extension(token))
33 | .with_state(state);
34 |
35 | let schemas = vec![
36 | MineStreamer::schema(),
37 | ConfigType::schema(),
38 | LiveStreamer::schema(),
39 | ];
40 |
41 | let paths = make_paths!(
42 | __path_streamer,
43 | __path_live_streamers,
44 | __path_mine_streamer,
45 | __path_remove_streamer
46 | );
47 |
48 | (routes, schemas, paths)
49 | }
50 |
51 | #[derive(Debug, Error)]
52 | pub enum StreamerError {
53 | #[error("Streamer is already being mined")]
54 | StreamerAlreadyMined,
55 | }
56 |
57 | impl WebApiError for StreamerError {
58 | fn make_response(&self) -> axum::response::Response {
59 | use StreamerError::*;
60 | let status_code = match self {
61 | StreamerAlreadyMined => StatusCode::CONFLICT,
62 | };
63 |
64 | (status_code, self.to_string()).into_response()
65 | }
66 | }
67 |
68 | #[utoipa::path(
69 | get,
70 | path = "/api/streamers/{streamer}",
71 | responses(
72 | (status = 200, description = "Get the entire application state information", body = [StreamerState]),
73 | (status = 404, description = "Could not find streamer")
74 | ),
75 | params(
76 | ("streamer" = String, Path, description = "Name of streamer to get state for")
77 | )
78 | )]
79 | async fn streamer(State(data): State, Path(streamer): Path) -> impl IntoResponse {
80 | let data = data.read().await;
81 | match data.get_by_name(streamer.as_str()) {
82 | Some(s) => Json(s.clone()).into_response(),
83 | None => (StatusCode::NOT_FOUND, "Streamer not found").into_response(),
84 | }
85 | }
86 |
87 | #[derive(Serialize, ToSchema)]
88 | struct LiveStreamer {
89 | id: i32,
90 | state: StreamerState,
91 | }
92 |
93 | #[utoipa::path(
94 | get,
95 | path = "/api/streamers/live",
96 | responses(
97 | (status = 200, description = "List of live streamers and their state", body = Vec)
98 | )
99 | )]
100 | async fn live_streamers(State(data): State) -> Json> {
101 | let data = data.read().await;
102 | let items = data
103 | .streamers
104 | .iter()
105 | .filter(|x| x.1.info.live)
106 | .map(|x| LiveStreamer {
107 | id: x.0.as_str().parse().unwrap(),
108 | state: x.1.clone(),
109 | })
110 | .collect::>();
111 | Json(items)
112 | }
113 |
114 | #[derive(Deserialize, ToSchema)]
115 | struct MineStreamer {
116 | config: ConfigType,
117 | }
118 |
119 | #[utoipa::path(
120 | put,
121 | path = "/api/streamers/mine/{channel_name}",
122 | responses(
123 | (status = 200, description = "Add streamer to mine", body = ()),
124 | ),
125 | params(
126 | ("channel_name" = String, Path, description = "Name of streamer to watch")
127 | ),
128 | request_body = MineStreamer
129 | )]
130 | async fn mine_streamer(
131 | State(data): State,
132 | Path(channel_name): Path,
133 | Json(payload): Json,
134 | ) -> Result<(), ApiError> {
135 | let mut writer = data.write().await;
136 | let res = writer
137 | .gql
138 | .streamer_metadata(&[&channel_name])
139 | .await
140 | .map_err(ApiError::twitch_api_error)?;
141 | if res.is_empty() || (!res.is_empty() && res[0].is_none()) {
142 | return Err(ApiError::StreamerDoesNotExist);
143 | }
144 |
145 | if writer
146 | .streamers
147 | .contains_key(&UserId::from(channel_name.clone()))
148 | {
149 | return sub_error!(StreamerError::StreamerAlreadyMined);
150 | }
151 |
152 | let config = writer.insert_config(&payload.config, &channel_name)?;
153 |
154 | let streamer = res[0].clone().unwrap();
155 | async fn rollback_steps(
156 | channel_name: &str,
157 | gql: &gql::Client,
158 | ) -> Result<(u32, Vec<(Event, bool)>), ApiError> {
159 | let points = gql
160 | .get_channel_points(&[channel_name])
161 | .await
162 | .map_err(ApiError::twitch_api_error)?[0]
163 | .0;
164 | let active_predictions = gql
165 | .channel_points_context(&[channel_name])
166 | .await
167 | .map_err(ApiError::twitch_api_error)?[0]
168 | .clone();
169 | Ok((points, active_predictions))
170 | }
171 |
172 | // rollback if any config was added, and an error occurred after
173 | let (points, active_predictions) = match rollback_steps(&channel_name, &writer.gql).await {
174 | Ok(s) => s,
175 | Err(err) => {
176 | if let ConfigType::Specific(_) = &payload.config {
177 | writer.configs.remove(&channel_name);
178 | }
179 | return Err(err);
180 | }
181 | };
182 |
183 | writer.config.streamers.insert(channel_name, payload.config);
184 | writer.streamers.insert(
185 | streamer.0.clone(),
186 | StreamerState {
187 | config,
188 | info: streamer.1.clone(),
189 | predictions: active_predictions
190 | .into_iter()
191 | .map(|x| (x.0.channel_id.clone(), x))
192 | .collect::>(),
193 | points,
194 | last_points_refresh: Instant::now(),
195 | },
196 | );
197 |
198 | writer.save_config("Mine streamer").await?;
199 | ws::add_streamer(&writer.ws_tx, streamer.0.as_str().parse().unwrap())
200 | .await
201 | .context("Add streamer to pubsub")
202 | .map_err(ApiError::internal_error)?;
203 |
204 | let id = streamer
205 | .0
206 | .as_str()
207 | .parse()
208 | .context("Parse streamer id")
209 | .map_err(ApiError::internal_error)?;
210 | let inserted = writer
211 | .analytics
212 | .execute(|analytics| analytics.insert_streamer(id, streamer.1.channel_name))
213 | .await?;
214 | if inserted {
215 | writer
216 | .analytics
217 | .execute(|analytics| {
218 | analytics.insert_points(
219 | id,
220 | points as i32,
221 | crate::analytics::model::PointsInfo::FirstEntry,
222 | )
223 | })
224 | .await?;
225 | }
226 |
227 | Ok(())
228 | }
229 |
230 | #[utoipa::path(
231 | delete,
232 | path = "/api/streamers/mine/{channel_name}/",
233 | responses(
234 | (status = 200, description = "Successfully removed streamer from the mine list"),
235 | (status = 404, description = "Could not find streamer")
236 | ),
237 | params(
238 | ("channel_name" = String, Path, description = "Name of streamer to delete")
239 | )
240 | )]
241 | async fn remove_streamer(
242 | State(data): State,
243 | Path(channel_name): Path,
244 | ) -> Result<(), ApiError> {
245 | let mut writer = data.write().await;
246 |
247 | let id = match writer.get_id_by_name(&channel_name) {
248 | Some(s) => UserId::from(s.to_owned()),
249 | None => return Err(ApiError::StreamerDoesNotExist),
250 | };
251 |
252 | writer.streamers.remove(&id);
253 | writer.config.streamers.shift_remove(&channel_name);
254 | writer.configs.remove(&channel_name);
255 |
256 | writer.save_config("Remove streamer").await?;
257 | ws::remove_streamer(&writer.ws_tx, id.as_str().parse().unwrap())
258 | .await
259 | .context("Remove streamer from pubsub")?;
260 | Ok(())
261 | }
262 |
--------------------------------------------------------------------------------
/assets/tpm-ui-edit-config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/assets/tpm-ui-edit-config.png
--------------------------------------------------------------------------------
/assets/tpm-ui-landing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/assets/tpm-ui-landing.png
--------------------------------------------------------------------------------
/assets/tpm-ui-make-prediction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/assets/tpm-ui-make-prediction.png
--------------------------------------------------------------------------------
/assets/tpm-ui-setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/assets/tpm-ui-setup.png
--------------------------------------------------------------------------------
/common/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "common"
3 | version = "0.1.11"
4 | edition = "2021"
5 |
6 | [lib]
7 | doctest = false
8 |
9 | [dependencies]
10 | validator = { version = "0.17", features = ["derive"], git = "https://github.com/Keats/validator", rev = "1dd03ed" }
11 | twitch_api = { features = ["tpm"], default-features = false, git = "https://github.com/t348575/twitch_api", branch = "hidden_pubsubs" }
12 | serde = { version = "1", features = ["derive"] }
13 | chrono = "0.4"
14 | indexmap = { version = "2.2", features = ["serde"] }
15 | eyre = "0.6"
16 | utoipa = { version = "4", features = ["chrono"], optional = true }
17 | base64 = { version = "0.22", default-features = false }
18 | flume = "0.11"
19 | serde_json = "1"
20 | tokio = { version = "1", features = ["full"] }
21 | tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
22 | strum_macros = "0.26"
23 | rand = "0.8"
24 | tracing = { version = "0.1", default-features = false }
25 | dialoguer = "0.11"
26 | testcontainers = { version = "0.16", optional = true }
27 | ctor = { version = "0.2", optional = true }
28 | rstest = { version = "0.19", optional = true }
29 | tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"], optional = true }
30 | futures-util = { version = "0.3", default-features = false }
31 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
32 |
33 | [features]
34 | web_api = ["dep:utoipa", "twitch_api/utoipa"]
35 | testing = ["dep:testcontainers", "dep:ctor", "dep:rstest", "dep:tracing-subscriber"]
36 |
--------------------------------------------------------------------------------
/common/src/config/filters.rs:
--------------------------------------------------------------------------------
1 | use chrono::{DateTime, Local};
2 | use eyre::Result;
3 | use serde::{Deserialize, Serialize};
4 | use twitch_api::pubsub::predictions::Event;
5 |
6 | use crate::types::StreamerState;
7 |
8 | #[derive(Debug, Clone, Serialize, Deserialize)]
9 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
10 | pub enum Filter {
11 | TotalUsers(u32),
12 | DelaySeconds(u32),
13 | DelayPercentage(f64),
14 | }
15 |
16 | pub fn filter_matches(prediction: &Event, filter: &Filter, _: &StreamerState) -> Result {
17 | let res = match filter {
18 | Filter::TotalUsers(t) => {
19 | prediction.outcomes.iter().fold(0, |a, b| a + b.total_users) as u32 >= *t
20 | }
21 | Filter::DelaySeconds(d) => {
22 | let created_at: DateTime =
23 | DateTime::parse_from_rfc3339(prediction.created_at.as_str())?.into();
24 | (chrono::Local::now() - created_at).num_seconds() as u32 >= *d
25 | }
26 | Filter::DelayPercentage(d) => {
27 | let created_at: DateTime =
28 | DateTime::parse_from_rfc3339(prediction.created_at.as_str())?.into();
29 | let d = prediction.prediction_window_seconds as f64 * (d / 100.0);
30 | (chrono::Local::now() - created_at).num_seconds() as f64 >= d
31 | }
32 | };
33 | Ok(res)
34 | }
35 |
--------------------------------------------------------------------------------
/common/src/config/mod.rs:
--------------------------------------------------------------------------------
1 | use eyre::{eyre, Result};
2 | use indexmap::IndexMap;
3 | use serde::{Deserialize, Serialize};
4 | use validator::Validate;
5 |
6 | use self::{filters::Filter, strategy::Strategy};
7 |
8 | pub mod filters;
9 | pub mod strategy;
10 |
11 | #[derive(Debug, Clone, Default, Serialize, Deserialize)]
12 | pub struct Config {
13 | pub watch_priority: Option>,
14 | pub streamers: IndexMap,
15 | pub presets: Option>,
16 | pub watch_streak: Option,
17 | }
18 |
19 | pub trait Normalize {
20 | fn normalize(&mut self);
21 | }
22 |
23 | #[derive(Debug, Default, Clone, Serialize, Deserialize, Validate)]
24 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
25 | pub struct StreamerConfig {
26 | pub follow_raid: bool,
27 | #[validate(nested)]
28 | pub prediction: PredictionConfig,
29 | }
30 |
31 | impl StreamerConfig {
32 | pub fn validate(&self) -> Result<()> {
33 | Ok(self.prediction.validate()?)
34 | }
35 | }
36 |
37 | #[derive(Debug, Default, Clone, Serialize, Deserialize, Validate)]
38 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
39 | #[validate(nested)]
40 | pub struct PredictionConfig {
41 | #[validate(nested)]
42 | pub strategy: Strategy,
43 | #[validate(length(min = 0))]
44 | pub filters: Vec,
45 | }
46 |
47 | #[derive(Debug, Clone, Serialize, Deserialize)]
48 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
49 | pub enum ConfigType {
50 | Preset(String),
51 | Specific(StreamerConfig),
52 | }
53 |
54 | impl Config {
55 | pub fn parse_and_validate(&mut self) -> Result<()> {
56 | for (_, c) in &mut self.streamers {
57 | match c {
58 | ConfigType::Preset(s_name) => {
59 | if self.presets.is_none() {
60 | return Err(eyre!(
61 | "No preset strategies given, so {s_name} cannot be used"
62 | ));
63 | }
64 |
65 | let s = self.presets.as_ref().unwrap().get(s_name);
66 | if s.is_none() {
67 | return Err(eyre!("Preset strategy {s_name} not found"));
68 | }
69 | s.unwrap().validate()?;
70 | }
71 | ConfigType::Specific(s) => {
72 | s.validate()?;
73 | s.prediction.strategy.normalize();
74 | }
75 | }
76 | }
77 |
78 | if let Some(p) = self.presets.as_mut() {
79 | for (key, c) in p {
80 | if self.streamers.contains_key(key) {
81 | return Err(eyre!("Preset {key} already in use as a streamer. Preset names cannot be the same as a streamer mentioned in the config"));
82 | }
83 |
84 | c.prediction.strategy.normalize();
85 | }
86 | }
87 | Ok(())
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/common/src/config/strategy.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use validator::Validate;
3 |
4 | use super::Normalize;
5 |
6 | #[derive(Debug, Clone, Serialize, Deserialize)]
7 | #[serde(rename_all = "camelCase")]
8 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
9 | pub enum Strategy {
10 | Detailed(Detailed),
11 | }
12 |
13 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)]
14 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
15 | #[validate(nested)]
16 | pub struct Detailed {
17 | #[validate(nested)]
18 | pub detailed: Option>,
19 | #[validate(nested)]
20 | pub default: DefaultPrediction,
21 | }
22 |
23 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)]
24 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
25 | #[validate(nested)]
26 | pub struct DefaultPrediction {
27 | #[validate(range(min = 0.0, max = 100.0))]
28 | #[serde(default = "defaults::_detailed_high_threshold_default")]
29 | pub max_percentage: f64,
30 | #[validate(range(min = 0.0, max = 100.0))]
31 | #[serde(default = "defaults::_detailed_low_threshold_default")]
32 | pub min_percentage: f64,
33 | #[validate(nested)]
34 | pub points: Points,
35 | }
36 |
37 | #[derive(Debug, Clone, Serialize, Deserialize, Default)]
38 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
39 | pub enum OddsComparisonType {
40 | #[default]
41 | Le,
42 | Ge,
43 | }
44 |
45 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)]
46 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
47 | #[validate(nested)]
48 | pub struct DetailedOdds {
49 | pub _type: OddsComparisonType,
50 | #[validate(range(min = 0.0, max = 100.0))]
51 | pub threshold: f64,
52 | #[validate(range(min = 0.0, max = 100.0))]
53 | pub attempt_rate: f64,
54 | #[validate(nested)]
55 | pub points: Points,
56 | }
57 |
58 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)]
59 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
60 | #[validate(nested)]
61 | pub struct Points {
62 | pub max_value: u32,
63 | #[validate(range(min = 0.0, max = 100.0))]
64 | pub percent: f64,
65 | }
66 |
67 | #[rustfmt::skip]
68 | mod defaults {
69 | pub const fn _detailed_low_threshold_default() -> f64 { 40.0 }
70 | pub const fn _detailed_high_threshold_default() -> f64 { 60.0 }
71 | }
72 |
73 | impl<'v_a> ::validator::ValidateNested<'v_a> for Strategy {
74 | type Args = ();
75 | fn validate_nested(
76 | &self,
77 | field_name: &'static str,
78 | _: Self::Args,
79 | ) -> ::std::result::Result<(), ::validator::ValidationErrors> {
80 | let res = self.validate();
81 | if let Err(e) = res {
82 | let new_err = validator::ValidationErrorsKind::Struct(::std::boxed::Box::new(e));
83 | std::result::Result::Err(validator::ValidationErrors(
84 | ::std::collections::HashMap::from([(field_name, new_err)]),
85 | ))
86 | } else {
87 | std::result::Result::Ok(())
88 | }
89 | }
90 | }
91 |
92 | impl Validate for Strategy {
93 | #[allow(unused_mut)]
94 | fn validate(&self) -> ::std::result::Result<(), ::validator::ValidationErrors> {
95 | let mut errors = ::validator::ValidationErrors::new();
96 | let mut result = if errors.is_empty() {
97 | ::std::result::Result::Ok(())
98 | } else {
99 | ::std::result::Result::Err(errors)
100 | };
101 | match self {
102 | Strategy::Detailed(t) => {
103 | ::validator::ValidationErrors::merge(result, "detailed", t.validate())
104 | }
105 | }
106 | }
107 | }
108 |
109 | impl Normalize for Detailed {
110 | fn normalize(&mut self) {
111 | self.default.normalize();
112 |
113 | if let Some(h) = self.detailed.as_mut() {
114 | h.iter_mut().for_each(|x| {
115 | x.threshold /= 100.0;
116 | x.attempt_rate /= 100.0;
117 | x.points.normalize();
118 | });
119 | }
120 | }
121 | }
122 |
123 | impl Normalize for DefaultPrediction {
124 | fn normalize(&mut self) {
125 | self.max_percentage /= 100.0;
126 | self.min_percentage /= 100.0;
127 | self.points.normalize();
128 | }
129 | }
130 |
131 | impl Points {
132 | pub fn value(&self, current_points: u32) -> u32 {
133 | if self.max_value == 0 {
134 | (self.percent * current_points as f64) as u32
135 | } else {
136 | let percent_value = (self.percent * current_points as f64) as u32;
137 | if percent_value < self.max_value {
138 | percent_value
139 | } else {
140 | self.max_value
141 | }
142 | }
143 | }
144 | }
145 |
146 | impl Normalize for Points {
147 | fn normalize(&mut self) {
148 | self.percent /= 100.0;
149 | }
150 | }
151 |
152 | impl Default for Strategy {
153 | fn default() -> Self {
154 | Self::Detailed(Default::default())
155 | }
156 | }
157 |
158 | impl Normalize for Strategy {
159 | fn normalize(&mut self) {
160 | match self {
161 | Strategy::Detailed(s) => s.normalize(),
162 | }
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/common/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod config;
2 | pub mod twitch;
3 | pub mod types;
4 |
5 | pub fn remove_duplicates_in_place(mut arr: Vec, by: F) -> Vec
6 | where
7 | T: Clone,
8 | F: Fn(&T, &T) -> bool,
9 | {
10 | let mut kept = 0;
11 | for i in 0..arr.len() {
12 | let (head, tail) = arr.split_at_mut(i);
13 | let x = tail.first_mut().unwrap();
14 | if !head[0..kept].iter().any(|y| by(y, x)) {
15 | if kept != i {
16 | std::mem::swap(&mut head[kept], x);
17 | }
18 | kept += 1;
19 | }
20 | }
21 | arr[0..kept].to_vec()
22 | }
23 |
24 | #[cfg(feature = "testing")]
25 | pub mod testing {
26 | use rstest::fixture;
27 | use testcontainers::{core::WaitFor, runners::AsyncRunner, ContainerAsync, GenericImage};
28 |
29 | #[ctor::ctor]
30 | fn init() {
31 | init_tracing();
32 |
33 | let should_build = std::env::var("BUILD")
34 | .unwrap_or("1".to_owned())
35 | .parse::()
36 | .unwrap();
37 | if should_build == 0 {
38 | return;
39 | }
40 |
41 | let mut child = std::process::Command::new("docker")
42 | .arg("build")
43 | .arg("-f")
44 | .arg(format!(
45 | "{}/../mock.dockerfile",
46 | std::env::var("CARGO_MANIFEST_DIR").unwrap()
47 | ))
48 | .arg("--tag")
49 | .arg("twitch-mock:latest")
50 | .arg(format!(
51 | "{}/../",
52 | std::env::var("CARGO_MANIFEST_DIR").unwrap()
53 | ))
54 | .stdout(std::process::Stdio::piped())
55 | .spawn()
56 | .expect("Could not build twitch-mock:latest");
57 | if !child.wait().expect("Could not run docker").success() {
58 | panic!("Could not build twitch-mock:latest");
59 | }
60 | }
61 |
62 | fn image() -> GenericImage {
63 | GenericImage::new("twitch-mock", "latest")
64 | .with_exposed_port(3000)
65 | .with_wait_for(WaitFor::message_on_stdout("ready"))
66 | }
67 |
68 | pub struct TestContainer {
69 | pub port: u16,
70 | #[allow(dead_code)]
71 | container: Option>,
72 | }
73 |
74 | #[fixture]
75 | pub async fn start_container() -> ContainerAsync {
76 | image().start().await
77 | }
78 |
79 | #[fixture]
80 | pub async fn container(
81 | #[future] start_container: ContainerAsync,
82 | ) -> TestContainer {
83 | let should_build = std::env::var("BUILD")
84 | .unwrap_or("1".to_owned())
85 | .parse::()
86 | .unwrap();
87 | if should_build == 0 {
88 | return TestContainer {
89 | port: 3000,
90 | container: None,
91 | };
92 | }
93 |
94 | let container = start_container.await;
95 | TestContainer {
96 | port: container.get_host_port_ipv4(3000).await,
97 | container: Some(container),
98 | }
99 | }
100 |
101 | pub fn init_tracing() {
102 | use tracing_subscriber::EnvFilter;
103 |
104 | let log_level = std::env::var("LOG").unwrap_or("error".to_owned());
105 | tracing_subscriber::fmt()
106 | .with_env_filter(
107 | EnvFilter::new(format!(
108 | "common={log_level},twitch_points_miner={log_level}"
109 | ))
110 | .add_directive(format!("tower_http::trace={log_level}").parse().unwrap()),
111 | )
112 | .init()
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/common/src/twitch/api.rs:
--------------------------------------------------------------------------------
1 | use base64::{engine::general_purpose::URL_SAFE, Engine};
2 | use eyre::{eyre, Result};
3 | use serde::{Deserialize, Serialize};
4 | use twitch_api::types::UserId;
5 |
6 | use crate::{
7 | twitch::DEVICE_ID,
8 | types::{MinuteWatched, StreamerInfo},
9 | };
10 |
11 | use super::{CHROME_USER_AGENT, CLIENT_ID};
12 |
13 | pub async fn get_spade_url(streamer: &str, base_url: &str) -> Result {
14 | let client = reqwest::Client::new();
15 | let page_text = client
16 | .get(&format!("{base_url}/{streamer}"))
17 | .header("User-Agent", CHROME_USER_AGENT)
18 | .send()
19 | .await?
20 | .text()
21 | .await?;
22 |
23 | async fn inner(
24 | text: &str,
25 | uri: &str,
26 | #[cfg(feature = "testing")] base_url: &str,
27 | ) -> Result {
28 | match text.split_once(uri) {
29 | Some((_, after)) => match after.split_once(".js") {
30 | Some((pattern_js, _)) => {
31 | #[cfg(feature = "testing")]
32 | let prefix = format!("{base_url}/");
33 | #[cfg(not(feature = "testing"))]
34 | let prefix = "";
35 | let client = reqwest::Client::new();
36 | let text = client
37 | .get(&format!("{prefix}{uri}{pattern_js}.js"))
38 | .header("User-Agent", CHROME_USER_AGENT)
39 | .send()
40 | .await?
41 | .text()
42 | .await?;
43 | match text.split_once(r#""spade_url":""#) {
44 | Some((_, after)) => match after.split_once('"') {
45 | Some((url, _)) => Ok(url.to_string()),
46 | None => Err(eyre!(r#"Failed to get spade url: ""#)),
47 | },
48 | None => Err(eyre!(r#"Failed to get spade url: "spade_url":""#)),
49 | }
50 | }
51 | None => Err(eyre!("Failed to get spade url: .js")),
52 | },
53 | None => Err(eyre!("Failed to get spade url: {uri}")),
54 | }
55 | }
56 |
57 | match inner(
58 | &page_text,
59 | #[cfg(feature = "testing")]
60 | "config/settings.",
61 | #[cfg(not(feature = "testing"))]
62 | "https://static.twitchcdn.net/config/settings.",
63 | #[cfg(feature = "testing")]
64 | base_url,
65 | )
66 | .await
67 | {
68 | Ok(s) => Ok(s),
69 | Err(_) => {
70 | inner(
71 | &page_text,
72 | "https://assets.twitch.tv/config/settings.",
73 | #[cfg(feature = "testing")]
74 | base_url,
75 | )
76 | .await
77 | }
78 | }
79 | }
80 |
81 | #[derive(Debug, Serialize, Deserialize)]
82 | #[serde(rename_all = "camelCase")]
83 | pub struct SetViewership {
84 | /// constant: "minute-watched"
85 | pub event: String,
86 | pub properties: MinuteWatched,
87 | }
88 |
89 | pub async fn set_viewership(
90 | user_name: String,
91 | user_id: u32,
92 | channel_id: UserId,
93 | info: StreamerInfo,
94 | spade_url: &str,
95 | ) -> Result<()> {
96 | let watch_event = SetViewership {
97 | event: "minute-watched".to_owned(),
98 | properties: MinuteWatched::from_streamer_info(user_name, user_id, channel_id, info),
99 | };
100 |
101 | let body = serde_json::to_string(&[watch_event])?;
102 |
103 | let client = reqwest::Client::new();
104 | let res = client
105 | .post(spade_url)
106 | .header("Client-Id", CLIENT_ID)
107 | .header("User-Agent", CHROME_USER_AGENT)
108 | .header("X-Device-Id", DEVICE_ID)
109 | .form(&[("data", &URL_SAFE.encode(body))])
110 | .send()
111 | .await?;
112 |
113 | if !res.status().is_success() {
114 | return Err(eyre!("Failed to set viewership"));
115 | }
116 |
117 | Ok(())
118 | }
119 |
--------------------------------------------------------------------------------
/common/src/twitch/auth.rs:
--------------------------------------------------------------------------------
1 | use eyre::{eyre, Context, Result};
2 | use serde::{Deserialize, Serialize};
3 |
4 | use super::{CLIENT_ID, DEVICE_ID, USER_AGENT};
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
7 | pub struct LoginFlowStart {
8 | pub device_code: String,
9 | pub expires_in: i64,
10 | pub interval: i64,
11 | pub user_code: String,
12 | pub verification_uri: String,
13 | }
14 |
15 | #[derive(Debug, Default, Clone, Serialize, Deserialize)]
16 | pub struct Token {
17 | pub access_token: String,
18 | pub refresh_token: String,
19 | pub scope: Vec,
20 | pub token_type: String,
21 | }
22 |
23 | pub async fn login(tokens: &str) -> Result<()> {
24 | let client = reqwest::Client::new();
25 | let flow: LoginFlowStart = client.post("https://id.twitch.tv/oauth2/device")
26 | .header("Client-Id", CLIENT_ID)
27 | .header("User-Agent", USER_AGENT)
28 | .header("X-Device-Id", DEVICE_ID)
29 | .form(&[
30 | ("client_id", CLIENT_ID),
31 | ("scopes", "channel_read chat:read user_blocks_edit user_blocks_read user_follows_edit user_read")
32 | ]).send().await?.json().await?;
33 |
34 | if !dialoguer::Confirm::new()
35 | .with_prompt(format!(
36 | "Open https://www.twitch.tv/activate and enter this code: {}",
37 | flow.user_code
38 | ))
39 | .interact()?
40 | {
41 | return Err(eyre!("User cancelled login"));
42 | }
43 |
44 | let client = reqwest::Client::new();
45 | let res: Token = client
46 | .post("https://id.twitch.tv/oauth2/token")
47 | .header("Client-Id", CLIENT_ID)
48 | .header("Host", "id.twitch.tv")
49 | .header("Origin", "https://android.tv.twitch.tv")
50 | .header("Refer", "https://android.tv.twitch.tv")
51 | .header("User-Agent", USER_AGENT)
52 | .header("X-Device-Id", DEVICE_ID)
53 | .form(&[
54 | ("client_id", CLIENT_ID),
55 | ("device_code", &flow.device_code),
56 | ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
57 | ])
58 | .send()
59 | .await?
60 | .json()
61 | .await?;
62 |
63 | tokio::fs::write(tokens, serde_json::to_string(&res)?)
64 | .await
65 | .context("Writing tokens file")?;
66 | Ok(())
67 | }
68 |
--------------------------------------------------------------------------------
/common/src/twitch/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod api;
2 | pub mod auth;
3 | pub mod gql;
4 | pub mod ws;
5 |
6 | const CLIENT_ID: &str = "ue6666qo983tsx6so1t0vnawi233wa";
7 | const DEVICE_ID: &str = "COF4t3ZVYpc87xfn8Jplkv5UQk8KVXvh";
8 | const USER_AGENT: &str = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36";
9 | const CHROME_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36";
10 |
11 | pub fn traverse_json<'a>(
12 | mut value: &'a mut serde_json::Value,
13 | mut path: &str,
14 | ) -> Option<&'a mut serde_json::Value> {
15 | loop {
16 | let (token, rest) = consume(path);
17 | path = rest;
18 | match token {
19 | Token::Object => {
20 | if value.is_object() {
21 | let (token, rest) = consume(path);
22 | path = rest;
23 |
24 | match token {
25 | Token::Name(name) => match value.as_object_mut().unwrap().get_mut(name) {
26 | Some(s) => value = s,
27 | None => return None,
28 | },
29 | _ => return None,
30 | }
31 | } else {
32 | return None;
33 | }
34 | }
35 | Token::Array(idx) => match idx.parse::() {
36 | Ok(idx) => {
37 | if value.is_array() {
38 | match value.as_array_mut().unwrap().get_mut(idx) {
39 | Some(s) => value = s,
40 | None => return None,
41 | }
42 | } else {
43 | return None;
44 | }
45 | }
46 | Err(_) => return None,
47 | },
48 | Token::Eos => return Some(value),
49 | Token::Name(_) => unreachable!(),
50 | }
51 | }
52 | }
53 |
54 | enum Token<'a> {
55 | Name(&'a str),
56 | Object,
57 | Array(&'a str),
58 | /// End of stream
59 | Eos,
60 | }
61 |
62 | fn consume(data: &str) -> (Token<'_>, &str) {
63 | let mut started = false;
64 | for (idx, char) in data.char_indices() {
65 | match char {
66 | '.' => {
67 | if !started {
68 | return (Token::Object, &data[idx + 1..]);
69 | } else {
70 | return (Token::Name(&data[..idx]), &data[idx..]);
71 | }
72 | }
73 | '[' => {
74 | if !started {
75 | let array_idx = consume(&data[idx + 1..]);
76 | let (_, leftover) = consume(array_idx.1);
77 | return (array_idx.0, leftover);
78 | } else {
79 | return (Token::Name(&data[..idx]), &data[idx..]);
80 | }
81 | }
82 | ']' => {
83 | if !started {
84 | return (Token::Eos, &data[idx + 1..]);
85 | } else {
86 | return (Token::Array(&data[..idx]), &data[idx..]);
87 | }
88 | }
89 | _ => {
90 | if !started {
91 | started = true;
92 | }
93 | }
94 | }
95 | }
96 | if started {
97 | (Token::Name(data), "")
98 | } else {
99 | (Token::Eos, data)
100 | }
101 | }
102 |
103 | fn camel_to_snake_case_json(value: &mut serde_json::Value) {
104 | if value.is_object() {
105 | let obj = value.as_object_mut().unwrap();
106 | let mut to_add = Vec::new();
107 | for item in obj.iter() {
108 | to_add.push((item.0.clone(), to_snake_case(item.0)));
109 | }
110 |
111 | to_add.into_iter().for_each(|(old, new)| {
112 | let mut json = obj.remove(&old).unwrap();
113 | if json.is_object() || json.is_array() {
114 | camel_to_snake_case_json(&mut json);
115 | }
116 | obj.insert(new, json);
117 | });
118 | } else if value.is_array() {
119 | for item in value.as_array_mut().unwrap() {
120 | if item.is_object() || item.is_array() {
121 | camel_to_snake_case_json(item);
122 | }
123 | }
124 | }
125 | }
126 |
127 | fn to_snake_case(input: &str) -> String {
128 | let mut result = String::new();
129 | let mut prev_char_was_uppercase = true;
130 |
131 | for c in input.chars() {
132 | if c.is_uppercase() {
133 | if !prev_char_was_uppercase {
134 | result.push('_');
135 | }
136 | result.push(c.to_lowercase().next().unwrap());
137 | prev_char_was_uppercase = true;
138 | } else {
139 | result.push(c);
140 | prev_char_was_uppercase = false;
141 | }
142 | }
143 |
144 | result
145 | }
146 |
147 | #[cfg(test)]
148 | mod test {
149 | use crate::twitch::traverse_json;
150 |
151 | #[test]
152 | fn traverse_regular() {
153 | let mut data: serde_json::Value = serde_json::from_str(
154 | r#"
155 | {
156 | "a": {
157 | "b": {
158 | "c": 1
159 | },
160 | "d": 2
161 | }
162 | }
163 | "#,
164 | )
165 | .unwrap();
166 |
167 | assert_eq!(
168 | traverse_json(&mut data, ".a.b.c"),
169 | Some(&mut serde_json::Value::Number(1.into()))
170 | );
171 | assert_eq!(
172 | traverse_json(&mut data, ".a.d"),
173 | Some(&mut serde_json::Value::Number(2.into()))
174 | );
175 | }
176 |
177 | #[test]
178 | fn traverse_array() {
179 | let mut data: serde_json::Value = serde_json::from_str(
180 | r#"
181 | {
182 | "a": [
183 | 1,
184 | 2
185 | ],
186 | "b": {
187 | "c": [
188 | {
189 | "d": 3
190 | },
191 | {
192 | "e": 4
193 | }
194 | ]
195 | }
196 | }
197 | "#,
198 | )
199 | .unwrap();
200 |
201 | assert_eq!(
202 | traverse_json(&mut data, ".a[0]"),
203 | Some(&mut serde_json::Value::Number(1.into()))
204 | );
205 | assert_eq!(
206 | traverse_json(&mut data, ".a[1]"),
207 | Some(&mut serde_json::Value::Number(2.into()))
208 | );
209 |
210 | assert_eq!(
211 | traverse_json(&mut data, ".b.c[0].d"),
212 | Some(&mut serde_json::Value::Number(3.into()))
213 | );
214 | assert_eq!(
215 | traverse_json(&mut data, ".b.c[1].e"),
216 | Some(&mut serde_json::Value::Number(4.into()))
217 | );
218 | }
219 | }
220 |
--------------------------------------------------------------------------------
/common/src/types.rs:
--------------------------------------------------------------------------------
1 | use std::{collections::HashMap, sync::Arc, time::Instant};
2 |
3 | use serde::{Deserialize, Serialize, Serializer};
4 | use twitch_api::{pubsub::predictions::Event, types::UserId};
5 |
6 | use crate::config::StreamerConfig;
7 |
8 | #[derive(Debug, Clone, Serialize)]
9 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
10 | pub struct StreamerState {
11 | pub info: StreamerInfo,
12 | pub predictions: HashMap,
13 | pub config: StreamerConfigRefWrapper,
14 | pub points: u32,
15 | #[serde(skip)]
16 | pub last_points_refresh: Instant,
17 | }
18 |
19 | impl Default for StreamerState {
20 | fn default() -> Self {
21 | Self {
22 | info: Default::default(),
23 | predictions: Default::default(),
24 | config: Default::default(),
25 | points: Default::default(),
26 | last_points_refresh: Instant::now(),
27 | }
28 | }
29 | }
30 |
31 | impl StreamerState {
32 | pub fn new(live: bool, channel_name: String) -> Self {
33 | StreamerState {
34 | info: StreamerInfo {
35 | live,
36 | channel_name,
37 | ..Default::default()
38 | },
39 | ..Default::default()
40 | }
41 | }
42 | }
43 |
44 | #[derive(Debug, Default, Clone, Serialize)]
45 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
46 | pub struct StreamerConfigRef {
47 | pub _type: ConfigTypeRef,
48 | pub config: StreamerConfig,
49 | }
50 |
51 | #[derive(Debug, Default, Clone)]
52 | pub struct StreamerConfigRefWrapper(pub Arc>);
53 |
54 | impl Serialize for StreamerConfigRefWrapper {
55 | fn serialize(&self, serializer: S) -> Result
56 | where
57 | S: Serializer,
58 | {
59 | let data = { self.0.read().map_err(serde::ser::Error::custom)?.clone() };
60 | serializer.serialize_newtype_struct("StreamerConfigRef", &data)
61 | }
62 | }
63 |
64 | #[cfg(feature = "web_api")]
65 | impl<'__s> utoipa::ToSchema<'__s> for StreamerConfigRefWrapper {
66 | fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> {
67 | let s = if let utoipa::openapi::RefOr::T(x) = StreamerConfigRef::schema().1 {
68 | x
69 | } else {
70 | panic!("Expected type, got ref")
71 | };
72 |
73 | vec![("StreamerConfigRefWrapper", s)]
74 | }
75 |
76 | fn schema() -> (
77 | &'__s str,
78 | utoipa::openapi::RefOr,
79 | ) {
80 | StreamerConfigRef::schema()
81 | }
82 | }
83 |
84 | impl StreamerConfigRefWrapper {
85 | pub fn new(config: StreamerConfigRef) -> Self {
86 | Self(Arc::new(std::sync::RwLock::new(config)))
87 | }
88 | }
89 |
90 | #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
91 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
92 | pub enum ConfigTypeRef {
93 | Preset(String),
94 | #[default]
95 | Specific,
96 | }
97 |
98 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99 | #[serde(rename_all = "camelCase")]
100 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
101 | pub struct StreamerInfo {
102 | pub broadcast_id: Option,
103 | pub live: bool,
104 | pub channel_name: String,
105 | pub game: Option,
106 | }
107 |
108 | impl StreamerInfo {
109 | pub fn with_channel_name(channel_name: &str) -> Self {
110 | Self {
111 | channel_name: channel_name.to_owned(),
112 | ..Default::default()
113 | }
114 | }
115 | }
116 |
117 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118 | #[serde(rename_all = "camelCase")]
119 | #[cfg_attr(feature = "web_api", derive(utoipa::ToSchema))]
120 | pub struct Game {
121 | pub id: String,
122 | pub name: String,
123 | }
124 |
125 | #[derive(Debug, Clone, Serialize, Deserialize)]
126 | pub struct MinuteWatched {
127 | pub channel_id: UserId,
128 | pub broadcast_id: Option,
129 | pub live: bool,
130 | /// Channel name string
131 | pub channel: String,
132 | pub game: Option,
133 | pub game_id: Option,
134 | /// constant: "site"
135 | pub player: String,
136 | /// constant: "Playing"
137 | pub player_state: String,
138 | /// Login user ID
139 | pub user_id: u32,
140 | pub login: String,
141 | }
142 |
143 | impl MinuteWatched {
144 | pub fn from_streamer_info(
145 | user_name: String,
146 | user_id: u32,
147 | channel_id: UserId,
148 | value: StreamerInfo,
149 | ) -> Self {
150 | Self {
151 | channel_id,
152 | broadcast_id: value.broadcast_id,
153 | live: value.live,
154 | channel: value.channel_name,
155 | game: value.game.clone().map(|x| x.name),
156 | game_id: value.game.map(|x| x.id),
157 | player: "site".to_owned(),
158 | user_id,
159 | player_state: "Playing".to_owned(),
160 | login: user_name,
161 | }
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/example.config.yaml:
--------------------------------------------------------------------------------
1 | # a list of streamers to give watch priority for when live
2 | watch_priority:
3 | - streamer_b
4 | streamers:
5 | streamer_a: !Specific
6 | follow_raid: true
7 | prediction:
8 | strategy: !detailed
9 | # bets placed when odds >= 90%, 100% of the time
10 | detailed:
11 | - _type: Ge
12 | threshold: 90.0
13 | attempt_rate: 100.0
14 | points:
15 | max_value: 1000
16 | percent: 1.0
17 | # bets placed when odds <= 10%, 1% of the time
18 | - _type: Le
19 | threshold: 10.0
20 | attempt_rate: 1.0
21 | points:
22 | max_value: 1000
23 | percent: 1.0
24 | - _type: Ge
25 | threshold: 70.0
26 | attempt_rate: 100.0
27 | points:
28 | max_value: 5000
29 | percent: 5.0
30 | - _type: Le
31 | threshold: 30.0
32 | attempt_rate: 3.0
33 | points:
34 | max_value: 5000
35 | percent: 5.0
36 | default:
37 | max_percentage: 55.0
38 | min_percentage: 45.0
39 | points:
40 | max_value: 100000
41 | percent: 25.0
42 | filters:
43 | # wait for half the prediction time to finish
44 | - !DelayPercentage 50.0
45 | # Attempt prediction only if at least 300 people have bet
46 | - !TotalUsers 300
47 | streamer_b: !Preset small
48 | presets:
49 | # a preset configuration that can be reused
50 | # this particular one only defines a base range
51 | small:
52 | follow_raid: false
53 | prediction:
54 | strategy: !detailed
55 | default:
56 | max_percentage: 0.0
57 | min_percentage: 0.0
58 | points:
59 | max_value: 0
60 | percent: 0.0
61 | filters: []
62 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-svelte"],
3 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
4 | }
--------------------------------------------------------------------------------
/frontend/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t348575/twitch-points-miner/ae9d39d3d10ff8240f24e2529fe7cf6dd701db66/frontend/bun.lockb
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://shadcn-svelte.com/schema.json",
3 | "style": "default",
4 | "tailwind": {
5 | "config": "tailwind.config.js",
6 | "css": "src/app.pcss",
7 | "baseColor": "slate"
8 | },
9 | "aliases": {
10 | "components": "$lib/components",
11 | "utils": "$lib/utils"
12 | },
13 | "typescript": true
14 | }
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Twitch points miner
7 |
8 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.1.11",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build --outDir ../dist --emptyOutDir",
9 | "preview": "vite preview",
10 | "check": "svelte-check --tsconfig ./tsconfig.json"
11 | },
12 | "devDependencies": {
13 | "@sveltejs/vite-plugin-svelte": "^3.0.1",
14 | "@tsconfig/svelte": "^5.0.2",
15 | "autoprefixer": "^10.4.16",
16 | "openapi-typescript": "^7.0.0-next.8",
17 | "postcss": "^8.4.32",
18 | "postcss-load-config": "^5.0.2",
19 | "prettier": "^3.2.5",
20 | "prettier-plugin-svelte": "^3.2.3",
21 | "svelte": "^4.2.12",
22 | "svelte-check": "^3.6.7",
23 | "svelte-headless-table": "^0.18.2",
24 | "tailwindcss": "^3.3.6",
25 | "tslib": "^2.6.2",
26 | "typescript": "^5.4.4",
27 | "vite": "^5.2.0"
28 | },
29 | "dependencies": {
30 | "@internationalized/date": "^3.5.2",
31 | "@unovis/svelte": "^1.4.0",
32 | "@unovis/ts": "^1.4.0",
33 | "bits-ui": "^0.21.7",
34 | "clsx": "^2.1.0",
35 | "lucide-svelte": "^0.366.0",
36 | "mode-watcher": "^0.3.0",
37 | "openapi-fetch": "^0.9.3",
38 | "svelte-sonner": "^0.3.22",
39 | "svelte-spa-router": "^4.0.1",
40 | "tailwind-merge": "^2.2.2",
41 | "tailwind-variants": "^0.2.1",
42 | "vaul-svelte": "^0.3.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const tailwindcss = require("tailwindcss");
2 | const autoprefixer = require("autoprefixer");
3 |
4 | const config = {
5 | plugins: [
6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind,
7 | tailwindcss(),
8 | //But others, like autoprefixer, need to run after,
9 | autoprefixer,
10 | ],
11 | };
12 |
13 | module.exports = config;
14 |
--------------------------------------------------------------------------------
/frontend/src/App.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
Twitch points miner
39 |
40 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/frontend/src/app.pcss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Roboto:wght@100;300;400;500&display=swap');
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | * {
8 | font-family: "Roboto", sans-serif !important;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 20 14.3% 4.1%;
15 | --card: 0 0% 100%;
16 | --card-foreground: 20 14.3% 4.1%;
17 | --popover: 0 0% 100%;
18 | --popover-foreground: 20 14.3% 4.1%;
19 | --primary: 24 9.8% 10%;
20 | --primary-foreground: 60 9.1% 97.8%;
21 | --secondary: 60 4.8% 95.9%;
22 | --secondary-foreground: 24 9.8% 10%;
23 | --muted: 60 4.8% 95.9%;
24 | --muted-foreground: 25 5.3% 44.7%;
25 | --accent: 60 4.8% 95.9%;
26 | --accent-foreground: 24 9.8% 10%;
27 | --destructive: 0 72.22% 50.59%;
28 | --destructive-foreground: 60 9.1% 97.8%;
29 | --border: 20 5.9% 90%;
30 | --input: 20 5.9% 90%;
31 | --ring: 20 14.3% 4.1%;
32 | --radius: 0.5rem;
33 | }
34 | .dark {
35 | --background: 20 14.3% 4.1%;
36 | --foreground: 60 9.1% 97.8%;
37 | --card: 20 14.3% 4.1%;
38 | --card-foreground: 60 9.1% 97.8%;
39 | --popover: 20 14.3% 4.1%;
40 | --popover-foreground: 60 9.1% 97.8%;
41 | --primary: 60 9.1% 97.8%;
42 | --primary-foreground: 24 9.8% 10%;
43 | --secondary: 12 6.5% 15.1%;
44 | --secondary-foreground: 60 9.1% 97.8%;
45 | --muted: 12 6.5% 15.1%;
46 | --muted-foreground: 24 5.4% 63.9%;
47 | --accent: 12 6.5% 15.1%;
48 | --accent-foreground: 60 9.1% 97.8%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 60 9.1% 97.8%;
51 | --border: 12 6.5% 15.1%;
52 | --input: 12 6.5% 15.1%;
53 | --ring: 24 5.7% 82.9%;
54 | }
55 | }
56 |
57 | @layer base {
58 | * {
59 | @apply border-border;
60 | }
61 | body {
62 | @apply bg-background text-foreground;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/src/common.ts:
--------------------------------------------------------------------------------
1 | import createClient from "openapi-fetch";
2 | import type { components, paths } from "./api";
3 | import { writable } from "svelte/store";
4 |
5 | export const streamers = writable([]);
6 |
7 | const baseUrl = import.meta.env.DEV
8 | ? "http://localhost:3000"
9 | : window.location.origin;
10 | const client = createClient({
11 | baseUrl,
12 | });
13 |
14 | export interface Streamer {
15 | id: number;
16 | data: components["schemas"]["StreamerState"];
17 | name: string;
18 | points: number;
19 | }
20 |
21 | export interface ValidateStrategy {
22 | status: boolean;
23 | data: components["schemas"]["Strategy"];
24 | }
25 |
26 | export interface FilterType {
27 | value: string;
28 | label: string;
29 | quantity: number;
30 | }
31 |
32 | export async function get_streamers(): Promise {
33 | const { data, error } = await client.GET("/api");
34 | if (error) {
35 | throw error;
36 | }
37 |
38 | let items: Streamer[] = [];
39 | for (const v in data.streamers) {
40 | const s = data.streamers[v] as components["schemas"]["StreamerState"];
41 | items.push({
42 | id: parseInt(v, 10),
43 | data: s,
44 | points: s.points,
45 | name: s.info.channelName,
46 | });
47 | }
48 |
49 | return items;
50 | }
51 |
52 | export async function mine_streamer(
53 | channel_name: string,
54 | config: components["schemas"]["ConfigType"],
55 | ) {
56 | const { error } = await client.PUT("/api/streamers/mine/{channel_name}", {
57 | params: {
58 | path: {
59 | channel_name,
60 | },
61 | },
62 | body: {
63 | config,
64 | },
65 | });
66 |
67 | if (error) {
68 | throw error;
69 | }
70 | return;
71 | }
72 |
73 | export async function remove_streamer(channelName: string) {
74 | const { error } = await client.DELETE("/api/streamers/mine/{channel_name}/", {
75 | params: {
76 | path: {
77 | channel_name: channelName,
78 | },
79 | },
80 | });
81 |
82 | if (error) {
83 | throw error;
84 | }
85 | return;
86 | }
87 |
88 | export async function save_streamer_config(
89 | channelName: string,
90 | config: components["schemas"]["ConfigType"],
91 | ) {
92 | const { error } = await client.POST("/api/config/streamer/{channel_name}", {
93 | params: {
94 | path: {
95 | channel_name: channelName,
96 | },
97 | },
98 | body: config,
99 | });
100 | if (error) {
101 | throw error;
102 | }
103 | }
104 |
105 | export async function place_bet_streamer(
106 | streamer: string,
107 | event_id: string,
108 | outcome_id: string,
109 | points: number | null,
110 | ) {
111 | const { error } = await client.POST("/api/predictions/bet/{streamer}", {
112 | params: {
113 | path: {
114 | streamer,
115 | },
116 | },
117 | body: {
118 | event_id,
119 | outcome_id,
120 | points,
121 | },
122 | });
123 |
124 | if (error) {
125 | throw error;
126 | }
127 | }
128 |
129 | export async function get_presets(): Promise<{
130 | [key: string]: components["schemas"]["StreamerConfig"];
131 | }> {
132 | const { data, error } = await client.GET("/api/config/presets");
133 | if (error) {
134 | throw error;
135 | }
136 | // @ts-ignore
137 | return data;
138 | }
139 |
140 | export async function add_or_update_preset(
141 | name: string,
142 | config: components["schemas"]["StreamerConfig"],
143 | ) {
144 | const { error } = await client.POST("/api/config/presets/", {
145 | body: {
146 | config,
147 | name,
148 | },
149 | });
150 | if (error) {
151 | throw error;
152 | }
153 | }
154 |
155 | export async function delete_preset(name: string) {
156 | const { error } = await client.DELETE("/api/config/presets/{name}", {
157 | params: {
158 | path: {
159 | name,
160 | },
161 | },
162 | });
163 | if (error) {
164 | throw error;
165 | }
166 | }
167 |
168 | export async function get_watching(): Promise<
169 | components["schemas"]["StreamerState"][]
170 | > {
171 | const { data, error } = await client.GET("/api");
172 | if (error) {
173 | throw error;
174 | }
175 | return data.watching;
176 | }
177 |
178 | export async function get_timeline(
179 | from: string,
180 | to: string,
181 | channels: Streamer[],
182 | ): Promise {
183 | const { data, error } = await client.POST("/api/analytics/timeline", {
184 | body: {
185 | channels: channels.map((a) => a.id),
186 | from,
187 | to,
188 | },
189 | });
190 |
191 | if (error) {
192 | throw error;
193 | }
194 | return data;
195 | }
196 |
197 | export async function get_live_streamers(): Promise<
198 | components["schemas"]["LiveStreamer"][]
199 | > {
200 | const { data, error } = await client.GET("/api/streamers/live");
201 | if (error) {
202 | throw error;
203 | }
204 | return data;
205 | }
206 |
207 | export async function get_last_prediction(
208 | channel_id: number,
209 | prediction_id: string,
210 | ): Promise {
211 | const { data, error } = await client.GET("/api/predictions/live", {
212 | params: {
213 | query: {
214 | prediction_id,
215 | channel_id,
216 | },
217 | },
218 | });
219 | if (error) {
220 | throw error;
221 | }
222 | return data;
223 | }
224 |
225 | export async function get_watch_priority(): Promise {
226 | const { data, error } = await client.GET("/api/config/watch_priority");
227 | if (error) {
228 | throw error;
229 | }
230 | return data;
231 | }
232 |
233 | export async function set_watch_priority(
234 | watch_priority: string[],
235 | ): Promise {
236 | const { error } = await client.POST("/api/config/watch_priority/", {
237 | body: watch_priority,
238 | });
239 | if (error) {
240 | throw error;
241 | }
242 | }
243 |
244 | export async function get_logs(
245 | page: number,
246 | page_size: number,
247 | ): Promise {
248 | const res = await fetch(
249 | `${baseUrl}/api/logs?page=${page}&per_page=${page_size}`,
250 | );
251 | return await res.text();
252 | }
253 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/ErrorAlert.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | {title}
11 | {message}
12 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/SortableList.svelte:
--------------------------------------------------------------------------------
1 |
69 |
70 | {#if list && list.length}
71 |
72 | {#each list as item, index (getKey(item))}
73 | -
86 |
87 |
{getKey(item)}
88 |
89 |
90 | {/each}
91 |
92 | {/if}
93 |
94 |
107 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/WatchPriority.svelte:
--------------------------------------------------------------------------------
1 |
99 |
100 |
101 |
102 |
103 |
104 | {action_title}
105 |
106 | {#if view_edit_watch_priority}
107 |
112 |
113 |
{item}
114 | {#if $streamers.find((x) => x.data.info.live && x.data.info.channelName == item) !== undefined}
115 |
118 | {/if}
119 |
120 |
121 |
122 |
123 | {:else}
124 |
125 |
126 |
127 |
128 |
129 | {#each streamers_list as p}
130 | {p.name}
131 | {/each}
132 |
133 |
134 | {/if}
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert-dialog/index.ts:
--------------------------------------------------------------------------------
1 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
2 |
3 | import Title from "./alert-dialog-title.svelte";
4 | import Action from "./alert-dialog-action.svelte";
5 | import Cancel from "./alert-dialog-cancel.svelte";
6 | import Portal from "./alert-dialog-portal.svelte";
7 | import Footer from "./alert-dialog-footer.svelte";
8 | import Header from "./alert-dialog-header.svelte";
9 | import Overlay from "./alert-dialog-overlay.svelte";
10 | import Content from "./alert-dialog-content.svelte";
11 | import Description from "./alert-dialog-description.svelte";
12 |
13 | const Root = AlertDialogPrimitive.Root;
14 | const Trigger = AlertDialogPrimitive.Trigger;
15 |
16 | export {
17 | Root,
18 | Title,
19 | Action,
20 | Cancel,
21 | Portal,
22 | Footer,
23 | Header,
24 | Trigger,
25 | Overlay,
26 | Content,
27 | Description,
28 | //
29 | Root as AlertDialog,
30 | Title as AlertDialogTitle,
31 | Action as AlertDialogAction,
32 | Cancel as AlertDialogCancel,
33 | Portal as AlertDialogPortal,
34 | Footer as AlertDialogFooter,
35 | Header as AlertDialogHeader,
36 | Trigger as AlertDialogTrigger,
37 | Overlay as AlertDialogOverlay,
38 | Content as AlertDialogContent,
39 | Description as AlertDialogDescription,
40 | };
41 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert/alert-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert/alert-title.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert/alert.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/alert/index.ts:
--------------------------------------------------------------------------------
1 | import { type VariantProps, tv } from "tailwind-variants";
2 |
3 | import Root from "./alert.svelte";
4 | import Description from "./alert-description.svelte";
5 | import Title from "./alert-title.svelte";
6 |
7 | export const alertVariants = tv({
8 | base: "relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
9 |
10 | variants: {
11 | variant: {
12 | default: "bg-background text-foreground",
13 | destructive:
14 | "border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | });
21 |
22 | export type Variant = VariantProps["variant"];
23 | export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
24 |
25 | export {
26 | Root,
27 | Description,
28 | Title,
29 | //
30 | Root as Alert,
31 | Description as AlertDescription,
32 | Title as AlertTitle,
33 | };
34 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/button/button.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/button/index.ts:
--------------------------------------------------------------------------------
1 | import { type VariantProps, tv } from "tailwind-variants";
2 | import type { Button as ButtonPrimitive } from "bits-ui";
3 | import Root from "./button.svelte";
4 |
5 | const buttonVariants = tv({
6 | base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
7 | variants: {
8 | variant: {
9 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
10 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
11 | outline:
12 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
13 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
14 | ghost: "hover:bg-accent hover:text-accent-foreground",
15 | link: "text-primary underline-offset-4 hover:underline",
16 | },
17 | size: {
18 | default: "h-10 px-4 py-2",
19 | sm: "h-9 rounded-md px-3",
20 | lg: "h-11 rounded-md px-8",
21 | icon: "h-10 w-10",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | });
29 |
30 | type Variant = VariantProps["variant"];
31 | type Size = VariantProps["size"];
32 |
33 | type Props = ButtonPrimitive.Props & {
34 | variant?: Variant;
35 | size?: Size;
36 | };
37 |
38 | type Events = ButtonPrimitive.Events;
39 |
40 | export {
41 | Root,
42 | type Props,
43 | type Events,
44 | //
45 | Root as Button,
46 | type Props as ButtonProps,
47 | type Events as ButtonEvents,
48 | buttonVariants,
49 | };
50 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card-content.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card-title.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/card.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/card/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./card.svelte";
2 | import Content from "./card-content.svelte";
3 | import Description from "./card-description.svelte";
4 | import Footer from "./card-footer.svelte";
5 | import Header from "./card-header.svelte";
6 | import Title from "./card-title.svelte";
7 |
8 | export {
9 | Root,
10 | Content,
11 | Description,
12 | Footer,
13 | Header,
14 | Title,
15 | //
16 | Root as Card,
17 | Content as CardContent,
18 | Description as CardDescription,
19 | Footer as CardFooter,
20 | Header as CardHeader,
21 | Title as CardTitle,
22 | };
23 |
24 | export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
25 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/checkbox/checkbox.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
24 |
29 | {#if isChecked}
30 |
31 | {:else if isIndeterminate}
32 |
33 | {/if}
34 |
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/checkbox/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./checkbox.svelte";
2 | export {
3 | Root,
4 | //
5 | Root as Checkbox,
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-content.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
28 |
29 |
32 |
33 | Close
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-portal.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/dialog-title.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/dialog/index.ts:
--------------------------------------------------------------------------------
1 | import { Dialog as DialogPrimitive } from "bits-ui";
2 |
3 | import Title from "./dialog-title.svelte";
4 | import Portal from "./dialog-portal.svelte";
5 | import Footer from "./dialog-footer.svelte";
6 | import Header from "./dialog-header.svelte";
7 | import Overlay from "./dialog-overlay.svelte";
8 | import Content from "./dialog-content.svelte";
9 | import Description from "./dialog-description.svelte";
10 |
11 | const Root = DialogPrimitive.Root;
12 | const Trigger = DialogPrimitive.Trigger;
13 |
14 | export {
15 | Root,
16 | Title,
17 | Portal,
18 | Footer,
19 | Header,
20 | Trigger,
21 | Overlay,
22 | Content,
23 | Description,
24 | //
25 | Root as Dialog,
26 | Title as DialogTitle,
27 | Portal as DialogPortal,
28 | Footer as DialogFooter,
29 | Header as DialogHeader,
30 | Trigger as DialogTrigger,
31 | Overlay as DialogOverlay,
32 | Content as DialogContent,
33 | Description as DialogDescription,
34 | };
35 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/input/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./input.svelte";
2 |
3 | export type FormInputEvent = T & {
4 | currentTarget: EventTarget & HTMLInputElement;
5 | };
6 | export type InputEvents = {
7 | blur: FormInputEvent;
8 | change: FormInputEvent;
9 | click: FormInputEvent;
10 | focus: FormInputEvent;
11 | focusin: FormInputEvent;
12 | focusout: FormInputEvent;
13 | keydown: FormInputEvent;
14 | keypress: FormInputEvent;
15 | keyup: FormInputEvent;
16 | mouseover: FormInputEvent;
17 | mouseenter: FormInputEvent;
18 | mouseleave: FormInputEvent;
19 | paste: FormInputEvent;
20 | input: FormInputEvent;
21 | wheel: FormInputEvent;
22 | };
23 |
24 | export {
25 | Root,
26 | //
27 | Root as Input,
28 | };
29 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/input/input.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
42 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/label/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./label.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Label,
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/label/label.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/index.ts:
--------------------------------------------------------------------------------
1 | import { Menubar as MenubarPrimitive } from "bits-ui";
2 |
3 | import Root from "./menubar.svelte";
4 | import CheckboxItem from "./menubar-checkbox-item.svelte";
5 | import Content from "./menubar-content.svelte";
6 | import Item from "./menubar-item.svelte";
7 | import Label from "./menubar-label.svelte";
8 | import RadioItem from "./menubar-radio-item.svelte";
9 | import Separator from "./menubar-separator.svelte";
10 | import Shortcut from "./menubar-shortcut.svelte";
11 | import SubContent from "./menubar-sub-content.svelte";
12 | import SubTrigger from "./menubar-sub-trigger.svelte";
13 | import Trigger from "./menubar-trigger.svelte";
14 |
15 | const Menu = MenubarPrimitive.Menu;
16 | const Group = MenubarPrimitive.Group;
17 | const Sub = MenubarPrimitive.Sub;
18 | const RadioGroup = MenubarPrimitive.RadioGroup;
19 |
20 | export {
21 | Root,
22 | CheckboxItem,
23 | Content,
24 | Item,
25 | Label,
26 | RadioItem,
27 | Separator,
28 | Shortcut,
29 | SubContent,
30 | SubTrigger,
31 | Trigger,
32 | Menu,
33 | Group,
34 | Sub,
35 | RadioGroup,
36 | //
37 | Root as Menubar,
38 | CheckboxItem as MenubarCheckboxItem,
39 | Content as MenubarContent,
40 | Item as MenubarItem,
41 | Label as MenubarLabel,
42 | RadioItem as MenubarRadioItem,
43 | Separator as MenubarSeparator,
44 | Shortcut as MenubarShortcut,
45 | SubContent as MenubarSubContent,
46 | SubTrigger as MenubarSubTrigger,
47 | Trigger as MenubarTrigger,
48 | Menu as MenubarMenu,
49 | Group as MenubarGroup,
50 | Sub as MenubarSub,
51 | RadioGroup as MenubarRadioGroup,
52 | };
53 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-checkbox-item.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-content.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-item.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-label.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-radio-item.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-separator.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-shortcut.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-sub-content.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-sub-trigger.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar-trigger.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/menubar/menubar.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/popover/index.ts:
--------------------------------------------------------------------------------
1 | import { Popover as PopoverPrimitive } from "bits-ui";
2 | import Content from "./popover-content.svelte";
3 | const Root = PopoverPrimitive.Root;
4 | const Trigger = PopoverPrimitive.Trigger;
5 | const Close = PopoverPrimitive.Close;
6 |
7 | export {
8 | Root,
9 | Content,
10 | Trigger,
11 | Close,
12 | //
13 | Root as Popover,
14 | Content as PopoverContent,
15 | Trigger as PopoverTrigger,
16 | Close as PopoverClose,
17 | };
18 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/popover/popover-content.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./range-calendar.svelte";
2 | import Cell from "./range-calendar-cell.svelte";
3 | import Day from "./range-calendar-day.svelte";
4 | import Grid from "./range-calendar-grid.svelte";
5 | import Header from "./range-calendar-header.svelte";
6 | import Months from "./range-calendar-months.svelte";
7 | import GridRow from "./range-calendar-grid-row.svelte";
8 | import Heading from "./range-calendar-heading.svelte";
9 | import GridBody from "./range-calendar-grid-body.svelte";
10 | import GridHead from "./range-calendar-grid-head.svelte";
11 | import HeadCell from "./range-calendar-head-cell.svelte";
12 | import NextButton from "./range-calendar-next-button.svelte";
13 | import PrevButton from "./range-calendar-prev-button.svelte";
14 |
15 | export {
16 | Day,
17 | Cell,
18 | Grid,
19 | Header,
20 | Months,
21 | GridRow,
22 | Heading,
23 | GridBody,
24 | GridHead,
25 | HeadCell,
26 | NextButton,
27 | PrevButton,
28 | //
29 | Root as RangeCalendar,
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-cell.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-day.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
40 |
41 | {date.day}
42 |
43 |
44 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-grid.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-heading.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
16 |
17 | {headingValue}
18 |
19 |
20 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-months.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/range-calendar/range-calendar.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | {#each months as month}
36 |
37 |
38 |
39 | {#each weekdays as weekday}
40 |
41 | {weekday.slice(0, 2)}
42 |
43 | {/each}
44 |
45 |
46 |
47 | {#each month.weeks as weekDates}
48 |
49 | {#each weekDates as date}
50 |
51 |
52 |
53 | {/each}
54 |
55 | {/each}
56 |
57 |
58 | {/each}
59 |
60 |
61 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/scroll-area/index.ts:
--------------------------------------------------------------------------------
1 | import Scrollbar from "./scroll-area-scrollbar.svelte";
2 | import Root from "./scroll-area.svelte";
3 |
4 | export {
5 | Root,
6 | Scrollbar,
7 | //,
8 | Root as ScrollArea,
9 | Scrollbar as ScrollAreaScrollbar,
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
23 |
24 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/scroll-area/scroll-area.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {#if orientation === "vertical" || orientation === "both"}
26 |
27 | {/if}
28 | {#if orientation === "horizontal" || orientation === "both"}
29 |
30 | {/if}
31 |
32 |
33 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/index.ts:
--------------------------------------------------------------------------------
1 | import { Select as SelectPrimitive } from "bits-ui";
2 |
3 | import Label from "./select-label.svelte";
4 | import Item from "./select-item.svelte";
5 | import Content from "./select-content.svelte";
6 | import Trigger from "./select-trigger.svelte";
7 | import Separator from "./select-separator.svelte";
8 |
9 | const Root = SelectPrimitive.Root;
10 | const Group = SelectPrimitive.Group;
11 | const Input = SelectPrimitive.Input;
12 | const Value = SelectPrimitive.Value;
13 |
14 | export {
15 | Root,
16 | Group,
17 | Input,
18 | Label,
19 | Item,
20 | Value,
21 | Content,
22 | Trigger,
23 | Separator,
24 | //
25 | Root as Select,
26 | Group as SelectGroup,
27 | Input as SelectInput,
28 | Label as SelectLabel,
29 | Item as SelectItem,
30 | Value as SelectValue,
31 | Content as SelectContent,
32 | Trigger as SelectTrigger,
33 | Separator as SelectSeparator,
34 | };
35 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/select-content.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/select-item.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {label || value}
39 |
40 |
41 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/select-label.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/select-separator.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/select/select-trigger.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | span]:line-clamp-1",
16 | className
17 | )}
18 | {...$$restProps}
19 | let:builder
20 | on:click
21 | on:keydown
22 | >
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/separator/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./separator.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Separator,
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/separator/separator.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
23 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/sonner/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Toaster } from "./sonner.svelte";
2 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/sonner/sonner.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
21 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/switch/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./switch.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Switch,
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/switch/switch.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
23 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./table.svelte";
2 | import Body from "./table-body.svelte";
3 | import Caption from "./table-caption.svelte";
4 | import Cell from "./table-cell.svelte";
5 | import Footer from "./table-footer.svelte";
6 | import Head from "./table-head.svelte";
7 | import Header from "./table-header.svelte";
8 | import Row from "./table-row.svelte";
9 | import Checkbox from "./table-checkbox.svelte"
10 |
11 | export {
12 | Root,
13 | Body,
14 | Caption,
15 | Cell,
16 | Footer,
17 | Head,
18 | Header,
19 | Row,
20 | //
21 | Root as Table,
22 | Body as TableBody,
23 | Caption as TableCaption,
24 | Cell as TableCell,
25 | Footer as TableFooter,
26 | Head as TableHead,
27 | Header as TableHeader,
28 | Row as TableRow,
29 | Checkbox as TableCheckbox
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-body.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-caption.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-cell.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
17 |
18 | |
19 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-checkbox.svelte:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-head.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
18 |
19 | |
20 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table-row.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/lib/components/ui/table/table.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/frontend/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import { cubicOut } from "svelte/easing";
4 | import type { TransitionConfig } from "svelte/transition";
5 |
6 | export function cn(...inputs: ClassValue[]) {
7 | return twMerge(clsx(inputs));
8 | }
9 |
10 | type FlyAndScaleParams = {
11 | y?: number;
12 | x?: number;
13 | start?: number;
14 | duration?: number;
15 | };
16 |
17 | export const flyAndScale = (
18 | node: Element,
19 | params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
20 | ): TransitionConfig => {
21 | const style = getComputedStyle(node);
22 | const transform = style.transform === "none" ? "" : style.transform;
23 |
24 | const scaleConversion = (
25 | valueA: number,
26 | scaleA: [number, number],
27 | scaleB: [number, number]
28 | ) => {
29 | const [minA, maxA] = scaleA;
30 | const [minB, maxB] = scaleB;
31 |
32 | const percentage = (valueA - minA) / (maxA - minA);
33 | const valueB = percentage * (maxB - minB) + minB;
34 |
35 | return valueB;
36 | };
37 |
38 | const styleToString = (
39 | style: Record
40 | ): string => {
41 | return Object.keys(style).reduce((str, key) => {
42 | if (style[key] === undefined) return str;
43 | return str + `${key}:${style[key]};`;
44 | }, "");
45 | };
46 |
47 | return {
48 | duration: params.duration ?? 200,
49 | delay: 0,
50 | css: (t) => {
51 | const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
52 | const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
53 | const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
54 |
55 | return styleToString({
56 | transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
57 | opacity: t
58 | });
59 | },
60 | easing: cubicOut
61 | };
62 | };
--------------------------------------------------------------------------------
/frontend/src/main.ts:
--------------------------------------------------------------------------------
1 | import "./app.pcss";
2 | import App from "./App.svelte";
3 |
4 | const app = new App({
5 | target: document.getElementById("app")!,
6 | });
7 |
8 | export default app;
9 |
--------------------------------------------------------------------------------
/frontend/src/routes/Logs.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {@html text}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
41 |
48 |
{page + 1}
49 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/frontend/src/routes/Points.svelte:
--------------------------------------------------------------------------------
1 |
200 |
201 |
202 |
203 |
204 |
205 |
209 |
210 |
211 |
212 |
213 | Descending
214 | Ascending
215 |
216 |
217 | {#each streamers_name as s, index}
218 |
223 | {/each}
224 |
225 |
226 |
227 |
228 |
229 |
252 |
253 |
254 | value.set({ start: v.start, end: v.end })}
260 | />
261 |
262 |
263 |
264 |
265 |
270 |
271 | new Date(t).toLocaleString()}
275 | gridLine={false}
276 | labelMargin={20}
277 | />
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
--------------------------------------------------------------------------------
/frontend/src/strategy/DetailedStrategy.svelte:
--------------------------------------------------------------------------------
1 |
84 |
85 |
86 | {#if error}
87 |
88 |
89 | Error
90 | {error}
91 |
92 | {/if}
93 |
Default odds
94 |
132 |
133 | Detailed odds
134 |
149 |
150 |
151 | {#each detailed_odds as f, index}
152 | {#if f.error}
153 |
154 |
155 | Error
156 | {f.error}
157 |
158 | {/if}
159 |
160 |
161 |
162 |
163 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 | {#each DETAILED_STRATEGY_ODDS_COMPARISON_TYPES as d}
178 | {d.label}
179 | {/each}
180 |
181 |
182 |
183 |
193 |
194 |
195 |
196 |
201 |
202 |
203 |
204 |
205 |
210 |
211 |
212 |
213 |
214 |
219 |
220 |
221 | {#if index + 1 != detailed_odds.length}
222 |
223 | {/if}
224 | {/each}
225 |
226 |
227 |
--------------------------------------------------------------------------------
/frontend/src/strategy/strategy.ts:
--------------------------------------------------------------------------------
1 | export function validate_detailed_strategy(obj: any): string | undefined {
2 | for (const v of Object.keys(obj)) {
3 | // points object
4 | if (typeof obj[v] == "object") {
5 | if (
6 | obj[v].percent == undefined ||
7 | obj[v].percent < 0.0 ||
8 | obj[v].percent > 100.0
9 | ) {
10 | return "Invalid points percentage";
11 | }
12 |
13 | if (obj[v].max_value == undefined) {
14 | return "Invalid max points value";
15 | }
16 | }
17 |
18 | if (v == "_type") {
19 | continue;
20 | }
21 |
22 | if (obj[v] == undefined || obj[v] < 0.0 || obj[v] > 100.0) {
23 | return `Invalid ${v.split("_").join(" ")}`;
24 | }
25 | }
26 | }
27 |
28 | function detailed_strategy_apply_function(
29 | obj: any,
30 | func: { (item: any): any },
31 | skip_max_value = false,
32 | ): any {
33 | let obj_copy = JSON.parse(JSON.stringify(obj));
34 | for (const v of Object.keys(obj)) {
35 | if (v == "_type") {
36 | continue;
37 | }
38 |
39 | // points object
40 | if (typeof obj[v] == "object") {
41 | obj_copy[v].percent = func(obj[v].percent);
42 | if (!skip_max_value) {
43 | obj_copy[v].max_value = func(obj[v].max_value);
44 | }
45 | } else {
46 | obj_copy[v] = func(obj[v]);
47 | }
48 | }
49 | return obj_copy;
50 | }
51 |
52 | export function detailed_strategy_parse(obj: any): any {
53 | return detailed_strategy_apply_function(obj, parseFloat);
54 | }
55 |
56 | export function detailed_strategy_stringify(obj: any): any {
57 | return detailed_strategy_apply_function(
58 | detailed_strategy_apply_function(obj, (x) => x * 100.0, true),
59 | (x) => x.toString(),
60 | );
61 | }
62 |
63 | export const DETAILED_STRATEGY_ODDS_COMPARISON_TYPES = [
64 | { value: "Le", label: "<= LE" },
65 | { value: "Ge", label: ">= GE" },
66 | ];
67 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/frontend/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: [vitePreprocess({})],
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import { fontFamily } from "tailwindcss/defaultTheme";
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | const config = {
5 | darkMode: ["class"],
6 | content: ["./src/**/*.{html,js,svelte,ts}"],
7 | safelist: ["dark"],
8 | theme: {
9 | container: {
10 | center: true,
11 | padding: "2rem",
12 | screens: {
13 | "2xl": "1400px"
14 | }
15 | },
16 | extend: {
17 | colors: {
18 | border: "hsl(var(--border) / )",
19 | input: "hsl(var(--input) / )",
20 | ring: "hsl(var(--ring) / )",
21 | background: "hsl(var(--background) / )",
22 | foreground: "hsl(var(--foreground) / )",
23 | primary: {
24 | DEFAULT: "hsl(var(--primary) / )",
25 | foreground: "hsl(var(--primary-foreground) / )"
26 | },
27 | secondary: {
28 | DEFAULT: "hsl(var(--secondary) / )",
29 | foreground: "hsl(var(--secondary-foreground) / )"
30 | },
31 | destructive: {
32 | DEFAULT: "hsl(var(--destructive) / )",
33 | foreground: "hsl(var(--destructive-foreground) / )"
34 | },
35 | muted: {
36 | DEFAULT: "hsl(var(--muted) / )",
37 | foreground: "hsl(var(--muted-foreground) / )"
38 | },
39 | accent: {
40 | DEFAULT: "hsl(var(--accent) / )",
41 | foreground: "hsl(var(--accent-foreground) / )"
42 | },
43 | popover: {
44 | DEFAULT: "hsl(var(--popover) / )",
45 | foreground: "hsl(var(--popover-foreground) / )"
46 | },
47 | card: {
48 | DEFAULT: "hsl(var(--card) / )",
49 | foreground: "hsl(var(--card-foreground) / )"
50 | }
51 | },
52 | borderRadius: {
53 | lg: "var(--radius)",
54 | md: "calc(var(--radius) - 2px)",
55 | sm: "calc(var(--radius) - 4px)"
56 | },
57 | fontFamily: {
58 | sans: [...fontFamily.sans]
59 | }
60 | }
61 | },
62 | };
63 |
64 | export default config;
65 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "resolveJsonModule": true,
8 | "allowSyntheticDefaultImports": true,
9 | "noUncheckedIndexedAccess" :true,
10 | /**
11 | * Typecheck JS in `.svelte` and `.js` files by default.
12 | * Disable checkJs if you'd like to use dynamic types in JS.
13 | * Note that setting allowJs false does not prevent the use
14 | * of JS in `.svelte` files.
15 | */
16 | "allowJs": true,
17 | "checkJs": true,
18 | "isolatedModules": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "$lib": ["./src/lib"],
22 | "$lib/*": ["./src/lib/*"]
23 | }
24 | },
25 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
26 | "references": [{ "path": "./tsconfig.node.json" }]
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "strict": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import path from "path";
3 | import { svelte } from '@sveltejs/vite-plugin-svelte'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [svelte()],
8 | resolve: {
9 | alias: {
10 | $lib: path.resolve("./src/lib"),
11 | },
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/mock.dockerfile:
--------------------------------------------------------------------------------
1 | FROM t348575/muslrust-chef:1.77.1-stable as chef
2 | WORKDIR /tpm
3 |
4 | FROM chef as planner
5 | ADD mock mock
6 | ADD common common
7 | COPY ["Cargo.toml", "Cargo.lock", "."]
8 | RUN perl -0777 -i -pe 's/members = \[[^\]]+\]/members = ["mock", "common"]/igs' Cargo.toml
9 | RUN cargo chef prepare --recipe-path recipe.json
10 |
11 | FROM chef as builder
12 | COPY --from=planner /tpm/recipe.json recipe.json
13 | ARG RUSTFLAGS='-C strip=symbols -C linker=clang -C link-arg=-fuse-ld=/usr/local/bin/mold'
14 | RUN RUSTFLAGS="$RUSTFLAGS" cargo chef cook --recipe-path recipe.json
15 | ADD mock mock
16 | ADD common common
17 | COPY ["Cargo.toml", "Cargo.lock", "."]
18 | RUN perl -0777 -i -pe 's/members = \[[^\]]+\]/members = ["mock", "common"]/igs' Cargo.toml
19 | RUN RUSTFLAGS="$RUSTFLAGS" cargo build --target x86_64-unknown-linux-musl
20 |
21 | FROM busybox AS runtime
22 | WORKDIR /
23 | COPY --from=builder /tpm/target/x86_64-unknown-linux-musl/debug/mock /app
24 | EXPOSE 3000
25 | ENTRYPOINT ["/app"]
--------------------------------------------------------------------------------
/mock/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "mock"
3 | version = "0.1.11"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | axum = { version = "0.7", features = ["macros", "ws"] }
8 | base64 = "0.22"
9 | eyre = "0.6"
10 | common = { path = "../common" }
11 | http = "1.1.0"
12 | serde = { version = "1", features = ["derive"] }
13 | serde_json = "1"
14 | tokio = { version = "1", features = ["full"] }
15 | tower-http = { version = "0.5", features = ["trace"] }
16 | tracing = "0.1"
17 | tracing-subscriber = { version = "0.3", features = ["env-filter"] }
18 | twitch_api = { features = ["tpm", "mock"], default-features = false, git = "https://github.com/t348575/twitch_api", branch = "hidden_pubsubs" }
19 |
--------------------------------------------------------------------------------