├── .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 | Views 4 | Build status 5 | Docker Image Version 6 | Docker Pulls 7 | Docker Image Size 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 | ![Landing page](assets/tpm-ui-landing.png "Web UI") 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 | ![Landing page](assets/tpm-ui-landing.png "Web UI") 77 | ![Place predictions](assets/tpm-ui-make-prediction.png "Place predictions manually") 78 | ![Setup page](assets/tpm-ui-setup.png "Setup page") 79 | ![Configuration editor](assets/tpm-ui-edit-config.png "Configuration editor") 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 | 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 |
12 | 13 | 14 |
15 |
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 |
95 |
96 | 97 | 103 |
104 |
105 | 106 | 112 |
113 |
114 | 115 | 121 |
122 |
123 | 124 | 130 |
131 |
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 | --------------------------------------------------------------------------------