Compare commits

...

12 Commits

Author SHA1 Message Date
Martyn 1f00cd699f Add link to BockTown script
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-09-08 21:25:59 +02:00
Martyn f7b0f074cf Redis refactor and video download script.
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-09-05 18:14:56 +02:00
Martyn 50303b3918 Production to external hosting
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-10 12:10:58 +02:00
Martyn 132b433bc7 Hide but accessible
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 22:19:19 +02:00
Martyn 317b72dd60 Hide but accessible
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 17:44:42 +02:00
Martyn cfff6bc46e Fix the recreation every startup
continuous-integration/drone/push Build is failing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 09:22:22 +02:00
Martyn fcd60fc08a Bot control panel frontend looking good
continuous-integration/drone/tag Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:34:04 +02:00
Martyn 705b1f5e12 Allow for reload in caching
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:32:33 +02:00
Martyn f721c91690 Actual data from my account
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-03 00:30:28 +02:00
Martyn f49d3ec9ab Cache refresh may work, local testing easier
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 17:07:59 +02:00
Martyn b5a0d8bda2 Cache refresh may work, local testing easier
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 17:07:41 +02:00
Martyn 6633fff3b6 Use sample data in dev mode, makes life easier
continuous-integration/drone/push Build is passing Details
Signed-off-by: Martyn Ranyard <m@rtyn.berlin>
2020-08-02 16:55:44 +02:00
29 changed files with 4646 additions and 3500 deletions

View File

