51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/tlp-recorder/prisma/migrations/20220525132132_split_up_match_outcome_column/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `outcome` on the `Match` table. All the data in the column will be lost.
5 | - Added the required column `opponentRoundWins` to the `Match` table without a default value. This is not possible if the table is not empty.
6 | - Added the required column `playerRoundWins` to the `Match` table without a default value. This is not possible if the table is not empty.
7 | - Added the required column `roundWinsRequired` to the `Match` table without a default value. This is not possible if the table is not empty.
8 |
9 | */
10 | -- RedefineTables
11 | PRAGMA foreign_keys=OFF;
12 | CREATE TABLE "new_Match" (
13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
14 | "playerCharacter" TEXT NOT NULL,
15 | "opponent" TEXT NOT NULL,
16 | "opponentCharacter" TEXT NOT NULL,
17 | "stage" TEXT,
18 | "playerRoundWins" INTEGER NOT NULL,
19 | "opponentRoundWins" INTEGER NOT NULL,
20 | "roundWinsRequired" INTEGER NOT NULL,
21 | "recordingUrl" TEXT
22 | );
23 | INSERT INTO "new_Match" ("id", "opponent", "opponentCharacter", "playerCharacter", "recordingUrl", "stage") SELECT "id", "opponent", "opponentCharacter", "playerCharacter", "recordingUrl", "stage" FROM "Match";
24 | DROP TABLE "Match";
25 | ALTER TABLE "new_Match" RENAME TO "Match";
26 | PRAGMA foreign_key_check;
27 | PRAGMA foreign_keys=ON;
28 |
--------------------------------------------------------------------------------
/tlp-webapp/prisma/migrations/20220525132132_split_up_match_outcome_column/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `outcome` on the `Match` table. All the data in the column will be lost.
5 | - Added the required column `opponentRoundWins` to the `Match` table without a default value. This is not possible if the table is not empty.
6 | - Added the required column `playerRoundWins` to the `Match` table without a default value. This is not possible if the table is not empty.
7 | - Added the required column `roundWinsRequired` to the `Match` table without a default value. This is not possible if the table is not empty.
8 |
9 | */
10 | -- RedefineTables
11 | PRAGMA foreign_keys=OFF;
12 | CREATE TABLE "new_Match" (
13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
14 | "playerCharacter" TEXT NOT NULL,
15 | "opponent" TEXT NOT NULL,
16 | "opponentCharacter" TEXT NOT NULL,
17 | "stage" TEXT,
18 | "playerRoundWins" INTEGER NOT NULL,
19 | "opponentRoundWins" INTEGER NOT NULL,
20 | "roundWinsRequired" INTEGER NOT NULL,
21 | "recordingUrl" TEXT
22 | );
23 | INSERT INTO "new_Match" ("id", "opponent", "opponentCharacter", "playerCharacter", "recordingUrl", "stage") SELECT "id", "opponent", "opponentCharacter", "playerCharacter", "recordingUrl", "stage" FROM "Match";
24 | DROP TABLE "Match";
25 | ALTER TABLE "new_Match" RENAME TO "Match";
26 | PRAGMA foreign_key_check;
27 | PRAGMA foreign_keys=ON;
28 |
--------------------------------------------------------------------------------
/tlp-webapp/src/modules/dashboard/MatchupsTable.tsx:
--------------------------------------------------------------------------------
1 | import { Loader, Table } from '@mantine/core';
2 |
3 | import Error from '@/components/common/Error';
4 | import { Matchup } from '@/lib/types';
5 |
6 | import MatchupsTableRow from './MatchupsTableRow';
7 | import useCharacterSummary from './useCharacterSummary';
8 |
9 | export interface MatchupsTableProps {}
10 |
11 | export default function MatchupsTable(props: MatchupsTableProps) {
12 | const { data: characterSummary, error, isLoading, isIdle, isError } = useCharacterSummary();
13 |
14 | if (isLoading || isIdle) {
15 | return ;
16 | }
17 |
18 | if (isError) {
19 | return ;
20 | }
21 |
22 | const { matchups } = characterSummary;
23 | const rows = matchups.map((matchup) => (
24 |
25 | ));
26 |
27 | return (
28 |
& {
7 | pageTitle?: string;
8 | breadCrumbs?: Breadcrumb[];
9 | };
10 |
11 | export type NextComponentWithLayout = NextComponentType &
12 | Partial;
13 |
14 | export interface Breadcrumb {
15 | label: string;
16 | path: string;
17 | }
18 |
19 | export interface Rival extends MatchesPlayed, LastMatchPlayed {
20 | name: string;
21 | }
22 |
23 | export interface Matchup extends MatchesPlayed, LastMatchPlayed {
24 | character: string;
25 | }
26 |
27 | export interface Stage extends MatchesPlayed, LastMatchPlayed {
28 | name: string;
29 | }
30 |
31 | export interface Character extends MatchesPlayed, LastMatchPlayed {
32 | name: string;
33 | }
34 |
35 | export interface CharacterSummary {
36 | character: Character;
37 | latestMatches: Match[];
38 | matchups: Matchup[];
39 | stages: Stage[];
40 | }
41 |
42 | // TODO: Maybe I want to show in the future if a setting is being overriden
43 | // by the corresponding environment variable
44 | export interface Settings {
45 | recorder: RecorderSettings;
46 | overrides?: {
47 | recorder?: Partial;
48 | };
49 | }
50 |
51 | export interface RecorderSettings {
52 | enableDatabaseSync: boolean;
53 | enableNotationSync: boolean;
54 | enableVideoRecording: boolean;
55 | enableVideoUpload: boolean;
56 | enableCleanup: boolean;
57 | obsWebsocketPort: number;
58 | obsWebsocketPassword: string;
59 | recordingPath: string;
60 | matchesPerRecording: number;
61 | uploadDelay: number;
62 | cleanupDelay: number;
63 | logLevel: 'verbose' | 'info';
64 | tickInterval: number;
65 | }
66 |
67 | export interface LastMatchPlayed {
68 | lastPlayed: Date;
69 | lastPlayedId: number;
70 | }
71 |
72 | export interface MatchesPlayed {
73 | games: number;
74 | wins: number;
75 | losses: number;
76 | draws: number;
77 | }
78 |
79 | export interface CharacterMatchesPlayed extends MatchesPlayed {
80 | name: string;
81 | }
82 |
83 | export interface WeeklyPerformance extends MatchesPlayed {
84 | characters: CharacterMatchesPlayed[];
85 | }
86 |
87 | export interface WeeklyPerformanceResponse {
88 | thisWeek: WeeklyPerformance;
89 | lastWeek: WeeklyPerformance;
90 | twoWeeksAgo: WeeklyPerformance;
91 | }
92 |
--------------------------------------------------------------------------------
/tlp-recorder/src/recording/youtube/uploader.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 |
3 | import { google } from 'googleapis';
4 | import { OAuth2Client } from 'googleapis/node_modules/google-auth-library';
5 |
6 | import db from '@/database';
7 | import log from '@/helpers/log';
8 | import config from '@/config';
9 | import getLatestVideoPath, { scheduleCleanup } from '@/recording/util';
10 | import { VideoOptions } from '@/types/types';
11 |
12 | export async function uploadVideo(
13 | client: OAuth2Client,
14 | path: string,
15 | options: VideoOptions,
16 | matchIds: number[],
17 | ): Promise {
18 | try {
19 | const service = google.youtube({
20 | version: 'v3',
21 | auth: client,
22 | });
23 |
24 | service.videos.insert(
25 | {
26 | part: ['snippet', 'status'],
27 | requestBody: {
28 | snippet: {
29 | title: options.title,
30 | description: options.description,
31 | tags: options.tags,
32 | categoryId: options.categoryId ?? '24',
33 | defaultLanguage: 'en',
34 | defaultAudioLanguage: 'en',
35 | },
36 | status: {
37 | privacyStatus: options.privacyStatus ?? 'private',
38 | },
39 | },
40 | media: {
41 | body: fs.createReadStream(path),
42 | },
43 | },
44 | async (err, res) => {
45 | if (err) {
46 | log.error('Failed to upload recording', err);
47 | }
48 |
49 | const videoUrl = `https://www.youtube.com/watch?v=${res?.data.id}`;
50 | log.info(`Upload finished: ${videoUrl}`);
51 |
52 | if (matchIds && matchIds.length > 0) {
53 | await db.match.updateMany({
54 | where: {
55 | id: { in: [...matchIds] },
56 | },
57 | data: {
58 | recordingUrl: videoUrl,
59 | },
60 | });
61 | log.debug('Adding YouTube URL to matches', { matchIds });
62 | }
63 |
64 | if (config.ENABLE_CLEANUP) {
65 | scheduleCleanup(path);
66 | }
67 | },
68 | );
69 | } catch (err) {
70 | log.error('Failed to upload video', err);
71 | }
72 | }
73 |
74 | export default async function uploadLatestVideo(
75 | client: OAuth2Client,
76 | options: VideoOptions,
77 | matchIds: number[],
78 | ): Promise {
79 | const path = getLatestVideoPath();
80 |
81 | log.debug('Uploading to YouTube', { path });
82 |
83 | if (!path) {
84 | throw new Error('Did not find any video file to upload');
85 | }
86 |
87 | return uploadVideo(client, path, options, matchIds);
88 | }
89 |
--------------------------------------------------------------------------------
/tlp-recorder/src/recording/obs/broker.ts:
--------------------------------------------------------------------------------
1 | import OBSWebSocket from 'obs-websocket-js';
2 |
3 | import config from '@/config';
4 | import log from '@/helpers/log';
5 | import getNotation from '@/tekken/notation';
6 | import { TickEventData } from '@/types/types';
7 |
8 | export default class Broker {
9 | private static instance: Broker;
10 |
11 | private obs: OBSWebSocket;
12 |
13 | private constructor() {
14 | this.obs = new OBSWebSocket();
15 | }
16 |
17 | static async getInstance(): Promise {
18 | if (Broker.instance) return Broker.instance;
19 |
20 | const instance = new Broker();
21 | await instance.connect();
22 | Broker.instance = instance;
23 |
24 | return instance;
25 | }
26 |
27 | async connect(): Promise {
28 | await this.obs.connect(`ws://127.0.0.1:${config.OBS_WS_PORT}`, config.OBS_WS_PASSWORD);
29 | log.info('Connected to OBS');
30 | }
31 |
32 | async startRecording(): Promise {
33 | await this.obs.call('StartRecord');
34 | }
35 |
36 | async stopRecording(): Promise {
37 | await this.obs.call('StopRecord');
38 | }
39 |
40 | async pauseRecording(): Promise {
41 | await this.obs.call('PauseRecord');
42 | }
43 |
44 | async resumeRecording(): Promise {
45 | await this.obs.call('ResumeRecord');
46 | }
47 |
48 | async getRecordingStatus(): Promise<{
49 | outputActive: boolean;
50 | ouputPaused: boolean;
51 | outputTimecode: string;
52 | outputDuration: number;
53 | outputBytes: number;
54 | }> {
55 | return this.obs.call('GetRecordStatus');
56 | }
57 |
58 | async updateMetadataFromTick(data: TickEventData, previousTickData: TickEventData) {
59 | const { playerInput, opponentInput } = data;
60 | const { playerInput: prevPlayerInput, opponentInput: prevOpponentInput } = previousTickData;
61 |
62 | if (playerInput.attack !== '' && playerInput.attack !== prevPlayerInput.attack) {
63 | await this.updateText('p1move', getNotation(playerInput));
64 | }
65 |
66 | if (opponentInput.attack !== '' && opponentInput.attack !== prevOpponentInput.attack) {
67 | await this.updateText('p2move', getNotation(opponentInput));
68 | }
69 | }
70 |
71 | async updateText(sourceName: string, text: string): Promise {
72 | await this.obs.call('SetInputSettings', {
73 | inputName: sourceName,
74 | inputSettings: {
75 | text,
76 | },
77 | });
78 | }
79 |
80 | async clearAllText(): Promise {
81 | const sources = ['p1move', 'p2move', 'p1framedata', 'p2framedata'];
82 | const updates = sources.map((source) => this.updateText(source, ''));
83 | await Promise.all(updates);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tlp-recorder/.gitignore:
--------------------------------------------------------------------------------
1 | /pkg
2 | /credentials
3 | /prisma/database.db*
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 | .pnpm-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional stylelint cache
62 | .stylelintcache
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variable files
80 | .env
81 | .env.development.local
82 | .env.test.local
83 | .env.production.local
84 | .env.local
85 |
86 | # parcel-bundler cache (https://parceljs.org/)
87 | .cache
88 | .parcel-cache
89 |
90 | # Next.js build output
91 | .next
92 | out
93 |
94 | # Nuxt.js build / generate output
95 | .nuxt
96 | dist
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 | .cache
110 |
111 | # Docusaurus cache and generated files
112 | .docusaurus
113 |
114 | # Serverless directories
115 | .serverless/
116 |
117 | # FuseBox cache
118 | .fusebox/
119 |
120 | # DynamoDB Local files
121 | .dynamodb/
122 |
123 | # TernJS port file
124 | .tern-port
125 |
126 | # Stores VSCode versions used for testing VSCode extensions
127 | .vscode-test
128 |
129 | # yarn v2
130 | .yarn/cache
131 | .yarn/unplugged
132 | .yarn/build-state.yml
133 | .yarn/install-state.gz
134 | .pnp.*
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Create test release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
12 | jobs:
13 | build-recorder:
14 | runs-on: windows-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 | - uses: actions/setup-node@v3
18 | with:
19 | node-version: 16
20 | - name: Install commands
21 | run: |
22 | npm install -g pkg
23 | npm install -g yarn
24 | - name: Install dependencies
25 | working-directory: ./tlp-recorder
26 | run: yarn install
27 | - name: Generate Prisma client
28 | working-directory: ./tlp-recorder
29 | run: yarn prisma generate
30 | - name: Build
31 | working-directory: ./tlp-recorder
32 | run: yarn build
33 | - name: Package
34 | working-directory: ./tlp-recorder
35 | run: pkg .
36 | - uses: actions/upload-artifact@master
37 | with:
38 | name: recorder-exe
39 | path: ./tlp-recorder/pkg/tlp-recorder.exe
40 | build-webapp:
41 | runs-on: windows-latest
42 | steps:
43 | - uses: actions/checkout@v3
44 | - uses: actions/setup-node@v3
45 | with:
46 | node-version: 16
47 | - name: Install commands
48 | run: |
49 | npm install -g pkg
50 | npm install -g yarn
51 | - name: Install dependencies
52 | working-directory: ./tlp-webapp
53 | run: yarn install
54 | - name: Generate Prisma client
55 | working-directory: ./tlp-webapp
56 | run: yarn prisma generate
57 | - name: Build
58 | working-directory: ./tlp-webapp
59 | run: yarn build
60 | - name: Package
61 | working-directory: ./tlp-webapp
62 | run: pkg .
63 | - uses: actions/upload-artifact@master
64 | with:
65 | name: webapp-exe
66 | path: ./tlp-webapp/pkg/tlp-webapp.exe
67 | create-release:
68 | needs: [build-recorder, build-webapp]
69 | runs-on: ubuntu-latest
70 | steps:
71 | - uses: actions/checkout@v3
72 | - uses: actions/download-artifact@master
73 | with:
74 | name: recorder-exe
75 | path: ./bin/
76 | - uses: actions/download-artifact@master
77 | with:
78 | name: webapp-exe
79 | path: ./bin/
80 | - name: Archive Release
81 | uses: montudor/action-zip@v1
82 | with:
83 | args: zip -qq -r ./tlp_win64.zip ./bin
84 | - name: Create Release
85 | uses: softprops/action-gh-release@v1
86 | with:
87 | files: ./tlp_win64.zip
88 | token: ${{ secrets.TOKEN }}
89 |
--------------------------------------------------------------------------------
/tlp-webapp/src/modules/settings/useSettingsForm.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useQueryClient } from 'react-query';
3 |
4 | import axios, { AxiosError } from 'axios';
5 | import { useForm } from '@mantine/form';
6 |
7 | import { RecorderSettings, Settings } from '@/lib/types';
8 |
9 | // TODO: Refactoring
10 | export default function useSettingsForm({ recorder }: Settings) {
11 | const queryClient = useQueryClient();
12 | const [loading, setLoading] = useState(false);
13 | const [success, setSuccess] = useState(false);
14 | const [error, setError] = useState(null);
15 | const form = useForm({
16 | initialValues: {
17 | enableDatabaseSync: recorder.enableDatabaseSync,
18 | enableNotationSync: recorder.enableNotationSync,
19 | enableVideoRecording: recorder.enableVideoRecording,
20 | enableVideoUpload: recorder.enableVideoUpload,
21 | enableCleanup: recorder.enableCleanup,
22 | obsWebsocketPort: recorder.obsWebsocketPort,
23 | obsWebsocketPassword: recorder.obsWebsocketPassword,
24 | recordingPath: recorder.recordingPath,
25 | matchesPerRecording: recorder.matchesPerRecording,
26 | uploadDelay: recorder.uploadDelay,
27 | cleanupDelay: recorder.cleanupDelay,
28 | logLevel: recorder.logLevel,
29 | tickInterval: recorder.tickInterval,
30 | },
31 | validate: {
32 | logLevel: (value) =>
33 | value === 'info' || value === 'verbose' ? null : 'Log level must be "info" or "verbose"',
34 | obsWebsocketPort: (value) =>
35 | value >= 0 && value <= 65535 ? null : 'OBS Websocket port must be between 0 and 65535',
36 | obsWebsocketPassword: (value) =>
37 | value.length > 0 ? null : 'OBS Websocket password cannot be empty',
38 | matchesPerRecording: (value) =>
39 | value > 0 ? null : 'Matches per recording must be greater than 0',
40 | recordingPath: (value) =>
41 | !value.includes('\\') ? null : 'Use "/" instead of "\\" for the recording path',
42 | },
43 | });
44 |
45 | async function handleSubmit(values: RecorderSettings) {
46 | const newSettings: Settings = {
47 | recorder: {
48 | ...values,
49 | },
50 | };
51 | try {
52 | setLoading(true);
53 | const response = await axios.post('/api/settings', newSettings);
54 | if (response.status === 200) {
55 | setSuccess(true);
56 | queryClient.invalidateQueries('settings');
57 | }
58 | } catch (err) {
59 | if (err instanceof AxiosError) {
60 | setError(err.response?.data ?? err.message);
61 | } else {
62 | setError('Unknown error');
63 | }
64 | } finally {
65 | setLoading(false);
66 | }
67 | }
68 |
69 | return {
70 | loading,
71 | error,
72 | setError,
73 | success,
74 | setSuccess,
75 | form,
76 | handleSubmit,
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '20 16 * * 2'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v3
42 |
43 | # Initializes the CodeQL tools for scanning.
44 | - name: Initialize CodeQL
45 | uses: github/codeql-action/init@v2
46 | with:
47 | languages: ${{ matrix.language }}
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 |
52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
53 | queries: +security-and-quality
54 |
55 |
56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
57 | # If this step fails, then you should remove it and run the build manually (see below)
58 | - name: Autobuild
59 | uses: github/codeql-action/autobuild@v2
60 |
61 | # ℹ️ Command-line programs to run using the OS shell.
62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
63 |
64 | # If the Autobuild fails above, remove it and uncomment the following three lines.
65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
66 |
67 | # - run: |
68 | # echo "Run, Build Application using script"
69 | # ./location_of_script_within_repo/buildscript.sh
70 |
71 | - name: Perform CodeQL Analysis
72 | uses: github/codeql-action/analyze@v2
73 |
--------------------------------------------------------------------------------
/tlp-webapp/src/pages/api/matches/weeklyPerformance.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | import sub from 'date-fns/sub';
4 |
5 | import db from '@/lib/database';
6 | import { WeeklyPerformanceResponse } from '@/lib/types';
7 |
8 | export default async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse,
11 | ) {
12 | const today = new Date();
13 | const lastWeek = sub(today, { weeks: 1 });
14 | const twoWeeksAgo = sub(today, { weeks: 2 });
15 | const threeWeeksAgo = sub(today, { weeks: 3 });
16 |
17 | const matchesThisWeek = await db.match.findMany({
18 | where: {
19 | createdAt: {
20 | gte: lastWeek,
21 | },
22 | },
23 | orderBy: {
24 | createdAt: 'desc',
25 | },
26 | });
27 | const winsThisWeek = matchesThisWeek.filter(
28 | (match) => match.playerRoundWins > match.opponentRoundWins,
29 | );
30 | const lossesThisWeek = matchesThisWeek.filter(
31 | (match) => match.playerRoundWins < match.opponentRoundWins,
32 | );
33 | const drawsThisWeek = matchesThisWeek.filter(
34 | (match) => match.playerRoundWins === match.opponentRoundWins,
35 | );
36 |
37 | const matchesLastWeek = await db.match.findMany({
38 | where: {
39 | createdAt: {
40 | gte: twoWeeksAgo,
41 | lt: lastWeek,
42 | },
43 | },
44 | orderBy: {
45 | createdAt: 'desc',
46 | },
47 | });
48 | const winsLastWeek = matchesLastWeek.filter(
49 | (match) => match.playerRoundWins > match.opponentRoundWins,
50 | );
51 | const lossesLastWeek = matchesLastWeek.filter(
52 | (match) => match.playerRoundWins < match.opponentRoundWins,
53 | );
54 | const drawsLastWeek = matchesLastWeek.filter(
55 | (match) => match.playerRoundWins === match.opponentRoundWins,
56 | );
57 |
58 | const matchesTwoWeeksAgo = await db.match.findMany({
59 | where: {
60 | createdAt: {
61 | gte: threeWeeksAgo,
62 | lt: twoWeeksAgo,
63 | },
64 | },
65 | orderBy: {
66 | createdAt: 'desc',
67 | },
68 | });
69 | const winsTwoWeeksAgo = matchesTwoWeeksAgo.filter(
70 | (match) => match.playerRoundWins > match.opponentRoundWins,
71 | );
72 | const lossesTwoWeeksAgo = matchesTwoWeeksAgo.filter(
73 | (match) => match.playerRoundWins < match.opponentRoundWins,
74 | );
75 | const drawsTwoWeeksAgo = matchesTwoWeeksAgo.filter(
76 | (match) => match.playerRoundWins === match.opponentRoundWins,
77 | );
78 |
79 | const response: WeeklyPerformanceResponse = {
80 | thisWeek: {
81 | games: matchesThisWeek.length,
82 | wins: winsThisWeek.length,
83 | losses: lossesThisWeek.length,
84 | draws: drawsThisWeek.length,
85 | characters: [], // TODO: implement this
86 | },
87 | lastWeek: {
88 | games: matchesThisWeek.length,
89 | wins: winsLastWeek.length,
90 | losses: lossesLastWeek.length,
91 | draws: drawsLastWeek.length,
92 | characters: [], // TODO: implement this
93 | },
94 | twoWeeksAgo: {
95 | games: matchesTwoWeeksAgo.length,
96 | wins: winsTwoWeeksAgo.length,
97 | losses: lossesTwoWeeksAgo.length,
98 | draws: drawsTwoWeeksAgo.length,
99 | characters: [], // TODO: implement this
100 | },
101 | };
102 |
103 | res.status(200).json(response);
104 | }
105 |
--------------------------------------------------------------------------------
/tlp-webapp/src/modules/settings/SettingsForm.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Title, Checkbox, TextInput, Group, Button, Notification } from '@mantine/core';
2 |
3 | import { Settings } from '@/lib/types';
4 | import useSettingsForm from './useSettingsForm';
5 | import { X, Check } from 'tabler-icons-react';
6 |
7 | export interface SettingsFormProps {
8 | settings: Settings;
9 | }
10 |
11 | export default function SettingsForm({ settings }: SettingsFormProps) {
12 | // TODO: Refactoring
13 | const { loading, success, setSuccess, error, setError, form, handleSubmit } =
14 | useSettingsForm(settings);
15 |
16 | return (
17 |
18 |
95 |
96 | {error && (
97 | }
99 | color="red"
100 | title="Something went wrong"
101 | onClose={() => setError(null)}
102 | mt={12}
103 | >
104 | {error}
105 |
106 | )}
107 |
108 | {success && (
109 | }
111 | color="teal"
112 | title="Configuration updated"
113 | onClose={() => setSuccess(false)}
114 | mt={12}
115 | >
116 | Please restart the recorder application to apply your settings.
117 |
118 | )}
119 |
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/tlp-webapp/src/pages/api/characters/[character].ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | import db from '@/lib/database';
4 | import { CharacterSummary, Matchup, Stage } from '@/lib/types';
5 |
6 | // TODO: Split this up
7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
8 | const limit = Number(req.query.limit);
9 | const characterName = req.query.character as string;
10 |
11 | const allMatches = await db.match.findMany({
12 | where: {
13 | playerCharacter: characterName,
14 | },
15 | orderBy: {
16 | createdAt: 'desc',
17 | },
18 | });
19 |
20 | const latestMatches = await db.match.findMany({
21 | where: {
22 | playerCharacter: characterName,
23 | },
24 | orderBy: {
25 | createdAt: 'desc',
26 | },
27 | take: isNaN(limit) ? 10 : limit,
28 | });
29 |
30 | const wins = allMatches.filter((match) => match.playerRoundWins > match.opponentRoundWins).length;
31 | const losses = allMatches.filter(
32 | (match) => match.playerRoundWins < match.opponentRoundWins,
33 | ).length;
34 | const draws = allMatches.length - wins - losses;
35 | const [latestMatch] = allMatches;
36 |
37 | const playedOpponentCharacters = await db.match.groupBy({
38 | by: ['opponentCharacter'],
39 | where: {
40 | playerCharacter: characterName,
41 | },
42 | _count: {
43 | id: true,
44 | },
45 | orderBy: {
46 | _count: {
47 | id: 'desc',
48 | },
49 | },
50 | });
51 |
52 | const matchups = playedOpponentCharacters.map((result) => {
53 | const relevantMatches = allMatches.filter(
54 | (match) => match.opponentCharacter === result.opponentCharacter,
55 | );
56 | const wins = relevantMatches.filter(
57 | (match) => match.playerRoundWins > match.opponentRoundWins,
58 | ).length;
59 | const losses = relevantMatches.filter(
60 | (match) => match.playerRoundWins < match.opponentRoundWins,
61 | ).length;
62 | const draws = relevantMatches.length - wins - losses;
63 | const [latestMatchForMatchup] = relevantMatches;
64 |
65 | return {
66 | character: result.opponentCharacter,
67 | games: result._count.id,
68 | wins,
69 | losses,
70 | draws,
71 | lastPlayed: latestMatchForMatchup.createdAt,
72 | lastPlayedId: latestMatchForMatchup.id,
73 | } as Matchup;
74 | });
75 |
76 | const playedStages = await db.match.groupBy({
77 | by: ['stage'],
78 | where: {
79 | playerCharacter: characterName,
80 | },
81 | _count: {
82 | id: true,
83 | },
84 | orderBy: {
85 | _count: {
86 | id: 'desc',
87 | },
88 | },
89 | });
90 |
91 | const stages = playedStages.map((result) => {
92 | const relevantMatches = allMatches.filter((match) => match.stage === result.stage);
93 | const wins = relevantMatches.filter(
94 | (match) => match.playerRoundWins > match.opponentRoundWins,
95 | ).length;
96 | const losses = relevantMatches.filter(
97 | (match) => match.playerRoundWins < match.opponentRoundWins,
98 | ).length;
99 | const draws = relevantMatches.length - wins - losses;
100 | const [latestMatchForMatchup] = relevantMatches;
101 |
102 | return {
103 | name: result.stage,
104 | games: result._count.id,
105 | wins,
106 | losses,
107 | draws,
108 | lastPlayed: latestMatchForMatchup.createdAt,
109 | lastPlayedId: latestMatchForMatchup.id,
110 | } as Stage;
111 | });
112 |
113 | const characterSummary: CharacterSummary = {
114 | character: {
115 | name: characterName,
116 | games: allMatches.length,
117 | wins,
118 | losses,
119 | draws,
120 | lastPlayed: latestMatch?.createdAt,
121 | lastPlayedId: latestMatch?.id,
122 | },
123 | latestMatches,
124 | matchups,
125 | stages,
126 | };
127 |
128 | res.status(200).json(characterSummary);
129 | }
130 |
--------------------------------------------------------------------------------
/tlp-recorder/src/recording/youtube/auth.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 |
4 | import open from 'open';
5 | import express from 'express';
6 | import { google } from 'googleapis';
7 | import { OAuth2Client } from 'googleapis/node_modules/google-auth-library';
8 |
9 | import log from '@/helpers/log';
10 | import config, { isDevl } from '@/config';
11 | import { GoogleAuthConfig } from '@/types/types';
12 |
13 | const { OAuth2 } = google.auth;
14 |
15 | let credentials: GoogleAuthConfig;
16 |
17 | if (config.ENABLE_VIDEO_UPLOAD) {
18 | const credentialFilePath = isDevl()
19 | ? path.join(process.cwd(), 'credentials', 'google.keys.json')
20 | : path.join(process.cwd(), 'google.keys.json');
21 |
22 | try {
23 | const credentialsExist = fs.existsSync(credentialFilePath);
24 | if (!credentialsExist) {
25 | log.error('File google.keys.json does not exist.');
26 | log.info('https://github.com/marcelherd/TekkenLearningPlatform/wiki/YouTube-Upload-Setup');
27 | process.exit(1);
28 | }
29 |
30 | fs.accessSync(credentialFilePath, fs.constants.R_OK);
31 | } catch (err) {
32 | log.error('File google.keys.json is not readable');
33 | process.exit(1);
34 | }
35 |
36 | const credentialContents: string = fs.readFileSync(credentialFilePath, 'utf8');
37 | credentials = JSON.parse(credentialContents);
38 |
39 | if (
40 | !credentials.web ||
41 | !(typeof credentials.web.client_id === 'string') ||
42 | !(typeof credentials.web.client_secret === 'string') ||
43 | !Array.isArray(credentials.web.redirect_uris)
44 | ) {
45 | throw new Error(
46 | 'google.keys.json is corrupted, please re-download it from Google Cloud Console',
47 | );
48 | }
49 |
50 | if (credentials.web.redirect_uris.length < 1) {
51 | throw new Error(
52 | 'Add http://localhost:5431 as redirect URL in Google Cloud Console then update google.keys.json',
53 | );
54 | }
55 | }
56 |
57 | export default async function getAuthenticatedClient(): Promise {
58 | return new Promise((resolve, reject) => {
59 | const clientId = credentials.web.client_id;
60 | const clientSecret = credentials.web.client_secret;
61 | const [redirectUrl] = credentials.web.redirect_uris;
62 |
63 | const client = new OAuth2(clientId, clientSecret, redirectUrl);
64 |
65 | const authorizeUrl = client.generateAuthUrl({
66 | access_type: 'offline',
67 | scope: ['https://www.googleapis.com/auth/youtube.upload'],
68 | });
69 |
70 | const app = express();
71 |
72 | const server = app.listen(5431, async () => {
73 | log.debug('Waiting for authorization code');
74 | log.info('Please open this URL in your browser and sign in using your YouTube account:');
75 | log.info(authorizeUrl);
76 |
77 | const childProcess = await open(authorizeUrl, { wait: false });
78 | childProcess.unref();
79 | });
80 |
81 | app.get('/callback', async (req, res) => {
82 | const { code } = req.query;
83 |
84 | if (typeof code === 'string') {
85 | log.debug('Received authorization code');
86 |
87 | const result = await client.getToken(code);
88 |
89 | if (result.tokens.access_token) {
90 | client.setCredentials(result.tokens);
91 |
92 | log.debug('Retrieved access token');
93 | log.info('Signed in, recordings will be uploaded to your YouTube channel.');
94 |
95 | res.send('Authentication successful, you can close this page now.');
96 | server.close();
97 | resolve(client);
98 | } else {
99 | log.error('Failed to retrieve access token for authorization code', { code });
100 |
101 | res.status(400).send('Failed to retrieve access token');
102 | reject();
103 | }
104 | } else {
105 | log.error('Received invalid authorization code', { code });
106 |
107 | res.status(400).send('No code provided');
108 | reject();
109 | }
110 | });
111 | });
112 | }
113 |
--------------------------------------------------------------------------------
/bin/config.yaml:
--------------------------------------------------------------------------------
1 | settings:
2 | recorder:
3 | # -----------------------------------------
4 | # Enable/disable functionalities
5 | # -----------------------------------------
6 |
7 | # If enabled, the recorder will keep track of any matches you play and add
8 | # them to your match history as well as consider them when computing
9 | # your statistics.
10 | enableDatabaseSync: true
11 |
12 | # If enabled, the recorder will send any button presses you or your
13 | # opponent input to OBS Studio.
14 | # This requires OBS Studio to be running prior to starting the
15 | # recorder module. See also:
16 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
17 | enableNotationSync: false
18 |
19 | # If enabled, the recorder will automatically record your matches as
20 | # video as you're playing. It will start recording when your match
21 | # starts and automatically end the recording when it finishes.
22 | # If the setting "matchesPerRecording" is greater than one, it will
23 | # automatically pause and resume the recording in between matches.
24 | # This requires OBS Studio to be running prior to starting the
25 | # recorder module. See also:
26 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
27 | enableVideoRecording: false
28 |
29 | # If enabled, the recorder will automatically upload your video recordings
30 | # once they are saved (i.e. when "matchesPerRecording" games have been played).
31 | # Enabling this setting does not do anything unless video recordings are enabled
32 | # as well.
33 | # This requires OBS Studio to be running prior to starting the
34 | # recorder module. See also:
35 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
36 | enableVideoUpload: false
37 |
38 | # If enabled, the recorder will automatically delete any video recordings it has
39 | # saved on your local filesystem after they have been uploaded.
40 | # Enabling this setting does not do anything unless "enableVideoRecording" and
41 | # "enableVideoUpload" have both been enabled as well.
42 | # This requires OBS Studio to be running prior to starting the
43 | # recorder module. See also:
44 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
45 | enableCleanup: true
46 |
47 | # -----------------------------------------
48 | # User preferences
49 | # -----------------------------------------
50 |
51 | # The port configured in your OBS Studio Websocket settings.
52 | # See also:
53 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
54 | obsWebsocketPort: 4455
55 |
56 | # The password configured in your OBS Studio Websocket settings.
57 | # See also:
58 | # https://github.com/marcelherd/TekkenLearningPlatform/wiki/OBS-Studio-Setup
59 | obsWebsocketPassword: "change-me"
60 |
61 | # Where you instructed OBS to save your video recordings. This must be
62 | # configured if "enableVideoUpload" and "enableCleanup" are enabled.
63 | # It is recommended to configure "flv" as "Recording Format"
64 | # in OBS Studio if you plan on uploading them to YouTube automatically.
65 | recordingPath: "C:/temp/TekkenLearningPlatform"
66 |
67 | # How many matches to save per video recording.
68 | # If recordings are uploaded to YouTube, set as high as possible
69 | # as the daily maximum quota is used up per video uploaded irrelevant
70 | # of video length.
71 | # If set to 1, it will create a separate video for each match and
72 | # rename the video appropriately.
73 | matchesPerRecording: 1
74 |
75 | # How many milliseconds to wait for OBS Studio to finish
76 | # saving the video recording.
77 | # If YouTube is unable to process uploaded videos correctly, consider
78 | # increasing the delay as it might be getting uploaded before OBS
79 | # has finished writing the file properly.
80 | uploadDelay: 500
81 |
82 | # How many milliseconds to wait after a video recording has finished
83 | # uploading to YouTube, before deleting it from the local filesystem.
84 | # If YouTube is unable to process uploaded videos correctly, consider
85 | # increasing the delay as it might be getting deleted too soon.
86 | cleanupDelay: 5000
87 |
88 | # -----------------------------------------
89 | # Developer settings
90 | # -----------------------------------------
91 |
92 | # Set to "verbose" to enable debug logging.
93 | logLevel: "info"
94 |
95 | # How often the game state is updated (interval in ms).
96 | tickInterval: 1
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
TekkenLearningPlatform
3 |
Keeps track of your matches and provides statistics to help you improve as a player
4 |
Currently not functional! As of the newest update Ver. 5.00 the memory addresses are no longer correct and have to be updated. I am open to pull requests but I will not be fixing this myself anytime soon.