Compare commits

..

No commits in common. "main" and "react-frontend" have entirely different histories.

51 changed files with 340 additions and 26649 deletions

View File

@ -11,7 +11,7 @@ build:
go build ${LDFLAGS} go build ${LDFLAGS}
build-frontend: build-frontend:
cd build/react-frontend && npm install && npm run build cd build/react-frontend && npm run build
rm -rf web/react-frontend ; mkdir -p web/react-frontend rm -rf web/react-frontend ; mkdir -p web/react-frontend
cp -r build/react-frontend/build/* web/react-frontend/ cp -r build/react-frontend/build/* web/react-frontend/

View File

@ -62,7 +62,7 @@ steps:
- name: build-frontend - name: build-frontend
image: node image: node
commands: commands:
- make build-frontend - make frontend
trigger: trigger:
ref: ref:

View File

@ -7,9 +7,9 @@ RUN cd /go/src/git.martyn.berlin/martyn/twitchsingstools/; make deps ; make stat
FROM library/node:14.7.0-stretch AS frontend FROM library/node:14.7.0-stretch AS frontend
COPY build/react-frontend /frontend COPY build/react-frontend /frontend
RUN cd /frontend; npm install && npm run build RUN cd /frontend; npm run build
FROM scratch FROM debian
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /go/src/git.martyn.berlin/martyn/twitchsingstools /app/ COPY --from=builder /go/src/git.martyn.berlin/martyn/twitchsingstools /app/
COPY web/ /app/web/ COPY web/ /app/web/

1
build/react-frontend Submodule

@ -0,0 +1 @@
Subproject commit 5d2ed3c5be7d050e48b6bedbd2591543d732bf52

View File

@ -1,23 +0,0 @@
# 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

@ -1,68 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -1,43 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"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

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

View File

@ -1 +0,0 @@
{"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

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

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
[{
"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

@ -1,31 +0,0 @@
[{
"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

@ -1,42 +0,0 @@
.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

@ -1,67 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,43 +0,0 @@
.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

@ -1,229 +0,0 @@
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

@ -1,164 +0,0 @@
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

@ -1,81 +0,0 @@
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

@ -1,176 +0,0 @@
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

@ -1,23 +0,0 @@
.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

@ -1,34 +0,0 @@
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

@ -1,154 +0,0 @@
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

@ -1,155 +0,0 @@
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

@ -1,13 +0,0 @@
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;
}

View File

@ -1,31 +0,0 @@
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

@ -1,107 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -1,107 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,141 +0,0 @@
// 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

@ -1,5 +0,0 @@
// 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';

View File

@ -1,39 +0,0 @@
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

@ -1,66 +0,0 @@
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,8 +32,6 @@ spec:
env: env:
- name: TSTOOLS_DATA_FOLDER - name: TSTOOLS_DATA_FOLDER
value: /data value: /data
- name: TSTOOLS_REDIS_HOST
value: {{ .Values.db.service.name }}
securityContext: securityContext:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}"

View File

@ -1,12 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -6,7 +6,7 @@ replicaCount: 1
image: image:
repository: imartyn/twitchsingstools repository: imartyn/twitchsingstools
pullPolicy: Always pullPolicy: IfNotPresent
tag: dev tag: dev
imagePullSecrets: [] imagePullSecrets: []

View File

@ -1,81 +0,0 @@
# 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

@ -41,15 +41,6 @@ twitchapp: {}
storageSize: 10Gi storageSize: 10Gi
db:
service:
port: 4920
name: tstools-db
storageSize: 10Gi
image:
repository: pikadb/pika
pullPolicy: IfNotPresent
service: service:
type: ClusterIP type: ClusterIP
port: 80 port: 80

View File

@ -1,234 +0,0 @@
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,6 +2,7 @@ package irc
import ( import (
"bufio" "bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -14,8 +15,9 @@ import (
"strings" "strings"
"time" "time"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
rgb "github.com/foresthoffman/rgblog" 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" const UTCFormat = "Jan 2 15:04:05 UTC"
@ -56,6 +58,15 @@ type AppOAuthCred struct {
ClientSecret string `json:"client_secret,omitempty"` 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 { type KardBot struct {
Channel string Channel string
conn net.Conn conn net.Conn
@ -69,7 +80,33 @@ type KardBot struct {
Server string Server string
startTime time.Time startTime time.Time
Prompts []string Prompts []string
GlobalData data.GlobalData 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"`
} }
// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it // Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
@ -99,7 +136,7 @@ func (bb *KardBot) Disconnect() {
// Look at the channels I'm actually in // Look at the channels I'm actually in
func (bb *KardBot) ActiveChannels() int { func (bb *KardBot) ActiveChannels() int {
count := 0 count := 0
for _, channel := range bb.GlobalData.ChannelData { for _, channel := range bb.ChannelData {
if !channel.HasLeft { if !channel.HasLeft {
count = count + 1 count = count + 1
} }
@ -107,6 +144,29 @@ func (bb *KardBot) ActiveChannels() int {
return count 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 // 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. // continues until it gets disconnected, told to shutdown, or forcefully shutdown.
func (bb *KardBot) HandleChat() error { func (bb *KardBot) HandleChat() error {
@ -138,7 +198,11 @@ func (bb *KardBot) HandleChat() error {
matches := ConnectRegex.FindStringSubmatch(line) matches := ConnectRegex.FindStringSubmatch(line)
if nil != matches { if nil != matches {
realUserName := matches[1] realUserName := matches[1]
bb.GlobalData.UpdateJoined(realUserName, false) 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.JoinChannel(realUserName) bb.JoinChannel(realUserName)
} }
@ -169,24 +233,21 @@ func (bb *KardBot) HandleChat() error {
cmdMatches := CmdRegex.FindStringSubmatch(msg) cmdMatches := CmdRegex.FindStringSubmatch(msg)
if nil != cmdMatches { if nil != cmdMatches {
cmd := cmdMatches[1] cmd := cmdMatches[1]
cardCommand := ""
commands := bb.GlobalData.ChannelData[channel].Commands rgb.YPrintf("[%s] Checking cmd %s against %s\n", TimeStamp(), cmd, bb.ChannelData[channel].Command)
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 { switch cmd {
case cardCommand: case bb.ChannelData[channel].Command:
if cardCommand != "" {
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel) 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) bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
}
case "join": case "join":
if bb.GlobalData.ChannelData[channel].ControlChannel { if bb.ChannelData[channel].ControlChannel {
rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel) rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
bb.GlobalData.UpdateJoined(userName, false) 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.JoinChannel(userName) bb.JoinChannel(userName)
} }
} }
@ -203,7 +264,7 @@ func (bb *KardBot) HandleChat() error {
bb.Disconnect() bb.Disconnect()
return nil return nil
case "kcardadmin": case "kcardadmin":
magicCode := bb.GlobalData.ReadOrCreateChannelKey(channel) magicCode := bb.ReadOrCreateChannelKey(channel)
rgb.CPrintf( rgb.CPrintf(
"[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n", "[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n",
TimeStamp(), TimeStamp(),
@ -363,11 +424,18 @@ func (bb *KardBot) Start() {
return return
} }
err = bb.readChannelData()
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
return
}
for { for {
bb.Connect() bb.Connect()
bb.Login() bb.Login()
if len(bb.GlobalData.ChannelData) > 0 { if len(bb.ChannelData) > 0 {
for channelName, channelData := range bb.GlobalData.ChannelData { for channelName, channelData := range bb.ChannelData {
if !channelData.HasLeft { if !channelData.HasLeft {
bb.JoinChannel(channelName) bb.JoinChannel(channelName)
} }
@ -388,6 +456,85 @@ 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 { func TimeStamp() string {
return TimeStampFmt(UTCFormat) return TimeStampFmt(UTCFormat)
} }

View File

@ -6,10 +6,8 @@ import (
"math/rand" "math/rand"
"regexp" "regexp"
"sort" "sort"
"sync"
"unicode" "unicode"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc" irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
@ -78,7 +76,6 @@ type videosResponse struct {
} }
var ircBot *irc.KardBot var ircBot *irc.KardBot
var globalData *data.GlobalData
func HealthHandler(response http.ResponseWriter, request *http.Request) { func HealthHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("Content-type", "text/plain") response.Header().Add("Content-type", "text/plain")
@ -132,7 +129,7 @@ func TemplateHandler(response http.ResponseWriter, request *http.Request) {
// NotFoundHandler(response, request) // NotFoundHandler(response, request)
// return // return
} }
var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + globalData.Config.ExternalURL} var td = TemplateData{ircBot.Prompts[rand.Intn(len(ircBot.Prompts))], len(ircBot.Prompts), ircBot.ActiveChannels(), 0, ircBot.AppCredentials.ClientID, "https://" + ircBot.Config.ExternalUrl}
err = tmpl.Execute(response, td) err = tmpl.Execute(response, td)
if err != nil { if err != nil {
http.Error(response, err.Error(), http.StatusInternalServerError) http.Error(response, err.Error(), http.StatusInternalServerError)
@ -155,7 +152,6 @@ func humanTimeFromTimeString(s string) string {
type AugmentedSingsVideoStruct struct { type AugmentedSingsVideoStruct struct {
Date time.Time Date time.Time
NiceDate string NiceDate string
ShortDate string
FullTitle string FullTitle string
Duet bool Duet bool
OtherSinger string OtherSinger string
@ -164,15 +160,12 @@ type AugmentedSingsVideoStruct struct {
NiceLastSungSong string NiceLastSungSong string
LastSungSinger time.Time LastSungSinger time.Time
NiceLastSungSinger string NiceLastSungSinger string
VideoURL string
VideoNumber string //yes, I don't care any more.
} }
func AugmentSingsVideoStructForCSV(input data.SingsVideoStruct) AugmentedSingsVideoStruct { func AugmentSingsVideoStructForCSV(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
var ret AugmentedSingsVideoStruct var ret AugmentedSingsVideoStruct
ret.Date = input.Date ret.Date = input.Date
ret.NiceDate = input.Date.Format("2006-01-02 15:04:05") ret.NiceDate = input.Date.Format("2006-01-02 15:04:05")
ret.ShortDate = input.Date.Format("2006-01-02")
ret.FullTitle = input.FullTitle ret.FullTitle = input.FullTitle
ret.Duet = input.Duet ret.Duet = input.Duet
ret.OtherSinger = input.OtherSinger ret.OtherSinger = input.OtherSinger
@ -180,9 +173,6 @@ func AugmentSingsVideoStructForCSV(input data.SingsVideoStruct) AugmentedSingsVi
ret.LastSungSong = input.LastSungSong ret.LastSungSong = input.LastSungSong
ret.NiceLastSungSong = input.LastSungSong.Format("2006-01-02 15:04:05") ret.NiceLastSungSong = input.LastSungSong.Format("2006-01-02 15:04:05")
ret.LastSungSinger = input.LastSungSinger ret.LastSungSinger = input.LastSungSinger
ret.VideoURL = input.VideoURL
urlParts := strings.Split(input.VideoURL, "/")
ret.VideoNumber = urlParts[len(urlParts)-1]
if !ret.Duet { if !ret.Duet {
ret.NiceLastSungSinger = "Solo performance" ret.NiceLastSungSinger = "Solo performance"
} else { } else {
@ -191,7 +181,7 @@ func AugmentSingsVideoStructForCSV(input data.SingsVideoStruct) AugmentedSingsVi
return ret return ret
} }
func AugmentSingsVideoStructSliceForCSV(input []data.SingsVideoStruct) []AugmentedSingsVideoStruct { func AugmentSingsVideoStructSliceForCSV(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
ret := make([]AugmentedSingsVideoStruct, 0) ret := make([]AugmentedSingsVideoStruct, 0)
for _, record := range input { for _, record := range input {
ret = append(ret, AugmentSingsVideoStructForCSV(record)) ret = append(ret, AugmentSingsVideoStructForCSV(record))
@ -199,7 +189,7 @@ func AugmentSingsVideoStructSliceForCSV(input []data.SingsVideoStruct) []Augment
return ret return ret
} }
func AugmentSingsVideoStruct(input data.SingsVideoStruct) AugmentedSingsVideoStruct { func AugmentSingsVideoStruct(input irc.SingsVideoStruct) AugmentedSingsVideoStruct {
var ret AugmentedSingsVideoStruct var ret AugmentedSingsVideoStruct
ret.Date = input.Date ret.Date = input.Date
ret.NiceDate = humanize.Time(input.Date) ret.NiceDate = humanize.Time(input.Date)
@ -218,7 +208,7 @@ func AugmentSingsVideoStruct(input data.SingsVideoStruct) AugmentedSingsVideoStr
return ret return ret
} }
func AugmentSingsVideoStructSlice(input []data.SingsVideoStruct) []AugmentedSingsVideoStruct { func AugmentSingsVideoStructSlice(input []irc.SingsVideoStruct) []AugmentedSingsVideoStruct {
ret := make([]AugmentedSingsVideoStruct, 0) ret := make([]AugmentedSingsVideoStruct, 0)
for _, record := range input { for _, record := range input {
ret = append(ret, AugmentSingsVideoStruct(record)) ret = append(ret, AugmentSingsVideoStruct(record))
@ -226,6 +216,98 @@ func AugmentSingsVideoStructSlice(input []data.SingsVideoStruct) []AugmentedSing
return ret return ret
} }
func AdminHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
UnauthorizedHandler(response, request)
return
}
type TemplateData struct {
Channel string
Command string
ExtraStrings string
SinceTime time.Time
SinceTimeUTC string
Leaving bool
HasLeft bool
SongData []AugmentedSingsVideoStruct
TopNSongs []SongSings
TopNSingers []SingerSings
ChannelKey string
}
channelData := ircBot.ChannelData[vars["channel"]]
if time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours() > 1 {
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
if err != nil {
errCache := make([]irc.SingsVideoStruct, 0)
var ret irc.SingsVideoStruct
ret.FullTitle = "Error fetching videos: " + err.Error()
errCache = append(errCache, ret)
vids = errCache
}
updateCalculatedFields(vids)
ircBot.UpdateVideoCache(vars["channel"], vids)
} else {
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(ircBot.ChannelData[vars["channel"]].VideoCache), time.Now().Sub(ircBot.ChannelData[vars["channel"]].VideoCacheUpdated).Hours())
}
updateCalculatedFields(channelData.VideoCache)
for _, song := range channelData.VideoCache {
if song.Duet && song.OtherSinger == "" {
fmt.Printf("WARNING: found duet with no other singer! %s", song.SongTitle) // should never happen but debug in case it does!
}
}
topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
topNSingers := calculateTopNSingers(channelData.VideoCache, 10)
var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
if request.Method == "POST" {
request.ParseForm()
if strings.Join(request.PostForm["leave"], ",") == "Leave twitch channel" {
td.Leaving = true
} else if strings.Join(request.PostForm["reallyleave"], ",") == "Really leave twitch channel" {
record := ircBot.ChannelData[vars["channel"]]
record.HasLeft = true
ircBot.ChannelData[vars["channel"]] = record
ircBot.LeaveChannel(vars["channel"])
ircBot.Database.Write("channelData", vars["channel"], record)
LeaveHandler(response, request)
return
}
if strings.Join(request.PostForm["join"], ",") == "Come on in" {
record := ircBot.ChannelData[vars["channel"]]
td.HasLeft = false
record.Name = vars["channel"]
record.JoinTime = time.Now()
record.HasLeft = false
if record.Command == "" {
record.Command = "card"
}
ircBot.Database.Write("channelData", vars["channel"], record)
ircBot.ChannelData[vars["channel"]] = record
td = TemplateData{record.Name, record.Command, record.ExtraStrings, record.JoinTime, record.JoinTime.Format(irc.UTCFormat), false, record.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers, vars["key"]}
ircBot.JoinChannel(record.Name)
}
sourceData := ircBot.ChannelData[vars["channel"]]
if strings.Join(request.PostForm["Command"], ",") != "" {
sourceData.Command = strings.Join(request.PostForm["Command"], ",")
td.Command = sourceData.Command
ircBot.ChannelData[vars["channel"]] = sourceData
}
if strings.Join(request.PostForm["ExtraStrings"], ",") != sourceData.ExtraStrings {
sourceData.ExtraStrings = strings.Join(request.PostForm["ExtraStrings"], ",")
td.ExtraStrings = sourceData.ExtraStrings
ircBot.ChannelData[vars["channel"]] = sourceData
}
ircBot.Database.Write("channelData", vars["channel"], sourceData)
}
tmpl, err := template.New("admin.html").ParseFiles("web/admin.html")
if err != nil {
panic(err.Error())
}
tmpl.Execute(response, td)
}
func UnauthorizedHandler(response http.ResponseWriter, request *http.Request) { func UnauthorizedHandler(response http.ResponseWriter, request *http.Request) {
response.Header().Add("X-Template-File", "html"+request.URL.Path) response.Header().Add("X-Template-File", "html"+request.URL.Path)
response.WriteHeader(401) response.WriteHeader(401)
@ -249,12 +331,8 @@ func twitchHTTPClient(call string, bearer string) (string, error) {
defer resp.Body.Close() defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body) body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return string(http.StatusText(resp.StatusCode)), errors.New("HTTP ERROR: " + http.StatusText(resp.StatusCode))
} else {
return string([]byte(body)), nil return string([]byte(body)), nil
} }
}
func ValidateTwitchBearerToken(bearer string) (bool, error) { func ValidateTwitchBearerToken(bearer string) (bool, error) {
url := "https://id.twitch.tv/oauth2/validate" url := "https://id.twitch.tv/oauth2/validate"
@ -275,8 +353,8 @@ func ValidateTwitchBearerToken(bearer string) (bool, error) {
return resp.StatusCode == 200, nil return resp.StatusCode == 200, nil
} }
func twitchVidToSingsVid(twitchFormat videoStruct) (data.SingsVideoStruct, error) { func twitchVidToSingsVid(twitchFormat videoStruct) (irc.SingsVideoStruct, error) {
var ret data.SingsVideoStruct var ret irc.SingsVideoStruct
layout := "2006-01-02T15:04:05Z" layout := "2006-01-02T15:04:05Z"
var d time.Time var d time.Time
d, err := time.Parse(layout, twitchFormat.CreatedAt) d, err := time.Parse(layout, twitchFormat.CreatedAt)
@ -284,7 +362,6 @@ func twitchVidToSingsVid(twitchFormat videoStruct) (data.SingsVideoStruct, error
return ret, err return ret, err
} }
ret.Date = d ret.Date = d
ret.VideoURL = twitchFormat.URL
var DuetRegex = regexp.MustCompile(`^Duet with ([^ ]*): (.*)$`) var DuetRegex = regexp.MustCompile(`^Duet with ([^ ]*): (.*)$`)
matches := DuetRegex.FindAllStringSubmatch(twitchFormat.Title, -1) matches := DuetRegex.FindAllStringSubmatch(twitchFormat.Title, -1)
@ -317,7 +394,7 @@ type SingerSings struct {
Sings int Sings int
} }
func calculateTopNSongs(songCache []data.SingsVideoStruct, howMany int) []SongSings { func calculateTopNSongs(songCache []irc.SingsVideoStruct, howMany int) []SongSings {
songMap := map[string]int{} songMap := map[string]int{}
for _, record := range songCache { for _, record := range songCache {
sings := songMap[record.SongTitle] sings := songMap[record.SongTitle]
@ -348,7 +425,7 @@ func IsLower(s string) bool {
return true return true
} }
func unmangleSingerName(MangledCaseName string, songCache []data.SingsVideoStruct) string { func unmangleSingerName(MangledCaseName string, songCache []irc.SingsVideoStruct) string {
options := make(map[string]string, 0) options := make(map[string]string, 0)
for _, record := range songCache { for _, record := range songCache {
if strings.ToUpper(MangledCaseName) == strings.ToUpper(record.OtherSinger) { if strings.ToUpper(MangledCaseName) == strings.ToUpper(record.OtherSinger) {
@ -413,55 +490,7 @@ func (h *KVHeap) Pop() interface{} {
return x return x
} }
var cacheLock sync.Mutex func calculateTopNSingers(songCache []irc.SingsVideoStruct, howMany int) []SingerSings {
type CacheDetails struct {
Age time.Duration `json: "cache_age"`
AgeStr string `json: "cache_age_nice"`
SongCount int `json: "expires_in"`
}
func getCacheDetails(channel string) CacheDetails {
var ret CacheDetails
channelData := globalData.ChannelData[channel]
ret.Age = time.Now().Sub(channelData.VideoCacheUpdated)
ret.AgeStr = humanize.Time(channelData.VideoCacheUpdated)
ret.SongCount = len(channelData.VideoCache)
return ret
}
func forceUpdateCache(channel string) {
fmt.Printf("Forcing cache update!")
channelData := globalData.ChannelData[channel]
tenHours := time.Hour * -10
videoCacheUpdated := time.Now().Add(tenHours) // Subtract 10 hours from now, cache is 10 hours old.
channelData.VideoCacheUpdated = videoCacheUpdated
globalData.ChannelData[channel] = channelData
updateCacheIfNecessary(channel)
}
func updateCacheIfNecessary(channel string) {
cacheLock.Lock()
channelData := globalData.ChannelData[channel]
if time.Now().Sub(channelData.VideoCacheUpdated).Hours() > 1 {
fmt.Printf("Cache of %d performances is older than an hour - %.1f hours old to be precise... fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
if err != nil {
errCache := make([]data.SingsVideoStruct, 0)
var ret data.SingsVideoStruct
ret.FullTitle = "Error fetching videos: " + err.Error()
errCache = append(errCache, ret)
vids = errCache
}
updateCalculatedFields(vids)
globalData.UpdateVideoCache(channel, vids)
} else {
fmt.Printf("Cache of %d performances is younger than an hour - %.1f hours old to be precise... not fetching.\n", len(channelData.VideoCache), time.Now().Sub(globalData.ChannelData[channel].VideoCacheUpdated).Hours())
}
cacheLock.Unlock()
}
func calculateTopNSingers(songCache []data.SingsVideoStruct, howMany int) []SingerSings {
songMap := map[string]int{} songMap := map[string]int{}
songCount := 0 songCount := 0
for _, record := range songCache { for _, record := range songCache {
@ -485,7 +514,7 @@ func calculateTopNSingers(songCache []data.SingsVideoStruct, howMany int) []Sing
return slice return slice
} }
func calculateLastSungSongDate(songCache []data.SingsVideoStruct, SongTitle string) time.Time { func calculateLastSungSongDate(songCache []irc.SingsVideoStruct, SongTitle string) time.Time {
var t time.Time var t time.Time
for _, record := range songCache { for _, record := range songCache {
if record.SongTitle == SongTitle { if record.SongTitle == SongTitle {
@ -497,7 +526,7 @@ func calculateLastSungSongDate(songCache []data.SingsVideoStruct, SongTitle stri
return t return t
} }
func calculateLastSungSingerDate(songCache []data.SingsVideoStruct, Singer string) time.Time { func calculateLastSungSingerDate(songCache []irc.SingsVideoStruct, Singer string) time.Time {
var t time.Time var t time.Time
for _, record := range songCache { for _, record := range songCache {
if strings.ToUpper(record.OtherSinger) == strings.ToUpper(Singer) { if strings.ToUpper(record.OtherSinger) == strings.ToUpper(Singer) {
@ -512,7 +541,7 @@ func calculateLastSungSingerDate(songCache []data.SingsVideoStruct, Singer strin
return t return t
} }
func updateCalculatedFields(songCache []data.SingsVideoStruct) { func updateCalculatedFields(songCache []irc.SingsVideoStruct) {
for i, record := range songCache { for i, record := range songCache {
if record.Duet { if record.Duet {
songCache[i].LastSungSinger = calculateLastSungSingerDate(songCache, record.OtherSinger) songCache[i].LastSungSinger = calculateLastSungSingerDate(songCache, record.OtherSinger)
@ -521,7 +550,7 @@ func updateCalculatedFields(songCache []data.SingsVideoStruct) {
} }
} }
func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]data.SingsVideoStruct, error) { func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]irc.SingsVideoStruct, error) {
url := "" url := ""
if from == "" { if from == "" {
url = "videos?user_id=" + userID + "&first=100&type=upload" url = "videos?user_id=" + userID + "&first=100&type=upload"
@ -539,7 +568,7 @@ func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]data.
if err != nil { if err != nil {
return nil, err return nil, err
} }
titles := make([]data.SingsVideoStruct, 0) titles := make([]irc.SingsVideoStruct, 0)
for _, videoData := range fullResponse.Data { for _, videoData := range fullResponse.Data {
ret, err := twitchVidToSingsVid(videoData) ret, err := twitchVidToSingsVid(videoData)
if err != nil { if err != nil {
@ -559,19 +588,17 @@ func fetchVoDsPagesRecursive(userID string, bearer string, from string) ([]data.
return titles, nil return titles, nil
} }
func fetchAllVoDs(userID string, bearer string) ([]data.SingsVideoStruct, error) { func fetchAllVoDs(userID string, bearer string) ([]irc.SingsVideoStruct, error) {
tokenValid, err := ValidateTwitchBearerToken(bearer) tokenValid, err := ValidateTwitchBearerToken(bearer)
if err != nil { if err != nil {
fmt.Println("Error validating token : " + err.Error())
return nil, err return nil, err
} }
if !tokenValid { if !tokenValid {
fmt.Println("Error validating token (revoked?)")
return nil, errors.New("Failed to validate token with twitch (authorization revoked?!)") return nil, errors.New("Failed to validate token with twitch (authorization revoked?!)")
} }
titles, err := fetchVoDsPagesRecursive(userID, bearer, "") titles, err := fetchVoDsPagesRecursive(userID, bearer, "")
if err != nil { if err != nil {
return make([]data.SingsVideoStruct, 0), err return make([]irc.SingsVideoStruct, 0), err
} }
return titles, nil return titles, nil
} }
@ -588,7 +615,7 @@ func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
"client_secret": {ircBot.AppCredentials.ClientSecret}, "client_secret": {ircBot.AppCredentials.ClientSecret},
"code": {vars["code"]}, "code": {vars["code"]},
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}}) "redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}})
if err != nil { if err != nil {
response.WriteHeader(500) response.WriteHeader(500)
response.Header().Add("Content-type", "text/plain") response.Header().Add("Content-type", "text/plain")
@ -639,10 +666,10 @@ func TwitchAdminHandler(response http.ResponseWriter, request *http.Request) {
} }
user := usersObject.Data[0] user := usersObject.Data[0]
magicCode := globalData.ReadOrCreateChannelKey(user.Login) magicCode := ircBot.ReadOrCreateChannelKey(user.Login)
globalData.UpdateBearerToken(user.Login, oauthResponse.Access_token) ircBot.UpdateBearerToken(user.Login, oauthResponse.Access_token)
globalData.UpdateTwitchUserID(user.Login, user.Id) ircBot.UpdateTwitchUserID(user.Login, user.Id)
url := "https://" + globalData.Config.ExternalURL + "/admin/" + user.Login + "/" + magicCode url := "https://" + ircBot.Config.ExternalUrl + "/admin/" + user.Login + "/" + magicCode
http.Redirect(response, request, url, http.StatusFound) http.Redirect(response, request, url, http.StatusFound)
} else { } else {
@ -674,7 +701,7 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
// ircBot.AppCredentials.Password // ircBot.AppCredentials.Password
// vars["oauthtoken"] // vars["oauthtoken"]
// authorization_code // authorization_code
// "https://"+globalData.Config.ExternalURL+/twitchadmin // "https://"+ircBot.Config.ExternalUrl+/twitchadmin
fmt.Println("Asking twitch for more...") fmt.Println("Asking twitch for more...")
resp, err := http.PostForm( resp, err := http.PostForm(
"https://id.twitch.tv/oauth2/token", "https://id.twitch.tv/oauth2/token",
@ -683,7 +710,7 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
"client_secret": {ircBot.AppCredentials.ClientSecret}, "client_secret": {ircBot.AppCredentials.ClientSecret},
"code": {vars["code"]}, "code": {vars["code"]},
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"redirect_uri": {"https://" + globalData.Config.ExternalURL + "/twitchadmin"}}) "redirect_uri": {"https://" + ircBot.Config.ExternalUrl + "/twitchadmin"}})
if err != nil { if err != nil {
response.Header().Add("Content-type", "text/plain") response.Header().Add("Content-type", "text/plain")
fmt.Fprint(response, "ERROR: "+err.Error()) fmt.Fprint(response, "ERROR: "+err.Error())
@ -703,13 +730,13 @@ func TwitchBackendHandler(response http.ResponseWriter, request *http.Request) {
func CSVHandler(response http.ResponseWriter, request *http.Request) { func CSVHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request) vars := mux.Vars(request)
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey { if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
UnauthorizedHandler(response, request) UnauthorizedHandler(response, request)
return return
} }
type TemplateData struct { type TemplateData struct {
Channel string Channel string
Commands []data.CommandStruct Command string
ExtraStrings string ExtraStrings string
SinceTime time.Time SinceTime time.Time
SinceTimeUTC string SinceTimeUTC string
@ -719,11 +746,25 @@ func CSVHandler(response http.ResponseWriter, request *http.Request) {
TopNSongs []SongSings TopNSongs []SongSings
TopNSingers []SingerSings TopNSingers []SingerSings
} }
updateCacheIfNecessary(vars["channel"]) channelData := ircBot.ChannelData[vars["channel"]]
channelData := globalData.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) topNSongs := calculateTopNSongs(channelData.VideoCache, 10)
topNSingers := calculateTopNSingers(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} var td = TemplateData{channelData.Name, channelData.Command, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSliceForCSV(channelData.VideoCache), topNSongs, topNSingers}
if request.URL.Path[0:4] == "/csv" { if request.URL.Path[0:4] == "/csv" {
response.Header().Add("Content-Disposition", "attachment; filename=\"duets.csv\"") response.Header().Add("Content-Disposition", "attachment; filename=\"duets.csv\"")
response.Header().Add("Content-type", "text/csv") response.Header().Add("Content-type", "text/csv")
@ -739,14 +780,13 @@ func CSVHandler(response http.ResponseWriter, request *http.Request) {
func JSONHandler(response http.ResponseWriter, request *http.Request) { func JSONHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request) vars := mux.Vars(request)
if vars["key"] != globalData.ChannelData[vars["channel"]].AdminKey { if vars["key"] != ircBot.ChannelData[vars["channel"]].AdminKey {
fmt.Printf("%s != %s\n", vars["key"], globalData.ChannelData[vars["channel"]].AdminKey)
UnauthorizedHandler(response, request) UnauthorizedHandler(response, request)
return return
} }
type TemplateData struct { type TemplateData struct {
Channel string Channel string
Commands []data.CommandStruct Command string
ExtraStrings string ExtraStrings string
SinceTime time.Time SinceTime time.Time
SinceTimeUTC string SinceTimeUTC string
@ -756,17 +796,25 @@ func JSONHandler(response http.ResponseWriter, request *http.Request) {
TopNSongs []SongSings TopNSongs []SongSings
TopNSingers []SingerSings TopNSingers []SingerSings
} }
updateCacheIfNecessary(vars["channel"]) channelData := ircBot.ChannelData[vars["channel"]]
channelData := globalData.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())
var topNSongs []SongSings vids, err := fetchAllVoDs(channelData.TwitchUserID, channelData.Bearer)
var topNSingers []SingerSings if err != nil {
errCache := make([]irc.SingsVideoStruct, 0)
if request.URL.Path[0:4] != "/deb" { var ret irc.SingsVideoStruct
topNSongs = calculateTopNSongs(channelData.VideoCache, 10) ret.FullTitle = "Error fetching videos: " + err.Error()
topNSingers = calculateTopNSingers(channelData.VideoCache, 10) errCache = append(errCache, ret)
vids = errCache
} }
var td = TemplateData{channelData.Name, channelData.Commands, channelData.ExtraStrings, channelData.JoinTime, channelData.JoinTime.Format(irc.UTCFormat), false, channelData.HasLeft, AugmentSingsVideoStructSlice(channelData.VideoCache), topNSongs, topNSingers} 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}
response.Header().Add("Content-type", "application/json") response.Header().Add("Content-type", "application/json")
if request.URL.Path[0:5] == "/json" { if request.URL.Path[0:5] == "/json" {
tmpl := template.Must(template.ParseFiles("web/data.json")) tmpl := template.Must(template.ParseFiles("web/data.json"))
@ -780,78 +828,6 @@ func JSONHandler(response http.ResponseWriter, request *http.Request) {
} }
} }
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) { func ReactIndexHandler(entrypoint string) func(w http.ResponseWriter, r *http.Request) {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, entrypoint) http.ServeFile(w, r, entrypoint)
@ -860,47 +836,18 @@ func ReactIndexHandler(entrypoint string) func(w http.ResponseWriter, r *http.Re
return http.HandlerFunc(fn) return http.HandlerFunc(fn)
} }
func ForceRefreshHandler(response http.ResponseWriter, request *http.Request) { func HandleHTTP(passedIrcBot *irc.KardBot) {
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 ircBot = passedIrcBot
globalData = passedGlobalData
r := mux.NewRouter() r := mux.NewRouter()
loggedRouter := handlers.LoggingHandler(os.Stdout, r) loggedRouter := handlers.LoggingHandler(os.Stdout, r)
r.NotFoundHandler = http.HandlerFunc(NotFoundHandler) r.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
r.HandleFunc("/healthz", HealthHandler) r.HandleFunc("/healthz", HealthHandler)
r.HandleFunc("/web/{.*}", TemplateHandler) r.HandleFunc("/web/{.*}", TemplateHandler)
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("/csv/{channel}/{key}", CSVHandler)
r.HandleFunc("/tsv/{channel}/{key}", CSVHandler) r.HandleFunc("/tsv/{channel}/{key}", CSVHandler)
r.HandleFunc("/json/{channel}/{key}", JSONHandler) r.HandleFunc("/json/{channel}/{key}", JSONHandler)
r.HandleFunc("/debug/{channel}/{key}", JSONHandler)
r.HandleFunc("/topsongs/{channel}/{key}", JSONHandler) r.HandleFunc("/topsongs/{channel}/{key}", JSONHandler)
r.HandleFunc("/topsingers/{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("/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.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("/static").Handler(http.StripPrefix("/static", http.FileServer(http.Dir("./web/react-frontend/static"))))

View File

@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data" irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
) )
func TestDateRegex(t *testing.T) { func TestDateRegex(t *testing.T) {
@ -72,9 +72,9 @@ func TestSoloRegexes(t *testing.T) {
} }
func TestCalculatedDates(t *testing.T) { func TestCalculatedDates(t *testing.T) {
var record data.SingsVideoStruct var record irc.SingsVideoStruct
format := "2006-01-02 15:04:05 +0000 UTC" format := "2006-01-02 15:04:05 +0000 UTC"
var mockCache []data.SingsVideoStruct var mockCache []irc.SingsVideoStruct
record.Date, _ = time.Parse(format, "2020-07-13 19:43:11 +0000 UTC") record.Date, _ = time.Parse(format, "2020-07-13 19:43:11 +0000 UTC")
record.SongTitle = "Words Fail" record.SongTitle = "Words Fail"
record.OtherSinger = "FullOfEmily" record.OtherSinger = "FullOfEmily"

31
main.go
View File

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/ioutil" "io/ioutil"
"math/rand" "math/rand"
"os" "os"
@ -10,7 +9,6 @@ import (
"time" "time"
builtins "git.martyn.berlin/martyn/twitchsingstools/internal/builtins" builtins "git.martyn.berlin/martyn/twitchsingstools/internal/builtins"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc" irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
webserver "git.martyn.berlin/martyn/twitchsingstools/internal/webserver" webserver "git.martyn.berlin/martyn/twitchsingstools/internal/webserver"
rgb "github.com/foresthoffman/rgblog" rgb "github.com/foresthoffman/rgblog"
@ -25,7 +23,7 @@ var selectablePrompts []string
var customStrings customStringsStruct var customStrings customStringsStruct
var config data.ConfigStruct var config irc.ConfigStruct
func readConfig() { func readConfig() {
var data []byte var data []byte
@ -176,32 +174,12 @@ func main() {
} }
} else { } else {
if _, err := os.Stat(config.AppOAuthPath); os.IsNotExist(err) { if _, err := os.Stat(config.AppOAuthPath); os.IsNotExist(err) {
rgb.RPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath) rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
os.Exit(1) os.Exit(1)
} }
appOauthPath = config.AppOAuthPath 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 // Replace the channel name, bot name, and the path to the private directory with your respective
// values. // values.
var myBot irc.KardBot var myBot irc.KardBot
@ -215,12 +193,13 @@ func main() {
AppPrivatePath: appOauthPath, AppPrivatePath: appOauthPath,
Server: "irc.chat.twitch.tv", Server: "irc.chat.twitch.tv",
Prompts: selectablePrompts, Prompts: selectablePrompts,
GlobalData: globalData, Database: *persistentData,
Config: config,
} }
} }
go func() { go func() {
rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353") rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353")
webserver.HandleHTTP(&myBot, &globalData) webserver.HandleHTTP(&myBot)
}() }()
if ircOauthPath != "" { if ircOauthPath != "" {
myBot.Start() myBot.Start()

View File

@ -1,5 +0,0 @@
@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 }}