Compare commits

...

29 Commits
v0.0.5 ... main

Author SHA1 Message Date
Martyn 1f00cd699f Add link to BockTown script
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-09-08 21:25:59 +02:00
Martyn f7b0f074cf Redis refactor and video download script.
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-09-05 18:14:56 +02:00
Martyn 50303b3918 Production to external hosting
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-10 12:10:58 +02:00
Martyn 132b433bc7 Hide but accessible
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 22:19:19 +02:00
Martyn 317b72dd60 Hide but accessible
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 17:44:42 +02:00
Martyn cfff6bc46e Fix the recreation every startup
continuous-integration/drone/push Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 09:22:22 +02:00
Martyn fcd60fc08a Bot control panel frontend looking good
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:34:04 +02:00
Martyn 705b1f5e12 Allow for reload in caching
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:32:33 +02:00
Martyn f721c91690 Actual data from my account
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:30:28 +02:00
Martyn f49d3ec9ab Cache refresh may work, local testing easier
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 17:07:59 +02:00
Martyn b5a0d8bda2 Cache refresh may work, local testing easier
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 17:07:41 +02:00
Martyn 6633fff3b6 Use sample data in dev mode, makes life easier
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 16:55:44 +02:00
Martyn 4c33360649 Ensure if you're sorting by last dueted with date, solo performances are always at the end
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 12:57:02 +02:00
Martyn 56a80d8936 Remove the fat image I used for testing
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 12:34:36 +02:00
Martyn a8dbd68031 Lock the cache check, so we don't refresh it three times for every singer
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 12:34:06 +02:00
Martyn 3966e6e226 Should have used the makefile here too I guess, oh well
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 12:04:14 +02:00
Martyn c783586027 linting fixes
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:58:21 +02:00
Martyn cb1929a245 does npm install fix this?
continuous-integration/drone/push Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:50:30 +02:00
Martyn 3f6d1e20ce re-add react-frontend
continuous-integration/drone/push Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:48:56 +02:00
Martyn 982d9bc535 Wierdness from create-react-app
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:47:51 +02:00
Martyn 554d1d445d yeah, typoish
continuous-integration/drone/push Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:42:33 +02:00
Martyn 68df7a5af2 Merge pull request 'react-frontend' (#2) from react-frontend into main
continuous-integration/drone/push Build is failing Details
Reviewed-on: #2
2020-08-01 09:40:05 +00:00
Martyn 4c27138497 Build the React frontend
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:38:05 +02:00
Martyn 8de2c0bd74 Serve the React frontend
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-01 11:37:40 +02:00
Martyn c403bff0ec Merge pull request 'It's not great, but it's something.' (#1) from basic-sorting into main
continuous-integration/drone/push Build is passing Details
Reviewed-on: #1
2020-07-31 13:01:32 +00:00
Martyn 999e049003 It's not great, but it's something.
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-07-31 14:59:48 +02:00
Martyn 37aabb3dfc Fix two bugs. Column misname and case insensitivity
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-07-16 22:00:49 +02:00
Martyn 7f1393c72d Sodit, fed up with forgetting
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-07-16 22:00:07 +02:00
Martyn c06bd71a1a Dev setup
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-07-16 21:59:01 +02:00
57 changed files with 26964 additions and 334 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -59,6 +59,11 @@ steps:
- make test
- make
- name: build-frontend
image: node
commands:
- make build-frontend
trigger:
ref:
- refs/heads/main

View File

@ -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"]

23
build/react-frontend/.gitignore vendored Normal file
View File

@ -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*

View File

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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

14400
build/react-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -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"}]}

View File

@ -0,0 +1 @@
{"Age":1726047740493,"AgeStr":"28 minutes ago","SongCount":1075}

File diff suppressed because it is too large Load Diff

View File

@ -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"
}]

View File

@ -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"
}]

View File

@ -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;
}

View File

@ -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;

View File

@ -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();
});

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

31
build/react-frontend/src/index.js vendored Normal file
View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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);
});
}
}

View File

@ -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';

39
build/react-frontend/src/theme.js vendored Normal file
View File

@ -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;

View File

@ -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 }}

View File

@ -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" }}"

View File

@ -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 }}

View File

@ -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 }}

View File

@ -0,0 +1,85 @@
# Default values for twitchsingstools.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: imartyn/twitchsingstools
pullPolicy: Always
tag: dev
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-dev.martyn.berlin
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: twitchsingstools-dev.ing.martyn.berlin
paths:
- /
- host: twitchsingstools-dev.martyn.berlin
paths:
- /
tls:
- secretName: tstools-tls
hosts:
- twitchsingstools-dev.ing.martyn.berlin
- twitchsingstools-dev.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: {}

View File

@ -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: {}

View File