@ -1163,6 +1163,19 @@
"resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-10.1.0.tgz",
"integrity": "sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg=="
},
"@date-io/core": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz",
"integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA=="
},
"@date-io/date-fns": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz",
"integrity": "sha512-yXxGzcRUPcogiMj58wVgFjc9qUYrCnnU9eLcyNbsQCmae4jPuZCDoIBR21j8ZURsM7GRtU62VOw5yNd4dDHunA==",
"requires": {
"@date-io/core": "^1.3.13"
}
},
"@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
@ -1420,6 +1433,19 @@
"@babel/runtime": "^7.4.4"
}
},
"@material-ui/pickers": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz",
"integrity": "sha512-B8G6Obn5S3RCl7hwahkQj9sKUapwXWFjiaz/Bsw1fhYFdNMnDUolRiWQSoKPb1/oKe37Dtfszoywi1Ynbo3y8w==",
"requires": {
"@babel/runtime": "^7.6.0",
"@date-io/core": "1.x",
"@types/styled-jsx": "^2.2.8",
"clsx": "^1.0.2",
"react-transition-group": "^4.0.0",
"rifm": "^0.7.0"
}
},
"@material-ui/styles": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz",
@ -1865,6 +1891,14 @@
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw=="
},
"@types/styled-jsx": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.8.tgz",
"integrity": "sha512-Yjye9VwMdYeXfS71ihueWRSxrruuXTwKCbzue4+5b2rjnQ//AtyM7myZ1BEhNhBQ/nL/RE7bdToUoLln2miKvg==",
"requires": {
"@types/react": "*"
}
},
"@types/testing-library__dom": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.14.0.tgz",
@ -2572,37 +2606,6 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz",
"integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA=="
},
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"requires": {
"follow-redirects": "1.5.10"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"requires": {
"debug": "=3.1.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"axobject-query": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
@ -3075,6 +3078,11 @@
}
}
},
"base64-arraybuffer": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
"integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg="
},
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@ -3476,6 +3484,103 @@
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001109.tgz",
"integrity": "sha512-4JIXRodHzdS3HdK8nSgIqXYLExOvG+D2/EenSvcub2Kp3QEADjo2v2oUn5g0n0D+UNwG9BtwKOyGcSq2qvQXvQ=="
},
"canvg": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-1.5.3.tgz",
"integrity": "sha512-7Gn2IuQzvUQWPIuZuFHrzsTM0gkPz2RRT9OcbdmA03jeKk8kltrD8gqUzNX15ghY/4PV5bbe5lmD6yDLDY6Ybg==",
"requires": {
"jsdom": "^8.1.0",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^1.4.1",
"xmldom": "^0.1.22"
},
"dependencies": {
"abab": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz",
"integrity": "sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4="
},
"acorn": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz",
"integrity": "sha1-q259nYhqrKiwhbwzEreaGYQz8Oc="
},
"acorn-globals": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz",
"integrity": "sha1-VbtemGkVB7dFedBRNBMhfDgMVM8=",
"requires": {
"acorn": "^2.1.0"
}
},
"cssstyle": {
"version": "0.2.37",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz",
"integrity": "sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ=",
"requires": {
"cssom": "0.3.x"
}
},
"jsdom": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-8.5.0.tgz",
"integrity": "sha1-1Nj12/J2hjW2KmKCO5R89wcevJg=",
"requires": {
"abab": "^1.0.0",
"acorn": "^2.4.0",
"acorn-globals": "^1.0.4",
"array-equal": "^1.0.0",
"cssom": ">= 0.3.0 < 0.4.0",
"cssstyle": ">= 0.2.34 < 0.3.0",
"escodegen": "^1.6.1",
"iconv-lite": "^0.4.13",
"nwmatcher": ">= 1.3.7 < 2.0.0",
"parse5": "^1.5.1",
"request": "^2.55.0",
"sax": "^1.1.4",
"symbol-tree": ">= 3.1.0 < 4.0.0",
"tough-cookie": "^2.2.0",
"webidl-conversions": "^3.0.1",
"whatwg-url": "^2.0.1",
"xml-name-validator": ">= 2.0.1 < 3.0.0"
}
},
"parse5": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz",
"integrity": "sha1-m387DeMr543CQBsXVzzK8Pb1nZQ="
},
"stackblur-canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-1.4.1.tgz",
"integrity": "sha1-hJqm+UsnL/JvZHH6QTDtH35HlVs="
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
},
"whatwg-url": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-2.0.1.tgz",
"integrity": "sha1-U5ayBD8CDub3BNnEXqhRnnJN5lk=",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"xml-name-validator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz",
"integrity": "sha1-TYuPHszTQZqjYgYb7O9RXh5VljU="
}
}
},
"capture-exit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz",
@ -3617,6 +3722,11 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
@ -4097,6 +4207,14 @@
"postcss": "^7.0.5"
}
},
"css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"requires": {
"tiny-invariant": "^1.0.6"
}
},
"css-color-names": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
@ -4137,6 +4255,14 @@
}
}
},
"css-line-break": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-1.0.1.tgz",
"integrity": "sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo=",
"requires": {
"base64-arraybuffer": "^0.1.5"
}
},
"css-loader": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.4.2.tgz",
@ -4399,6 +4525,16 @@
}
}
},
"date-fns": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.15.0.tgz",
"integrity": "sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ=="
},
"debounce": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz",
"integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg=="
},
"debug": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
@ -5876,6 +6012,15 @@
"schema-utils": "^2.5.0"
}
},
"file-saver": {
"version": "github:eligrey/FileSaver.js#e865e37af9f9947ddcced76b549e27dc45c1cb2e",
"from": "github:eligrey/FileSaver.js#1.3.8"
},
"filefy": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/filefy/-/filefy-0.1.10.tgz",
"integrity": "sha512-VgoRVOOY1WkTpWH+KBy8zcU1G7uQTVsXqhWEgzryB9A5hg2aqCyZ6aQ/5PSzlqM5+6cnVrX6oYV0XqD3HZSnmQ=="
},
"filesize": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
@ -6584,6 +6729,14 @@
}
}
},
"html2canvas": {
"version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz",
"integrity": "sha1-OxmS48mz9WBjw1/WIElPN+uohRM=",
"requires": {
"css-line-break": "1.0.1"
}
},
"htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
@ -7889,6 +8042,24 @@
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
},
"jspdf": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-1.5.3.tgz",
"integrity": "sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw==",
"requires": {
"canvg": "1.5.3",
"file-saver": "github:eligrey/FileSaver.js#1.3.8",
"html2canvas": "1.0.0-alpha.12",
"omggif": "1.0.7",
"promise-polyfill": "8.1.0",
"stackblur-canvas": "2.2.0"
}
},
"jspdf-autotable": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.3.tgz",
"integrity": "sha512-K+cNWW3x6w0R/1B5m6PYOm6v8CTTDXy/g32lZouc7SuC6zhvzMN2dauhk6dDYxPD0pky0oyPIJFwSJ/tV8PAeg=="
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@ -8283,6 +8454,32 @@
"object-visit": "^1.0.0"
}
},
"material-table": {
"version": "1.67.1",
"resolved": "https://registry.npmjs.org/material-table/-/material-table-1.67.1.tgz",
"integrity": "sha512-cMmzIO601D1b4C+ZYlpiRxBUtvrAVfJ74wcpeF39K4wdfweXyFgQ+bXdfFSIycBv36ZPTFWoku/3bAJiBn5Gzw==",
"requires": {
"@date-io/date-fns": "^1.1.0",
"@material-ui/pickers": "^3.2.2",
"classnames": "^2.2.6",
"date-fns": "^2.0.0-alpha.27",
"debounce": "^1.2.0",
"fast-deep-equal": "2.0.1",
"filefy": "0.1.10",
"jspdf": "1.5.3",
"jspdf-autotable": "3.5.3",
"prop-types": "^15.6.2",
"react-beautiful-dnd": "^13.0.0",
"react-double-scrollbar": "0.0.15"
},
"dependencies": {
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
}
}
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -8313,6 +8510,11 @@
"p-is-promise": "^2.0.0"
}
},
"memoize-one": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
"integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
},
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@ -8869,6 +9071,11 @@
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
},
"nwmatcher": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz",
"integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ=="
},
"nwsapi": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz",
@ -9006,6 +9213,11 @@
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
},
"omggif": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.7.tgz",
"integrity": "sha1-WdLuywJj3oRjWz/riHwMmXPx5J0="
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -10436,6 +10648,11 @@
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM="
},
"promise-polyfill": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.0.tgz",
"integrity": "sha512-OzSf6gcCUQ01byV4BgwyUCswlaQQ6gzXc23aLQWhicvfX9kfsUiUhgt3CCQej8jDnl8/PhGF31JdHX2/MzF3WA=="
},
"prompts": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz",
@ -10571,6 +10788,11 @@
"performance-now": "^2.1.0"
}
},
"raf-schd": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz",
"integrity": "sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -10634,6 +10856,20 @@
"whatwg-fetch": "^3.0.0"
}
},
"react-beautiful-dnd": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz",
"integrity": "sha512-87It8sN0ineoC3nBW0SbQuTFXM6bUqM62uJGY4BtTf0yzPl8/3+bHMWkgIe0Z6m8e+gJgjWxefGRVfpE3VcdEg==",
"requires": {
"@babel/runtime": "^7.8.4",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.1.1",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
}
},
"react-dev-utils": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz",
@ -10846,6 +11082,11 @@
"scheduler": "^0.19.1"
}
},
"react-double-scrollbar": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz",
"integrity": "sha1-6RWrjLO5WYdwdfSUNt6/2wQoj+Q="
},
"react-error-overlay": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz",
@ -10856,6 +11097,18 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-redux": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.1.tgz",
"integrity": "sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==",
"requires": {
"@babel/runtime": "^7.5.5",
"hoist-non-react-statics": "^3.3.0",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^16.9.0"
}
},
"react-scripts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.4.1.tgz",
@ -10989,6 +11242,15 @@
"strip-indent": "^3.0.0"
}
},
"redux": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"
}
},
"regenerate": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz",
@ -11349,6 +11611,19 @@
"resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz",
"integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM="
},
"rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha1-1lBezbMEplldom+ktDMHMGd1lF0="
},
"rifm": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz",
"integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==",
"requires": {
"@babel/runtime": "^7.3.1"
}
},
"rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
@ -12078,6 +12353,11 @@
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz",
"integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA=="
},
"stackblur-canvas": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.2.0.tgz",
"integrity": "sha512-5Gf8dtlf8k6NbLzuly2NkGrkS/Ahh+I5VUjO7TnFizdJtgpfpLLEdQlLe9umbcnZlitU84kfYjXE67xlSXfhfQ=="
},
"static-extend": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
@ -12425,6 +12705,11 @@
"util.promisify": "~1.0.0"
}
},
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
},
"symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -12663,6 +12948,11 @@
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
},
"tiny-invariant": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",
"integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw=="
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@ -12979,6 +13269,11 @@
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"use-memo-one": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz",
"integrity": "sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ=="
},
"util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
@ -14020,6 +14315,11 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"xmldom": {
"version": "0.1.31",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
},
"xregexp": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",

