Compare commits
25 Commits
basic-sort
...
main
Author | SHA1 | Date |
---|---|---|
Martyn | 1f00cd699f | |
Martyn | f7b0f074cf | |
Martyn | 50303b3918 | |
Martyn | 132b433bc7 | |
Martyn | 317b72dd60 | |
Martyn | cfff6bc46e | |
Martyn | fcd60fc08a | |
Martyn | 705b1f5e12 | |
Martyn | f721c91690 | |
Martyn | f49d3ec9ab | |
Martyn | b5a0d8bda2 | |
Martyn | 6633fff3b6 | |
Martyn | 4c33360649 | |
Martyn | 56a80d8936 | |
Martyn | a8dbd68031 | |
Martyn | 3966e6e226 | |
Martyn | c783586027 | |
Martyn | cb1929a245 | |
Martyn | 3f6d1e20ce | |
Martyn | 982d9bc535 | |
Martyn | 554d1d445d | |
Martyn | 68df7a5af2 | |
Martyn | 4c27138497 | |
Martyn | 8de2c0bd74 | |
Martyn | c403bff0ec |
|
@ -12,4 +12,7 @@
|
|||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Frontend compiled version gets built from source
|
||||
/web/react-frontend
|
||||
|
||||
/twitchsingstools
|
||||
|
|
5
Makefile
5
Makefile
|
@ -10,6 +10,11 @@ test:
|
|||
build:
|
||||
go build ${LDFLAGS}
|
||||
|
||||
build-frontend:
|
||||
cd build/react-frontend && npm install && npm run build
|
||||
rm -rf web/react-frontend ; mkdir -p web/react-frontend
|
||||
cp -r build/react-frontend/build/* web/react-frontend/
|
||||
|
||||
deps:
|
||||
go get
|
||||
|
||||
|
|
|
@ -59,6 +59,11 @@ steps:
|
|||
- make test
|
||||
- make
|
||||
|
||||
- name: build-frontend
|
||||
image: node
|
||||
commands:
|
||||
- make build-frontend
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
- refs/heads/main
|
||||
|
|
|
@ -5,9 +5,14 @@ COPY internal/ /go/src/git.martyn.berlin/martyn/twitchsingstools/internal/
|
|||
COPY Makefile /go/src/git.martyn.berlin/martyn/twitchsingstools/
|
||||
RUN cd /go/src/git.martyn.berlin/martyn/twitchsingstools/; make deps ; make static
|
||||
|
||||
FROM library/node:14.7.0-stretch AS frontend
|
||||
COPY build/react-frontend /frontend
|
||||
RUN cd /frontend; npm install && npm run build
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /go/src/git.martyn.berlin/martyn/twitchsingstools /app/
|
||||
COPY web/ /app/web/
|
||||
COPY --from=frontend /frontend/build /app/web/react-frontend
|
||||
WORKDIR /app
|
||||
CMD ["/app/twitchsingstools"]
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
|
@ -0,0 +1,68 @@
|
|||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "twitchsingstools-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.5.0",
|
||||
"@testing-library/user-event": "^7.2.1",
|
||||
"material-table": "^1.67.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-scripts": "3.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Tools for Twitch Sings provided by iMartynOnTwitch"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>TwitchSingsTools</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1 @@
|
|||
{"jointime":"0001-01-01T00:00:00Z","hasleft":false,"videoCacheUpdated":"2020-08-02T17:40:13.480470725Z", "commands": [{"command":"card","action":"RandomPrompt"},{"command":"whotosingwith","action":"AgingSinger"}]}
|
|
@ -0,0 +1 @@
|
|||
{"Age":1726047740493,"AgeStr":"28 minutes ago","SongCount":1075}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,31 @@
|
|||
[{
|
||||
"singerName": "Terraglotte",
|
||||
"singCount": "40"
|
||||
},{
|
||||
"singerName": "pepperwinkle",
|
||||
"singCount": "30"
|
||||
},{
|
||||
"singerName": "BatSam",
|
||||
"singCount": "29"
|
||||
},{
|
||||
"singerName": "ProfChumbles",
|
||||
"singCount": "25"
|
||||
},{
|
||||
"singerName": "FullOfEmily",
|
||||
"singCount": "22"
|
||||
},{
|
||||
"singerName": "Grisu_xXxx",
|
||||
"singCount": "17"
|
||||
},{
|
||||
"singerName": "ChantryRae",
|
||||
"singCount": "16"
|
||||
},{
|
||||
"singerName": "CheshieCat",
|
||||
"singCount": "14"
|
||||
},{
|
||||
"singerName": "karlicYO",
|
||||
"singCount": "14"
|
||||
},{
|
||||
"singerName": "GabiAgura",
|
||||
"singCount": "14"
|
||||
}]
|
|
@ -0,0 +1,31 @@
|
|||
[{
|
||||
"songName": "Mad World",
|
||||
"singCount": "19"
|
||||
},{
|
||||
"songName": "Waving Through a Window",
|
||||
"singCount": "19"
|
||||
},{
|
||||
"songName": "Suddenly Seymour",
|
||||
"singCount": "18"
|
||||
},{
|
||||
"songName": "Someone You Loved",
|
||||
"singCount": "18"
|
||||
},{
|
||||
"songName": "Gravity",
|
||||
"singCount": "17"
|
||||
},{
|
||||
"songName": "Only Us",
|
||||
"singCount": "17"
|
||||
},{
|
||||
"songName": "Fireflies",
|
||||
"singCount": "15"
|
||||
},{
|
||||
"songName": "Behind Blue Eyes",
|
||||
"singCount": "14"
|
||||
},{
|
||||
"songName": "Hey There Delilah",
|
||||
"singCount": "14"
|
||||
},{
|
||||
"songName": "Poor Unfortunate Souls",
|
||||
"singCount": "14"
|
||||
}]
|
|
@ -0,0 +1,42 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 20vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.App-menu {
|
||||
max-width: 98%;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
max-width: 98%;
|
||||
background-color: #6207a4 !important;
|
||||
}
|
||||
|
||||
.Footer a {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.FooterCell {
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
import logo from './../../logo.svg';
|
||||
import './App.css';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Button, Paper, Container, AppBar, Table, TableBody, TableCell, TableContainer, TableRow } from '@material-ui/core';
|
||||
|
||||
function App() {
|
||||
const client_id = "sau3e70wvs369jw1u25ex8g3cve599"
|
||||
const redirect_uri = "https://" + encodeURI(window.location.href.split("/")[2]) + "/twitchadmin"
|
||||
const twitchURL = "https://id.twitch.tv/oauth2/authorize?client_id="+ client_id +"&redirect_uri="+ redirect_uri +"&response_type=code&scope=user:read:broadcast"
|
||||
|
||||
return (
|
||||
<Container className="App">
|
||||
<Paper>
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Twitch Sings Tools
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" href={twitchURL}>
|
||||
Log in with Twitch
|
||||
</Button>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Data about Twitch Sings <b>published</b> performances
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>
|
||||
This set of tools uses the standard twitch APIs to create a list of songs you have sung and singers you have sung with. <i>Note</i>: If twitch sings ever changes how they name published performances, this may get harder to do.
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Some insights
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>
|
||||
There's a "top 10 people you sing with" and a "top 10 songs you sing". There's actually not that much insight that can be drawn other than those without getting people involved :-)
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
CSV Export!
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>
|
||||
You can bring the data into Excel, Google Sheets, Libre/OpenOffice, Lotus 1-2-3 or whatever, and analyse/graph to your hearts content!
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Chatbot
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>
|
||||
...Coming "Soon"(tm)
|
||||
</Typography>
|
||||
<AppBar position="static" className="Footer">
|
||||
<TableContainer component={Container}>
|
||||
<Table><TableBody>
|
||||
<TableRow className="FooterRow">
|
||||
<TableCell className="FooterCell"><a href="https://discord.gg/sVgZeRt">Discord</a></TableCell>
|
||||
<TableCell className="FooterCell"><a href="https://git.martyn.berlin/martyn/twitchsingstools">Source Code</a></TableCell>
|
||||
<TableCell className="FooterCell"><a href="https://twitch.tv/iMartynOnTwitch">Martyn's Twitch</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="FooterRow">
|
||||
<TableCell className="FooterCell">Announcements, Support, Optional notifications</TableCell>
|
||||
<TableCell className="FooterCell">Geek out at the really hacky source code from an SRE type person</TableCell>
|
||||
<TableCell className="FooterCell">Streaming and singing is my hobby, not my job, so only if you want to...</TableCell>
|
||||
</TableRow>
|
||||
</TableBody></Table>
|
||||
</TableContainer>
|
||||
</AppBar>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
const { getByText } = render(<App />);
|
||||
const linkElement = getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
pointer-events: none;
|
||||
max-width: 20vmin;
|
||||
max-height: 9vmin;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
|
||||
.App-menu {
|
||||
max-width: 98%;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
max-width: 98%;
|
||||
background-color: #6207a4 !important;
|
||||
}
|
||||
|
||||
.Footer a {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.FooterCell {
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
import React from 'react';
|
||||
import logo from './../../logo-small.svg';
|
||||
import './AppAdmin.css';
|
||||
import { Paper, Container, AppBar, Tabs, Tab, Box } from '@material-ui/core';
|
||||
import PropTypes from 'prop-types';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import TopTenSongs from "../TopTenSongs/TopTenSongs";
|
||||
import TopTenSingers from "../TopTenSingers/TopTenSingers";
|
||||
import DuetData from "../DuetData/DuetData";
|
||||
import CacheDeets from "../CacheDeets/CacheDeets";
|
||||
import BotDeets from "../BotDeets/BotDeets";
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@material-ui/core';
|
||||
import ProblemContainer from "../ProblemContainer/ProblemContainer";
|
||||
|
||||
function TabPanel(props) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component="div"
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`vertical-tabpanel-${index}`}
|
||||
aria-labelledby={`vertical-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
<Box p={3}>{children}</Box>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
TabPanel.propTypes = {
|
||||
children: PropTypes.node,
|
||||
index: PropTypes.any.isRequired,
|
||||
value: PropTypes.any.isRequired
|
||||
};
|
||||
|
||||
function a11yProps(index) {
|
||||
return {
|
||||
id: `tab-${index}`,
|
||||
"aria-controls": `tabpanel-${index}`
|
||||
};
|
||||
}
|
||||
|
||||
class AppAdmin extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.setPage = this.setPage.bind(this);
|
||||
this.reloadSongComponent = this.reloadSongComponent.bind(this);
|
||||
this.reloadSingersComponent = this.reloadSingersComponent.bind(this);
|
||||
this.reloadDuetComponent = this.reloadDuetComponent.bind(this);
|
||||
this.state = {
|
||||
page: 0,
|
||||
reloadSongComponent: false,
|
||||
reloadSingersComponent: false,
|
||||
reloadDuetComponent: false,
|
||||
};
|
||||
}
|
||||
|
||||
setPage(pageNum) {
|
||||
let newState = this.state;
|
||||
newState.page = pageNum;
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
reloadSongComponent(components) {
|
||||
let newState = this.state;
|
||||
newState.reloadComponents = components;
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
reloadSingersComponent(components) {
|
||||
let newState = this.state;
|
||||
newState.reloadComponents = components;
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
reloadDuetComponent(components) {
|
||||
let newState = this.state;
|
||||
newState.reloadDuetComponent = components;
|
||||
console.log("I'm trying!")
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
render() {
|
||||
const page = this.state.page
|
||||
if (
|
||||
(window.location.href.split("/").length < 6) ||
|
||||
(window.location.href.split("/")[3].search(/^admin/) < 0) ||
|
||||
(window.location.href.split("/")[5].length !== 48)
|
||||
) {
|
||||
if (window.location.href.split("/")[2] !== "192.168.1.111:3000") {
|
||||
return (
|
||||
<ProblemContainer />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const csvURL = "/csv/"+ channelName + "/" + adminToken
|
||||
const tsvURL = "/tsv/"+ channelName + "/" + adminToken
|
||||
const scriptURL = "/script.bat/"+ channelName + "/" + adminToken
|
||||
const bockScriptURL = "https://tsdownloader.azurewebsites.net/api/ScriptApi/GenerateScript?userName="+ channelName
|
||||
const setPage = this.setPage
|
||||
const reloadDuetComponent = this.state.reloadDuetComponent
|
||||
const reloadSongComponent = this.state.reloadSongComponent
|
||||
const reloadSingersComponent = this.state.reloadSingersComponent
|
||||
|
||||
function handleChange(event, newValue) {
|
||||
setPage(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="App">
|
||||
<Paper>
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Twitch Sings Tools
|
||||
</Typography>
|
||||
<AppBar position="static" className="App-menu">
|
||||
<Tabs value={page} onChange={handleChange} aria-label="simple tabs example">
|
||||
<Tab label="Top Songs" {...a11yProps(0)}/>
|
||||
<Tab label="Top Singers" {...a11yProps(1)}/>
|
||||
<Tab label="Data" {...a11yProps(2)}/>
|
||||
<Tab label="Download Videos" {...a11yProps(3)}/>
|
||||
<Tab label="Export" {...a11yProps(4)}/>
|
||||
<Tab label="Cache Details" {...a11yProps(5)}/>
|
||||
<Tab label="Chatbot" {...a11yProps(6)}/>
|
||||
</Tabs>
|
||||
</AppBar>
|
||||
<TabPanel value={page} index={0}>
|
||||
<TopTenSongs reloadSongComponent={reloadSongComponent}
|
||||
onReloadedChange={this.reloadSongComponent}/>
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={1}>
|
||||
<TopTenSingers reloadSingersComponent={reloadSingersComponent}
|
||||
onReloadedChange={this.reloadSingersComponent}/>
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={2}>
|
||||
<DuetData reloadDuetComponent={reloadDuetComponent}
|
||||
onReloadedChange={this.reloadDuetComponent}/>
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={3}>
|
||||
<Typography variant="h3" component="h2" gutterBottom>
|
||||
RIP Twitch sings!
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Here is a script to download all your published duets and solo performances.
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
First, you need to <a href="https://github.com/ytdl-org/youtube-dl/releases/download/2020.07.28/youtube-dl.exe">download youtube-dl from here</a>.
|
||||
Then, download your <a href={scriptURL}>personal script from here</a>.<br/><br/>
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
Put both files in the same folder and run script.bat. I know, that's not a nice interface, but, it works. Right now, I can't bear to do much more,
|
||||
hurts to even think about the situation. It creates a subfolder per person and dates the files.
|
||||
It even works if you sang the same song with the same person on the same day.<br/><br/>
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
If you need to pause, press "Control" and "C" and say "Y" to the question. Next run the script will actually happily resume where it left off.<br/><br/>
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
You might be interested in BockTown's script that does the other side of the coin - finds and downloads duets completed by others of your open seeds
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
You can download your version of his script <a href={bockScriptURL}>from here</a>. It'll take a bit to generate so be patient, click the link once and wait. ;-)
|
||||
</Typography>
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={4}>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Download your data as :
|
||||
</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>TSV</TableCell>
|
||||
<TableCell>CSV</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><a href={tsvURL}>here</a></TableCell>
|
||||
<TableCell><a href={csvURL}>here</a></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Note: Excel is not very good at handling CSV format it seems...
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>It is important to "Import Data" not "Open" the csv in many cases (8 year old discussion of this behaviour <a href="https://superuser.com/questions/407082/easiest-way-to-open-csv-with-commas-in-excel">here</a>) - from that post the instructions are :</Typography>
|
||||
<Typography component="q" gutterBottom>In Excel, DATA tab, in the Get External Data subsection, click "From Text" and import your CSV in the Wizard.</Typography>
|
||||
<Typography component="p" gutterBottom>LibreOffice calc kinda just works...just sayin' ;-)</Typography>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={4}>
|
||||
<CacheDeets
|
||||
onReloadSongsChange={this.reloadSongComponent}
|
||||
onReloadSingersChange={this.reloadSingersComponent}
|
||||
onReloadDuetChange={this.reloadDuetComponent}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={page} index={5}>
|
||||
<BotDeets />
|
||||
</TabPanel>
|
||||
<AppBar position="static" className="Footer">
|
||||
<TableContainer component={Container}>
|
||||
<Table><TableBody>
|
||||
<TableRow className="FooterRow">
|
||||
<TableCell className="FooterCell"><a href="https://discord.gg/sVgZeRt">Discord</a></TableCell>
|
||||
<TableCell className="FooterCell"><a href="https://git.martyn.berlin/martyn/twitchsingstools">Source Code</a></TableCell>
|
||||
<TableCell className="FooterCell"><a href="https://twitch.tv/iMartynOnTwitch">Martyn's Twitch</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="FooterRow">
|
||||
<TableCell className="FooterCell">Announcements, Support, Optional notifications</TableCell>
|
||||
<TableCell className="FooterCell">Geek out at the really hacky source code from an SRE type person</TableCell>
|
||||
<TableCell className="FooterCell">Streaming and singing is my hobby, not my job, so only if you want to...</TableCell>
|
||||
</TableRow>
|
||||
</TableBody></Table>
|
||||
</TableContainer>
|
||||
</AppBar>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default AppAdmin;
|
|
@ -0,0 +1,164 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Container, Typography, Button } from '@material-ui/core';
|
||||
import { AddBox, ArrowDownward } from "@material-ui/icons";
|
||||
import MaterialTable from "material-table";
|
||||
import Check from '@material-ui/icons/Check';
|
||||
import ChevronLeft from '@material-ui/icons/ChevronLeft';
|
||||
import ChevronRight from '@material-ui/icons/ChevronRight';
|
||||
import Clear from '@material-ui/icons/Clear';
|
||||
import DeleteOutline from '@material-ui/icons/DeleteOutline';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import FilterList from '@material-ui/icons/FilterList';
|
||||
import FirstPage from '@material-ui/icons/FirstPage';
|
||||
import LastPage from '@material-ui/icons/LastPage';
|
||||
import Remove from '@material-ui/icons/Remove';
|
||||
import SaveAlt from '@material-ui/icons/SaveAlt';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const dataURL = "/botdeets/"+ channelName + "/" + adminToken
|
||||
|
||||
function BotDeets() {
|
||||
|
||||
const [botState, setBotState] = React.useState({loading: true, botData: { commands: [{"command":"card","action":"RandomPrompt"},{"command":"whotosingwith","action":"AgingSinger"}]} });
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref) => <AddBox {...props} ref={ref} />),
|
||||
Check: forwardRef((props, ref) => <Check {...props} ref={ref} />),
|
||||
Clear: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
|
||||
Delete: forwardRef((props, ref) => <DeleteOutline {...props} ref={ref} />),
|
||||
DetailPanel: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
|
||||
Edit: forwardRef((props, ref) => <Edit {...props} ref={ref} />),
|
||||
Export: forwardRef((props, ref) => <SaveAlt {...props} ref={ref} />),
|
||||
Filter: forwardRef((props, ref) => <FilterList {...props} ref={ref} />),
|
||||
FirstPage: forwardRef((props, ref) => <FirstPage {...props} ref={ref} />),
|
||||
LastPage: forwardRef((props, ref) => <LastPage {...props} ref={ref} />),
|
||||
NextPage: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
|
||||
PreviousPage: forwardRef((props, ref) => <ChevronLeft {...props} ref={ref} />),
|
||||
ResetSearch: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
|
||||
Search: forwardRef((props, ref) => <Search {...props} ref={ref} />),
|
||||
SortArrow: forwardRef((props, ref) => <ArrowDownward {...props} ref={ref} />),
|
||||
ThirdStateCheck: forwardRef((props, ref) => <Remove {...props} ref={ref} />),
|
||||
ViewColumn: forwardRef((props, ref) => <ViewColumn {...props} ref={ref} />)
|
||||
};
|
||||
useEffect(() => {
|
||||
// We should only fetch once!
|
||||
if (botState.loading) {
|
||||
let actualURL = dataURL
|
||||
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
|
||||
//Frontend dev mode only
|
||||
actualURL = "/sampleData/bot.json"
|
||||
}
|
||||
fetch(actualURL).then((res) => res.json().then((data)=>{
|
||||
setBotState({botData: data, loading: false })
|
||||
}));
|
||||
}
|
||||
}, [setBotState, botState.loading]);
|
||||
|
||||
if (botState.loading) {
|
||||
return <p>Sorry, still loading...</p>
|
||||
}
|
||||
|
||||
const dd = botState.botData
|
||||
const cmds = dd.commands
|
||||
if (dd.hasleft) {
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
The chatbot is currently <b>not</b> in your channel. To change the settings, invite it to your channel using the button below :
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
You really don't wanna do that, because it's not ready yet.
|
||||
</Typography>
|
||||
<Typography variant="p">
|
||||
<Button variant="contained" color="primary">Join my channel!</Button>
|
||||
</Typography>
|
||||
</Container>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Coming soon! Bot control panel!
|
||||
</Typography>
|
||||
<div style={{ maxWidth: "100%", display: "none" }}>
|
||||
<MaterialTable
|
||||
editable={{
|
||||
isEditable: rowData => true,
|
||||
isDeletable: rowData => true,
|
||||
onRowAddCancelled: rowData => console.log('Row adding cancelled'),
|
||||
onRowUpdateCancelled: rowData => console.log('Row editing cancelled'),
|
||||
onRowUpdate: (newData, oldData) =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const index = cmds.indexOf(oldData);
|
||||
let newCommands = cmds;
|
||||
let newState = botState;
|
||||
newCommands[index] = newData;
|
||||
newState.botData.commands = newCommands;
|
||||
setBotState(newState);
|
||||
resolve();
|
||||
}, 1000);
|
||||
}),
|
||||
onRowDelete: oldData =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const index = cmds.indexOf(oldData);
|
||||
let newCommands = cmds;
|
||||
let newState = botState;
|
||||
newCommands.splice(index, 1);
|
||||
newState.botData.commands = newCommands;
|
||||
setBotState(newState);
|
||||
resolve();
|
||||
}, 1000);
|
||||
}),
|
||||
onRowAdd: newData =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
let newCommands = cmds;
|
||||
let newState = botState;
|
||||
newCommands[cmds.length] = newData;
|
||||
newState.botData.commands = newCommands;
|
||||
setBotState(newState);
|
||||
resolve();
|
||||
}, 1000);
|
||||
}),
|
||||
}}
|
||||
icons={tableIcons}
|
||||
columns={[
|
||||
{ title: "Command", field: "command" },
|
||||
{
|
||||
title: "Action description",
|
||||
field: "action",
|
||||
lookup: { RandomPrompt: "Random prompt", AgingSinger: "Singer I haven't sung with in ages", AgingSong: "Song I haven't sung in ages"},
|
||||
/* editComponent: tableData =>
|
||||
<Select
|
||||
id="action"
|
||||
className=""
|
||||
optionId="key"
|
||||
optionName="value"
|
||||
value={tableData.rowData.action}
|
||||
dataAutoId={dropdowns('action')}
|
||||
>
|
||||
<MenuItem value="RandomPrompt">Random prompt</MenuItem>
|
||||
<MenuItem value="AgingSinger">Singer I haven't sung with in ages</MenuItem>
|
||||
<MenuItem value="AgingSong">Song I haven't sung in ages</MenuItem>
|
||||
</Select>*/
|
||||
},
|
||||
]}
|
||||
data={cmds}
|
||||
title="Commands"
|
||||
options={{
|
||||
search: false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BotDeets;
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import { Container, Typography, Button } from '@material-ui/core';
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const dataURL = "/cachedeets/"+ channelName + "/" + adminToken
|
||||
const forceURL = "/force/"+ channelName + "/" + adminToken
|
||||
|
||||
class CacheDeets extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cacheData: [],
|
||||
loading: true
|
||||
};
|
||||
this.setCacheData = this.setCacheData.bind(this);
|
||||
this.setLoading = this.setLoading.bind(this);
|
||||
}
|
||||
|
||||
setCacheData(data) {
|
||||
let newState = this.state
|
||||
newState.cacheData = data
|
||||
this.setState(newState)
|
||||
}
|
||||
|
||||
setLoading(data) {
|
||||
let newState = this.state
|
||||
newState.loading = data
|
||||
this.setState(newState)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// We should only fetch once!
|
||||
if (this.state.loading) {
|
||||
let actualURL = dataURL
|
||||
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
|
||||
//Frontend dev mode only
|
||||
actualURL = "/sampleData/cache.json"
|
||||
}
|
||||
fetch(actualURL).then((res) => res.json().then((data)=>{
|
||||
this.setLoading(false)
|
||||
this.setCacheData(data)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const loading = this.state.loading
|
||||
const setLoading = this.setLoading
|
||||
const cacheData = this.state.cacheData
|
||||
|
||||
function handleClick(e) {
|
||||
setLoading(true)
|
||||
fetch(forceURL).then((data)=>{
|
||||
setLoading(false)
|
||||
window.location.reload()
|
||||
})
|
||||
console.log('The link was clicked.');
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p>Sorry, still loading...</p>
|
||||
}
|
||||
|
||||
let dd = cacheData
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Server has cached data from <b>{dd.SongCount} published</b> performances
|
||||
</Typography>
|
||||
<Typography component="p" gutterBottom>
|
||||
Cache data is from about {dd.AgeStr} - it automatically gets updated if it's older than an hour. If you're convinced using Martyn's bandwidth to refresh the cache earlier than that is worth it, here's the button below :
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>Force refresh cache</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CacheDeets;
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Paper } from '@material-ui/core';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const dataURL = "/json/"+ channelName + "/" + adminToken
|
||||
|
||||
const headCells = [
|
||||
{ id: 'displayPublishDate', numeric: false, disablePadding: true, label: 'Published' },
|
||||
{ id: 'songName', numeric: false, disablePadding: false, label: 'Song' },
|
||||
{ id: 'singerName', numeric: false, disablePadding: false, label: 'Singer' },
|
||||
{ id: 'displayLastSongDate', numeric: false, disablePadding: false, label: 'Last Sang Song' },
|
||||
{ id: 'displayLastDuetDate', numeric: false, disablePadding: false, label: 'Last Dueted with Singer' },
|
||||
];
|
||||
|
||||
function descendingComparator(a, b, orderBy, order) {
|
||||
if ((b[orderBy] === "Solo performance") && (a[orderBy] !== "Solo performance")) {
|
||||
return order === 'desc' ? -1 : 1
|
||||
}
|
||||
if (orderBy === "displayPublishDate") {
|
||||
orderBy = "publishDate"
|
||||
}
|
||||
if (orderBy === "displayLastSongDate") {
|
||||
orderBy = "lastSongDate"
|
||||
}
|
||||
if (orderBy === "displayLastDuetDate") {
|
||||
orderBy = "lastDuetDate"
|
||||
}
|
||||
if (b[orderBy] < a[orderBy]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[orderBy] > a[orderBy]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stableSort(array, comparator) {
|
||||
const stabilizedThis = array.map((el, index) => [el, index]);
|
||||
stabilizedThis.sort((a, b) => {
|
||||
const order = comparator(a[0], b[0]);
|
||||
if (order !== 0) return order;
|
||||
return a[1] - b[1];
|
||||
});
|
||||
return stabilizedThis.map((el) => el[0]);
|
||||
}
|
||||
|
||||
function getComparator(order, orderBy) {
|
||||
return order === 'desc'
|
||||
? (a, b) => descendingComparator(a, b, orderBy, order)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy, order);
|
||||
}
|
||||
|
||||
function EnhancedTableHead(props) {
|
||||
const { classes, order, orderBy, onRequestSort } = props;
|
||||
const createSortHandler = (property) => (event) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'default'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<span className={classes.visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</span>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedTableHead.propTypes = {
|
||||
classes: PropTypes.object.isRequired,
|
||||
numSelected: PropTypes.number.isRequired,
|
||||
onRequestSort: PropTypes.func.isRequired,
|
||||
onSelectAllClick: PropTypes.func.isRequired,
|
||||
order: PropTypes.oneOf(['asc', 'desc']).isRequired,
|
||||
orderBy: PropTypes.string.isRequired,
|
||||
rowCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
function DuetData() {
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
});
|
||||
|
||||
const [order, setOrder] = React.useState('asc');
|
||||
const [orderBy, setOrderBy] = React.useState('calories');
|
||||
const [duetState, setDuetState] = React.useState({loading: true, duetData: []})
|
||||
const classes = useStyles();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// We should only fetch once!
|
||||
if (duetState.loading) {
|
||||
let actualURL = dataURL
|
||||
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
|
||||
//Frontend dev mode only
|
||||
actualURL = "/sampleData/data.json"
|
||||
}
|
||||
fetch(actualURL).then((res) => res.json().then((data)=>{
|
||||
setDuetState({duetData: data, loading: false })
|
||||
}));
|
||||
}
|
||||
}, [setDuetState, duetState.loading]);
|
||||
const handleRequestSort = (event, property) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
if (duetState.loading) {
|
||||
return <p>Sorry, still loading...</p>
|
||||
}
|
||||
|
||||
let dd = duetState.duetData
|
||||
return (
|
||||
<TableContainer component={Paper}
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}>
|
||||
<Table className={classes.table} size="small" aria-label="simple table">
|
||||
<EnhancedTableHead onRequestSort={handleRequestSort}>
|
||||
<TableRow>
|
||||
<TableCell>Song Name</TableCell>
|
||||
<TableCell align="right">Sings</TableCell>
|
||||
</TableRow>
|
||||
</EnhancedTableHead>
|
||||
<TableBody>
|
||||
{stableSort(dd, getComparator(order, orderBy))
|
||||
.map((row, index) => {
|
||||
const labelId = `enhanced-table-${index}`;
|
||||
|
||||
return (
|
||||
<TableRow key={row.publishDate} >
|
||||
<TableCell component="th" id={labelId} scope="row" padding="none" title={row.publishDate}>
|
||||
{row.displayPublishDate}
|
||||
</TableCell>
|
||||
<TableCell align="left">{row.songName}</TableCell>
|
||||
<TableCell align="left">{row.singerName}</TableCell>
|
||||
<TableCell component="th" id={labelId} scope="row" padding="none" title={row.lastSongDate}>
|
||||
{row.displayLastSongDate}
|
||||
</TableCell>
|
||||
<TableCell component="th" id={labelId} scope="row" padding="none" title={row.lastDuetDate}>
|
||||
{row.displayLastDuetDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DuetData;
|
|
@ -0,0 +1,23 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 20vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import logo from './../../logo.svg';
|
||||
import './ProblemContainer.css';
|
||||
import Container from '@material-ui/core/Container';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Button, Paper } from '@material-ui/core';
|
||||
|
||||
function ProblemContainer() {
|
||||
const client_id = "sau3e70wvs369jw1u25ex8g3cve599"
|
||||
const redirect_uri = "https://" + encodeURI(window.location.href.split("/")[2]) + "/twitchadmin"
|
||||
const twitchURL = "https://id.twitch.tv/oauth2/authorize?client_id="+ client_id +"&redirect_uri="+ redirect_uri +"&response_type=code&scope=user:read:broadcast"
|
||||
|
||||
return (
|
||||
<Container className="App">
|
||||
<Paper>
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Twitch Sings Tools
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
PROBLEM.
|
||||
</Typography>
|
||||
<Typography variant="p" component="p" gutterBottom>
|
||||
Sorry, for some reason the login didn't work out. Gotta bail. Rain check?!
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" href={twitchURL}>
|
||||
Log in with Twitch
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProblemContainer;
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Paper } from '@material-ui/core';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const dataURL = "/topsingers/"+ channelName + "/" + adminToken
|
||||
const headCells = [
|
||||
{ id: 'singerName', numeric: false, disablePadding: true, label: 'Singer Name' },
|
||||
{ id: 'singCount', numeric: true, disablePadding: false, label: 'Count' },
|
||||
];
|
||||
|
||||
function descendingComparator(a, b, orderBy) {
|
||||
if (b[orderBy] < a[orderBy]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[orderBy] > a[orderBy]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stableSort(array, comparator) {
|
||||
const stabilizedThis = array.map((el, index) => [el, index]);
|
||||
stabilizedThis.sort((a, b) => {
|
||||
const order = comparator(a[0], b[0]);
|
||||
if (order !== 0) return order;
|
||||
return a[1] - b[1];
|
||||
});
|
||||
return stabilizedThis.map((el) => el[0]);
|
||||
}
|
||||
|
||||
function getComparator(order, orderBy) {
|
||||
return order === 'desc'
|
||||
? (a, b) => descendingComparator(a, b, orderBy)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy);
|
||||
}
|
||||
|
||||
function EnhancedTableHead(props) {
|
||||
const { classes, order, orderBy, onRequestSort } = props;
|
||||
const createSortHandler = (property) => (event) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'default'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<span className={classes.visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</span>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedTableHead.propTypes = {
|
||||
classes: PropTypes.object.isRequired,
|
||||
numSelected: PropTypes.number.isRequired,
|
||||
onRequestSort: PropTypes.func.isRequired,
|
||||
onSelectAllClick: PropTypes.func.isRequired,
|
||||
order: PropTypes.oneOf(['asc', 'desc']).isRequired,
|
||||
orderBy: PropTypes.string.isRequired,
|
||||
rowCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
function TopTenSingers() {
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
});
|
||||
|
||||
const [order, setOrder] = React.useState('asc');
|
||||
const [orderBy, setOrderBy] = React.useState('calories');
|
||||
const [singerState, setSingerState] = React.useState({loading: true, duetData: []})
|
||||
const classes = useStyles();
|
||||
|
||||
const handleRequestSort = (event, property) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
useEffect(() => {
|
||||
// We should only fetch once!
|
||||
if (singerState.loading) {
|
||||
let actualURL = dataURL
|
||||
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
|
||||
//Frontend dev mode only
|
||||
actualURL = "/sampleData/singers.json"
|
||||
}
|
||||
fetch(actualURL).then((res) => res.json().then((data)=>{
|
||||
setSingerState({duetData: data, loading: false })
|
||||
}));
|
||||
}
|
||||
}, [setSingerState, singerState.loading]);
|
||||
|
||||
if (singerState.loading) {
|
||||
return <p>Sorry, still loading...</p>
|
||||
}
|
||||
|
||||
let dd = singerState.duetData
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}>
|
||||
<Table className={classes.table} size="small" aria-label="simple table">
|
||||
<EnhancedTableHead onRequestSort={handleRequestSort}>
|
||||
<TableRow>
|
||||
<TableCell>Song Name</TableCell>
|
||||
<TableCell align="right">Sings</TableCell>
|
||||
</TableRow>
|
||||
</EnhancedTableHead>
|
||||
<TableBody>
|
||||
{stableSort(dd, getComparator(order, orderBy))
|
||||
.map((row, index) => {
|
||||
const labelId = `enhanced-table-${index}`;
|
||||
|
||||
return (
|
||||
<TableRow key={row.singerName} >
|
||||
<TableCell component="th" id={labelId} scope="row" padding="none">
|
||||
{row.singerName}
|
||||
</TableCell>
|
||||
<TableCell align="right">{row.singCount}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopTenSingers;
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Paper } from '@material-ui/core';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const channelName = window.location.href.split("/")[4]
|
||||
const adminToken = window.location.href.split("/")[5]
|
||||
const dataURL = "/topsongs/"+ channelName + "/" + adminToken
|
||||
|
||||
const headCells = [
|
||||
{ id: 'songName', numeric: false, disablePadding: true, label: 'Song Name' },
|
||||
{ id: 'singCount', numeric: true, disablePadding: false, label: 'Count' },
|
||||
];
|
||||
|
||||
function descendingComparator(a, b, orderBy) {
|
||||
if (b[orderBy] < a[orderBy]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[orderBy] > a[orderBy]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stableSort(array, comparator) {
|
||||
const stabilizedThis = array.map((el, index) => [el, index]);
|
||||
stabilizedThis.sort((a, b) => {
|
||||
const order = comparator(a[0], b[0]);
|
||||
if (order !== 0) return order;
|
||||
return a[1] - b[1];
|
||||
});
|
||||
return stabilizedThis.map((el) => el[0]);
|
||||
}
|
||||
|
||||
function getComparator(order, orderBy) {
|
||||
return order === 'desc'
|
||||
? (a, b) => descendingComparator(a, b, orderBy)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy);
|
||||
}
|
||||
|
||||
function EnhancedTableHead(props) {
|
||||
const { classes, order, orderBy, onRequestSort } = props;
|
||||
const createSortHandler = (property) => (event) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'default'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<span className={classes.visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</span>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedTableHead.propTypes = {
|
||||
classes: PropTypes.object.isRequired,
|
||||
numSelected: PropTypes.number.isRequired,
|
||||
onRequestSort: PropTypes.func.isRequired,
|
||||
onSelectAllClick: PropTypes.func.isRequired,
|
||||
order: PropTypes.oneOf(['asc', 'desc']).isRequired,
|
||||
orderBy: PropTypes.string.isRequired,
|
||||
rowCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
function TopTenSongs() {
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
});
|
||||
|
||||
const [order, setOrder] = React.useState('asc');
|
||||
const [orderBy, setOrderBy] = React.useState('calories');
|
||||
const [songState, setSongState] = React.useState({loading: true, duetData: []})
|
||||
const classes = useStyles();
|
||||
|
||||
const handleRequestSort = (event, property) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
useEffect(() => {
|
||||
// We should only fetch once!
|
||||
if (songState.loading) {
|
||||
let actualURL = dataURL
|
||||
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
|
||||
//Frontend dev mode only
|
||||
actualURL = "/sampleData/songs.json"
|
||||
}
|
||||
fetch(actualURL).then((res) => res.json().then((data)=>{
|
||||
setSongState({duetData: data, loading: false })
|
||||
}));
|
||||
}
|
||||
}, [setSongState, songState.loading]);
|
||||
|
||||
if (songState.loading) {
|
||||
return <p>Sorry, still loading...</p>
|
||||
}
|
||||
|
||||
let dd = songState.duetData
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}>
|
||||
<Table className={classes.table} size="small" aria-label="simple table">
|
||||
<EnhancedTableHead onRequestSort={handleRequestSort}>
|
||||
<TableRow>
|
||||
<TableCell>Song Name</TableCell>
|
||||
<TableCell align="right">Sings</TableCell>
|
||||
</TableRow>
|
||||
</EnhancedTableHead>
|
||||
<TableBody>
|
||||
{stableSort(dd, getComparator(order, orderBy))
|
||||
.map((row, index) => {
|
||||
const labelId = `enhanced-table-${index}`;
|
||||
|
||||
return (
|
||||
<TableRow key={row.songName} >
|
||||
<TableCell component="th" id={labelId} scope="row" padding="none">
|
||||
{row.songName}
|
||||
</TableCell>
|
||||
<TableCell align="right">{row.singCount}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopTenSongs;
|
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import App from './Components/App/App';
|
||||
import AppAdmin from './Components/AppAdmin/AppAdmin';
|
||||
import theme from './theme';
|
||||
import * as serviceWorker from './serviceWorker';
|
||||
|
||||
function whichApp() {
|
||||
if (window.location.href.split("/")[3].search(/^admin/) >= 0) {
|
||||
return <AppAdmin />
|
||||
} else {
|
||||
return <App />
|
||||
}
|
||||
}
|
||||
const app = whichApp()
|
||||
ReactDOM.render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
|
||||
<CssBaseline />
|
||||
{app}
|
||||
</ThemeProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://bit.ly/CRA-PWA
|
||||
serviceWorker.unregister();
|
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="80.730843mm"
|
||||
height="26.72481mm"
|
||||
viewBox="0 0 80.730843 26.72481"
|
||||
version="1.1"
|
||||
id="svg997"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="logo-small.svg">
|
||||
<defs
|
||||
id="defs991" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.175"
|
||||
inkscape:cx="3675.6034"
|
||||
inkscape:cy="-1948.4018"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="text1547"
|
||||
showgrid="false"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1377"
|
||||
inkscape:window-x="2552"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0" />
|
||||
<metadata
|
||||
id="metadata994">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(445.82239,280.74791)">
|
||||
<g
|
||||
aria-label="tools"
|
||||
style="font-style:normal;font-weight:normal;font-size:704.71020508px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:26.42663383"
|
||||
id="text1547">
|
||||
<path
|
||||
d="m -440.42684,-278.7583 v 5.36182 h 6.39034 v 2.41113 h -6.39034 v 10.25154 q 0,2.30997 0.62386,2.96755 0.64072,0.65758 2.57974,0.65758 h 3.18674 v 2.59661 h -3.18674 q -3.59141,0 -4.95716,-1.33203 -1.36574,-1.34888 -1.36574,-4.88971 v -10.25154 h -2.27625 v -2.41113 h 2.27625 v -5.36182 z"
|
||||
style="stroke-width:1.29493093"
|
||||
id="path1551"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -422.62155,-271.2214 q -2.49543,0 -3.94549,1.95589 -1.45005,1.93902 -1.45005,5.32809 0,3.38908 1.43319,5.34497 1.45005,1.93902 3.96235,1.93902 2.47858,0 3.92863,-1.95588 1.45006,-1.95589 1.45006,-5.32811 0,-3.35535 -1.45006,-5.31124 -1.45005,-1.97274 -3.92863,-1.97274 z m 0,-2.63033 q 4.04666,0 6.35663,2.63033 2.30997,2.63033 2.30997,7.28398 0,4.6368 -2.30997,7.28399 -2.30997,2.63033 -6.35663,2.63033 -4.06351,0 -6.37348,-2.63033 -2.29311,-2.64719 -2.29311,-7.28399 0,-4.65365 2.29311,-7.28398 2.30997,-2.63033 6.37348,-2.63033 z"
|
||||
style="stroke-width:1.29493093"
|
||||
id="path1553"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -401.51148,-271.2214 q -2.49543,0 -3.94549,1.95589 -1.45005,1.93902 -1.45005,5.32809 0,3.38908 1.43319,5.34497 1.45005,1.93902 3.96235,1.93902 2.47859,0 3.92864,-1.95588 1.45005,-1.95589 1.45005,-5.32811 0,-3.35535 -1.45005,-5.31124 -1.45005,-1.97274 -3.92864,-1.97274 z m 0,-2.63033 q 4.04667,0 6.35663,2.63033 2.30997,2.63033 2.30997,7.28398 0,4.6368 -2.30997,7.28399 -2.30996,2.63033 -6.35663,2.63033 -4.06351,0 -6.37348,-2.63033 -2.29311,-2.64719 -2.29311,-7.28399 0,-4.65365 2.29311,-7.28398 2.30997,-2.63033 6.37348,-2.63033 z"
|
||||
style="stroke-width:1.29493093"
|
||||
id="path1555"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -387.71911,-280.74791 h 3.10244 v 26.23584 h -3.10244 z"
|
||||
style="stroke-width:1.29493093"
|
||||
id="path1557"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -366.10321,-272.84007 v 2.93383 q -1.31517,-0.67444 -2.7315,-1.01166 -1.41632,-0.33722 -2.93382,-0.33722 -2.30997,0 -3.47339,0.70816 -1.14655,0.70817 -1.14655,2.1245 0,1.07911 0.82619,1.70297 0.82619,0.60699 3.32164,1.16341 l 1.06225,0.23606 q 3.30476,0.70816 4.68738,2.00646 1.39946,1.28145 1.39946,3.59141 0,2.63033 -2.09077,4.16469 -2.07391,1.53436 -5.7159,1.53436 -1.5175,0 -3.16989,-0.3035 -1.63552,-0.28664 -3.45652,-0.87678 v -3.2036 q 1.71983,0.89364 3.38908,1.34888 1.66925,0.43839 3.30477,0.43839 2.19194,0 3.37221,-0.74188 1.18028,-0.75875 1.18028,-2.1245 0,-1.26458 -0.85991,-1.93903 -0.84306,-0.67444 -3.7263,-1.2983 l -1.07911,-0.25291 q -2.88325,-0.607 -4.16469,-1.85472 -1.28144,-1.26458 -1.28144,-3.45652 0,-2.66405 1.88844,-4.11411 1.88844,-1.45005 5.36182,-1.45005 1.71983,0 3.23733,0.25292 1.5175,0.25291 2.79894,0.75874 z"
|
||||
style="stroke-width:1.29493093"
|
||||
id="path1559"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff8c0a;stroke-width:0.68919873;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:transform-center-x="0.68257895"
|
||||
inkscape:transform-center-y="-1.9869007"
|
||||
d="m -416.5125,-258.27705 -1.63148,1.4538 -4.39724,-4.25187 -3.98975,4.15707 -1.73568,-1.46418 2.81877,-4.6018 -5.0913,-2.25985 0.6072,-2.38846 5.40138,1.29402 0.75555,-5.82372 2.04374,0.005 0.67427,5.65487 5.11647,-1.54971 0.80025,2.09668 -4.68721,2.70586 z"
|
||||
id="path1542"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccccccccccccccc" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccccccccccccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1549"
|
||||
d="m -395.32425,-258.20296 -1.63148,1.4538 -4.39723,-4.25188 -3.98975,4.15708 -1.73569,-1.46418 2.81878,-4.60181 -5.0913,-2.25985 0.6072,-2.38845 5.40137,1.29402 0.75555,-5.82373 2.04375,0.005 0.67427,5.65487 5.11647,-1.5497 0.80024,2.09667 -4.6872,2.70587 z"
|
||||
inkscape:transform-center-y="-1.9869003"
|
||||
inkscape:transform-center-x="0.68257979"
|
||||
style="fill:none;fill-opacity:1;stroke:#ff8c0a;stroke-width:0.68919873;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.1 KiB |
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="368.46396mm"
|
||||
height="121.97481mm"
|
||||
viewBox="0 0 368.46396 121.97481"
|
||||
version="1.1"
|
||||
id="svg997"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="logo.svg">
|
||||
<defs
|
||||
id="defs991" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.175"
|
||||
inkscape:cx="3675.6034"
|
||||
inkscape:cy="-1588.4018"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="text1547"
|
||||
showgrid="false"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1377"
|
||||
inkscape:window-x="2552"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0" />
|
||||
<metadata
|
||||
id="metadata994">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(445.82239,280.74791)">
|
||||
<g
|
||||
aria-label="tools"
|
||||
style="font-style:normal;font-weight:normal;font-size:704.71020508px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:26.42663383"
|
||||
id="text1547">
|
||||
<path
|
||||
d="m -421.19656,-271.66713 v 24.47192 h 29.16621 v 11.00466 h -29.16621 v 46.78908 q 0,10.54293 2.84736,13.5442 2.92432,3.00128 11.77422,3.00128 h 14.54463 v 11.85118 h -14.54463 q -16.39156,0 -22.62497,-6.0795 -6.23342,-6.15646 -6.23342,-22.31716 v -46.78908 h -10.38902 v -11.00466 h 10.38902 v -24.47192 z"
|
||||
style="stroke-width:5.91019917"
|
||||
id="path1551"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -339.93133,-237.26793 q -11.38944,0 -18.00763,8.92687 -6.6182,8.8499 -6.6182,24.318 0,15.4681 6.54124,24.39496 6.61819,8.84991 18.08459,8.84991 11.31249,0 17.93068,-8.92686 6.6182,-8.92687 6.6182,-24.31801 0,-15.31419 -6.6182,-24.24105 -6.61819,-9.00382 -17.93068,-9.00382 z m 0,-12.00509 q 18.46938,0 29.01231,12.00509 10.54293,12.00509 10.54293,33.24487 0,21.16282 -10.54293,33.24487 -10.54293,12.00509 -29.01231,12.00509 -18.54632,0 -29.08926,-12.00509 -10.46598,-12.08205 -10.46598,-33.24487 0,-21.23978 10.46598,-33.24487 10.54294,-12.00509 29.08926,-12.00509 z"
|
||||
style="stroke-width:5.91019917"
|
||||
id="path1553"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -243.58277,-237.26793 q -11.38944,0 -18.00763,8.92687 -6.61819,8.8499 -6.61819,24.318 0,15.4681 6.54123,24.39496 6.61819,8.84991 18.08459,8.84991 11.3125,0 17.93069,-8.92686 6.61819,-8.92687 6.61819,-24.31801 0,-15.31419 -6.61819,-24.24105 -6.61819,-9.00382 -17.93069,-9.00382 z m 0,-12.00509 q 18.46938,0 29.01231,12.00509 10.54293,12.00509 10.54293,33.24487 0,21.16282 -10.54293,33.24487 -10.54293,12.00509 -29.01231,12.00509 -18.54632,0 -29.08925,-12.00509 -10.46598,-12.08205 -10.46598,-33.24487 0,-21.23978 10.46598,-33.24487 10.54293,-12.00509 29.08925,-12.00509 z"
|
||||
style="stroke-width:5.91019917"
|
||||
id="path1555"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -180.63298,-280.74791 h 14.15985 v 119.7431 h -14.15985 z"
|
||||
style="stroke-width:5.91019917"
|
||||
id="path1557"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m -81.975769,-244.65568 v 13.3903 q -6.002557,-3.07823 -12.466831,-4.61734 -6.46427,-1.53912 -13.39029,-1.53912 -10.54294,0 -15.85288,3.23214 -5.23299,3.23214 -5.23299,9.69642 0,4.92517 3.77083,7.77253 3.77082,2.7704 15.16029,5.30994 l 4.84821,1.07738 q 15.083302,3.23214 21.393685,9.15773 6.387317,5.84864 6.387317,16.39157 0,12.00509 -9.542508,19.00806 -9.465552,7.00297 -26.087984,7.00297 -6.92601,0 -14.46768,-1.3852 -7.4647,-1.30825 -15.77592,-4.0017 v -14.62158 q 7.84948,4.07865 15.4681,6.15645 7.61862,2.00085 15.08332,2.00085 10.00425,0 15.391134,-3.38605 5.386906,-3.46301 5.386906,-9.69642 0,-5.77168 -3.924733,-8.84991 -3.8478,-3.07823 -17.007217,-5.92559 l -4.92517,-1.15433 q -13.15943,-2.77041 -19.00806,-8.46513 -5.84864,-5.77168 -5.84864,-15.77592 0,-12.15901 8.61904,-18.7772 8.61904,-6.61819 24.47191,-6.61819 7.84949,0 14.775504,1.15434 6.926012,1.15433 12.774657,3.463 z"
|
||||
style="stroke-width:5.91019917"
|
||||
id="path1559"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff8c0a;stroke-width:3.14557457;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:transform-center-x="3.1153683"
|
||||
inkscape:transform-center-y="-9.0684207"
|
||||
d="m -312.04901,-178.18855 -7.44625,6.63529 -20.06943,-19.40599 -18.20965,18.97332 -7.92185,-6.68267 12.8652,-21.00312 -23.23724,-10.31419 2.77132,-10.90116 24.65245,5.90605 3.4484,-26.58009 9.32787,0.0215 3.07745,25.80942 23.35208,-7.07303 3.65241,9.56944 -21.39289,12.34986 z"
|
||||
id="path1542"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccccccccccccccc" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccccccccccccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1549"
|
||||
d="m -215.34361,-177.85042 -7.44625,6.63529 -20.06943,-19.40599 -18.20965,18.97332 -7.92184,-6.68267 12.86519,-21.00312 -23.23724,-10.31419 2.77132,-10.90116 24.65245,5.90605 3.4484,-26.5801 9.32787,0.0215 3.07745,25.80943 23.35208,-7.07303 3.65241,9.56944 -21.39288,12.34986 z"
|
||||
inkscape:transform-center-y="-9.0684254"
|
||||
inkscape:transform-center-x="3.1153696"
|
||||
style="fill:none;fill-opacity:1;stroke:#ff8c0a;stroke-width:3.14557457;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.3 KiB |
|
@ -0,0 +1,141 @@
|
|||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://bit.ly/CRA-PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export function register(config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl, config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl, config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' },
|
||||
})
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then(registration => {
|
||||
registration.unregister();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
|
@ -0,0 +1,39 @@
|
|||
import { red } from '@material-ui/core/colors';
|
||||
import { createMuiTheme } from '@material-ui/core/styles';
|
||||
// A custom theme for this app
|
||||
const theme = createMuiTheme({
|
||||
palette: {
|
||||
type: 'light',
|
||||
primary: {
|
||||
main: '#61dafb',
|
||||
light: '#61dafb',
|
||||
dark: '#21a1c4',
|
||||
},
|
||||
secondary: {
|
||||
main: '#b5ecfb',
|
||||
light: '#61dafb',
|
||||
dark: '#21a1c4',
|
||||
},
|
||||
error: {
|
||||
main: red.A400,
|
||||
},
|
||||
background: {
|
||||
default: '#282c34',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
padding: '20px 10px',
|
||||
margin: '10px',
|
||||
backgroundColor: '#fff', // 5d737e
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
root: {
|
||||
margin: '5px',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export default theme;
|
|
@ -0,0 +1,66 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "twitchsingstools.fullname" . }}-db
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
labels:
|
||||
{{- include "twitchsingstools.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "twitchsingstools.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "twitchsingstools.selectorLabels" . | nindent 8 }}
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "twitchsingstools.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.db.image.repository }}:{{ .Values.db.image.tag | default "latest" }}"
|
||||
imagePullPolicy: {{ .Values.db.image.pullPolicy }}
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 9221
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: redis
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: redis
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- mountPath: /pika/db
|
||||
name: data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: tstools-db
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
|
@ -32,6 +32,8 @@ spec:
|
|||
env:
|
||||
- name: TSTOOLS_DATA_FOLDER
|
||||
value: /data
|
||||
- name: TSTOOLS_REDIS_HOST
|
||||
value: {{ .Values.db.service.name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}"
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: tstools-db
|
||||
labels:
|
||||
{{- include "twitchsingstools.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.db.storageSize }}
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Values.db.service.name }}
|
||||
labels:
|
||||
{{- include "twitchsingstools.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.db.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.db.service.port }}
|
||||
targetPort: redis
|
||||
protocol: TCP
|
||||
name: redis
|
||||
selector:
|
||||
{{- include "twitchsingstools.selectorLabels" . | nindent 4 }}
|
|
@ -6,7 +6,7 @@ replicaCount: 1
|
|||
|
||||
image:
|
||||
repository: imartyn/twitchsingstools
|
||||
pullPolicy: IfNotPresent
|
||||
pullPolicy: Always
|
||||
tag: dev
|
||||
|
||||
imagePullSecrets: []
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
# Default values for twitchsingstools.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: imartyn/twitchsingstools
|
||||
tag: 0.0-linux-amd64
|
||||
pullPolicy: Always
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name:
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
secretFiles: {}
|
||||
|
||||
irc:
|
||||
nick: tstools
|
||||
|
||||
twitchapp: {}
|
||||
|
||||
storageSize: 10Gi
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
externalHostname: twitchsingstools.martyn.berlin
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: traefik
|
||||
cert-manager.io/cluster-issuer: letsencrypt
|
||||
hosts:
|
||||
- host: twitchsingstools.martyn.berlin
|
||||
paths:
|
||||
- /
|
||||
tls:
|
||||
- secretName: tstools-tls
|
||||
hosts:
|
||||
- twitchsingstools.martyn.berlin
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
|
@ -41,6 +41,15 @@ twitchapp: {}
|
|||
|
||||
storageSize: 10Gi
|
||||
|
||||
db:
|
||||
service:
|
||||
port: 4920
|
||||
name: tstools-db
|
||||
storageSize: 10Gi
|
||||
image:
|
||||
repository: pikadb/pika
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
|
|
@ -0,0 +1,234 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
rgb "github.com/foresthoffman/rgblog"
|
||||
redis "github.com/gomodule/redigo/redis"
|
||||
uuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ConfigStruct is the base for the config file
|
||||
type ConfigStruct struct {
|
||||
InitialChannels []string `json:"channels"`
|
||||
IrcOAuthPath string `json:"ircoauthpath,omitempty"`
|
||||
StringPath string `json:"authpath,omitempty"`
|
||||
DataPath string `json:"datapath,omitempty"`
|
||||
ExternalURL string `json:"externalurl,omitempty"`
|
||||
AppOAuthPath string `json:"appoauthpath,omitempty"`
|
||||
DatabaseSVC string `json:"databasesvc,omitempty"`
|
||||
}
|
||||
|
||||
// CommandType Kinda an enum
|
||||
type CommandType string
|
||||
|
||||
// CommandType literals
|
||||
const (
|
||||
RandomSinger CommandType = "RandomSinger"
|
||||
RandomPrompt CommandType = "RandomPrompt"
|
||||
RandomSong CommandType = "RandomSong"
|
||||
AgingSinger CommandType = "AgingSinger"
|
||||
AgingSong CommandType = "AgingSong"
|
||||
)
|
||||
|
||||
// IsValid Is CommandType a valid enum?
|
||||
func (ct CommandType) IsValid() error {
|
||||
switch ct {
|
||||
case RandomSinger, RandomPrompt, RandomSong, AgingSinger, AgingSong:
|
||||
return nil
|
||||
}
|
||||
return errors.New("Invalid command type")
|
||||
}
|
||||
|
||||
// ChannelData is what we store in the BitRaft (Redis) database
|
||||
type ChannelData struct {
|
||||
ControlChannel bool
|
||||
Name string `json:"name"`
|
||||
AdminKey string `json:"value,omitempty"`
|
||||
Commands []CommandStruct `json:"commands,omitempty"`
|
||||
ExtraStrings string `json:"extrastrings,omitempty"`
|
||||
JoinTime time.Time `json:"jointime"`
|
||||
HasLeft bool `json:"hasleft"`
|
||||
VideoCache []SingsVideoStruct `json:"videoCache"`
|
||||
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
|
||||
Bearer string `json:"bearer"`
|
||||
TwitchUserID string `json:"twitchUserID"`
|
||||
}
|
||||
|
||||
// SingsVideoStruct The data we pull from Twitch
|
||||
type SingsVideoStruct struct {
|
||||
Date time.Time `json:"date"` // Golang date of creation
|
||||
FullTitle string `json:"fullTitle"` // Full Title
|
||||
Duet bool `json:"duet"` // Is it a duet?
|
||||
OtherSinger string `json:"otherSinger"` // Twitch NAME of the other singer, extracted from the title
|
||||
SongTitle string `json:"songTitle"` // extracted from title
|
||||
LastSungSong time.Time `json:"LastSungSong"` // Last time this SONG was sung
|
||||
LastSungSinger time.Time `json:"LastSungSinger"` // Last time a duet was sung with this SINGER, regardless of song, only Duets have this date initialised
|
||||
VideoURL string `json:"VideoURL"` // RIP Twitch Sings
|
||||
}
|
||||
|
||||
// CommandStruct keypair for irc command -> actual thing to do
|
||||
type CommandStruct struct {
|
||||
CommandName CommandType `json:"commandName"`
|
||||
KeyWord string `json:"keyword"`
|
||||
}
|
||||
|
||||
// GlobalData Some kind of architect would kill me for this
|
||||
type GlobalData struct {
|
||||
ChannelData map[string]ChannelData
|
||||
Config ConfigStruct
|
||||
Database redis.Conn
|
||||
ControlChannel string
|
||||
}
|
||||
|
||||
// ConnectDatabase Connects to the database set in the config struct
|
||||
func (gd *GlobalData) ConnectDatabase() {
|
||||
var err error
|
||||
rgb.YPrintf("[%s] Connecting to \"redis\" %s...\n", TimeStamp(), gd.Config.DatabaseSVC, err)
|
||||
gd.Database, err = redis.Dial("tcp", gd.Config.DatabaseSVC+":4920")
|
||||
if err != nil {
|
||||
rgb.RPrintf("[%s] Failed connecting to \"redis\" %s : %s\n", TimeStamp(), gd.Config.DatabaseSVC, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
rgb.YPrintf("[%s] No error... wtf?\n", TimeStamp())
|
||||
}
|
||||
|
||||
// UpdateVideoCache Updates the in-memory data and updates redis
|
||||
func (gd *GlobalData) UpdateVideoCache(user string, videos []SingsVideoStruct) {
|
||||
record := gd.ChannelData[user]
|
||||
rgb.YPrintf("Replacing cache of %d performances with a new cache of %d performances\n", len(record.VideoCache), len(videos))
|
||||
record.VideoCache = videos
|
||||
record.VideoCacheUpdated = time.Now()
|
||||
asJson, _ := json.Marshal(record)
|
||||
_, err := gd.Database.Do("SET", user, asJson)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
gd.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (gd *GlobalData) UpdateBearerToken(user string, token string) {
|
||||
record := gd.ChannelData[user]
|
||||
record.Bearer = token
|
||||
asJson, _ := json.Marshal(record)
|
||||
gd.Database.Do("SET", user, asJson)
|
||||
gd.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (gd *GlobalData) UpdateTwitchUserID(user string, userid string) {
|
||||
record := gd.ChannelData[user]
|
||||
record.TwitchUserID = userid
|
||||
asJson, _ := json.Marshal(record)
|
||||
gd.Database.Do("SET", user, asJson)
|
||||
gd.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (gd *GlobalData) UpdateChannelKey(user string, channelKey string) {
|
||||
record := gd.ChannelData[user]
|
||||
record.AdminKey = channelKey
|
||||
asJson, _ := json.Marshal(record)
|
||||
gd.Database.Do("SET", user, asJson)
|
||||
gd.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (gd *GlobalData) UpdateChannelName(user string, newName string) {
|
||||
record := gd.ChannelData[user]
|
||||
record.Name = newName
|
||||
asJson, _ := json.Marshal(record)
|
||||
gd.Database.Do("SET", newName, asJson)
|
||||
gd.ChannelData[newName] = record
|
||||
//dunno why we'd need this but I guess in case?
|
||||
if newName != user {
|
||||
delete(gd.ChannelData, user)
|
||||
gd.Database.Do("DEL", newName)
|
||||
}
|
||||
}
|
||||
|
||||
func (gd *GlobalData) UpdateJoined(user string, invert bool) {
|
||||
record := gd.ChannelData[user]
|
||||
|
||||
if record.Name == "" {
|
||||
record = ChannelData{Name: user, JoinTime: time.Now(), Commands: nil, ControlChannel: true}
|
||||
}
|
||||
record.JoinTime = time.Now()
|
||||
asJson, _ := json.Marshal(record)
|
||||
if invert {
|
||||
record.HasLeft = true
|
||||
} else {
|
||||
record.HasLeft = false
|
||||
}
|
||||
gd.Database.Do("SET", user, asJson)
|
||||
gd.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (gd *GlobalData) ReadChannelData() error {
|
||||
keys, err := redis.Strings(gd.Database.Do("KEYS", "*"))
|
||||
if err != nil {
|
||||
rgb.RPrintf("[%s] ERROR with redis fetch : %s\n", TimeStamp(), err.Error())
|
||||
rgb.YPrintf("[%s] Maybe an empty redis, creating a record...\n", TimeStamp())
|
||||
keys = []string{}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
rgb.YPrintf("[%s] Looks like an empty redis, creating a record...\n", TimeStamp())
|
||||
record := ChannelData{Name: gd.ControlChannel, JoinTime: time.Now(), Commands: nil}
|
||||
asJSON, _ := json.Marshal(record)
|
||||
gd.Database.Do("SET", gd.ControlChannel, asJSON)
|
||||
keys = []string{gd.ControlChannel}
|
||||
}
|
||||
rgb.YPrintf("[%s] \"redis\" has %d records!\n", TimeStamp(), len(keys))
|
||||
for _, channel := range keys {
|
||||
fetchedData, err := redis.String(gd.Database.Do("GET", channel))
|
||||
if err != nil {
|
||||
rgb.YPrintf("[%s] failed to read key %s from redis, good luck!...\n", TimeStamp(), channel)
|
||||
}
|
||||
rgb.YPrintf("[%s] data from \"redis\" for %s is %s\n", TimeStamp(), channel, fetchedData)
|
||||
cd := gd.ChannelData
|
||||
if cd == nil {
|
||||
cd = make(map[string]ChannelData)
|
||||
}
|
||||
d := &ChannelData{}
|
||||
err = json.Unmarshal([]byte(fetchedData), d)
|
||||
if err != nil {
|
||||
rgb.RPrintf("[%s] channel data could not be unmarshalled : %s\n", TimeStamp(), err.Error())
|
||||
}
|
||||
cd[channel] = *d
|
||||
gd.ChannelData = cd
|
||||
rgb.YPrintf("[%s] channel data : %v\n", TimeStamp(), gd.ChannelData)
|
||||
}
|
||||
// Managed to leave the main channel!?
|
||||
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(gd.ChannelData))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gd *GlobalData) ReadOrCreateChannelKey(channel string) string {
|
||||
record := gd.ChannelData[channel]
|
||||
magicCode := ""
|
||||
if record.AdminKey == "" {
|
||||
rgb.YPrintf("[%s] No channel key for #%s exists, creating one\n", TimeStamp(), channel)
|
||||
newuu, _ := uuid.NewRandom()
|
||||
magicCode = base64.StdEncoding.EncodeToString([]byte(newuu.String()))
|
||||
gd.UpdateJoined(channel, true)
|
||||
gd.UpdateChannelKey(channel, magicCode)
|
||||
gd.UpdateChannelName(channel, channel)
|
||||
rgb.YPrintf("[%s] Cached channel key for #%s\n", TimeStamp(), record.Name)
|
||||
} else {
|
||||
magicCode = record.AdminKey
|
||||
rgb.YPrintf("[%s] Loaded data for #%s\n", TimeStamp(), channel)
|
||||
}
|
||||
return magicCode
|
||||
}
|
||||
|
||||
const UTCFormat = "Jan 2 15:04:05 UTC"
|
||||
|
||||
func TimeStamp() string {
|
||||
return TimeStampFmt(UTCFormat)
|
||||
}
|
||||
|
||||
func TimeStampFmt(format string) string {
|
||||
return time.Now().Format(format)
|
||||
}
|
|
@ -2,7 +2,6 @@ package irc
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -15,9 +14,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
|
||||
rgb "github.com/foresthoffman/rgblog"
|
||||
uuid "github.com/google/uuid"
|
||||
scribble "github.com/nanobox-io/golang-scribble"
|
||||
)
|
||||
|
||||
const UTCFormat = "Jan 2 15:04:05 UTC"
|
||||
|
@ -58,15 +56,6 @@ type AppOAuthCred struct {
|
|||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigStruct struct {
|
||||
InitialChannels []string `json:"channels"`
|
||||
IrcOAuthPath string `json:"ircoauthpath,omitempty"`
|
||||
StringPath string `json:"authpath,omitempty"`
|
||||
DataPath string `json:"datapath,omitempty"`
|
||||
ExternalUrl string `json:"externalurl,omitempty"`
|
||||
AppOAuthPath string `json:"appoauthpath,omitempty"`
|
||||
}
|
||||
|
||||
type KardBot struct {
|
||||
Channel string
|
||||
conn net.Conn
|
||||
|
@ -80,33 +69,7 @@ type KardBot struct {
|
|||
Server string
|
||||
startTime time.Time
|
||||
Prompts []string
|
||||
Database scribble.Driver
|
||||
ChannelData map[string]ChannelData
|
||||
Config ConfigStruct
|
||||
}
|
||||
|
||||
type SingsVideoStruct struct {
|
||||
Date time.Time `json:"date"` // Golang date of creation
|
||||
FullTitle string `json:"fullTitle"` // Full Title
|
||||
Duet bool `json:"duet"` // Is it a duet?
|
||||
OtherSinger string `json:"otherSinger"` // Twitch NAME of the other singer, extracted from the title
|
||||
SongTitle string `json:"songTitle"` // extracted from title
|
||||
LastSungSong time.Time `json:"LastSungSong"` // Last time this SONG was sung
|
||||
LastSungSinger time.Time `json:"LastSungSinger"` // Last time a duet was sung with this SINGER, regardless of song, only Duets have this date initialised
|
||||
}
|
||||
|
||||
type ChannelData struct {
|
||||
Name string `json:"name"`
|
||||
AdminKey string `json:"value,omitempty"`
|
||||
Command string `json:"customcommand,omitempty"`
|
||||
ExtraStrings string `json:"extrastrings,omitempty"`
|
||||
JoinTime time.Time `json:"jointime"`
|
||||
ControlChannel bool
|
||||
HasLeft bool `json:"hasleft"`
|
||||
VideoCache []SingsVideoStruct `json:"videoCache"`
|
||||
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
|
||||
Bearer string `json:"bearer"`
|
||||
TwitchUserID string `json:"twitchUserID"`
|
||||
GlobalData data.GlobalData
|
||||
}
|
||||
|
||||
// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
|
||||
|
@ -136,7 +99,7 @@ func (bb *KardBot) Disconnect() {
|
|||
// Look at the channels I'm actually in
|
||||
func (bb *KardBot) ActiveChannels() int {
|
||||
count := 0
|
||||
for _, channel := range bb.ChannelData {
|
||||
for _, channel := range bb.GlobalData.ChannelData {
|
||||
if !channel.HasLeft {
|
||||
count = count + 1
|
||||
}
|
||||
|
@ -144,29 +107,6 @@ func (bb *KardBot) ActiveChannels() int {
|
|||
return count
|
||||
}
|
||||
|
||||
func (bb *KardBot) UpdateVideoCache(user string, videos []SingsVideoStruct) {
|
||||
record := bb.ChannelData[user]
|
||||
fmt.Printf("Replacing cache of %d performances with a new cache of %d performances", len(record.VideoCache), len(videos))
|
||||
record.VideoCache = videos
|
||||
record.VideoCacheUpdated = time.Now()
|
||||
bb.Database.Write("channelData", user, record)
|
||||
bb.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (bb *KardBot) UpdateBearerToken(user string, token string) {
|
||||
record := bb.ChannelData[user]
|
||||
record.Bearer = token
|
||||
bb.Database.Write("channelData", user, record)
|
||||
bb.ChannelData[user] = record
|
||||
}
|
||||
|
||||
func (bb *KardBot) UpdateTwitchUserID(user string, userid string) {
|
||||
record := bb.ChannelData[user]
|
||||
record.TwitchUserID = userid
|
||||
bb.Database.Write("channelData", user, record)
|
||||
bb.ChannelData[user] = record
|
||||
}
|
||||
|
||||
// Listens for and logs messages from chat. Responds to commands from the channel owner. The bot
|
||||
// continues until it gets disconnected, told to shutdown, or forcefully shutdown.
|
||||
func (bb *KardBot) HandleChat() error {
|
||||
|
@ -198,11 +138,7 @@ func (bb *KardBot) HandleChat() error {
|
|||
matches := ConnectRegex.FindStringSubmatch(line)
|
||||
if nil != matches {
|
||||
realUserName := matches[1]
|
||||
if bb.ChannelData[realUserName].Name == "" {
|
||||
record := ChannelData{Name: realUserName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
|
||||
bb.Database.Write("channelData", realUserName, record)
|
||||
bb.ChannelData[realUserName] = record
|
||||
}
|
||||
bb.GlobalData.UpdateJoined(realUserName, false)
|
||||
bb.JoinChannel(realUserName)
|
||||
}
|
||||
|
||||
|
@ -233,21 +169,24 @@ func (bb *KardBot) HandleChat() error {
|
|||
cmdMatches := CmdRegex.FindStringSubmatch(msg)
|
||||
if nil != cmdMatches {
|
||||
cmd := cmdMatches[1]
|
||||
|
||||
rgb.YPrintf("[%s] Checking cmd %s against %s\n", TimeStamp(), cmd, bb.ChannelData[channel].Command)
|
||||
switch cmd {
|
||||
case bb.ChannelData[channel].Command:
|
||||
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
|
||||
|
||||
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
|
||||
case "join":
|
||||
if bb.ChannelData[channel].ControlChannel {
|
||||
rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
|
||||
if bb.ChannelData[userName].Name == "" {
|
||||
record := ChannelData{Name: userName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
|
||||
bb.Database.Write("channelData", userName, record)
|
||||
bb.ChannelData[userName] = record
|
||||
cardCommand := ""
|
||||
commands := bb.GlobalData.ChannelData[channel].Commands
|
||||
for _, command := range commands {
|
||||
if command.CommandName == data.RandomPrompt {
|
||||
cardCommand = command.KeyWord
|
||||
}
|
||||
}
|
||||
rgb.YPrintf("[%s] Checking cmd %s against [%s]\n", TimeStamp(), cmd, bb.GlobalData.ChannelData[channel].Commands)
|
||||
switch cmd {
|
||||
case cardCommand:
|
||||
if cardCommand != "" {
|
||||
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
|
||||
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
|
||||
}
|
||||
case "join":
|
||||
if bb.GlobalData.ChannelData[channel].ControlChannel {
|
||||
rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
|
||||
bb.GlobalData.UpdateJoined(userName, false)
|
||||
bb.JoinChannel(userName)
|
||||
}
|
||||
}
|
||||
|
@ -264,7 +203,7 @@ func (bb *KardBot) HandleChat() error {
|
|||
bb.Disconnect()
|
||||
return nil
|
||||
case "kcardadmin":
|
||||
magicCode := bb.ReadOrCreateChannelKey(channel)
|
||||
magicCode := bb.GlobalData.ReadOrCreateChannelKey(channel)
|
||||
rgb.CPrintf(
|
||||
"[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n",
|
||||
TimeStamp(),
|
||||
|
@ -424,18 +363,11 @@ func (bb *KardBot) Start() {
|
|||
return
|
||||
}
|
||||
|
||||
err = bb.readChannelData()
|
||||
if nil != err {
|
||||
fmt.Println(err)
|
||||
fmt.Println("Aborting!")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
bb.Connect()
|
||||
bb.Login()
|
||||
if len(bb.ChannelData) > 0 {
|
||||
for channelName, channelData := range bb.ChannelData {
|
||||
if len(bb.GlobalData.ChannelData) > 0 {
|
||||
for channelName, channelData := range bb.GlobalData.ChannelData {
|
||||
if !channelData.HasLeft {
|
||||
bb.JoinChannel(channelName)
|
||||
}
|
||||
|
@ -456,85 +388,6 @@ func (bb *KardBot) Start() {
|
|||
}
|
||||
}
|
||||
|
||||
func (bb *KardBot) readChannelData() error {
|
||||
records, err := bb.Database.ReadAll("channelData")
|
||||
if err != nil {
|
||||
// no db? initialise one?
|
||||
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
|
||||
rgb.YPrintf("[%s] No channel table for #%s exists, creating...\n", TimeStamp(), bb.Channel)
|
||||
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
|
||||
return err
|
||||
}
|
||||
bb.ChannelData = make(map[string]ChannelData)
|
||||
bb.ChannelData[bb.Channel] = record
|
||||
} else {
|
||||
bb.ChannelData = make(map[string]ChannelData)
|
||||
}
|
||||
for _, data := range records {
|
||||
record := ChannelData{}
|
||||
err := json.Unmarshal([]byte(data), &record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Name != "" {
|
||||
if record.Command == "" {
|
||||
record.Command = "card"
|
||||
|
||||
rgb.YPrintf("[%s] Rewriting data for #%s...\n", TimeStamp(), bb.Channel)
|
||||
if err := bb.Database.Write("channelData", record.Name, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
bb.ChannelData[record.Name] = record
|
||||
}
|
||||
}
|
||||
// Managed to leave the main channel!?
|
||||
if bb.ChannelData[bb.Channel].Name == "" {
|
||||
rgb.YPrintf("[%s] No channel data for #%s exists, creating...\n", TimeStamp(), bb.Channel)
|
||||
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
|
||||
bb.ChannelData[bb.Channel] = record
|
||||
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
|
||||
return err
|
||||
}
|
||||
records, err = bb.Database.ReadAll("channelData")
|
||||
}
|
||||
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(bb.ChannelData))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bb *KardBot) ReadOrCreateChannelKey(channel string) string {
|
||||
magicCode := ""
|
||||
var err error
|
||||
var record ChannelData
|
||||
if record, ok := bb.ChannelData[channel]; !ok {
|
||||
rgb.YPrintf("[%s] No channel data for #%s exists, creating\n", TimeStamp(), channel)
|
||||
err = bb.Database.Read("channelData", channel, &record)
|
||||
if err == nil {
|
||||
bb.ChannelData[channel] = record
|
||||
}
|
||||
}
|
||||
record = bb.ChannelData[channel]
|
||||
if err != nil || record.AdminKey == "" {
|
||||
rgb.YPrintf("[%s] No channel key for #%s exists, creating one\n", TimeStamp(), channel)
|
||||
newuu, _ := uuid.NewRandom()
|
||||
magicCode = base64.StdEncoding.EncodeToString([]byte(newuu.String()))
|
||||
record.HasLeft = true
|
||||
record.AdminKey = magicCode
|
||||
if record.Name == "" {
|
||||
record.Name = channel
|
||||
}
|
||||
if err := bb.Database.Write("channelData", channel, record); err != nil {
|
||||
rgb.RPrintf("[%s] Error writing channel data for #%s\n", TimeStamp(), channel)
|
||||
}
|
||||
bb.ChannelData[record.Name] = record
|
||||
rgb.YPrintf("[%s] Cached channel key for #%s\n", TimeStamp(), record.Name)
|
||||
} else {
|
||||
magicCode = record.AdminKey
|
||||
rgb.YPrintf("[%s] Loaded data for #%s\n", TimeStamp(), channel)
|
||||
}
|
||||
return magicCode
|
||||
}
|
||||
|
||||
func TimeStamp() string {
|
||||
return TimeStampFmt(UTCFormat)
|
||||
}
|
||||
|
|
|
@ -6,8 +6,10 @@ import (
|
|||
"math/rand"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
|
||||
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/gorilla/handlers"
|
||||
|
@ -76,6 +78,7 @@ type videosResponse struct {
|
|||
}
|
||||
|
||||
var ircBot *irc.KardBot
|
||||
var globalData *data.GlobalData
|
||||
|
||||
func HealthHandler(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Add("Content-type", "text/plain")
|
||||
|
@ -129,7 +132,7 @@ func TemplateHandler(response http.ResponseWriter, request *http.Request) {
|
|||
// NotFoundHandler(response, request)
|
||||
// return
|
||||
}
|
||||
var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + ircBot.Config.ExternalUrl}
|
||||
var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + globalData.Config.ExternalURL}
|
||||
err = tmpl.Execute(response, td)
|
||||
if err != nil {
|
||||
http.Error(response, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -152,6 +155,7 @@ func humanTimeFromTimeString(s string) string {
|
|||
type AugmentedSingsVideoStruct struct {
|
||||
Date time.Time
|
||||
NiceDate string
|
||||
ShortDate string
|
||||
FullTitle string
|
||||
Duet bool
|
||||
OtherSinger string
|
||||
|
@ -160,12 +164,15 @@ type AugmentedSingsVideoStruct struct {
|
|||
NiceLastSungSong string
|
||||
LastSungSinger time.Time
|
||||
NiceLastSungSinger string
|
||||
VideoURL string
|
||||
VideoNumber string //yes, I don't care any more.
|
||||
}
|
||||
|
||||
func AugmentSingsVideoStructForCSV(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
|
||||
func AugmentSingsVideoStructForCSV(input data.SingsVideoStruct) AugmentedSingsVideoStruct {
|
||||
var ret AugmentedSingsVideoStruct
|
||||
ret.Date = input.Date
|
||||
ret.NiceDate = input.Date.Format("2006-01-02 15:04:05")
|
||||
ret.ShortDate = input.Date.Format("2006-01-02")
|
||||
ret.FullTitle = input.FullTitle
|
||||
ret.Duet = input.Duet
|
||||
ret.OtherSinger = input.OtherSinger
|
||||
|
@ -173,6 +180,9 @@ func AugmentSingsVideoStructForCSV(input irc.SingsVideoStruct) AugmentedSingsVid
|
|||
ret.LastSungSong = input.LastSungSong
|
||||
ret.NiceLastSungSong = input.LastSungSong.Format("2006-01-02 15:04:05")
|
||||
ret.LastSungSinger = input.LastSungSinger
|
||||
ret.VideoURL = input.VideoURL
|
||||
urlParts := strings.Split(input.VideoURL, "/")
|
||||
ret.VideoNumber = urlParts[len(urlParts)-1]
|
||||
if !ret.Duet {
|
||||
ret.NiceLastSungSinger = "Solo performance"
|
||||
} else {
|
||||
|
@ -181,7 +191,7 @@ func AugmentSingsVideoStructForCSV(input irc.SingsVideoStruct) AugmentedSingsVid
|
|||
return ret
|
||||
}
|
||||
|
||||
func AugmentSingsVideoStructSliceForCSV(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
||||
func AugmentSingsVideoStructSliceForCSV(input []data.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
||||
ret := make([]AugmentedSingsVideoStruct, 0)
|
||||
for _, record := range input {
|
||||
ret = append(ret, AugmentSingsVideoStructForCSV(record))
|
||||
|
@ -189,7 +199,7 @@ func AugmentSingsVideoStructSliceForCSV(input []irc.SingsVideoStruct) []Augmente
|
|||
return ret
|
||||
}
|
||||
|
||||
func AugmentSingsVideoStruct(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
|
||||
func AugmentSingsVideoStruct(input data.SingsVideoStruct) AugmentedSingsVideoStruct {
|
||||
var ret AugmentedSingsVideoStruct
|
||||
ret.Date = input.Date
|
||||
ret.NiceDate = humanize.Time(input.Date)
|
||||
|
@ -208,7 +218,7 @@ func AugmentSingsVideoStruct(input irc.SingsVideoStruct) AugmentedSingsVideoStru
|
|||
return ret
|
||||
}
|
||||
|
||||
func AugmentSingsVideoStructSlice(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
||||
func AugmentSingsVideoStructSlice(input []data.SingsVideoStruct) []AugmentedSingsVideoStruct {
|
||||
ret := make([]AugmentedSingsVideoStruct, 0)
|
||||
for _, record := range input {
|
||||
ret = append(ret, AugmentSingsVideoStruct(record))
|
||||
|
@ -216,98 +226,6 @@ func AugmentSingsVideoStructSlice(input []irc.SingsVideoStruct) []AugmentedSings
|
|||
return ret
|
||||
}
|
||||
|
||||
func AdminHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
type TemplateData struct {
|
||||
Channel string
|
||||
Command string
|
||||
ExtraStrings string
|
||||
SinceTime time.Time
|
||||
SinceTimeUTC string
|
||||
Leaving bool
|
||||
HasLeft bool
|
||||
SongData []AugmentedSingsVideoStruct
|
||||
TopNSongs []SongSings
|
||||
TopNSingers []SingerSings
|
||||
ChannelKey string
|
||||
}
|
||||
channelData := ircBot.ChannelData[vars["channel"]]
|
||||
if time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours() > 1 {
|
||||
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
|
||||
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
|
||||
if err != nil {
|
||||
errCache := make([]irc.SingsVideoStruct, 0)
|
||||
var ret irc.SingsVideoStruct
|
||||
ret.FullTitle = "Error fetching videos: " + err.Error()
|
||||
errCache = append(errCache, ret)
|
||||
vids = errCache
|
||||
}
|
||||
updateCalculatedFields(vids)
|
||||
ircBot.UpdateVideoCache(vars["channel"], vids)
|
||||
} else {
|
||||
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
|
||||
}
|
||||
updateCalculatedFields(channelData.VideoCache)
|
||||
for _, song := range channelData.VideoCache {
|
||||
if song.Duet && song.OtherSinger == "" {
|
||||
fmt.Printf("WARNING: found duet with no other singer! %s", song.SongTitle) // should never happen but debug in case it does!
|
||||
}
|
||||
}
|
||||
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
|
||||
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
|
||||
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
|
||||
|
||||
if request.Method == "POST" {
|
||||
request.ParseForm()
|
||||
if strings.Join(request.PostForm["leave"], ",") == "Leave twitch channel" {
|
||||
td.Leaving = true
|
||||
} else if strings.Join(request.PostForm["reallyleave"], ",") == "Really leave twitch channel" {
|
||||
record := ircBot.ChannelData[vars["channel"]]
|
||||
record.HasLeft = true
|
||||
ircBot.ChannelData[vars["channel"]] = record
|
||||
ircBot.LeaveChannel(vars["channel"])
|
||||
ircBot.Database.Write("channelData", vars["channel"], record)
|
||||
LeaveHandler(response, request)
|
||||
return
|
||||
}
|
||||
if strings.Join(request.PostForm["join"], ",") == "Come on in" {
|
||||
record := ircBot.ChannelData[vars["channel"]]
|
||||
td.HasLeft = false
|
||||
record.Name = vars["channel"]
|
||||
record.JoinTime = time.Now()
|
||||
record.HasLeft = false
|
||||
if record.Command == "" {
|
||||
record.Command = "card"
|
||||
}
|
||||
ircBot.Database.Write("channelData", vars["channel"], record)
|
||||
ircBot.ChannelData[vars["channel"]] = record
|
||||
td = TemplateData{record.Name, record.Command, record.ExtraStrings, record.JoinTime, record.JoinTime.Format(irc.UTCFormat), false, record.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
|
||||
ircBot.JoinChannel(record.Name)
|
||||
}
|
||||
sourceData := ircBot.ChannelData[vars["channel"]]
|
||||
if strings.Join(request.PostForm["Command"], ",") != "" {
|
||||
sourceData.Command = strings.Join(request.PostForm["Command"], ",")
|
||||
td.Command = sourceData.Command
|
||||
ircBot.ChannelData[vars["channel"]] = sourceData
|
||||
}
|
||||
if strings.Join(request.PostForm["ExtraStrings"], ",") != sourceData.ExtraStrings {
|
||||
sourceData.ExtraStrings = strings.Join(request.PostForm["ExtraStrings"], ",")
|
||||
td.ExtraStrings = sourceData.ExtraStrings
|
||||
ircBot.ChannelData[vars["channel"]] = sourceData
|
||||
}
|
||||
ircBot.Database.Write("channelData", vars["channel"], sourceData)
|
||||
}
|
||||
tmpl, err := template.New("admin.html").ParseFiles("web/admin.html")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
tmpl.Execute(response, td)
|
||||
}
|
||||
|
||||
func UnauthorizedHandler(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Add("X-Template-File", "html"+request.URL.Path)
|
||||
response.WriteHeader(401)
|
||||
|
@ -331,8 +249,12 @@ func twitchHTTPClient(call string, bearer string) (string, error) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return string(http.StatusText(resp.StatusCode)), errors.New("HTTP ERROR: " + http.StatusText(resp.StatusCode))
|
||||
} else {
|
||||
return string([]byte(body)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateTwitchBearerToken(bearer string) (bool, error) {
|
||||
url := "https://id.twitch.tv/oauth2/validate"
|
||||
|
@ -353,8 +275,8 @@ func ValidateTwitchBearerToken(bearer string) (bool, error) {
|
|||
return resp.StatusCode == 200, nil
|
||||
}
|
||||
|
||||
func twitchVidToSingsVid(twitchFormat videoStruct) (irc.SingsVideoStruct, error) {
|
||||
var ret irc.SingsVideoStruct
|
||||
func twitchVidToSingsVid(twitchFormat videoStruct) (data.SingsVideoStruct, error) {
|
||||
var ret data.SingsVideoStruct
|
||||
layout := "2006-01-02T15:04:05Z"
|
||||
var d time.Time
|
||||
d, err := time.Parse(layout, twitchFormat.CreatedAt)
|
||||
|
@ -362,6 +284,7 @@ func twitchVidToSingsVid(twitchFormat videoStruct) (irc.SingsVideoStruct, error)
|
|||
return ret, err
|
||||
}
|
||||
ret.Date = d
|
||||
ret.VideoURL = twitchFormat.URL
|
||||
|
||||
var DuetRegex = regexp.MustCompile(`^Duet with ([^ ]*): (.*)$`)
|
||||
matches := DuetRegex.FindAllStringSubmatch(twitchFormat.Title, -1)
|
||||
|
@ -394,7 +317,7 @@ type SingerSings struct {
|
|||
Sings int
|
||||
}
|
||||
|
||||
func calculateTopNSongs(songCache []irc.SingsVideoStruct, howMany int) []SongSings {
|
||||
func calculateTopNSongs(songCache []data.SingsVideoStruct, howMany int) []SongSings {
|
||||
songMap := map[string]int{}
|
||||
for _, record := range songCache {
|
||||
sings := songMap[record.SongTitle]
|
||||
|
@ -425,7 +348,7 @@ func IsLower(s string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func unmangleSingerName(MangledCaseName string, songCache []irc.SingsVideoStruct) string {
|
||||
func unmangleSingerName(MangledCaseName string, songCache []data.SingsVideoStruct) string {
|
||||
options := make(map[string]string, 0)
|
||||
for _, record := range songCache {
|
||||
if strings.ToUpper(MangledCaseName) == strings.ToUpper(record.OtherSinger) {
|
||||
|
@ -490,7 +413,55 @@ func (h *KVHeap) Pop() interface{} {
|
|||
return x
|
||||
}
|
||||
|
||||
func calculateTopNSingers(songCache []irc.SingsVideoStruct, howMany int) []SingerSings {
|
||||
var cacheLock sync.Mutex
|
||||
|
||||
type CacheDetails struct {
|
||||
Age time.Duration `json: "cache_age"`
|
||||
AgeStr string `json: "cache_age_nice"`
|
||||
SongCount int `json: "expires_in"`
|
||||
}
|
||||
|
||||
func getCacheDetails(channel string) CacheDetails {
|
||||
var ret CacheDetails
|
||||
channelData := globalData.ChannelData[channel]
|
||||
ret.Age = time.Now().Sub(channelData.VideoCacheUpdated)
|
||||
ret.AgeStr = humanize.Time(channelData.VideoCacheUpdated)
|
||||
ret.SongCount = len(channelData.VideoCache)
|
||||
return ret
|
||||
}
|
||||
|
||||
func forceUpdateCache(channel string) {
|
||||
fmt.Printf("Forcing cache update!")
|
||||
channelData := globalData.ChannelData[channel]
|
||||
tenHours := time.Hour * -10
|
||||
videoCacheUpdated := time.Now().Add(tenHours) // Subtract 10 hours from now, cache is 10 hours old.
|
||||
channelData.VideoCacheUpdated = videoCacheUpdated
|
||||
globalData.ChannelData[channel] = channelData
|
||||
updateCacheIfNecessary(channel)
|
||||
}
|
||||
|
||||
func updateCacheIfNecessary(channel string) {
|
||||
cacheLock.Lock()
|
||||
channelData := globalData.ChannelData[channel]
|
||||
if time.Now().Sub(channelData.VideoCacheUpdated).Hours() > 1 {
|
||||
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
|
||||
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
|
||||
if err != nil {
|
||||
errCache := make([]data.SingsVideoStruct, 0)
|
||||
var ret data.SingsVideoStruct
|
||||
ret.FullTitle = "Error fetching videos: " + err.Error()
|
||||
errCache = append(errCache, ret)
|
||||
vids = errCache
|
||||
}
|
||||
updateCalculatedFields(vids)
|
||||
globalData.UpdateVideoCache(channel, vids)
|
||||
} else {
|
||||
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
|
||||
}
|
||||
cacheLock.Unlock()
|
||||
}
|
||||
|
||||
func calculateTopNSingers(songCache []data.SingsVideoStruct, howMany int) []SingerSings {
|
||||
songMap := map[string]int{}
|
||||
songCount := 0
|
||||
for _, record := range songCache {
|
||||
|
@ -514,7 +485,7 @@ func calculateTopNSingers(songCache []irc.SingsVideoStruct, howMany int) []Singe
|
|||
return slice
|
||||
}
|
||||
|
||||
func calculateLastSungSongDate(songCache []irc.SingsVideoStruct, SongTitle string) time.Time {
|
||||
func calculateLastSungSongDate(songCache []data.SingsVideoStruct, SongTitle string) time.Time {
|
||||
var t time.Time
|
||||
for _, record := range songCache {
|
||||
if record.SongTitle == SongTitle {
|
||||
|
@ -526,7 +497,7 @@ func calculateLastSungSongDate(songCache []irc.SingsVideoStruct, SongTitle strin
|
|||
return t
|
||||
}
|
||||
|
||||
func calculateLastSungSingerDate(songCache []irc.SingsVideoStruct, Singer string) time.Time {
|
||||
func calculateLastSungSingerDate(songCache []data.SingsVideoStruct, Singer string) time.Time {
|
||||
var t time.Time
|
||||
for _, record := range songCache {
|
||||
if strings.ToUpper(record.OtherSinger) == strings.ToUpper(Singer) {
|
||||
|
@ -541,7 +512,7 @@ func calculateLastSungSingerDate(songCache []irc.SingsVideoStruct, Singer string
|
|||
return t
|
||||
}
|
||||
|
||||
func updateCalculatedFields(songCache []irc.SingsVideoStruct) {
|
||||
func updateCalculatedFields(songCache []data.SingsVideoStruct) {
|
||||
for i, record := range songCache {
|
||||
if record.Duet {
|
||||
songCache[i].LastSungSinger = calculateLastSungSingerDate(songCache, record.OtherSinger)
|
||||
|
@ -550,7 +521,7 @@ func updateCalculatedFields(songCache []irc.SingsVideoStruct) {
|
|||
}
|
||||
}
|
||||
|
||||
func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]irc.SingsVideoStruct, error) {
|
||||
func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]data.SingsVideoStruct, error) {
|
||||
url := ""
|
||||
if from == "" {
|
||||
url = "videos?user_id=" + userID + "&first=100&type=upload"
|
||||
|
@ -568,7 +539,7 @@ func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]irc.S
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
titles := make([]irc.SingsVideoStruct, 0)
|
||||
titles := make([]data.SingsVideoStruct, 0)
|
||||
for _, videoData := range fullResponse.Data {
|
||||
ret, err := twitchVidToSingsVid(videoData)
|
||||
if err != nil {
|
||||
|
@ -588,17 +559,19 @@ func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]irc.S
|
|||
return titles, nil
|
||||
}
|
||||
|
||||
func fetchAllVoDs(userID string, bearer string) ([]irc.SingsVideoStruct, error) {
|
||||
func fetchAllVoDs(userID string, bearer string) ([]data.SingsVideoStruct, error) {
|
||||
tokenValid, err := ValidateTwitchBearerToken(bearer)
|
||||
if err != nil {
|
||||
fmt.Println("Error validating token : " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if !tokenValid {
|
||||
fmt.Println("Error validating token (revoked?)")
|
||||
return nil, errors.New("Failed to validate token with twitch (authorization revoked?!)")
|
||||
}
|
||||
titles, err := fetchVoDsPagesRecursive(userID, bearer, "")
|
||||
if err != nil {
|
||||
return make([]irc.SingsVideoStruct, 0), err
|
||||
return make([]data.SingsVideoStruct, 0), err
|
||||
}
|
||||
return titles, nil
|
||||
}
|
||||
|
@ -615,7 +588,7 @@ func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
|
|||
"client_secret": {ircBot.AppCredentials.ClientSecret},
|
||||
"code": {vars["code"]},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}})
|
||||
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}})
|
||||
if err != nil {
|
||||
response.WriteHeader(500)
|
||||
response.Header().Add("Content-type", "text/plain")
|
||||
|
@ -666,10 +639,10 @@ func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
|
|||
}
|
||||
|
||||
user := usersObject.Data[0]
|
||||
magicCode := ircBot.ReadOrCreateChannelKey(user.Login)
|
||||
ircBot.UpdateBearerToken(user.Login, oauthResponse.Access_token)
|
||||
ircBot.UpdateTwitchUserID(user.Login, user.Id)
|
||||
url := "https://" + ircBot.Config.ExternalUrl + "/admin/" + user.Login + "/" + magicCode
|
||||
magicCode := globalData.ReadOrCreateChannelKey(user.Login)
|
||||
globalData.UpdateBearerToken(user.Login, oauthResponse.Access_token)
|
||||
globalData.UpdateTwitchUserID(user.Login, user.Id)
|
||||
url := "https://" + globalData.Config.ExternalURL + "/admin/" + user.Login + "/" + magicCode
|
||||
http.Redirect(response, request, url, http.StatusFound)
|
||||
|
||||
} else {
|
||||
|
@ -701,7 +674,7 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
|
|||
// ircBot.AppCredentials.Password
|
||||
// vars["oauthtoken"]
|
||||
// authorization_code
|
||||
// "https://"+ircBot.Config.ExternalUrl+/twitchadmin
|
||||
// "https://"+globalData.Config.ExternalURL+/twitchadmin
|
||||
fmt.Println("Asking twitch for more...")
|
||||
resp, err := http.PostForm(
|
||||
"https://id.twitch.tv/oauth2/token",
|
||||
|
@ -710,7 +683,7 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
|
|||
"client_secret": {ircBot.AppCredentials.ClientSecret},
|
||||
"code": {vars["code"]},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}})
|
||||
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}})
|
||||
if err != nil {
|
||||
response.Header().Add("Content-type", "text/plain")
|
||||
fmt.Fprint(response, "ERROR: "+err.Error())
|
||||
|
@ -730,13 +703,13 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
|
|||
|
||||
func CSVHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
type TemplateData struct {
|
||||
Channel string
|
||||
Command string
|
||||
Commands []data.CommandStruct
|
||||
ExtraStrings string
|
||||
SinceTime time.Time
|
||||
SinceTimeUTC string
|
||||
|
@ -746,25 +719,11 @@ func CSVHandler(response http.ResponseWriter, request *http.Request) {
|
|||
TopNSongs []SongSings
|
||||
TopNSingers []SingerSings
|
||||
}
|
||||
channelData := ircBot.ChannelData[vars["channel"]]
|
||||
if time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours() > 1 {
|
||||
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
|
||||
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
|
||||
if err != nil {
|
||||
errCache := make([]irc.SingsVideoStruct, 0)
|
||||
var ret irc.SingsVideoStruct
|
||||
ret.FullTitle = "Error fetching videos: " + err.Error()
|
||||
errCache = append(errCache, ret)
|
||||
vids = errCache
|
||||
}
|
||||
updateCalculatedFields(vids)
|
||||
ircBot.UpdateVideoCache(vars["channel"], vids)
|
||||
} else {
|
||||
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
|
||||
}
|
||||
updateCacheIfNecessary(vars["channel"])
|
||||
channelData := globalData.ChannelData[vars["channel"]]
|
||||
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
|
||||
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
|
||||
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
|
||||
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
|
||||
if request.URL.Path[0:4] == "/csv" {
|
||||
response.Header().Add("Content-Disposition", "attachment; filename=\"duets.csv\"")
|
||||
response.Header().Add("Content-type", "text/csv")
|
||||
|
@ -778,23 +737,174 @@ func CSVHandler(response http.ResponseWriter, request *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func HandleHTTP(passedIrcBot *irc.KardBot) {
|
||||
func JSONHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
fmt.Printf("%s != %s\n", vars["key"], globalData.ChannelData[vars["channel"]].AdminKey)
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
type TemplateData struct {
|
||||
Channel string
|
||||
Commands []data.CommandStruct
|
||||
ExtraStrings string
|
||||
SinceTime time.Time
|
||||
SinceTimeUTC string
|
||||
Leaving bool
|
||||
HasLeft bool
|
||||
SongData []AugmentedSingsVideoStruct
|
||||
TopNSongs []SongSings
|
||||
TopNSingers []SingerSings
|
||||
}
|
||||
updateCacheIfNecessary(vars["channel"])
|
||||
channelData := globalData.ChannelData[vars["channel"]]
|
||||
|
||||
var topNSongs []SongSings
|
||||
var topNSingers []SingerSings
|
||||
|
||||
if request.URL.Path[0:4] != "/deb" {
|
||||
topNSongs = calculateTopNSongs(channelData.VideoCache, 10)
|
||||
topNSingers = calculateTopNSingers(channelData.VideoCache, 10)
|
||||
}
|
||||
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers}
|
||||
response.Header().Add("Content-type", "application/json")
|
||||
if request.URL.Path[0:5] == "/json" {
|
||||
tmpl := template.Must(template.ParseFiles("web/data.json"))
|
||||
tmpl.Execute(response, td)
|
||||
} else if request.URL.Path[0:9] == "/topsongs" {
|
||||
tmpl := template.Must(template.ParseFiles("web/topsongs.json"))
|
||||
tmpl.Execute(response, td)
|
||||
} else { // top 10 singers!
|
||||
tmpl := template.Must(template.ParseFiles("web/topsingers.json"))
|
||||
tmpl.Execute(response, td)
|
||||
}
|
||||
}
|
||||
|
||||
func ScriptHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
type TemplateData struct {
|
||||
Channel string
|
||||
Commands []data.CommandStruct
|
||||
ExtraStrings string
|
||||
SinceTime time.Time
|
||||
SinceTimeUTC string
|
||||
Leaving bool
|
||||
HasLeft bool
|
||||
SongData []AugmentedSingsVideoStruct
|
||||
TopNSongs []SongSings
|
||||
TopNSingers []SingerSings
|
||||
}
|
||||
updateCacheIfNecessary(vars["channel"])
|
||||
channelData := globalData.ChannelData[vars["channel"]]
|
||||
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
|
||||
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
|
||||
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
|
||||
if request.URL.Path[0:11] == "/script.bat" {
|
||||
response.Header().Add("Content-Disposition", "attachment; filename=\"script.bat\"")
|
||||
response.Header().Add("Content-type", "application/x-bat")
|
||||
tmpl := template.Must(template.ParseFiles("web/script.bat"))
|
||||
tmpl.Execute(response, td)
|
||||
} else {
|
||||
response.Header().Add("Content-Disposition", "attachment; filename=\"script.sh\"")
|
||||
response.Header().Add("Content-type", "text/x-shellscript")
|
||||
tmpl := template.Must(template.ParseFiles("web/script.sh"))
|
||||
tmpl.Execute(response, td)
|
||||
}
|
||||
}
|
||||
|
||||
func CacheDetailsHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
deets := getCacheDetails(vars["channel"])
|
||||
response.Header().Add("Content-type", "application/json")
|
||||
enc := json.NewEncoder(response)
|
||||
enc.Encode(deets)
|
||||
}
|
||||
|
||||
func BotDetailsHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
type ChannelDataSmaller struct {
|
||||
Commands []data.CommandStruct `json:"commands,omitempty"`
|
||||
ExtraStrings string `json:"extrastrings,omitempty"`
|
||||
JoinTime time.Time `json:"jointime"`
|
||||
HasLeft bool `json:"hasleft"`
|
||||
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
|
||||
}
|
||||
var deets ChannelDataSmaller
|
||||
deets.Commands = globalData.ChannelData[vars["channel"]].Commands
|
||||
deets.ExtraStrings = globalData.ChannelData[vars["channel"]].ExtraStrings
|
||||
deets.JoinTime = globalData.ChannelData[vars["channel"]].JoinTime
|
||||
deets.HasLeft = globalData.ChannelData[vars["channel"]].HasLeft
|
||||
deets.VideoCacheUpdated = globalData.ChannelData[vars["channel"]].VideoCacheUpdated
|
||||
response.Header().Add("Content-type", "application/json")
|
||||
enc := json.NewEncoder(response)
|
||||
enc.Encode(deets)
|
||||
}
|
||||
|
||||
func ReactIndexHandler(entrypoint string) func(w http.ResponseWriter, r *http.Request) {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, entrypoint)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func ForceRefreshHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
forceUpdateCache(vars["channel"])
|
||||
response.Header().Add("Content-type", "application/json")
|
||||
enc := json.NewEncoder(response)
|
||||
enc.Encode(true)
|
||||
}
|
||||
|
||||
func JoinHandler(response http.ResponseWriter, request *http.Request) {
|
||||
vars := mux.Vars(request)
|
||||
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey {
|
||||
UnauthorizedHandler(response, request)
|
||||
return
|
||||
}
|
||||
globalData.UpdateJoined(vars["channel"], false)
|
||||
ircBot.JoinChannel(vars["channel"])
|
||||
}
|
||||
|
||||
func HandleHTTP(passedIrcBot *irc.KardBot, passedGlobalData *data.GlobalData) {
|
||||
ircBot = passedIrcBot
|
||||
globalData = passedGlobalData
|
||||
r := mux.NewRouter()
|
||||
loggedRouter := handlers.LoggingHandler(os.Stdout, r)
|
||||
r.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
|
||||
r.HandleFunc("/", RootHandler)
|
||||
r.HandleFunc("/healthz", HealthHandler)
|
||||
r.HandleFunc("/web/{.*}", TemplateHandler)
|
||||
r.PathPrefix("/static/").Handler(http.FileServer(http.Dir("./web/")))
|
||||
r.HandleFunc("/cover.css", CSSHandler)
|
||||
r.HandleFunc("/admin/{channel}/{key}", AdminHandler)
|
||||
r.HandleFunc("/cachedeets/{channel}/{key}", CacheDetailsHandler)
|
||||
r.HandleFunc("/botdeets/{channel}/{key}", BotDetailsHandler)
|
||||
r.HandleFunc("/join/{channel}/{key}", JoinHandler)
|
||||
r.HandleFunc("/force/{channel}/{key}", ForceRefreshHandler)
|
||||
r.HandleFunc("/csv/{channel}/{key}", CSVHandler)
|
||||
r.HandleFunc("/tsv/{channel}/{key}", CSVHandler)
|
||||
//r.HandleFunc("/twitchadmin", TwitchAdminHandler)
|
||||
//r.HandleFunc("/twitchtobackend", TwitchBackendHandler)
|
||||
r.HandleFunc("/json/{channel}/{key}", JSONHandler)
|
||||
r.HandleFunc("/debug/{channel}/{key}", JSONHandler)
|
||||
r.HandleFunc("/topsongs/{channel}/{key}", JSONHandler)
|
||||
r.HandleFunc("/topsingers/{channel}/{key}", JSONHandler)
|
||||
r.HandleFunc("/script.bat/{channel}/{key}", ScriptHandler)
|
||||
r.Path("/twitchtobackend").Queries("access_token", "{access_token}", "scope", "{scope}", "token_type", "{token_type}").HandlerFunc(TwitchBackendHandler)
|
||||
r.Path("/twitchadmin").Queries("code", "{code}", "scope", "{scope}").HandlerFunc(TwitchAdminHandler)
|
||||
r.PathPrefix("/static").Handler(http.StripPrefix("/static", http.FileServer(http.Dir("./web/react-frontend/static"))))
|
||||
r.PathPrefix("/").HandlerFunc(ReactIndexHandler("./web/react-frontend/index.html"))
|
||||
http.Handle("/", r)
|
||||
srv := &http.Server{
|
||||
Handler: loggedRouter,
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
|
||||
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
|
||||
)
|
||||
|
||||
func TestDateRegex(t *testing.T) {
|
||||
|
@ -72,9 +72,9 @@ func TestSoloRegexes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCalculatedDates(t *testing.T) {
|
||||
var record irc.SingsVideoStruct
|
||||
var record data.SingsVideoStruct
|
||||
format := "2006-01-02 15:04:05 +0000 UTC"
|
||||
var mockCache []irc.SingsVideoStruct
|
||||
var mockCache []data.SingsVideoStruct
|
||||
record.Date, _ = time.Parse(format, "2020-07-13 19:43:11 +0000 UTC")
|
||||
record.SongTitle = "Words Fail"
|
||||
record.OtherSinger = "FullOfEmily"
|
||||
|
|
31
main.go
31
main.go
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
|
@ -9,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
builtins "git.martyn.berlin/martyn/twitchsingstools/internal/builtins"
|
||||
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
|
||||
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
|
||||
webserver "git.martyn.berlin/martyn/twitchsingstools/internal/webserver"
|
||||
rgb "github.com/foresthoffman/rgblog"
|
||||
|
@ -23,7 +25,7 @@ var selectablePrompts []string
|
|||
|
||||
var customStrings customStringsStruct
|
||||
|
||||
var config irc.ConfigStruct
|
||||
var config data.ConfigStruct
|
||||
|
||||
func readConfig() {
|
||||
var data []byte
|
||||
|
@ -174,12 +176,32 @@ func main() {
|
|||
}
|
||||
} else {
|
||||
if _, err := os.Stat(config.AppOAuthPath); os.IsNotExist(err) {
|
||||
rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
|
||||
rgb.RPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
appOauthPath = config.AppOAuthPath
|
||||
}
|
||||
|
||||
rgb.YPrintf("[%s] Starting connection to redis...\n", irc.TimeStamp())
|
||||
//TODO: unhardcode this
|
||||
if os.Getenv("TSTOOLS_REDIS_HOST") != "" {
|
||||
config.DatabaseSVC = os.Getenv("TSTOOLS_REDIS_HOST")
|
||||
} else {
|
||||
// assume localhost, which should fail.
|
||||
config.DatabaseSVC = "localhost"
|
||||
}
|
||||
var globalData data.GlobalData
|
||||
globalData.Config = config
|
||||
globalData.ConnectDatabase()
|
||||
defer globalData.Database.Close()
|
||||
rgb.GPrintf("[%s] Connected to \"redis\" %s\n", irc.TimeStamp(), "config.DatabaseSVC")
|
||||
err := globalData.ReadChannelData()
|
||||
if nil != err {
|
||||
fmt.Println(err)
|
||||
fmt.Println("Aborting!")
|
||||
os.Exit(1)
|
||||
}
|
||||
rgb.GPrintf("[%s] Read the channel data from \"redis\" successfully, now have %d records\n", irc.TimeStamp(), len(globalData.ChannelData))
|
||||
// Replace the channel name, bot name, and the path to the private directory with your respective
|
||||
// values.
|
||||
var myBot irc.KardBot
|
||||
|
@ -193,13 +215,12 @@ func main() {
|
|||
AppPrivatePath: appOauthPath,
|
||||
Server: "irc.chat.twitch.tv",
|
||||
Prompts: selectablePrompts,
|
||||
Database: *persistentData,
|
||||
Config: config,
|
||||
GlobalData: globalData,
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353")
|
||||
webserver.HandleHTTP(&myBot)
|
||||
webserver.HandleHTTP(&myBot, &globalData)
|
||||
}()
|
||||
if ircOauthPath != "" {
|
||||
myBot.Start()
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
[{{ range $index, $data := .SongData }}{{ if $index }},{{end}}{
|
||||
"publishDate": "{{ $data.Date }}",
|
||||
"displayPublishDate": "{{ $data.NiceDate }}",
|
||||
"songName": "{{ $data.SongTitle }}",
|
||||
"singerName": "{{ $data.OtherSinger }}",
|
||||
"lastSongDate": "{{ $data.LastSungSong }}",
|
||||
"displayLastSongDate": "{{ $data.NiceLastSungSong }}",
|
||||
"lastDuetDate": "{{ $data.LastSungSinger }}",
|
||||
"displayLastDuetDate": "{{ $data.NiceLastSungSinger }}"
|
||||
}{{ end }}]
|
|
@ -0,0 +1,5 @@
|
|||
@ECHO OFF
|
||||
{{ range .SongData -}}
|
||||
IF NOT EXIST "{{.OtherSinger}}\" MKDIR {{.OtherSinger}}
|
||||
IF NOT EXIST "{{.OtherSinger}}\{{.ShortDate}}-{{.OtherSinger}}-{{.SongTitle}}-{{.VideoNumber}}.mp4" youtube-dl -o "{{.OtherSinger}}\{{.ShortDate}}-{{.OtherSinger}}-{{.SongTitle}}-{{.VideoNumber}}.mp4" {{.VideoURL}}
|
||||
{{ end }}
|
|
@ -0,0 +1,4 @@
|
|||
[{{ range $index, $data := .TopNSingers }}{{ if $index }},{{end}}{
|
||||
"singerName": "{{ .SingerName }}",
|
||||
"singCount": "{{ .Sings }}"
|
||||
}{{ end }}]
|
|
@ -0,0 +1,4 @@
|
|||
[{{ range $index, $data := .TopNSongs }}{{ if $index }},{{end}}{
|
||||
"songName": "{{ .SongTitle }}",
|
||||
"singCount": "{{ .Sings }}"
|
||||
}{{ end }}]
|
Loading…
Reference in New Issue