@ -6,7 +6,7 @@ replicaCount: 1
image:
repository: imartyn/twitchsingstools
pullPolicy: IfNotPresent
pullPolicy: Always
imagePullSecrets: []
nameOverride: ""
@ -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

234
internal/data/data.go Executable file
View File

@ -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)
}

View File

@ -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)
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 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 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.ChannelData[channel].ControlChannel {
if bb.GlobalData.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
}
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)
}

View File

@ -1,11 +1,15 @@
package webserver
import (
"container/heap"
"errors"
"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"
@ -74,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")
@ -127,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)
@ -150,6 +155,7 @@ func humanTimeFromTimeString(s string) string {
type AugmentedSingsVideoStruct struct {
Date time.Time
NiceDate string
ShortDate string
FullTitle string
Duet bool
OtherSinger string
@ -158,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
@ -171,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 {
@ -179,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))
@ -187,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)
@ -206,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))
@ -214,92 +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())
}
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)
@ -323,7 +249,11 @@ func twitchHTTPClient(call string, bearer string) (string, error) {
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
return string([]byte(body)), nil
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) {
@ -345,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)
@ -354,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)
@ -386,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]
@ -408,31 +339,153 @@ func calculateTopNSongs(songCache []irc.SingsVideoStruct, howMany int) []SongSin
return ret
}
func calculateTopNSingers(songCache []irc.SingsVideoStruct, howMany int) []SingerSings {
songMap := map[string]int{}
for _, record := range songCache {
if record.Duet {
sings := songMap[record.OtherSinger]
sings += 1
songMap[record.OtherSinger] = sings
func IsLower(s string) bool {
for _, r := range s {
if !unicode.IsLower(r) && unicode.IsLetter(r) {
return false
}
}
slice := make([]SingerSings, 0)
for key, value := range songMap {
ss := SingerSings{key, value}
slice = append(slice, ss)
return true
}
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) {
options[record.OtherSinger] = "WHATEVER"
}
}
sort.SliceStable(slice, func(i, j int) bool {
return slice[i].Sings > slice[j].Sings
})
var ret []SingerSings
for i := 1; i <= howMany; i++ {
ret = append(ret, slice[i])
// One answer means we don't care upper, lower, mixed.
if len(options) == 1 {
for key := range options {
return key
}
}
// More than one is probably closed-beta where the name was lowercased.
for key := range options {
if !IsLower(key) {
return key
}
}
// Eep, we shouldn't get here, let's just return something.
for key := range options {
return key
}
return ""
}
func prependSong(x []SingerSings, y SingerSings) []SingerSings {
x = append(x, SingerSings{})
copy(x[1:], x)
x[0] = y
return x
}
type kv struct {
Key string
Value int
}
func getHeap(m map[string]int) *KVHeap {
h := &KVHeap{}
heap.Init(h)
for k, v := range m {
heap.Push(h, kv{k, v})
}
return h
}
type KVHeap []kv
func (h KVHeap) Len() int { return len(h) }
func (h KVHeap) Less(i, j int) bool { return h[i].Value > h[j].Value }
func (h KVHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *KVHeap) Push(x interface{}) {
*h = append(*h, x.(kv))
}
func (h *KVHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
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 calculateLastSungSongDate(songCache []irc.SingsVideoStruct, SongTitle string) time.Time {
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 {
if record.Duet {
sings := songMap[strings.ToUpper(record.OtherSinger)]
sings++
songCount++
songMap[strings.ToUpper(record.OtherSinger)] = sings
}
}
slice := make([]SingerSings, 0)
h := getHeap(songMap)
for i := 0; i < howMany; i++ {
deets := heap.Pop(h)
position := i + 1
fmt.Printf("%d) %#v\n", position, deets)
ss := SingerSings{unmangleSingerName(deets.(kv).Key, songCache), deets.(kv).Value}
slice = append(slice, ss)
}
fmt.Printf("Considered %d songs, Shan has %d\n", songCount, songMap["SHANXOX_"])
return slice
}
func calculateLastSungSongDate(songCache []data.SingsVideoStruct, SongTitle string) time.Time {
var t time.Time
for _, record := range songCache {
if record.SongTitle == SongTitle {
@ -444,19 +497,22 @@ 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) {
if record.Date.After(t) {
t = record.Date
if strings.ToUpper(Singer) == "SHANXOX_" {
fmt.Printf("Last sang with %s (%s) %s", Singer, record.OtherSinger, t)
}
}
}
}
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)
@ -465,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"
@ -483,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 {
@ -503,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
}
@ -530,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")
@ -581,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 {
@ -616,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",
@ -625,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())
@ -645,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
@ -661,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")
@ -693,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,

View File

@ -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
View File

@ -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()

View File

@ -146,8 +146,8 @@
</table>
</div>
<div style="width: 100%; overflow-y: scroll; display: none;" id="datapanel" class="controlpanel">
<table>
<thead><tr><th>Published</th><th>Who</th><th>What</th><th>Last sang this song</th><th>Last dueted with performer</th></th></thead>
<table id="dataTable">
<thead><tr><th>Published</th><th>What</th><th>Who</th><th>Last sang this song</th><th>Last dueted with performer</th></th></thead>
{{ range .SongData }}
<tr><td title="{{ .Date }}">{{ .NiceDate }}</td><td>{{ .SongTitle }}</td><td>{{ .OtherSinger }}</td><td title="{{ .LastSungSong }}">{{ .NiceLastSungSong }}</td><td title="{{ .LastSungSinger }}">{{ .NiceLastSungSinger }}</td></tr>
{{ end }}
@ -164,6 +164,7 @@
<h3>Excel is not very good at handling CSV format it seems...</h3>
<p>It is important to "Import Data" not "Open" the csv in many cases (8 year old discussion of this behaviour here) - from that post the instructions are : </p>
<blockquote>In Excel, DATA tab, in the Get External Data subsection, click "From Text" and import your CSV in the Wizard.</blockquote>
<p>LibreOffice calc kinda just works...just sayin' ;-)</p>
</div>
<div style="width: 100%; overflow-y: scroll; display: none;" id="botpanel" class="controlpanel">
<h2>The bot isn't really ready yet... it just has the old Karaokards facility at the moment. I woudn't bother inviting it yet.</h2>
@ -206,6 +207,62 @@
}
document.getElementById(self.id+"panel").style.display = "block"
}
/**
* Modified and more readable version of the answer by Paul S. to sort a table with ASC and DESC order
* with the <thead> and <tbody> structure easily.
*
* https://stackoverflow.com/a/14268260/4241030
*/
var TableSorter = {
makeSortable: function(table){
// Store context of this in the object
var _this = this;
var th = table.tHead, i;
th && (th = th.rows[0]) && (th = th.cells);
if (th){
i = th.length;
}else{
return; // if no `<thead>` then do nothing
}
// Loop through every <th> inside the header
while (--i >= 0) (function (i) {
var dir = 1;
// Append click listener to sort
th[i].addEventListener('click', function () {
_this._sort(table, i, (dir = 1 - dir));
});
}(i));
},
_sort: function (table, col, reverse) {
var tb = table.tBodies[0], // use `<tbody>` to ignore `<thead>` and `<tfoot>` rows
tr = Array.prototype.slice.call(tb.rows, 0), // put rows into array
i;
reverse = -((+reverse) || -1);
// Sort rows
tr = tr.sort(function (a, b) {
// `-1 *` if want opposite order
return reverse * (
// Using `.textContent.trim()` for test
a.cells[col].textContent.trim().localeCompare(
b.cells[col].textContent.trim()
)
);
});
for(i = 0; i < tr.length; ++i){
// Append rows in new order
tb.appendChild(tr[i]);
}
}
};
window.onload = function(){
TableSorter.makeSortable(document.getElementById("dataTable"));
};
</script>
</main>
<footer class="mastfoot mt-auto">

View File

@ -1,4 +1,4 @@
Published,Who,What,Last sang this song,Last dueted with performer
Published,What,Who,Last sang this song,Last dueted with performer
{{ range .SongData -}}
{{- .NiceDate }},"{{ .SongTitle }}",{{ .OtherSinger }},{{ .NiceLastSungSong }},{{ .NiceLastSungSinger }}
{{ end }}

Can't render this file because it has a wrong number of fields in line 2.

10
web/data.json Executable file
View File

@ -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 }}]

View File

@ -1,4 +1,4 @@
Published Who What Last sang this song Last dueted with performer
Published What Who Last sang this song Last dueted with performer
{{ range .SongData -}}
{{- .NiceDate }} "{{ .SongTitle }}" {{ .OtherSinger }} {{ .NiceLastSungSong }} {{ .NiceLastSungSinger }}
{{ end }}

Can't render this file because it has a wrong number of fields in line 2.

5
web/script.bat Executable file
View File

@ -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 }}

4
web/topsingers.json Executable file
View File

@ -0,0 +1,4 @@
[{{ range $index, $data := .TopNSingers }}{{ if $index }},{{end}}{
"singerName": "{{ .SingerName }}",
"singCount": "{{ .Sings }}"
}{{ end }}]

4
web/topsongs.json Executable file
View File

@ -0,0 +1,4 @@
[{{ range $index, $data := .TopNSongs }}{{ if $index }},{{end}}{
"songName": "{{ .SongTitle }}",
"singCount": "{{ .Sings }}"
}{{ end }}]