View File

@ -8,7 +8,7 @@
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"axios": "^0.19.2",
"material-table": "^1.67.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1"

View File

@ -0,0 +1 @@
{"jointime":"0001-01-01T00:00:00Z","hasleft":false,"videoCacheUpdated":"2020-08-02T17:40:13.480470725Z", "commands": [{"command":"card","action":"RandomPrompt"},{"command":"whotosingwith","action":"AgingSinger"}]}

View File

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

View File

@ -0,0 +1,31 @@
[{
"singerName": "Terraglotte",
"singCount": "40"
},{
"singerName": "pepperwinkle",
"singCount": "30"
},{
"singerName": "BatSam",
"singCount": "29"
},{
"singerName": "ProfChumbles",
"singCount": "25"
},{
"singerName": "FullOfEmily",
"singCount": "22"
},{
"singerName": "Grisu_xXxx",
"singCount": "17"
},{
"singerName": "ChantryRae",
"singCount": "16"
},{
"singerName": "CheshieCat",
"singCount": "14"
},{
"singerName": "karlicYO",
"singCount": "14"
},{
"singerName": "GabiAgura",
"singCount": "14"
}]

View File

@ -0,0 +1,31 @@
[{
"songName": "Mad World",
"singCount": "19"
},{
"songName": "Waving Through a Window",
"singCount": "19"
},{
"songName": "Suddenly Seymour",
"singCount": "18"
},{
"songName": "Someone You Loved",
"singCount": "18"
},{
"songName": "Gravity",
"singCount": "17"
},{
"songName": "Only Us",
"singCount": "17"
},{
"songName": "Fireflies",
"singCount": "15"
},{
"songName": "Behind Blue Eyes",
"singCount": "14"
},{
"songName": "Hey There Delilah",
"singCount": "14"
},{
"songName": "Poor Unfortunate Souls",
"singCount": "14"
}]

View File

@ -25,3 +25,18 @@
.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,9 +1,8 @@
import React from 'react';
import logo from './../../logo.svg';
import './App.css';
import Container from '@material-ui/core/Container';
import Typography from '@material-ui/core/Typography';
import { Button, Paper } from '@material-ui/core';
import { Button, Paper, Container, AppBar, Table, TableBody, TableCell, TableContainer, TableRow } from '@material-ui/core';
function App() {
const client_id = "sau3e70wvs369jw1u25ex8g3cve599"
@ -44,6 +43,22 @@ function App() {
<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>
);

View File

@ -26,3 +26,18 @@
.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

@ -7,6 +7,8 @@ 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";
@ -40,82 +42,188 @@ function a11yProps(index) {
};
}
function AppAdmin() {
const [page, setPage] = React.useState(0);
if (
(window.location.href.split("/").length < 6) ||
(window.location.href.split("/")[3].search(/^admin/) < 0) ||
(window.location.href.split("/")[5].length !== 48)
) {
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 (
<ProblemContainer />
)
}
<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>
const channelName = window.location.href.split("/")[4]
const adminToken = window.location.href.split("/")[5]
const csvURL = "/csv/"+ channelName + "/" + adminToken
const tsvURL = "/tsv/"+ channelName + "/" + adminToken
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="Export" {...a11yProps(3)}/>
</Tabs>
</AppBar>
<TabPanel value={page} index={0}>
<TopTenSongs />
</TabPanel>
<TabPanel value={page} index={1}>
<TopTenSingers />
</TabPanel>
<TabPanel value={page} index={2}>
<DuetData />
</TabPanel>
<TabPanel value={page} index={3}>
<Typography variant="h5" component="h2" gutterBottom>
Download your data as :
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>TSV</TableCell>
<TableCell>CSV</TableCell>
</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>
</TableHead>
<TableBody>
<TableRow>
<TableCell><a href={tsvURL}>here</a></TableCell>
<TableCell><a href={csvURL}>here</a></TableCell>
<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>
<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>
</Paper>
</Container>
);
</TableBody></Table>
</TableContainer>
</AppBar>
</Paper>
</Container>
);
}
}
export default AppAdmin;

View File

@ -0,0 +1,164 @@
import React, { useEffect } from 'react';
import { Container, Typography, Button } from '@material-ui/core';
import { AddBox, ArrowDownward } from "@material-ui/icons";
import MaterialTable from "material-table";
import Check from '@material-ui/icons/Check';
import ChevronLeft from '@material-ui/icons/ChevronLeft';
import ChevronRight from '@material-ui/icons/ChevronRight';
import Clear from '@material-ui/icons/Clear';
import DeleteOutline from '@material-ui/icons/DeleteOutline';
import Edit from '@material-ui/icons/Edit';
import FilterList from '@material-ui/icons/FilterList';
import FirstPage from '@material-ui/icons/FirstPage';
import LastPage from '@material-ui/icons/LastPage';
import Remove from '@material-ui/icons/Remove';
import SaveAlt from '@material-ui/icons/SaveAlt';
import Search from '@material-ui/icons/Search';
import ViewColumn from '@material-ui/icons/ViewColumn';
import { forwardRef } from 'react';
const channelName = window.location.href.split("/")[4]
const adminToken = window.location.href.split("/")[5]
const dataURL = "/botdeets/"+ channelName + "/" + adminToken
function BotDeets() {
const [botState, setBotState] = React.useState({loading: true, botData: { commands: [{"command":"card","action":"RandomPrompt"},{"command":"whotosingwith","action":"AgingSinger"}]} });
const tableIcons = {
Add: forwardRef((props, ref) => <AddBox {...props} ref={ref} />),
Check: forwardRef((props, ref) => <Check {...props} ref={ref} />),
Clear: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
Delete: forwardRef((props, ref) => <DeleteOutline {...props} ref={ref} />),
DetailPanel: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
Edit: forwardRef((props, ref) => <Edit {...props} ref={ref} />),
Export: forwardRef((props, ref) => <SaveAlt {...props} ref={ref} />),
Filter: forwardRef((props, ref) => <FilterList {...props} ref={ref} />),
FirstPage: forwardRef((props, ref) => <FirstPage {...props} ref={ref} />),
LastPage: forwardRef((props, ref) => <LastPage {...props} ref={ref} />),
NextPage: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
PreviousPage: forwardRef((props, ref) => <ChevronLeft {...props} ref={ref} />),
ResetSearch: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
Search: forwardRef((props, ref) => <Search {...props} ref={ref} />),
SortArrow: forwardRef((props, ref) => <ArrowDownward {...props} ref={ref} />),
ThirdStateCheck: forwardRef((props, ref) => <Remove {...props} ref={ref} />),
ViewColumn: forwardRef((props, ref) => <ViewColumn {...props} ref={ref} />)
};
useEffect(() => {
// We should only fetch once!
if (botState.loading) {
let actualURL = dataURL
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
//Frontend dev mode only
actualURL = "/sampleData/bot.json"
}
fetch(actualURL).then((res) => res.json().then((data)=>{
setBotState({botData: data, loading: false })
}));
}
}, [setBotState, botState.loading]);
if (botState.loading) {
return <p>Sorry, still loading...</p>
}
const dd = botState.botData
const cmds = dd.commands
if (dd.hasleft) {
return (
<Container>
<Typography variant="h5" component="h2" gutterBottom>
The chatbot is currently <b>not</b> in your channel. To change the settings, invite it to your channel using the button below :
</Typography>
<Typography variant="p">
You really don't wanna do that, because it's not ready yet.
</Typography>
<Typography variant="p">
<Button variant="contained" color="primary">Join my channel!</Button>
</Typography>
</Container>
)
} else {
return (
<Container>
<Typography variant="h5" component="h2" gutterBottom>
Coming soon! Bot control panel!
</Typography>
<div style={{ maxWidth: "100%", display: "none" }}>
<MaterialTable
editable={{
isEditable: rowData => true,
isDeletable: rowData => true,
onRowAddCancelled: rowData => console.log('Row adding cancelled'),
onRowUpdateCancelled: rowData => console.log('Row editing cancelled'),
onRowUpdate: (newData, oldData) =>
new Promise((resolve, reject) => {
setTimeout(() => {
const index = cmds.indexOf(oldData);
let newCommands = cmds;
let newState = botState;
newCommands[index] = newData;
newState.botData.commands = newCommands;
setBotState(newState);
resolve();
}, 1000);
}),
onRowDelete: oldData =>
new Promise((resolve, reject) => {
setTimeout(() => {
const index = cmds.indexOf(oldData);
let newCommands = cmds;
let newState = botState;
newCommands.splice(index, 1);
newState.botData.commands = newCommands;
setBotState(newState);
resolve();
}, 1000);
}),
onRowAdd: newData =>
new Promise((resolve, reject) => {
setTimeout(() => {
let newCommands = cmds;
let newState = botState;
newCommands[cmds.length] = newData;
newState.botData.commands = newCommands;
setBotState(newState);
resolve();
}, 1000);
}),
}}
icons={tableIcons}
columns={[
{ title: "Command", field: "command" },
{
title: "Action description",
field: "action",
lookup: { RandomPrompt: "Random prompt", AgingSinger: "Singer I haven't sung with in ages", AgingSong: "Song I haven't sung in ages"},
/* editComponent: tableData =>
<Select
id="action"
className=""
optionId="key"
optionName="value"
value={tableData.rowData.action}
dataAutoId={dropdowns('action')}
>
<MenuItem value="RandomPrompt">Random prompt</MenuItem>
<MenuItem value="AgingSinger">Singer I haven't sung with in ages</MenuItem>
<MenuItem value="AgingSong">Song I haven't sung in ages</MenuItem>
</Select>*/
},
]}
data={cmds}
title="Commands"
options={{
search: false
}}
/>
</div>
</Container>
);
}
}
export default BotDeets;

View File

@ -0,0 +1,81 @@
import React from 'react';
import { Container, Typography, Button } from '@material-ui/core';
const channelName = window.location.href.split("/")[4]
const adminToken = window.location.href.split("/")[5]
const dataURL = "/cachedeets/"+ channelName + "/" + adminToken
const forceURL = "/force/"+ channelName + "/" + adminToken
class CacheDeets extends React.Component {
constructor(props) {
super(props);
this.state = {
cacheData: [],
loading: true
};
this.setCacheData = this.setCacheData.bind(this);
this.setLoading = this.setLoading.bind(this);
}
setCacheData(data) {
let newState = this.state
newState.cacheData = data
this.setState(newState)
}
setLoading(data) {
let newState = this.state
newState.loading = data
this.setState(newState)
}
componentDidMount() {
// We should only fetch once!
if (this.state.loading) {
let actualURL = dataURL
if (window.location.href.split("/")[2] === "192.168.1.111:3000") {
//Frontend dev mode only
actualURL = "/sampleData/cache.json"
}
fetch(actualURL).then((res) => res.json().then((data)=>{
this.setLoading(false)
this.setCacheData(data)
}));
}
}
render() {
const loading = this.state.loading
const setLoading = this.setLoading
const cacheData = this.state.cacheData
function handleClick(e) {
setLoading(true)
fetch(forceURL).then((data)=>{
setLoading(false)
window.location.reload()
})
console.log('The link was clicked.');
}
if (loading) {
return <p>Sorry, still loading...</p>
}
let dd = cacheData
return (
<Container>
<Typography variant="h5" component="h2" gutterBottom>
Server has cached data from <b>{dd.SongCount} published</b> performances
</Typography>
<Typography component="p" gutterBottom>
Cache data is from about {dd.AgeStr} - it automatically gets updated if it's older than an hour. If you're convinced using Martyn's bandwidth to refresh the cache earlier than that is worth it, here's the button below :
</Typography>
<Button variant="contained" color="primary" onClick={handleClick}>Force refresh cache</Button>
</Container>
);
}
}
export default CacheDeets;

View File

@ -113,7 +113,12 @@ function DuetData() {
useEffect(() => {
// We should only fetch once!
if (duetState.loading) {
fetch(dataURL).then((res) => res.json().then((data)=>{
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 })
}));
}

View File

@ -102,7 +102,12 @@ function TopTenSingers() {
useEffect(() => {
// We should only fetch once!
if (singerState.loading) {
fetch(dataURL).then((res) => res.json().then((data)=>{
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 })
}));
}

View File

@ -103,7 +103,12 @@ function TopTenSongs() {
useEffect(() => {
// We should only fetch once!
if (songState.loading) {
fetch(dataURL).then((res) => res.json().then((data)=>{
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 })
}));
}

View File

@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twitchsingstools.fullname" . }}-db
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
{{- include "twitchsingstools.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "twitchsingstools.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "twitchsingstools.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "twitchsingstools.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.db.image.repository }}:{{ .Values.db.image.tag | default "latest" }}"
imagePullPolicy: {{ .Values.db.image.pullPolicy }}
ports:
- name: redis
containerPort: 9221
protocol: TCP
livenessProbe:
tcpSocket:
port: redis
readinessProbe:
tcpSocket:
port: redis
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- mountPath: /pika/db
name: data
volumes:
- name: data
persistentVolumeClaim:
claimName: tstools-db
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -32,6 +32,8 @@ spec:
env:
- name: TSTOOLS_DATA_FOLDER
value: /data
- name: TSTOOLS_REDIS_HOST
value: {{ .Values.db.service.name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}"

View File

@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tstools-db
labels:
{{- include "twitchsingstools.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.db.storageSize }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.db.service.name }}
labels:
{{- include "twitchsingstools.labels" . | nindent 4 }}
spec:
type: {{ .Values.db.service.type }}
ports:
- port: {{ .Values.db.service.port }}
targetPort: redis
protocol: TCP
name: redis
selector:
{{- include "twitchsingstools.selectorLabels" . | nindent 4 }}

View File

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

View File

@ -0,0 +1,81 @@
# Default values for twitchsingstools.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: imartyn/twitchsingstools
tag: 0.0-linux-amd64
pullPolicy: Always
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name:
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
secretFiles: {}
irc:
nick: tstools
twitchapp: {}
storageSize: 10Gi
service:
type: ClusterIP
port: 80
externalHostname: twitchsingstools.martyn.berlin
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: traefik
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: twitchsingstools.martyn.berlin
paths:
- /
tls:
- secretName: tstools-tls
hosts:
- twitchsingstools.martyn.berlin
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}

View File

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

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

@ -0,0 +1,234 @@
package data
import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"os"
"time"
rgb "github.com/foresthoffman/rgblog"
redis "github.com/gomodule/redigo/redis"
uuid "github.com/google/uuid"
)
// ConfigStruct is the base for the config file
type ConfigStruct struct {
InitialChannels []string `json:"channels"`
IrcOAuthPath string `json:"ircoauthpath,omitempty"`
StringPath string `json:"authpath,omitempty"`
DataPath string `json:"datapath,omitempty"`
ExternalURL string `json:"externalurl,omitempty"`
AppOAuthPath string `json:"appoauthpath,omitempty"`
DatabaseSVC string `json:"databasesvc,omitempty"`
}
// CommandType Kinda an enum
type CommandType string
// CommandType literals
const (
RandomSinger CommandType = "RandomSinger"
RandomPrompt CommandType = "RandomPrompt"
RandomSong CommandType = "RandomSong"
AgingSinger CommandType = "AgingSinger"
AgingSong CommandType = "AgingSong"
)
// IsValid Is CommandType a valid enum?
func (ct CommandType) IsValid() error {
switch ct {
case RandomSinger, RandomPrompt, RandomSong, AgingSinger, AgingSong:
return nil
}
return errors.New("Invalid command type")
}
// ChannelData is what we store in the BitRaft (Redis) database
type ChannelData struct {
ControlChannel bool
Name string `json:"name"`
AdminKey string `json:"value,omitempty"`
Commands []CommandStruct `json:"commands,omitempty"`
ExtraStrings string `json:"extrastrings,omitempty"`
JoinTime time.Time `json:"jointime"`
HasLeft bool `json:"hasleft"`
VideoCache []SingsVideoStruct `json:"videoCache"`
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
Bearer string `json:"bearer"`
TwitchUserID string `json:"twitchUserID"`
}
// SingsVideoStruct The data we pull from Twitch
type SingsVideoStruct struct {
Date time.Time `json:"date"` // Golang date of creation
FullTitle string `json:"fullTitle"` // Full Title
Duet bool `json:"duet"` // Is it a duet?
OtherSinger string `json:"otherSinger"` // Twitch NAME of the other singer, extracted from the title
SongTitle string `json:"songTitle"` // extracted from title
LastSungSong time.Time `json:"LastSungSong"` // Last time this SONG was sung
LastSungSinger time.Time `json:"LastSungSinger"` // Last time a duet was sung with this SINGER, regardless of song, only Duets have this date initialised
VideoURL string `json:"VideoURL"` // RIP Twitch Sings
}
// CommandStruct keypair for irc command -> actual thing to do
type CommandStruct struct {
CommandName CommandType `json:"commandName"`
KeyWord string `json:"keyword"`
}
// GlobalData Some kind of architect would kill me for this
type GlobalData struct {
ChannelData map[string]ChannelData
Config ConfigStruct
Database redis.Conn
ControlChannel string
}
// ConnectDatabase Connects to the database set in the config struct
func (gd *GlobalData) ConnectDatabase() {
var err error
rgb.YPrintf("[%s] Connecting to \"redis\" %s...\n", TimeStamp(), gd.Config.DatabaseSVC, err)
gd.Database, err = redis.Dial("tcp", gd.Config.DatabaseSVC+":4920")
if err != nil {
rgb.RPrintf("[%s] Failed connecting to \"redis\" %s : %s\n", TimeStamp(), gd.Config.DatabaseSVC, err)
os.Exit(1)
}
rgb.YPrintf("[%s] No error... wtf?\n", TimeStamp())
}
// UpdateVideoCache Updates the in-memory data and updates redis
func (gd *GlobalData) UpdateVideoCache(user string, videos []SingsVideoStruct) {
record := gd.ChannelData[user]
rgb.YPrintf("Replacing cache of %d performances with a new cache of %d performances\n", len(record.VideoCache), len(videos))
record.VideoCache = videos
record.VideoCacheUpdated = time.Now()
asJson, _ := json.Marshal(record)
_, err := gd.Database.Do("SET", user, asJson)
if err != nil {
log.Fatal(err)
}
gd.ChannelData[user] = record
}
func (gd *GlobalData) UpdateBearerToken(user string, token string) {
record := gd.ChannelData[user]
record.Bearer = token
asJson, _ := json.Marshal(record)
gd.Database.Do("SET", user, asJson)
gd.ChannelData[user] = record
}
func (gd *GlobalData) UpdateTwitchUserID(user string, userid string) {
record := gd.ChannelData[user]
record.TwitchUserID = userid
asJson, _ := json.Marshal(record)
gd.Database.Do("SET", user, asJson)
gd.ChannelData[user] = record
}
func (gd *GlobalData) UpdateChannelKey(user string, channelKey string) {
record := gd.ChannelData[user]
record.AdminKey = channelKey
asJson, _ := json.Marshal(record)
gd.Database.Do("SET", user, asJson)
gd.ChannelData[user] = record
}
func (gd *GlobalData) UpdateChannelName(user string, newName string) {
record := gd.ChannelData[user]
record.Name = newName
asJson, _ := json.Marshal(record)
gd.Database.Do("SET", newName, asJson)
gd.ChannelData[newName] = record
//dunno why we'd need this but I guess in case?
if newName != user {
delete(gd.ChannelData, user)
gd.Database.Do("DEL", newName)
}
}
func (gd *GlobalData) UpdateJoined(user string, invert bool) {
record := gd.ChannelData[user]
if record.Name == "" {
record = ChannelData{Name: user, JoinTime: time.Now(), Commands: nil, ControlChannel: true}
}
record.JoinTime = time.Now()
asJson, _ := json.Marshal(record)
if invert {
record.HasLeft = true
} else {
record.HasLeft = false
}
gd.Database.Do("SET", user, asJson)
gd.ChannelData[user] = record
}
func (gd *GlobalData) ReadChannelData() error {
keys, err := redis.Strings(gd.Database.Do("KEYS", "*"))
if err != nil {
rgb.RPrintf("[%s] ERROR with redis fetch : %s\n", TimeStamp(), err.Error())
rgb.YPrintf("[%s] Maybe an empty redis, creating a record...\n", TimeStamp())
keys = []string{}
}
if len(keys) == 0 {
rgb.YPrintf("[%s] Looks like an empty redis, creating a record...\n", TimeStamp())
record := ChannelData{Name: gd.ControlChannel, JoinTime: time.Now(), Commands: nil}
asJSON, _ := json.Marshal(record)
gd.Database.Do("SET", gd.ControlChannel, asJSON)
keys = []string{gd.ControlChannel}
}
rgb.YPrintf("[%s] \"redis\" has %d records!\n", TimeStamp(), len(keys))
for _, channel := range keys {
fetchedData, err := redis.String(gd.Database.Do("GET", channel))
if err != nil {
rgb.YPrintf("[%s] failed to read key %s from redis, good luck!...\n", TimeStamp(), channel)
}
rgb.YPrintf("[%s] data from \"redis\" for %s is %s\n", TimeStamp(), channel, fetchedData)
cd := gd.ChannelData
if cd == nil {
cd = make(map[string]ChannelData)
}
d := &ChannelData{}
err = json.Unmarshal([]byte(fetchedData), d)
if err != nil {
rgb.RPrintf("[%s] channel data could not be unmarshalled : %s\n", TimeStamp(), err.Error())
}
cd[channel] = *d
gd.ChannelData = cd
rgb.YPrintf("[%s] channel data : %v\n", TimeStamp(), gd.ChannelData)
}
// Managed to leave the main channel!?
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(gd.ChannelData))
return nil
}
func (gd *GlobalData) ReadOrCreateChannelKey(channel string) string {
record := gd.ChannelData[channel]
magicCode := ""
if record.AdminKey == "" {
rgb.YPrintf("[%s] No channel key for #%s exists, creating one\n", TimeStamp(), channel)
newuu, _ := uuid.NewRandom()
magicCode = base64.StdEncoding.EncodeToString([]byte(newuu.String()))
gd.UpdateJoined(channel, true)
gd.UpdateChannelKey(channel, magicCode)
gd.UpdateChannelName(channel, channel)
rgb.YPrintf("[%s] Cached channel key for #%s\n", TimeStamp(), record.Name)
} else {
magicCode = record.AdminKey
rgb.YPrintf("[%s] Loaded data for #%s\n", TimeStamp(), channel)
}
return magicCode
}
const UTCFormat = "Jan 2 15:04:05 UTC"
func TimeStamp() string {
return TimeStampFmt(UTCFormat)
}
func TimeStampFmt(format string) string {
return time.Now().Format(format)
}

View File

@ -2,7 +2,6 @@ package irc
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -15,9 +14,8 @@ import (
"strings"
"time"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
rgb "github.com/foresthoffman/rgblog"
uuid "github.com/google/uuid"
scribble "github.com/nanobox-io/golang-scribble"
)
const UTCFormat = "Jan 2 15:04:05 UTC"
@ -58,15 +56,6 @@ type AppOAuthCred struct {
ClientSecret string `json:"client_secret,omitempty"`
}
type ConfigStruct struct {
InitialChannels []string `json:"channels"`
IrcOAuthPath string `json:"ircoauthpath,omitempty"`
StringPath string `json:"authpath,omitempty"`
DataPath string `json:"datapath,omitempty"`
ExternalUrl string `json:"externalurl,omitempty"`
AppOAuthPath string `json:"appoauthpath,omitempty"`
}
type KardBot struct {
Channel string
conn net.Conn
@ -80,33 +69,7 @@ type KardBot struct {
Server string
startTime time.Time
Prompts []string
Database scribble.Driver
ChannelData map[string]ChannelData
Config ConfigStruct
}
type SingsVideoStruct struct {
Date time.Time `json:"date"` // Golang date of creation
FullTitle string `json:"fullTitle"` // Full Title
Duet bool `json:"duet"` // Is it a duet?
OtherSinger string `json:"otherSinger"` // Twitch NAME of the other singer, extracted from the title
SongTitle string `json:"songTitle"` // extracted from title
LastSungSong time.Time `json:"LastSungSong"` // Last time this SONG was sung
LastSungSinger time.Time `json:"LastSungSinger"` // Last time a duet was sung with this SINGER, regardless of song, only Duets have this date initialised
}
type ChannelData struct {
Name string `json:"name"`
AdminKey string `json:"value,omitempty"`
Command string `json:"customcommand,omitempty"`
ExtraStrings string `json:"extrastrings,omitempty"`
JoinTime time.Time `json:"jointime"`
ControlChannel bool
HasLeft bool `json:"hasleft"`
VideoCache []SingsVideoStruct `json:"videoCache"`
VideoCacheUpdated time.Time `json:"videoCacheUpdated"`
Bearer string `json:"bearer"`
TwitchUserID string `json:"twitchUserID"`
GlobalData data.GlobalData
}
// Connects the bot to the Twitch IRC server. The bot will continue to try to connect until it
@ -136,7 +99,7 @@ func (bb *KardBot) Disconnect() {
// Look at the channels I'm actually in
func (bb *KardBot) ActiveChannels() int {
count := 0
for _, channel := range bb.ChannelData {
for _, channel := range bb.GlobalData.ChannelData {
if !channel.HasLeft {
count = count + 1
}
@ -144,29 +107,6 @@ func (bb *KardBot) ActiveChannels() int {
return count
}
func (bb *KardBot) UpdateVideoCache(user string, videos []SingsVideoStruct) {
record := bb.ChannelData[user]
fmt.Printf("Replacing cache of %d performances with a new cache of %d performances", len(record.VideoCache), len(videos))
record.VideoCache = videos
record.VideoCacheUpdated = time.Now()
bb.Database.Write("channelData", user, record)
bb.ChannelData[user] = record
}
func (bb *KardBot) UpdateBearerToken(user string, token string) {
record := bb.ChannelData[user]
record.Bearer = token
bb.Database.Write("channelData", user, record)
bb.ChannelData[user] = record
}
func (bb *KardBot) UpdateTwitchUserID(user string, userid string) {
record := bb.ChannelData[user]
record.TwitchUserID = userid
bb.Database.Write("channelData", user, record)
bb.ChannelData[user] = record
}
// Listens for and logs messages from chat. Responds to commands from the channel owner. The bot
// continues until it gets disconnected, told to shutdown, or forcefully shutdown.
func (bb *KardBot) HandleChat() error {
@ -198,11 +138,7 @@ func (bb *KardBot) HandleChat() error {
matches := ConnectRegex.FindStringSubmatch(line)
if nil != matches {
realUserName := matches[1]
if bb.ChannelData[realUserName].Name == "" {
record := ChannelData{Name: realUserName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
bb.Database.Write("channelData", realUserName, record)
bb.ChannelData[realUserName] = record
}
bb.GlobalData.UpdateJoined(realUserName, false)
bb.JoinChannel(realUserName)
}
@ -233,21 +169,24 @@ func (bb *KardBot) HandleChat() error {
cmdMatches := CmdRegex.FindStringSubmatch(msg)
if nil != cmdMatches {
cmd := cmdMatches[1]
rgb.YPrintf("[%s] Checking cmd %s against %s\n", TimeStamp(), cmd, bb.ChannelData[channel].Command)
cardCommand := ""
commands := bb.GlobalData.ChannelData[channel].Commands
for _, command := range commands {
if command.CommandName == data.RandomPrompt {
cardCommand = command.KeyWord
}
}
rgb.YPrintf("[%s] Checking cmd %s against [%s]\n", TimeStamp(), cmd, bb.GlobalData.ChannelData[channel].Commands)
switch cmd {
case bb.ChannelData[channel].Command:
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
case cardCommand:
if cardCommand != "" {
rgb.CPrintf("[%s] Card asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
bb.Say("Your prompt is : "+bb.Prompts[rand.Intn(len(bb.Prompts))], channel)
}
case "join":
if bb.ChannelData[channel].ControlChannel {
if bb.GlobalData.ChannelData[channel].ControlChannel {
rgb.CPrintf("[%s] Join asked for by %s on %s' channel!\n", TimeStamp(), userName, channel)
if bb.ChannelData[userName].Name == "" {
record := ChannelData{Name: userName, JoinTime: time.Now(), Command: "card", ControlChannel: true}
bb.Database.Write("channelData", userName, record)
bb.ChannelData[userName] = record
}
bb.GlobalData.UpdateJoined(userName, false)
bb.JoinChannel(userName)
}
}
@ -264,7 +203,7 @@ func (bb *KardBot) HandleChat() error {
bb.Disconnect()
return nil
case "kcardadmin":
magicCode := bb.ReadOrCreateChannelKey(channel)
magicCode := bb.GlobalData.ReadOrCreateChannelKey(channel)
rgb.CPrintf(
"[%s] Magic code is %s - https://karaokards.ing.martyn.berlin/admin/%s/%s\n",
TimeStamp(),
@ -424,18 +363,11 @@ func (bb *KardBot) Start() {
return
}
err = bb.readChannelData()
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
return
}
for {
bb.Connect()
bb.Login()
if len(bb.ChannelData) > 0 {
for channelName, channelData := range bb.ChannelData {
if len(bb.GlobalData.ChannelData) > 0 {
for channelName, channelData := range bb.GlobalData.ChannelData {
if !channelData.HasLeft {
bb.JoinChannel(channelName)
}
@ -456,85 +388,6 @@ func (bb *KardBot) Start() {
}
}
func (bb *KardBot) readChannelData() error {
records, err := bb.Database.ReadAll("channelData")
if err != nil {
// no db? initialise one?
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
rgb.YPrintf("[%s] No channel table for #%s exists, creating...\n", TimeStamp(), bb.Channel)
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
return err
}
bb.ChannelData = make(map[string]ChannelData)
bb.ChannelData[bb.Channel] = record
} else {
bb.ChannelData = make(map[string]ChannelData)
}
for _, data := range records {
record := ChannelData{}
err := json.Unmarshal([]byte(data), &record)
if err != nil {
return err
}
if record.Name != "" {
if record.Command == "" {
record.Command = "card"
rgb.YPrintf("[%s] Rewriting data for #%s...\n", TimeStamp(), bb.Channel)
if err := bb.Database.Write("channelData", record.Name, record); err != nil {
return err
}
}
bb.ChannelData[record.Name] = record
}
}
// Managed to leave the main channel!?
if bb.ChannelData[bb.Channel].Name == "" {
rgb.YPrintf("[%s] No channel data for #%s exists, creating...\n", TimeStamp(), bb.Channel)
record := ChannelData{Name: bb.Channel, JoinTime: time.Now(), Command: "card"}
bb.ChannelData[bb.Channel] = record
if err := bb.Database.Write("channelData", bb.Channel, record); err != nil {
return err
}
records, err = bb.Database.ReadAll("channelData")
}
rgb.YPrintf("[%s] Read channel data for %d channels\n", TimeStamp(), len(bb.ChannelData))
return nil
}
func (bb *KardBot) ReadOrCreateChannelKey(channel string) string {
magicCode := ""
var err error
var record ChannelData
if record, ok := bb.ChannelData[channel]; !ok {
rgb.YPrintf("[%s] No channel data for #%s exists, creating\n", TimeStamp(), channel)
err = bb.Database.Read("channelData", channel, &record)
if err == nil {
bb.ChannelData[channel] = record
}
}
record = bb.ChannelData[channel]
if err != nil || record.AdminKey == "" {
rgb.YPrintf("[%s] No channel key for #%s exists, creating one\n", TimeStamp(), channel)
newuu, _ := uuid.NewRandom()
magicCode = base64.StdEncoding.EncodeToString([]byte(newuu.String()))
record.HasLeft = true
record.AdminKey = magicCode
if record.Name == "" {
record.Name = channel
}
if err := bb.Database.Write("channelData", channel, record); err != nil {
rgb.RPrintf("[%s] Error writing channel data for #%s\n", TimeStamp(), channel)
}
bb.ChannelData[record.Name] = record
rgb.YPrintf("[%s] Cached channel key for #%s\n", TimeStamp(), record.Name)
} else {
magicCode = record.AdminKey
rgb.YPrintf("[%s] Loaded data for #%s\n", TimeStamp(), channel)
}
return magicCode
}
func TimeStamp() string {
return TimeStampFmt(UTCFormat)
}

View File

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

View File

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

31
main.go
View File

@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
@ -9,6 +10,7 @@ import (
"time"
builtins "git.martyn.berlin/martyn/twitchsingstools/internal/builtins"
data "git.martyn.berlin/martyn/twitchsingstools/internal/data"
irc "git.martyn.berlin/martyn/twitchsingstools/internal/irc"
webserver "git.martyn.berlin/martyn/twitchsingstools/internal/webserver"
rgb "github.com/foresthoffman/rgblog"
@ -23,7 +25,7 @@ var selectablePrompts []string
var customStrings customStringsStruct
var config irc.ConfigStruct
var config data.ConfigStruct
func readConfig() {
var data []byte
@ -174,12 +176,32 @@ func main() {
}
} else {
if _, err := os.Stat(config.AppOAuthPath); os.IsNotExist(err) {
rgb.YPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
rgb.RPrintf("[%s] Error config-specified oauth file %s doesn't exist, bailing!\n", irc.TimeStamp(), config.AppOAuthPath)
os.Exit(1)
}
appOauthPath = config.AppOAuthPath
}
rgb.YPrintf("[%s] Starting connection to redis...\n", irc.TimeStamp())
//TODO: unhardcode this
if os.Getenv("TSTOOLS_REDIS_HOST") != "" {
config.DatabaseSVC = os.Getenv("TSTOOLS_REDIS_HOST")
} else {
// assume localhost, which should fail.
config.DatabaseSVC = "localhost"
}
var globalData data.GlobalData
globalData.Config = config
globalData.ConnectDatabase()
defer globalData.Database.Close()
rgb.GPrintf("[%s] Connected to \"redis\" %s\n", irc.TimeStamp(), "config.DatabaseSVC")
err := globalData.ReadChannelData()
if nil != err {
fmt.Println(err)
fmt.Println("Aborting!")
os.Exit(1)
}
rgb.GPrintf("[%s] Read the channel data from \"redis\" successfully, now have %d records\n", irc.TimeStamp(), len(globalData.ChannelData))
// Replace the channel name, bot name, and the path to the private directory with your respective
// values.
var myBot irc.KardBot
@ -193,13 +215,12 @@ func main() {
AppPrivatePath: appOauthPath,
Server: "irc.chat.twitch.tv",
Prompts: selectablePrompts,
Database: *persistentData,
Config: config,
GlobalData: globalData,
}
}
go func() {
rgb.YPrintf("[%s] Starting webserver on port %s\n", irc.TimeStamp(), "5353")
webserver.HandleHTTP(&myBot)
webserver.HandleHTTP(&myBot, &globalData)
}()
if ircOauthPath != "" {
myBot.Start()

5
web/script.bat Executable file
View File

@ -0,0 +1,5 @@
@ECHO OFF
{{ range .SongData -}}
IF NOT EXIST "{{.OtherSinger}}\" MKDIR {{.OtherSinger}}
IF NOT EXIST "{{.OtherSinger}}\{{.ShortDate}}-{{.OtherSinger}}-{{.SongTitle}}-{{.VideoNumber}}.mp4" youtube-dl -o "{{.OtherSinger}}\{{.ShortDate}}-{{.OtherSinger}}-{{.SongTitle}}-{{.VideoNumber}}.mp4" {{.VideoURL}}
{{ end }}