Compare commits

...

535 Commits

Author SHA1 Message Date
Gabe Kangas 503d30cb44
Merge branch 'replays-clips-recordings' into gek/initial-replay-functionality 2 years ago
Gabe Kangas 2e5f14a6da fix: revert Dockerfile repo change 2 years ago
Owncast 7ab2ddfd7a Commit updated API documentation 2 years ago
Owncast 7a0e5a2736 Bundle embedded web app 2 years ago
renovate[bot] 7ce6bbed53 chore(deps): update dependency typescript to v5 (#3387) 2 years ago
Owncast c2c7286673 Bundle embedded web app 2 years ago
Gabe Kangas 21552de0f9 fix(storybook): update stories to support mdx2 2 years ago
Gabe Kangas d99234b7d4 fix(storybook): less+sass updates for storybook7 2 years ago
Gabe Kangas d0d3b84afa chore(deps): update storybook to v7 2 years ago
Owncast 8779cd502f Commit screenshots 2 years ago
Owncast 9f276fcaa6 Bundle embedded web app 2 years ago
Gabe Kangas 468b7376af fix(admin): hopefully fix an exception that is being thrown in develop. Closes #3373 2 years ago
Owncast b2d266d2ad Commit updated API documentation 2 years ago
Meisam 908c515a5a fix api/admin/config/pagecontent example (#3392) 2 years ago
Owncast 1f9c409f60 Bundle embedded web app 2 years ago
renovate[bot] d0b307f260 fix(deps): update dependency video.js to v8.6.1 2 years ago
renovate[bot] 7a90320137 chore(deps): update peter-evans/create-or-update-comment digest to c0693c5 2 years ago
Owncast 481e601731 Bundle embedded web app 2 years ago
Pranav Joglekar 8322b5057e fix: prevent floating mobile action menu button (#3383) 2 years ago
Owncast 8c7358e14c Commit screenshots 2 years ago
Gabe Kangas be4629b127 More changed-files troubleshooting 2 years ago
Gabe Kangas 5fcbe9daef Looks like we were using the changed-files action incorrectly. Hopefully this fixes it? 2 years ago
Owncast 613bae6201 Commit screenshots 2 years ago
Owncast 97d62513ac Bundle embedded web app 2 years ago
renovate[bot] b267288b05 chore(deps): update dependency eslint to v8.52.0 2 years ago
Owncast 442d57f522 Bundle embedded web app 2 years ago
renovate[bot] b62de83082 chore(deps): update dependency @types/react to v18.2.31 2 years ago
Owncast 41b0116ca6 Bundle embedded web app 2 years ago
renovate[bot] 7f0250146d chore(deps): lock file maintenance (#3384) 2 years ago
Owncast 35b4a9b500 Bundle embedded web app 2 years ago
renovate[bot] 19f99a6e34 fix(deps): update dependency @fontsource/inter to v5.0.14 (#3386) 2 years ago
Owncast b765509dd9 Bundle embedded web app 2 years ago
renovate[bot] 8a85defedb fix(deps): update nextjs monorepo to v13.5.6 2 years ago
Owncast c289078629 Bundle embedded web app 2 years ago
renovate[bot] 983d7424ea chore(deps): update dependency npm to v10.2.1 2 years ago
Owncast d4a830a83f Bundle embedded web app 2 years ago
renovate[bot] 8cde247244 chore(deps): update dependency cypress to v13.3.2 2 years ago
Owncast 9934ed2bd3 Commit screenshots 2 years ago
Owncast b88ac8a2c6 Bundle embedded web app 2 years ago
renovate[bot] 638bf7b466 chore(deps): update dependency @types/video.js to v7.3.55 2 years ago
Owncast e8688dd26a Bundle embedded web app 2 years ago
renovate[bot] 9a647f07c0 chore(deps): update dependency @types/react to v18.2.30 2 years ago
Owncast 890ca4b31f Bundle embedded web app 2 years ago
renovate[bot] 0dd5bfc94f chore(deps): update dependency @types/ua-parser-js to v0.7.38 2 years ago
Owncast 703eff5a24 Bundle embedded web app 2 years ago
renovate[bot] 9046dec692 chore(deps): update dependency @types/sanitize-html to v2.9.3 2 years ago
Owncast 13713e2e4e Bundle embedded web app 2 years ago
renovate[bot] 1542618843 chore(deps): update dependency @types/react-linkify to v1.0.3 2 years ago
Owncast 965fd47faa Bundle embedded web app 2 years ago
renovate[bot] 2bd0be67c8 chore(deps): update dependency @types/react to v18.2.29 2 years ago
Owncast a153cf1314 Bundle embedded web app 2 years ago
renovate[bot] 584a42df7d chore(deps): update dependency @types/prop-types to v15.7.9 2 years ago
Gabe Kangas 61f2f159ac fix(emoji): hopefully guard against the crash in #3331 2 years ago
Owncast 772a87db70 Bundle embedded web app 2 years ago
Patrick Bollinger 1da0828aa3 Fix embedded status bar being cut off (#3352) 2 years ago
Owncast 0f33546804 Commit screenshots 2 years ago
Owncast be53ad935d Bundle embedded web app 2 years ago
renovate[bot] 6707c481f3 chore(deps): update dependency @types/markdown-it to v13.0.4 2 years ago
Owncast a21fbda241 Bundle embedded web app 2 years ago
renovate[bot] 3ef5ed9d5c chore(deps): update dependency @types/node to v18.18.6 2 years ago
Owncast acf2d5ed89 Bundle embedded web app 2 years ago
renovate[bot] ee9e0fd669 chore(deps): update dependency @types/jest to v29.5.6 (#3380) 2 years ago
renovate[bot] 9fc1697f7e chore(deps): update dependency @types/markdown-it to v13.0.3 (#3381) 2 years ago
Owncast edd9434aa6 Bundle embedded web app 2 years ago
Alyssa Ross de2f8974ab Fix parsing of Authorization Bearer header (#3376) 2 years ago
renovate[bot] 1082d5eaae chore(deps): update dependency knip to v2.34.1 (#3379) 2 years ago
Gabe Kangas 7b9ca72f77 chore(lint): silence linter warnings 2 years ago
Owncast dd4bb8385f Bundle embedded web app 2 years ago
renovate[bot] e87983196e chore(deps): update dependency @types/chart.js to v2.9.39 2 years ago
Owncast 4318d95311 Commit screenshots 2 years ago
Owncast 990e7992e6 Bundle embedded web app 2 years ago
renovate[bot] 642f9cbaf6 chore(deps): update dependency sass to v1.69.4 2 years ago
Owncast fd48b677d5 Bundle embedded web app 2 years ago
renovate[bot] c9396f7aa1 chore(deps): update typescript-eslint monorepo to v6.8.0 2 years ago
Owncast b7eb272533 Commit screenshots 2 years ago
Owncast 4954086177 Bundle embedded web app 2 years ago
renovate[bot] 2a60e228c4 fix(deps): update nextjs monorepo to v13.5.5 2 years ago
Owncast a7f03d7bc4 Bundle embedded web app 2 years ago
renovate[bot] 77bf57c10f fix(deps): update dependency react-virtuoso to v4.6.2 2 years ago
renovate[bot] 6c9366b481 chore(deps): update peter-evans/create-or-update-comment digest to 23ff157 2 years ago
Owncast 3ee37c2a3b Commit screenshots 2 years ago
armadi1809 7997f760b8 Added a check for the port before calling the splitHostPort function (#3372) 2 years ago
renovate[bot] 724d7a5068 chore(deps): update peter-evans/create-or-update-comment digest to d85800f 2 years ago
renovate[bot] cd0292e809 fix(deps): update module golang.org/x/mod to v0.13.0 2 years ago
Owncast 0e7a45940a Commit screenshots 2 years ago
renovate[bot] 5b4264199d fix(deps): update module github.com/microcosm-cc/bluemonday to v1.0.26 (#3356) 2 years ago
Gabe Kangas 8add5afa74 chore(go): bump project version number to 1.21 2 years ago
renovate[bot] 0860ffa46a fix(deps): update module github.com/aws/aws-sdk-go to v1.45.27 2 years ago
Owncast 880c2f207f Bundle embedded web app 2 years ago
renovate[bot] 46e0db59bb fix(deps): update dependency yaml to v2.3.3 2 years ago
Owncast 2e671cb75a Commit screenshots 2 years ago
Owncast 31e7a0ee50 Bundle embedded web app 2 years ago
renovate[bot] b3128bcf63 chore(deps): update dependency chromatic to v7.4.0 2 years ago
dependabot[bot] 224761ab2f Bump @babel/traverse from 7.12.5 to 7.23.2 in /test/automated/api (#3366) 2 years ago
A. Singh 97d22a3157 fix: issue with lint and prettier during js format build (#3362) 2 years ago
dependabot[bot] 6f009f293f Bump @babel/traverse from 7.12.5 to 7.23.2 in /test/automated/hls (#3367) 2 years ago
Owncast 9bc7d1c58c Bundle embedded web app 2 years ago
renovate[bot] 7a2151188d fix(deps): update dependency react-markdown to v9 (#3365) 2 years ago
Owncast a5f1982e85 Bundle embedded web app 2 years ago
renovate[bot] 446d425ccd chore(deps): lock file maintenance 2 years ago
Owncast 1d82a380a3 Bundle embedded web app 2 years ago
renovate[bot] 3add568613 fix(deps): update dependency @uiw/react-codemirror to v4.21.20 2 years ago
renovate[bot] 6cdeb679f2 fix(deps): update module mvdan.cc/xurls to v2 (#3363) 2 years ago
Owncast d307a4b47d Bundle embedded web app 2 years ago
renovate[bot] f1a9aadece fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.20 2 years ago
Owncast 2a2579e618 Commit screenshots 2 years ago
Owncast 830bbe26f2 Bundle embedded web app 2 years ago
Gabe Kangas 92bf2487c1 chore: downgrade x/mod to silence go 1.21 toolchain error 2 years ago
renovate[bot] b7fa7ffc31 chore(deps): update dependency knip to v2.33.3 (#3353) 2 years ago
Owncast 819cbd2742 Bundle embedded web app 2 years ago
armadi1809 2c1b06c387 Set aria-live to off on span responsible for rendering the online message on a stream (#3361) 2 years ago
Owncast 5bb5536398 Bundle embedded web app 2 years ago
renovate[bot] a4b14ab59d chore(deps): update dependency @types/node to v18.18.5 2 years ago
armadi1809 e5a0116bb5 Fix geo details for viewers not showing on CDN connection (#3359) 2 years ago
Owncast f5fe1b0353 Bundle embedded web app 2 years ago
renovate[bot] 0a14abfa85 chore(deps): update dependency sass to v1.69.3 2 years ago
Owncast 70ee29cad3 Commit screenshots 2 years ago
Owncast 9a549650f1 Bundle embedded web app 2 years ago
renovate[bot] 6f1091eb9e chore(deps): update dependency @babel/core to v7.23.2 2 years ago
Owncast cbf282d372 Bundle embedded web app 2 years ago
renovate[bot] 68a00fbbbb chore(deps): update dependency cypress to v13.3.1 2 years ago
Owncast 062d1830d5 Bundle embedded web app 2 years ago
renovate[bot] aa870e020a chore(deps): update dependency chromatic to v7.3.0 (#3357) 2 years ago
Owncast f97c35dbfd Bundle embedded web app 2 years ago
renovate[bot] a2931888b3 chore(deps): update dependency sass to v1.69.2 2 years ago
Owncast 25fdab6b6f Bundle embedded web app 2 years ago
renovate[bot] 7c3c3dde45 chore(deps): update dependency eslint-plugin-prettier to v5.0.1 2 years ago
Owncast 19100ff68b Bundle embedded web app 2 years ago
renovate[bot] fda82ee252 chore(deps): update dependency @types/sanitize-html to v2.9.2 2 years ago
Owncast e25fd78517 Commit screenshots 2 years ago
Owncast 662d1710e0 Bundle embedded web app 2 years ago
renovate[bot] a147f4a28c chore(deps): update dependency @types/react to v18.2.28 2 years ago
Owncast 892b498475 Bundle embedded web app 2 years ago
renovate[bot] 7e9a0cf388 fix(deps): update dependency react-virtuoso to v4.6.1 2 years ago
Owncast 691b53cb95 Bundle embedded web app 2 years ago
renovate[bot] 9cdfa53801 fix(deps): update dependency video.js to v8.6.0 2 years ago
Owncast 0cf6e28f02 Bundle embedded web app 2 years ago
renovate[bot] 88e3c2460f chore(deps): update dependency sass to v1.69.1 2 years ago
Owncast 088c17e91d Commit screenshots 2 years ago
Owncast bb87d0abd0 Bundle embedded web app 2 years ago
renovate[bot] c66c8f79cd chore(deps): update typescript-eslint monorepo to v6.7.5 2 years ago
Owncast ba409e016f Bundle embedded web app 2 years ago
renovate[bot] 70505f10b1 chore(deps): update dependency @types/react to v18.2.27 2 years ago
Owncast c55a16f173 Commit screenshots 2 years ago
renovate[bot] c43b9f6e89 fix(deps): update module golang.org/x/net to v0.17.0 [security] 2 years ago
Owncast 7bfa59e23b Bundle embedded web app 2 years ago
renovate[bot] e1f15de101 chore(deps): update dependency eslint-plugin-storybook to v0.6.15 2 years ago
renovate[bot] 96c6f52763 chore(deps): update peter-evans/create-or-update-comment digest to ac8e650 2 years ago
Owncast 04394db1db Bundle embedded web app 2 years ago
Owncast f01ba6725a Commit screenshots 2 years ago
renovate[bot] b1950b3d23 fix(deps): update dependency @fontsource/inter to v5.0.13 (#3354) 2 years ago
Owncast ce485ccdc7 Bundle embedded web app 2 years ago
renovate[bot] 9db9a24d58 fix(deps): update nextjs monorepo to v13.5.4 2 years ago
renovate[bot] c754778a6c fix(deps): update module gopkg.in/evanphx/json-patch.v5 to v5.7.0 2 years ago
renovate[bot] 3291567179 fix(deps): update module golang.org/x/mod to v0.13.0 2 years ago
renovate[bot] e6c6947e3b fix(deps): update module github.com/sherclockholmes/webpush-go to v1.3.0 2 years ago
renovate[bot] e310d5abed fix(deps): update module github.com/prometheus/client_golang to v1.17.0 2 years ago
renovate[bot] 2101919590 fix(deps): update module github.com/shirou/gopsutil/v3 to v3.23.9 2 years ago
Owncast 646e33cfb9 Bundle embedded web app 2 years ago
renovate[bot] 137d519199 chore(deps): update dependency @types/node to v18.18.4 2 years ago
Owncast 91f1f511ec Commit screenshots 2 years ago
renovate[bot] 70988f4457 fix(deps): update module github.com/aws/aws-sdk-go to v1.45.24 2 years ago
Owncast 833aca7805 Bundle embedded web app 2 years ago
renovate[bot] e23cdee794 chore(deps): update dependency knip to v2.31.0 2 years ago
Owncast 1fe3add5aa Bundle embedded web app 2 years ago
renovate[bot] 4eeeca3936 chore(deps): update dependency eslint to v8.51.0 2 years ago
Owncast 23f211b070 Bundle embedded web app 2 years ago
renovate[bot] 4bbd23afc5 chore(deps): update dependency sass to v1.69.0 2 years ago
Owncast 41ec63844f Bundle embedded web app 2 years ago
renovate[bot] c2926fe28d fix(deps): update dependency @codemirror/lang-markdown to v6.2.2 2 years ago
Owncast 6483910b72 Bundle embedded web app 2 years ago
renovate[bot] 75bc588ab4 chore(deps): update dependency chromatic to v7.2.3 2 years ago
Owncast 755290eb93 Bundle embedded web app 2 years ago
renovate[bot] 2a4da3271c chore(deps): update dependency mermaid to v10.5.0 2 years ago
Owncast 13d0ffadc3 Bundle embedded web app 2 years ago
renovate[bot] a79901c8a0 chore(deps): update dependency knip to v2.30.1 2 years ago
Owncast fee5e4e2ab Bundle embedded web app 2 years ago
Owncast acc01e5bfe Commit screenshots 2 years ago
renovate[bot] 1ddd51a76a chore(deps): update dependency eslint to v8.50.0 2 years ago
Owncast 62b5fae837 Bundle embedded web app 2 years ago
Patrick Bollinger d42a705d33 Stop Firefox from adding mysterious hash (#3348) 2 years ago
Owncast cfe3cfb5b4 Bundle embedded web app 2 years ago
renovate[bot] 0cf77a1faa chore(deps): update dependency cypress to v13.3.0 2 years ago
Gabe Kangas 9363166614 chore(tests): temp comment out a couple config tests that are breaking due to race conditions 2 years ago
Gabe Kangas 105766049f chore(go): run betteralign and gofumpt on codebase 2 years ago
Owncast 333d79221d Bundle embedded web app 2 years ago
renovate[bot] b5e4001f78 chore(deps): update dependency @babel/core to v7.23.0 2 years ago
Owncast b006c74b73 Bundle embedded web app 2 years ago
renovate[bot] a4aec200dc fix(deps): update dependency @uiw/react-codemirror to v4.21.19 2 years ago
Owncast cb702944a3 Bundle embedded web app 2 years ago
renovate[bot] 1c671fda35 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.19 2 years ago
Owncast 208bca1d56 Bundle embedded web app 2 years ago
renovate[bot] 466c20e9d1 fix(deps): update dependency @fontsource/inter to v5.0.12 2 years ago
Owncast f1ee3c9dd6 Bundle embedded web app 2 years ago
renovate[bot] c16b9cbe27 chore(deps): update typescript-eslint monorepo to v6.7.4 2 years ago
Owncast 9736a23fb4 Commit screenshots 2 years ago
Owncast 482d3286a3 Bundle embedded web app 2 years ago
renovate[bot] fbb18d2097 chore(deps): update dependency @types/react to v18.2.25 (#3318) 2 years ago
Owncast 7da6721604 Bundle embedded web app 2 years ago
renovate[bot] 4e7d137d7d chore(deps): update dependency @types/video.js to v7.3.53 (#3345) 2 years ago
Owncast 66e4159ec3 Bundle embedded web app 2 years ago
renovate[bot] f8871624a4 chore(deps): update dependency stylelint-config-standard-scss to v11 (#3322) 2 years ago
renovate[bot] 62ce0f2c3b chore(deps): update docker/setup-qemu-action action to v3 (#3323) 2 years ago
Owncast af07fdc4e1 Bundle embedded web app 2 years ago
Owncast a1fa06a410 Bundle embedded web app 2 years ago
renovate[bot] 9415211c97 chore(deps): update actions/checkout action to v4 (#3321) 2 years ago
renovate[bot] 9058042c08 chore(deps): update tj-actions/changed-files action to v39 (#3324) 2 years ago
Owncast dcf22215f1 Bundle embedded web app 2 years ago
renovate[bot] ec8a27e316 chore(deps): update dependency chromatic to v7 (#3325) 2 years ago
renovate[bot] e15a3a4657 chore(deps): update dependency npm to v10 (#3326) 2 years ago
Vishal Sharma f788e07989 Update ClientTable.tsx (#3342) 2 years ago
Owncast 6f59070b5b Bundle embedded web app 2 years ago
renovate[bot] 8e36363cdf chore(deps): update dependency eslint-plugin-storybook to v0.6.14 2 years ago
Owncast 0cace98825 Bundle embedded web app 2 years ago
renovate[bot] c1b29452bd chore(deps): update dependency @types/sanitize-html to v2.9.1 2 years ago
Owncast ba041c48ed Bundle embedded web app 2 years ago
renovate[bot] 9a5ed23cbc chore(deps): update dependency @types/react-linkify to v1.0.2 2 years ago
Owncast 75c1b17d12 Bundle embedded web app 2 years ago
renovate[bot] 42bf334c83 chore(deps): update dependency @types/node to v18.18.3 2 years ago
Owncast 1450f70e05 Bundle embedded web app 2 years ago
renovate[bot] 7d5a8455aa chore(deps): update dependency @types/markdown-it to v13.0.2 2 years ago
Owncast 3e72f3793d Commit screenshots 2 years ago
Owncast e76d542f43 Bundle embedded web app 2 years ago
renovate[bot] 3ba1d551e5 chore(deps): update dependency @storybook/testing-library to v0.2.2 2 years ago
renovate[bot] eadb9df489 chore(deps): update alpine docker tag to v3.18.4 2 years ago
renovate[bot] 2b378d3a9c chore(deps): update peter-evans/create-or-update-comment digest to e3645dd 2 years ago
renovate[bot] 892f4cc6ec fix(deps): update module golang.org/x/net to v0.16.0 2 years ago
Owncast e0779f62df Bundle embedded web app 2 years ago
renovate[bot] 016334f18c fix(deps): update dependency react-virtuoso to v4.6.0 2 years ago
Owncast 3eb7dbd6dd Bundle embedded web app 2 years ago
renovate[bot] a7b6fe285b chore(deps): update dependency sass to v1.68.0 2 years ago
Owncast e9d23bc5c3 Bundle embedded web app 2 years ago
dependabot[bot] 0e3ae5a6a0 Bump zod and next in /web (#3340) 2 years ago
Owncast 0ad77fa8e8 Commit screenshots 2 years ago
Owncast c2995b03cc Bundle embedded web app 2 years ago
dependabot[bot] e89e433c71 Bump postcss from 8.4.29 to 8.4.31 in /web (#3336) 2 years ago
renovate[bot] 90df637210 fix(deps): update dependency @uiw/react-codemirror to v4.21.18 (#3341) 2 years ago
Owncast b183819d03 Bundle embedded web app 2 years ago
renovate[bot] 11d2c56eb7 fix(deps): update dependency autoprefixer to v10.4.16 2 years ago
Owncast 5920a7cdd9 Bundle embedded web app 2 years ago
renovate[bot] 08b3a07213 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.18 2 years ago
Owncast a2ee4e2e00 Bundle embedded web app 2 years ago
renovate[bot] 28881c9157 fix(deps): update dependency sharp to v0.32.6 (#3320) 2 years ago
Owncast 67e4d4b184 Bundle embedded web app 2 years ago
renovate[bot] 12bff5a1c0 chore(deps): update typescript-eslint monorepo to v6.7.3 2 years ago
Owncast 0d5ae476a0 Bundle embedded web app 2 years ago
renovate[bot] 193f2767eb chore(deps): update dependency @types/prop-types to v15.7.8 2 years ago
Owncast 7a26f39b08 Bundle embedded web app 2 years ago
renovate[bot] 9a6f97f8c9 chore(deps): update dependency @types/node to v18.18.1 2 years ago
Gabe Kangas 31a8e2ce53 Remove install request of chrome for unavailable version 2 years ago
Gabe Kangas fd47475c11 Specify old version of chrome that is not broken 2 years ago
Owncast 86e880a46d Commit screenshots 2 years ago
Gabe Kangas e3384eb6db Get updated version of chrome that doesn't break test 2 years ago
Owncast 5ada2bf240 Commit screenshots 2 years ago
Owncast 8e96b0c496 Commit screenshots 2 years ago
Owncast ebdd9b030b Commit screenshots 2 years ago
Owncast 216ddf6dbb Commit screenshots 2 years ago
Owncast c7a9e660d5 Commit screenshots 2 years ago
Owncast c499862463 Commit screenshots 2 years ago
Owncast 2404223173 Commit screenshots 2 years ago
Owncast b68226f964 Commit screenshots 2 years ago
Owncast e820f9acce Commit screenshots 2 years ago
Owncast 9c8e85b784 Commit screenshots 2 years ago
Owncast e947229797 Commit screenshots 2 years ago
Owncast 8cbb1a83b7 Commit screenshots 2 years ago
renovate[bot] 49ae88ba4d fix(deps): update module github.com/aws/aws-sdk-go to v1.45.14 2 years ago
Owncast fc7f63af66 Bundle embedded web app 2 years ago
janWilejan 933cf1fab1 Add offline option to bundle web.sh (#3202) 2 years ago
Gabe Kangas 3076161d59 chore: rename web package 2 years ago
Tom Funken ef04c1fd86 Renamed rewriteRemotePlaylist (#3313) 2 years ago
Owncast e4a7a337a3 Commit screenshots 2 years ago
renovate[bot] b252f0cd82 chore(deps): update peter-evans/create-or-update-comment digest to 46da6c0 2 years ago
Owncast a941a38377 Commit screenshots 2 years ago
Owncast d280352895 Bundle embedded web app 2 years ago
renovate[bot] 18ca933f62 chore(deps): update dependency @babel/core to v7.22.20 2 years ago
Owncast 42f0e4315c Bundle embedded web app 2 years ago
renovate[bot] 2b5171349e chore(deps): update dependency @types/node to v18.17.17 2 years ago
Owncast 0f65afaba6 Commit screenshots 2 years ago
Owncast d47dfa5823 Bundle embedded web app 2 years ago
renovate[bot] 92eeaca968 chore(deps): lock file maintenance (#3314) 2 years ago
Owncast a86a54717f Bundle embedded web app 2 years ago
renovate[bot] a29549afb5 chore(deps): update dependency @types/node to v18.17.16 2 years ago
Owncast 1b021f6f3d Bundle embedded web app 2 years ago
renovate[bot] 6acbe6ab17 chore(deps): update dependency @storybook/testing-library to v0.2.1 2 years ago
Owncast 59ef3a532b Commit screenshots 2 years ago
Owncast c56906a754 Bundle embedded web app 2 years ago
renovate[bot] 80e919d16f chore(deps): update dependency @babel/core to v7.22.19 2 years ago
Owncast dd1417b735 Bundle embedded web app 2 years ago
renovate[bot] e3106a5e92 chore(deps): update dependency knip to v2.24.1 2 years ago
Owncast 1216af9a21 Bundle embedded web app 2 years ago
renovate[bot] 602681ac39 fix(deps): update dependency @codemirror/lang-markdown to v6.2.1 2 years ago
Owncast 531e9fe2de Bundle embedded web app 2 years ago
renovate[bot] 2d26d43c59 chore(deps): update dependency sass to v1.67.0 2 years ago
Owncast 0ddc812120 Commit screenshots 2 years ago
Owncast 5a8ae659ae Commit screenshots 2 years ago
Owncast 0cb71c4099 Bundle embedded web app 2 years ago
renovate[bot] ea50b4a13f chore(deps): update dependency knip to v2.24.0 2 years ago
Owncast 27b6df3592 Bundle embedded web app 2 years ago
renovate[bot] 18afea5770 chore(deps): update dependency knip to v2.23.0 2 years ago
Owncast 9d32214b8b Commit screenshots 2 years ago
Owncast fabcccdcd8 Bundle embedded web app 2 years ago
dependabot[bot] f19b3ea050 Bump @cypress/request and cypress in /web (#3310) 2 years ago
Owncast 0ef1d54cc8 Bundle embedded web app 2 years ago
renovate[bot] d4b01b1555 chore(deps): update typescript-eslint monorepo to v6.7.0 2 years ago
Owncast b60f74b947 Commit screenshots 2 years ago
renovate[bot] 8208f9f237 chore(deps): update peter-evans/create-or-update-comment digest to 1f6c514 2 years ago
Owncast bd53752192 Commit screenshots 2 years ago
Owncast 42a70b587d Bundle embedded web app 2 years ago
renovate[bot] 2f5cb55c89 fix(deps): update dependency ua-parser-js to v1.0.36 2 years ago
Owncast 18de983d07 Bundle embedded web app 2 years ago
renovate[bot] 1d3d40b85e chore(deps): update dependency eslint to v8.49.0 2 years ago
Owncast f6026e27b4 Bundle embedded web app 2 years ago
renovate[bot] ba4f8fabe9 chore(deps): update dependency @types/node to v18.17.15 2 years ago
Owncast 0562bdf3ee Bundle embedded web app 2 years ago
renovate[bot] aeddf0b073 chore(deps): update dependency @babel/core to v7.22.17 2 years ago
Owncast e74c22fb63 Bundle embedded web app 2 years ago
renovate[bot] 6ad0c78d5c chore(deps): lock file maintenance 2 years ago
Owncast d356c8dc3e Commit screenshots 2 years ago
Owncast f01a5d962a Bundle embedded web app 2 years ago
Tiffany cbbc219091 Handle error thrown in postConfigUpdateToAPI (#3299) 2 years ago
Owncast c815b8ebf4 Bundle embedded web app 2 years ago
Gabe Kangas eb28ceca54 feat(chat): add support for chat part messages. Closes #3201 (#3291) 2 years ago
dependabot[bot] b5a894cff8 Bump fast-xml-parser and artillery in /test/load (#3300) 2 years ago
Owncast 326e693d88 Bundle embedded web app 2 years ago
renovate[bot] 3a98c3c10e chore(deps): update dependency knip to v2.22.0 2 years ago
Owncast cd451a8d11 Commit screenshots 2 years ago
Owncast 4b67e90a9b Bundle embedded web app 2 years ago
renovate[bot] 81ee8327dc fix(deps): update dependency react-virtuoso to v4.5.1 2 years ago
Owncast 844e94710e Bundle embedded web app 2 years ago
renovate[bot] 33b5580467 fix(deps): update dependency antd to v4.24.14 2 years ago
Owncast 092d2ae5c0 Commit screenshots 2 years ago
Owncast 3f09db1445 Bundle embedded web app 2 years ago
renovate[bot] 8d71ae4782 chore(deps): update dependency knip to v2.21.2 2 years ago
Owncast 60ead3fe95 Commit screenshots 2 years ago
Owncast 5fc18107f2 Bundle embedded web app 2 years ago
renovate[bot] b8fd7b1bb0 chore(deps): update typescript-eslint monorepo to v6.6.0 2 years ago
Owncast f832beaf30 Bundle embedded web app 2 years ago
renovate[bot] c32b9018e2 chore(deps): update dependency @types/chart.js to v2.9.38 2 years ago
Owncast 19cdfbd29f Bundle embedded web app 2 years ago
renovate[bot] b7ba3d529a chore(deps): update dependency @babel/core to v7.22.15 2 years ago
renovate[bot] 2cac4c549d fix(deps): update module github.com/cafxx/httpcompression to v0.0.9 2 years ago
Owncast 525e7ababf Commit screenshots 2 years ago
Shreyas 34cdce3f71 Block Private URLs at `serverurl` API endpoint (#3295) 2 years ago
renovate[bot] 541b0981f0 chore(deps): update peter-evans/create-or-update-comment digest to 223779b 2 years ago
Owncast ac4bd0bad0 Commit screenshots 2 years ago
Owncast 947b83de1f Bundle embedded web app 2 years ago
renovate[bot] 099cc02fe6 chore(deps): update dependency @types/node to v18.17.14 2 years ago
renovate[bot] 9b89bf3fe7 chore(deps): update peter-evans/create-or-update-comment digest to 46846e5 2 years ago
Owncast fe30150edd Commit screenshots 2 years ago
renovate[bot] 0f77ac3989 fix(deps): update dependency @uiw/react-codemirror to v4.21.13 2 years ago
Owncast 7b23f2b73a Bundle embedded web app 2 years ago
renovate[bot] d38039069e chore(deps): update dependency @types/node to v18.17.13 2 years ago
Owncast afe5536bf1 Bundle embedded web app 2 years ago
renovate[bot] 175f6f3f10 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.13 2 years ago
renovate[bot] ac1066547d chore(deps): lock file maintenance 2 years ago
Owncast 1a59707852 Commit screenshots 2 years ago
Owncast dfd04ae83b Bundle embedded web app 2 years ago
renovate[bot] dd3814890f chore(deps): update dependency knip to v2.21.1 2 years ago
Owncast 768c090801 Commit screenshots 2 years ago
Owncast b2f2fa93e4 Bundle embedded web app 2 years ago
renovate[bot] 9e18fbf783 chore(deps): update dependency @types/ua-parser-js to v0.7.37 2 years ago
Owncast dfe4f43de2 Bundle embedded web app 2 years ago
renovate[bot] 6682ce3a44 chore(deps): update dependency knip to v2.21.0 2 years ago
Owncast 43573fceb3 Commit screenshots 2 years ago
Owncast a4ef64e66d Bundle embedded web app 2 years ago
renovate[bot] 4704b603b1 fix(deps): update dependency @uiw/react-codemirror to v4.21.12 2 years ago
Owncast ecfa957913 Bundle embedded web app 2 years ago
renovate[bot] eb485d5515 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.12 2 years ago
Owncast 5c83af36f0 Bundle embedded web app 2 years ago
renovate[bot] f7754f2ae9 chore(deps): update dependency prettier to v3.0.3 2 years ago
Owncast 7ba1065dac Commit screenshots 2 years ago
Owncast 77c0a659c3 Bundle embedded web app 2 years ago
renovate[bot] 02098529ee chore(deps): update typescript-eslint monorepo to v6.5.0 2 years ago
Owncast a753c141ee Bundle embedded web app 2 years ago
renovate[bot] 14c9a5a0eb fix(deps): update codemirror 2 years ago
Owncast 1bd57620e7 Bundle embedded web app 2 years ago
renovate[bot] e37df8056e fix(deps): update dependency yaml to v2.3.2 2 years ago
Owncast 604957e972 Bundle embedded web app 2 years ago
renovate[bot] 3b94388198 chore(deps): update dependency knip to v2.20.2 2 years ago
Owncast 3ccc989dd0 Commit screenshots 2 years ago
Owncast 8113862b18 Bundle embedded web app 2 years ago
renovate[bot] 58971ab97c chore(deps): update dependency @types/node to v18.17.12 2 years ago
renovate[bot] 259a53520c chore(deps): update peter-evans/create-or-update-comment digest to 94ff342 (#3287) 2 years ago
renovate[bot] fe753df120 fix(deps): update module github.com/aws/aws-sdk-go to v1.44.334 2 years ago
Owncast 4d1332e9c8 Bundle embedded web app 2 years ago
renovate[bot] b7fbb7fd1f fix(deps): update dependency @codemirror/lang-javascript to v6.2.0 2 years ago
Owncast 70f3bade2f Commit screenshots 2 years ago
Owncast bccd18822d Bundle embedded web app 2 years ago
renovate[bot] 8f627c5060 chore(deps): update dependency knip to v2.20.1 2 years ago
Owncast c6c7e6d32e Bundle embedded web app 2 years ago
renovate[bot] b9c5e2fe47 chore(deps): update dependency @types/markdown-it to v13.0.1 2 years ago
Owncast be1e3d7661 Bundle embedded web app 2 years ago
renovate[bot] 013f9c83bc fix(deps): update dependency @uiw/react-codemirror to v4.21.11 2 years ago
Owncast fdfad42afb Bundle embedded web app 2 years ago
renovate[bot] b218bad10f fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.11 2 years ago
Owncast 6d67ae489c Commit screenshots 2 years ago
Owncast 73bc1c69f2 Bundle embedded web app 2 years ago
renovate[bot] 5ea8a6f659 chore(deps): update dependency eslint to v8.48.0 2 years ago
Owncast b833f94e3e Bundle embedded web app 2 years ago
renovate[bot] 5a0ef12eb2 fix(deps): update dependency @uiw/react-codemirror to v4.21.10 2 years ago
Owncast ce5b131aee Bundle embedded web app 2 years ago
renovate[bot] 20ac8ccbd1 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.21.10 2 years ago
Owncast 5cf84a4368 Bundle embedded web app 2 years ago
renovate[bot] 50de017dfc chore(deps): update dependency chromatic to v6.24.1 2 years ago
Owncast 3f478a8316 Bundle embedded web app 2 years ago
renovate[bot] 476c8a354d chore(deps): update dependency mermaid to v10.4.0 2 years ago
Owncast 0146700701 Bundle embedded web app 2 years ago
renovate[bot] 48459c1e4b chore(deps): update dependency @types/node to v18.17.11 2 years ago
Gabe Kangas 1713852ae1 fix: export correct timestamps on build artifacts. Closes #3282 2 years ago
Owncast 6c0864a74a Commit screenshots 2 years ago
Owncast 73806d7cd2 Bundle embedded web app 2 years ago
renovate[bot] 0508fc7662 fix(deps): update dependency chart.js to v4.4.0 2 years ago
Owncast 5a7de87190 Bundle embedded web app 2 years ago
renovate[bot] 755aa95b2b chore(deps): update dependency chromatic to v6.24.0 2 years ago
Owncast c9f321b45d Bundle embedded web app 2 years ago
renovate[bot] 377f206d15 chore(deps): update dependency @babel/core to v7.22.11 2 years ago
Owncast 3f2eef3977 Commit screenshots 2 years ago
Owncast 56ea57144d Bundle embedded web app 2 years ago
renovate[bot] 21db901ed8 chore(deps): update dependency @types/node to v18.17.9 2 years ago
renovate[bot] 134cc6fc78 chore(deps): update tj-actions/changed-files action to v38 (#3280) 2 years ago
renovate[bot] 12ff3458f6 fix(deps): update module github.com/aws/aws-sdk-go to v1.44.332 2 years ago
Owncast 174d32adbd Bundle embedded web app 2 years ago
renovate[bot] d0bb5d2975 chore(deps): update dependency knip to v2.19.11 2 years ago
Owncast 220702f124 Bundle embedded web app 2 years ago
renovate[bot] 81fa707def chore(deps): update typescript-eslint monorepo to v6.4.1 (#3274) 2 years ago
Owncast bc237de7e1 Bundle embedded web app 2 years ago
renovate[bot] d49fa83716 chore(deps): update dependency chromatic to v6.23.0 (#3276) 2 years ago
Owncast f399cb0fd8 Commit screenshots 2 years ago
Owncast 74d40646f6 Bundle embedded web app 2 years ago
renovate[bot] b2a4869590 chore(deps): update dependency @types/jest to v29.5.4 (#3278) 2 years ago
renovate[bot] 96aecc2cbe chore(deps): update peter-evans/create-or-update-comment digest to 8c21c80 (#3272) 2 years ago
renovate[bot] 2e45ec1a00 chore(deps): update dependency @types/react to v18.2.21 (#3279) 2 years ago
Gabe Kangas 2a2544b42e fix: updates for new linter rules. Closes #3277 2 years ago
Owncast 2dc57bcf42 Bundle embedded web app 2 years ago
renovate[bot] c04618feb0 chore(deps): update dependency @types/node to v18.17.8 (#3275) 2 years ago
Owncast 80e05e2f60 Bundle embedded web app 2 years ago
renovate[bot] dbf65a2d7a chore(deps): update dependency knip to v2.19.9 2 years ago
Owncast b7c6f4a81b Commit screenshots 2 years ago
Owncast d05a92da3d Bundle embedded web app 2 years ago
renovate[bot] 99ee7ea946 chore(deps): update dependency knip to v2.19.8 2 years ago
Owncast c05ced0f55 Commit screenshots 2 years ago
Owncast 690fb26aaf Commit screenshots 2 years ago
Owncast dcddd0c3a7 Bundle embedded web app 2 years ago
renovate[bot] c630419ab0 chore(deps): update dependency sass to v1.66.1 (#3269) 2 years ago
Owncast 68d631da0e Commit screenshots 2 years ago
Owncast 620aaea5ff Bundle embedded web app 2 years ago
renovate[bot] fc9563681e chore(deps): update nextjs monorepo to v13.4.19 2 years ago
Owncast dcf2240b8c Bundle embedded web app 2 years ago
renovate[bot] 3e3d952982 chore(deps): update dependency @types/node to v18.17.6 2 years ago
Owncast 88eafc9501 Bundle embedded web app 2 years ago
renovate[bot] d2379c029f chore(deps): lock file maintenance 2 years ago
Owncast af3e72ca8a Bundle embedded web app 2 years ago
renovate[bot] 1d80f490b4 chore(deps): update nextjs monorepo to v13.4.18 2 years ago
Owncast c159e4a63c Commit screenshots 2 years ago
Owncast f03c6f4b2f Bundle embedded web app 2 years ago
renovate[bot] befc1ec689 chore(deps): update dependency sass to v1.66.0 2 years ago
Gabe Kangas 4fc9247ade chore(ci): fix duplicate runs of bundle step 2 years ago
Owncast 4f4d1f153d Bundle embedded web app 2 years ago
Owncast 6e340e6d98 Bundle embedded web app 2 years ago
renovate[bot] 9014b18ff1 chore(deps): update nextjs monorepo to v13.4.17 2 years ago
Owncast 256caa481b Commit screenshots 2 years ago
renovate[bot] adfbd54457 fix(deps): update module golang.org/x/net to v0.14.0 2 years ago
Owncast db97e4dfd6 Bundle embedded web app 2 years ago
Owncast 72d05bc964 Bundle embedded web app 2 years ago
renovate[bot] f68d90fadf chore(deps): update dependency chromatic to v6.22.0 2 years ago
Owncast 06576746e9 Bundle embedded web app 2 years ago
Owncast 7cc9636e75 Bundle embedded web app 2 years ago
renovate[bot] 2db62608a7 chore(deps): update dependency eslint-plugin-react to v7.33.2 2 years ago
Owncast ebdaa7a657 Bundle embedded web app 2 years ago
Owncast 58310a3f37 Bundle embedded web app 2 years ago
renovate[bot] 41277d48e3 chore(deps): update typescript-eslint monorepo to v6 (#3265) 2 years ago
Owncast 1851148b01 Bundle embedded web app 2 years ago
Gabe Kangas 827b3f6576 chore(ci): cancel other build runs on duplicat 2 years ago
Owncast d4aeb2d461 Bundle embedded web app 2 years ago
Owncast b98f02216e Bundle embedded web app 2 years ago
Owncast 4cf0026915 Bundle embedded web app 2 years ago
renovate[bot] 749fbf217d chore(deps): update dependency mdx-mermaid to v2 (#3264) 2 years ago
renovate[bot] 9e30041d3c chore(deps): update dependency @svgr/webpack to v8 (#3262) 2 years ago
renovate[bot] bbb096a036 chore(deps): update dependency eslint-config-prettier to v9 (#3263) 2 years ago
Owncast 259bd8b988 Bundle embedded web app 2 years ago
Owncast 7ebc9d9eb9 Bundle embedded web app 2 years ago
renovate[bot] 7e6610b1a3 chore(deps): update dependency @types/markdown-it to v13 (#3261) 2 years ago
Owncast 4af4eec456 Commit screenshots 2 years ago
Owncast 71a21d5bd2 Bundle embedded web app 2 years ago
Owncast fd3fd7167d Bundle embedded web app 2 years ago
renovate[bot] b049923a8a chore(deps): lock file maintenance (#3257) 2 years ago
Gabe Kangas a84d8444a4 chore(ci): push_request_target -> push_request 2 years ago
Gabe Kangas 6587b53430 fix(test): select all wasn't working, making the display name typed in too long 2 years ago
renovate[bot] 05c95114ea fix(deps): update module github.com/aws/aws-sdk-go to v1.44.327 2 years ago
Owncast fc4bdb8c64 Bundle embedded web app 2 years ago
Owncast bc162443db Bundle embedded web app 2 years ago
renovate[bot] e9f6da2669 fix(deps): update dependency sharp to v0.32.5 2 years ago
Owncast eb89499b83 Bundle embedded web app 2 years ago
Owncast 2ccc6d0cb3 Bundle embedded web app 2 years ago
renovate[bot] 1e75f41f2a chore(deps): update dependency cypress to v12.17.4 2 years ago
renovate[bot] 7cc992bb2b fix(deps): update module github.com/shirou/gopsutil/v3 to v3.23.7 2 years ago
Owncast f982e83397 Bundle embedded web app 2 years ago
Owncast 981e99c2d6 Bundle embedded web app 2 years ago
renovate[bot] 57eafd3361 chore(deps): update dependency prettier to v3.0.2 2 years ago
renovate[bot] cd8567888f fix(deps): update module github.com/yuin/goldmark to v1.5.6 2 years ago
Owncast c434377123 Bundle embedded web app 2 years ago
Owncast 294a0e592e Bundle embedded web app 2 years ago
renovate[bot] 402ba7d4dc fix(deps): update dependency video.js to v8.5.2 2 years ago
Owncast 84c2246621 Bundle embedded web app 2 years ago
Owncast 1a8c0ab67a Bundle embedded web app 2 years ago
renovate[bot] 622b76a44c fix(deps): update nextjs monorepo to v13.4.16 2 years ago
Owncast f9b048f71b Commit screenshots 2 years ago
Owncast 6659eb1fc6 Bundle embedded web app 2 years ago
Owncast 5d1c7d0a41 Bundle embedded web app 2 years ago
Gabe Kangas 10bffb49fe fix: add spacing between user badges. Closes #3247 2 years ago
Gabe Kangas eda6839331 chore: manually build web project 2 years ago
Owncast 92e328f9ca Bundle embedded web app 2 years ago
Owncast 812ee5e8fa Bundle embedded web app 2 years ago
renovate[bot] 18ac45a12e fix(deps): update nextjs monorepo to v13.4.15 2 years ago
Owncast 8711d9cc05 Commit screenshots 2 years ago
Gabe Kangas b3a76feee5
Fix tests 2 years ago
Gabe Kangas 655ea319b9
Send StreamID in webhooks 2 years ago
Nick Wallace d00b435269 Updating Dockerfile to point to the correct repo 2 years ago
Gabe Kangas ec2b5103e5
Merge branch 'develop' into gek/segment-tracking-persistence 2 years ago
Gabe Kangas d947c4b4a4
WIP replay integration tests 2 years ago
Gabe Kangas 8021c66869
feat(api): put replay APIs behind feature flag 2 years ago
Gabe Kangas adb67e79b3
chore(tests): more clip validation tests 2 years ago
Gabe Kangas ea376477b6
chore(tests): add first couple replay functionality tests 2 years ago
Gabe Kangas 065e779e99
fix: message does not need to be logged to console 2 years ago
Gabe Kangas 1da7dc92dd
fix(video): create more vide-related paths taking the streamid into account 2 years ago
Gabe Kangas 66d4bb2321
fix(video): fix local storage cleanup job to use new stream id 2 years ago
Gabe Kangas 6aacd7bfce
feat: attempt to fix any streams marked as unended 2 years ago
Gabe Kangas 5555dc1751
feat: add enableReplayFeatures cli flag 2 years ago
Gabe Kangas 96fb5ebb82
feat(video): first pass at adding clip functionality 2 years ago
Gabe Kangas e1b9c160c9
fix: some small cleanup 2 years ago
Gabe Kangas 4ba36c17a3
feat(video): first pass at replay functionality 2 years ago
Gabe Kangas 08f8149b63
feat(storage): add support for custom object storage path prefix 2 years ago
Gabe Kangas 9020c874f4
Merge branch 'owncast:develop' into develop 2 years ago
Gabe Kangas f02f4fce88
Merge pull request #1 from FrontRowXP/gek/object-storage-path-prefix 2 years ago
Gabe Kangas 25710d5053
feat(storage): add support for custom object storage path prefix 2 years ago
  1. 3
      config/config.go
  2. 5
      controllers/admin/config.go
  3. 167
      controllers/clips.go
  4. 84
      controllers/replays.go
  5. 12
      core/core.go
  6. 1
      core/data/data.go
  7. 64
      core/data/replays.go
  8. 13
      core/offlineState.go
  9. 63
      core/storageproviders/local.go
  10. 35
      core/storageproviders/rewriteLocalPlaylist.go
  11. 69
      core/storageproviders/s3Storage.go
  12. 33
      core/streamState.go
  13. 7
      core/transcoder/fileWriterReceiverService.go
  14. 33
      core/transcoder/hlsHandler.go
  15. 36
      core/transcoder/transcoder.go
  16. 5
      core/transcoder/transcoder_nvenc_test.go
  17. 5
      core/transcoder/transcoder_omx_test.go
  18. 5
      core/transcoder/transcoder_vaapi_test.go
  19. 5
      core/transcoder/transcoder_videotoolbox_test.go
  20. 5
      core/transcoder/transcoder_x264_test.go
  21. 6
      core/transcoder/utils.go
  22. 26
      core/video.go
  23. 7
      core/webhooks/stream.go
  24. 5
      core/webhooks/stream_test.go
  25. 2
      db/db.go
  26. 41
      db/models.go
  27. 58
      db/query.sql
  28. 491
      db/query.sql.go
  29. 58
      db/schema.sql
  30. 3
      main.go
  31. 1
      models/currentBroadcast.go
  32. 63
      models/flexibledate.go
  33. 42
      models/flexibledate_test.go
  34. 7
      models/storageProvider.go
  35. 141
      replays/clips.go
  36. 122
      replays/hlsRecorder.go
  37. 13
      replays/hlsSegment.go
  38. 49
      replays/mediaPlaylistAllowCacheTag.go
  39. 27
      replays/outputConfiguration.go
  40. 146
      replays/playlistGenerator.go
  41. 136
      replays/playlistGenerator_test.go
  42. 154
      replays/replay_test.go
  43. 21
      replays/replays.go
  44. 6
      replays/storageProvider.go
  45. 41
      replays/stream.go
  46. 142
      replays/streamClipPlaylistGenerator.go
  47. 129
      replays/streamReplayPlaylistGenerator.go
  48. 9
      router/router.go
  49. 1
      static/web/_next/static/QNBMvcxExtLzshYBDiHgr/_buildManifest.js
  50. 1
      static/web/_next/static/QNBMvcxExtLzshYBDiHgr/_ssgManifest.js
  51. 35
      static/web/_next/static/chunks/5888-0b1d0f03187c2ed1.js
  52. 10985
      test/automated/replays/package-lock.json
  53. 18
      test/automated/replays/package.json
  54. 130
      test/automated/replays/replays.test.js
  55. 19
      test/automated/replays/run.sh
  56. 121
      test/automated/tools.sh
  57. 33
      utils/utils.go

3
config/config.go

@ -43,6 +43,9 @@ var EnableAutoUpdate = false
// A temporary stream key that can be set via the command line. // A temporary stream key that can be set via the command line.
var TemporaryStreamKey = "" var TemporaryStreamKey = ""
// EnableReplayFeatures will enable replay features.
var EnableReplayFeatures = true
// GetCommit will return an identifier used for identifying the point in time this build took place. // GetCommit will return an identifier used for identifying the point in time this build took place.
func GetCommit() string { func GetCommit() string {
if GitCommit == "" { if GitCommit == "" {

5
controllers/admin/config.go

@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/outbox" "github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/user"
@ -76,8 +77,10 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
return return
} }
if value != "" { if value != "" {
streamID := core.GetCurrentBroadcast().StreamID
sendSystemChatAction(fmt.Sprintf("Stream title changed to **%s**", value), true) sendSystemChatAction(fmt.Sprintf("Stream title changed to **%s**", value), true)
go webhooks.SendStreamStatusEvent(models.StreamTitleUpdated) go webhooks.SendStreamStatusEvent(models.StreamTitleUpdated, streamID)
} }
controllers.WriteSimpleResponse(w, true, "changed") controllers.WriteSimpleResponse(w, true, "changed")
} }

167
controllers/clips.go

@ -0,0 +1,167 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/replays"
log "github.com/sirupsen/logrus"
)
// GetAllClips will return all clips that have been previously created.
func GetAllClips(w http.ResponseWriter, r *http.Request) {
if !config.EnableReplayFeatures {
w.WriteHeader(http.StatusNotFound)
return
}
clips, err := replays.GetAllClips()
if err != nil {
log.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
WriteResponse(w, clips)
}
// AddClip will create a new clip for a given stream and time window.
func AddClip(w http.ResponseWriter, r *http.Request) {
if !config.EnableReplayFeatures {
w.WriteHeader(http.StatusNotFound)
return
}
type addClipRequest struct {
StreamId string `json:"streamId"`
ClipTitle string `json:"clipTitle"`
RelativeStartTimeSeconds float32 `json:"relativeStartTimeSeconds"`
RelativeEndTimeSeconds float32 `json:"relativeEndTimeSeconds"`
}
if r.Method != http.MethodPost {
BadRequestHandler(w, nil)
return
}
decoder := json.NewDecoder(r.Body)
var request addClipRequest
if request.RelativeEndTimeSeconds < request.RelativeStartTimeSeconds {
BadRequestHandler(w, errors.New("end time must be after start time"))
return
}
if err := decoder.Decode(&request); err != nil {
log.Errorln(err)
WriteSimpleResponse(w, false, "unable to create clip")
return
}
streamId := request.StreamId
clipTitle := request.ClipTitle
startTime := request.RelativeStartTimeSeconds
endTime := request.RelativeEndTimeSeconds
// Some validation
playlistGenerator := replays.NewPlaylistGenerator()
stream, err := playlistGenerator.GetStream(streamId)
if err != nil {
BadRequestHandler(w, errors.New("stream not found"))
return
}
if stream.StartTime.IsZero() {
BadRequestHandler(w, errors.New("stream start time not found"))
return
}
// Make sure the proposed clip start time and end time are within
// the start and end time of the stream.
finalSegment, err := replays.GetFinalSegmentForStream(streamId)
if err != nil {
InternalErrorHandler(w, err)
return
}
if finalSegment.RelativeTimestamp < startTime {
BadRequestHandler(w, errors.New("start time is after the known end of the stream"))
return
}
clipId, duration, err := replays.AddClipForStream(streamId, clipTitle, "", startTime, endTime)
if err != nil {
log.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
WriteSimpleResponse(w, true, "clip "+clipId+" created with duration of "+fmt.Sprint(duration)+" seconds")
}
// GetClip will return playable content for a given clip Id.
func GetClip(w http.ResponseWriter, r *http.Request) {
if !config.EnableReplayFeatures {
w.WriteHeader(http.StatusNotFound)
return
}
pathComponents := strings.Split(r.URL.Path, "/")
if len(pathComponents) == 3 {
// Return the master playlist for the requested stream
clipId := pathComponents[2]
getClipMasterPlaylist(clipId, w)
return
} else if len(pathComponents) == 4 {
// Return the media playlist for the requested stream and output config
clipId := pathComponents[2]
outputConfigId := pathComponents[3]
getClipMediaPlaylist(clipId, outputConfigId, w)
return
}
BadRequestHandler(w, nil)
}
// getReplayMasterPlaylist will return a complete replay of a stream
// as a HLS playlist.
func getClipMasterPlaylist(clipId string, w http.ResponseWriter) {
playlistGenerator := replays.NewPlaylistGenerator()
playlist, err := playlistGenerator.GenerateMasterPlaylistForClip(clipId)
if err != nil {
log.Println(err)
}
if playlist == nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Add("Content-Type", "application/x-mpegURL")
if _, err := w.Write(playlist.Encode().Bytes()); err != nil {
log.Errorln(err)
return
}
}
// getClipMediaPlaylist will return media playlist for a given clip
// and stream output configuration.
func getClipMediaPlaylist(clipId, outputConfigId string, w http.ResponseWriter) {
playlistGenerator := replays.NewPlaylistGenerator()
playlist, err := playlistGenerator.GenerateMediaPlaylistForClipAndConfiguration(clipId, outputConfigId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/x-mpegURL")
if _, err := w.Write(playlist.Encode().Bytes()); err != nil {
log.Errorln(err)
return
}
}

84
controllers/replays.go

@ -0,0 +1,84 @@
package controllers
import (
"net/http"
"strings"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/replays"
log "github.com/sirupsen/logrus"
)
// GetReplays will return a list of all available replays.
func GetReplays(w http.ResponseWriter, r *http.Request) {
if !config.EnableReplayFeatures {
w.WriteHeader(http.StatusNotFound)
return
}
streams, err := replays.GetStreams()
if err != nil {
log.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
WriteResponse(w, streams)
}
// GetReplay will return a playable content for a given stream Id.
func GetReplay(w http.ResponseWriter, r *http.Request) {
pathComponents := strings.Split(r.URL.Path, "/")
if len(pathComponents) == 3 {
// Return the master playlist for the requested stream
streamId := pathComponents[2]
getReplayMasterPlaylist(streamId, w)
return
} else if len(pathComponents) == 4 {
// Return the media playlist for the requested stream and output config
streamId := pathComponents[2]
outputConfigId := pathComponents[3]
getReplayMediaPlaylist(streamId, outputConfigId, w)
return
}
BadRequestHandler(w, nil)
}
// getReplayMasterPlaylist will return a complete replay of a stream as a HLS playlist.
// /api/replay/{streamId}.
func getReplayMasterPlaylist(streamId string, w http.ResponseWriter) {
playlistGenerator := replays.NewPlaylistGenerator()
playlist, err := playlistGenerator.GenerateMasterPlaylistForStream(streamId)
if err != nil {
log.Println(err)
}
if playlist == nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Add("Content-Type", "application/x-mpegURL")
if _, err := w.Write(playlist.Encode().Bytes()); err != nil {
log.Errorln(err)
return
}
}
// getReplayMediaPlaylist will return a media playlist for a given stream.
// /api/replay/{streamId}/{outputConfigId}.
func getReplayMediaPlaylist(streamId, outputConfigId string, w http.ResponseWriter) {
playlistGenerator := replays.NewPlaylistGenerator()
playlist, err := playlistGenerator.GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/x-mpegURL")
if _, err := w.Write(playlist.Encode().Bytes()); err != nil {
log.Errorln(err)
return
}
}

12
core/core.go

@ -17,6 +17,7 @@ import (
"github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/notifications" "github.com/owncast/owncast/notifications"
"github.com/owncast/owncast/replays"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
"github.com/owncast/owncast/yp" "github.com/owncast/owncast/yp"
) )
@ -58,6 +59,7 @@ func Start() error {
user.SetupUsers() user.SetupUsers()
auth.Setup(data.GetDatastore()) auth.Setup(data.GetDatastore())
replays.Setup()
fileWriter.SetupFileWriterReceiverService(&handler) fileWriter.SetupFileWriterReceiverService(&handler)
@ -99,8 +101,12 @@ func createInitialOfflineState() error {
func transitionToOfflineVideoStreamContent() { func transitionToOfflineVideoStreamContent() {
log.Traceln("Firing transcoder with offline stream state") log.Traceln("Firing transcoder with offline stream state")
_transcoder := transcoder.NewTranscoder() streamId := "offline"
_transcoder.SetIdentifier("offline") _storage.SetStreamId(streamId)
fileWriter.SetStreamID(streamId)
handler.SetStreamId(streamId)
_transcoder := transcoder.NewTranscoder(streamId)
_transcoder.SetLatencyLevel(models.GetLatencyLevel(4)) _transcoder.SetLatencyLevel(models.GetLatencyLevel(4))
_transcoder.SetIsEvent(true) _transcoder.SetIsEvent(true)
@ -127,7 +133,7 @@ func resetDirectories() {
log.Trace("Resetting file directories to a clean slate.") log.Trace("Resetting file directories to a clean slate.")
// Wipe hls data directory // Wipe hls data directory
utils.CleanupDirectory(config.HLSStoragePath) utils.CleanupDirectory(config.HLSStoragePath, config.EnableReplayFeatures)
// Remove the previous thumbnail // Remove the previous thumbnail
logo := data.GetLogoPath() logo := data.GetLogoPath()

1
core/data/data.go

@ -76,6 +76,7 @@ func SetupPersistence(file string) error {
createWebhooksTable() createWebhooksTable()
createUsersTable(db) createUsersTable(db)
createAccessTokenTable(db) createAccessTokenTable(db)
createRecordingTables(db)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config ( if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (
"key" string NOT NULL PRIMARY KEY, "key" string NOT NULL PRIMARY KEY,

64
core/data/replays.go

@ -0,0 +1,64 @@
package data
import (
"database/sql"
)
func createRecordingTables(db *sql.DB) {
createSegmentsTableSQL := `CREATE TABLE IF NOT EXISTS video_segments (
"id" string NOT NULL,
"stream_id" string NOT NULL,
"output_configuration_id" string NOT NULL,
"path" TEXT NOT NULL,
"relative_timestamp" REAL NOT NULL,
"timestamp" DATETIME,
PRIMARY KEY (id)
);CREATE INDEX video_segments_stream_id ON video_segments (stream_id);CREATE INDEX video_segments_stream_id_timestamp ON video_segments (stream_id,timestamp);`
createVideoOutputConfigsTableSQL := `CREATE TABLE IF NOT EXISTS video_segment_output_configuration (
"id" string NOT NULL,
"variant_id" string NOT NULL,
"name" string NOT NULL,
"stream_id" string NOT NULL,
"segment_duration" INTEGER NOT NULL,
"bitrate" INTEGER NOT NULL,
"framerate" INTEGER NOT NULL,
"resolution_width" INTEGER,
"resolution_height" INTEGER,
"timestamp" DATETIME,
PRIMARY KEY (id)
);CREATE INDEX video_segment_output_configuration_stream_id ON video_segment_output_configuration (stream_id);`
createVideoStreamsTableSQL := `CREATE TABLE IF NOT EXISTS streams (
"id" string NOT NULL,
"stream_title" TEXT,
"start_time" DATETIME,
"end_time" DATETIME,
PRIMARY KEY (id)
);
CREATE INDEX streams_id ON streams (id);
CREATE INDEX streams_start_time ON streams (start_time);
CREATE INDEX streams_start_end_time ON streams (start_time,end_time);
`
createClipsTableSQL := `CREATE TABLE IF NOT EXISTS replay_clips (
"id" string NOT NULL,
"stream_id" string NOT NULL,
"clipped_by" string,
"clip_title" TEXT,
"relative_start_time" REAL,
"relative_end_time" REAL,
"timestamp" DATETIME,
PRIMARY KEY (id),
FOREIGN KEY(stream_id) REFERENCES streams(id)
);
CREATE INDEX clip_id ON replay_clips (id);
CREATE INDEX clip_stream_id ON replay_clips (stream_id);
CREATE INDEX clip_start_end_time ON replay_clips (start_time,end_time);
`
MustExec(createSegmentsTableSQL, db)
MustExec(createVideoOutputConfigsTableSQL, db)
MustExec(createVideoStreamsTableSQL, db)
MustExec(createClipsTableSQL, db)
}

13
core/offlineState.go

@ -48,15 +48,16 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string) {
} }
} }
func makeVariantIndexOffline(index int, offlineFilePath string, offlineFilename string) { func makeVariantIndexOffline(streamId string, index int, offlineFilePath string, offlineFilename string) {
playlistFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, "%d/stream.m3u8"), index) playlistFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, streamId, "%d/stream.m3u8"), index)
segmentFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, "%d/%s"), index, offlineFilename) // segmentFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, streamId, "%d/%s"), index, offlineFilename)
segmentFileDestinationPath := fmt.Sprintf(filepath.Join("hls", streamId, "%d/%s"), index, offlineFilename)
if err := utils.Copy(offlineFilePath, segmentFilePath); err != nil { if err := utils.Copy(offlineFilePath, offlineFilePath); err != nil {
log.Warnln(err) log.Warnln(err)
} }
if _, err := _storage.Save(segmentFilePath, 0); err != nil { if _, err := _storage.Save(offlineFilePath, segmentFileDestinationPath, 0); err != nil {
log.Warnln(err) log.Warnln(err)
} }
@ -65,7 +66,7 @@ func makeVariantIndexOffline(index int, offlineFilePath string, offlineFilename
} else { } else {
createEmptyOfflinePlaylist(playlistFilePath, offlineFilename) createEmptyOfflinePlaylist(playlistFilePath, offlineFilename)
} }
if _, err := _storage.Save(playlistFilePath, 0); err != nil { if _, err := _storage.Save(playlistFilePath, playlistFilePath, 0); err != nil {
log.Warnln(err) log.Warnln(err)
} }
} }

63
core/storageproviders/local.go

@ -10,10 +10,12 @@ import (
"github.com/owncast/owncast/config" "github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
) )
// LocalStorage represents an instance of the local storage provider for HLS video. // LocalStorage represents an instance of the local storage provider for HLS video.
type LocalStorage struct { type LocalStorage struct {
streamID string
host string host string
} }
@ -22,6 +24,11 @@ func NewLocalStorage() *LocalStorage {
return &LocalStorage{} return &LocalStorage{}
} }
// SetStreamId sets the stream id for this storage provider.
func (s *LocalStorage) SetStreamId(streamID string) {
s.streamID = streamID
}
// Setup configures this storage provider. // Setup configures this storage provider.
func (s *LocalStorage) Setup() error { func (s *LocalStorage) Setup() error {
s.host = data.GetVideoServingEndpoint() s.host = data.GetVideoServingEndpoint()
@ -29,15 +36,27 @@ func (s *LocalStorage) Setup() error {
} }
// SegmentWritten is called when a single segment of video is written. // SegmentWritten is called when a single segment of video is written.
func (s *LocalStorage) SegmentWritten(localFilePath string) { func (s *LocalStorage) SegmentWritten(localFilePath string) (string, int, error) {
if _, err := s.Save(localFilePath, 0); err != nil { if s.streamID == "" {
log.Fatalln("stream id must be set when handling video segments")
}
destinationPath, err := s.Save(localFilePath, localFilePath, 0)
if err != nil {
log.Warnln(err) log.Warnln(err)
return "", 0, err
} }
return destinationPath, 0, nil
} }
// VariantPlaylistWritten is called when a variant hls playlist is written. // VariantPlaylistWritten is called when a variant hls playlist is written.
func (s *LocalStorage) VariantPlaylistWritten(localFilePath string) { func (s *LocalStorage) VariantPlaylistWritten(localFilePath string) {
if _, err := s.Save(localFilePath, 0); err != nil { if s.streamID == "" {
log.Fatalln("stream id must be set when handling video playlists")
}
if _, err := s.Save(localFilePath, localFilePath, 0); err != nil {
log.Errorln(err) log.Errorln(err)
return return
} }
@ -45,28 +64,38 @@ func (s *LocalStorage) VariantPlaylistWritten(localFilePath string) {
// MasterPlaylistWritten is called when the master hls playlist is written. // MasterPlaylistWritten is called when the master hls playlist is written.
func (s *LocalStorage) MasterPlaylistWritten(localFilePath string) { func (s *LocalStorage) MasterPlaylistWritten(localFilePath string) {
// If we're using a remote serving endpoint, we need to rewrite the master playlist if s.streamID == "" {
if s.host != "" { log.Fatalln("stream id must be set when handling video playlists")
if err := rewritePlaylistLocations(localFilePath, s.host, ""); err != nil { }
log.Warnln(err)
} masterPlaylistDestinationLocation := filepath.Join(config.HLSStoragePath, "/stream.m3u8")
} else { if err := rewriteLocalPlaylist(localFilePath, s.streamID, masterPlaylistDestinationLocation); err != nil {
if _, err := s.Save(localFilePath, 0); err != nil { log.Errorln(err)
log.Warnln(err) return
}
} }
} }
// Save will save a local filepath using the storage provider. // Save will save a local filepath using the storage provider.
func (s *LocalStorage) Save(filePath string, retryCount int) (string, error) { func (s *LocalStorage) Save(filePath, destinationPath string, retryCount int) (string, error) {
return filePath, nil if filePath != destinationPath {
if err := utils.Move(filePath, destinationPath); err != nil {
return "", errors.Wrap(err, "unable to move file")
}
}
return destinationPath, nil
} }
func (s *LocalStorage) Cleanup() error { func (s *LocalStorage) Cleanup() error {
// If we're recording, don't perform the cleanup.
if config.EnableReplayFeatures {
return nil
}
// Determine how many files we should keep on disk // Determine how many files we should keep on disk
maxNumber := data.GetStreamLatencyLevel().SegmentCount maxNumber := data.GetStreamLatencyLevel().SegmentCount
buffer := 10 buffer := 10
baseDirectory := config.HLSStoragePath baseDirectory := filepath.Join(config.HLSStoragePath, s.streamID)
files, err := getAllFilesRecursive(baseDirectory) files, err := getAllFilesRecursive(baseDirectory)
if err != nil { if err != nil {
@ -94,6 +123,10 @@ func (s *LocalStorage) Cleanup() error {
return nil return nil
} }
func (s *LocalStorage) GetRemoteDestinationPathFromLocalFilePath(localFilePath string) string {
return localFilePath
}
func getAllFilesRecursive(baseDirectory string) (map[string][]os.FileInfo, error) { func getAllFilesRecursive(baseDirectory string) (map[string][]os.FileInfo, error) {
files := make(map[string][]os.FileInfo) files := make(map[string][]os.FileInfo)

35
core/storageproviders/rewriteLocalPlaylist.go

@ -25,14 +25,7 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s
} }
for _, item := range p.Variants { for _, item := range p.Variants {
// Determine the final path to this playlist. item.URI = filepath.Join(remoteServingEndpoint, pathPrefix, item.URI)
var finalPath string
if pathPrefix != "" {
finalPath = filepath.Join(pathPrefix, "/hls")
} else {
finalPath = "/hls"
}
item.URI = remoteServingEndpoint + filepath.Join(finalPath, item.URI)
} }
publicPath := filepath.Join(config.HLSStoragePath, filepath.Base(localFilePath)) publicPath := filepath.Join(config.HLSStoragePath, filepath.Base(localFilePath))
@ -41,3 +34,29 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s
return playlist.WritePlaylist(newPlaylist, publicPath) return playlist.WritePlaylist(newPlaylist, publicPath)
} }
// rewriteLocalPlaylist will take a local master playlist and rewrite it to
// refer to the path that includes the stream ID.
func rewriteLocalPlaylist(localFilePath, streamID, destinationPath string) error {
f, err := os.Open(localFilePath) // nolint
if err != nil {
log.Fatalln(err)
}
p := m3u8.NewMasterPlaylist()
if err := p.DecodeFrom(bufio.NewReader(f), false); err != nil {
log.Warnln(err)
}
if streamID == "" {
log.Fatalln("stream id must be set when rewriting playlist contents")
}
for _, item := range p.Variants {
item.URI = filepath.Join("/hls", streamID, item.URI)
}
newPlaylist := p.String()
return playlist.WritePlaylist(newPlaylist, destinationPath)
}

69
core/storageproviders/s3Storage.go

@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -20,12 +21,11 @@ import (
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager" "github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/owncast/owncast/config"
) )
// S3Storage is the s3 implementation of a storage provider. // S3Storage is the s3 implementation of a storage provider.
type S3Storage struct { type S3Storage struct {
streamId string
sess *session.Session sess *session.Session
s3Client *s3.S3 s3Client *s3.S3
@ -56,6 +56,11 @@ func NewS3Storage() *S3Storage {
} }
} }
// SetStreamID sets the stream id for this storage provider.
func (s *S3Storage) SetStreamId(streamId string) {
s.streamId = streamId
}
// Setup sets up the s3 storage for saving the video to s3. // Setup sets up the s3 storage for saving the video to s3.
func (s *S3Storage) Setup() error { func (s *S3Storage) Setup() error {
log.Trace("Setting up S3 for external storage of video...") log.Trace("Setting up S3 for external storage of video...")
@ -88,16 +93,19 @@ func (s *S3Storage) Setup() error {
} }
// SegmentWritten is called when a single segment of video is written. // SegmentWritten is called when a single segment of video is written.
func (s *S3Storage) SegmentWritten(localFilePath string) { func (s *S3Storage) SegmentWritten(localFilePath string) (string, int, error) {
index := utils.GetIndexFromFilePath(localFilePath) index := utils.GetIndexFromFilePath(localFilePath)
performanceMonitorKey := "s3upload-" + index performanceMonitorKey := "s3upload-" + index
utils.StartPerformanceMonitor(performanceMonitorKey) utils.StartPerformanceMonitor(performanceMonitorKey)
// Upload the segment // Upload the segment
if _, err := s.Save(localFilePath, 0); err != nil { remoteDestinationPath := s.GetRemoteDestinationPathFromLocalFilePath(localFilePath)
remotePath, err := s.Save(localFilePath, remoteDestinationPath, 0)
if err != nil {
log.Errorln(err) log.Errorln(err)
return return "", 0, err
} }
averagePerformance := utils.GetAveragePerformance(performanceMonitorKey) averagePerformance := utils.GetAveragePerformance(performanceMonitorKey)
// Warn the user about long-running save operations // Warn the user about long-running save operations
@ -111,14 +119,17 @@ func (s *S3Storage) SegmentWritten(localFilePath string) {
// so the segments and the HLS playlist referencing // so the segments and the HLS playlist referencing
// them are in sync. // them are in sync.
playlistPath := filepath.Join(filepath.Dir(localFilePath), "stream.m3u8") playlistPath := filepath.Join(filepath.Dir(localFilePath), "stream.m3u8")
playlistRemoteDestinationPath := s.GetRemoteDestinationPathFromLocalFilePath(playlistPath)
if _, err := s.Save(playlistPath, 0); err != nil { if _, err := s.Save(playlistPath, playlistRemoteDestinationPath, 0); err != nil {
s.queuedPlaylistUpdates[playlistPath] = playlistPath s.queuedPlaylistUpdates[playlistPath] = playlistPath
if pErr, ok := err.(*os.PathError); ok { if pErr, ok := err.(*os.PathError); ok {
log.Debugln(pErr.Path, "does not yet exist locally when trying to upload to S3 storage.") log.Debugln(pErr.Path, "does not yet exist locally when trying to upload to S3 storage.")
return return remotePath, 0, pErr.Err
} }
} }
return remotePath, 0, nil
} }
// VariantPlaylistWritten is called when a variant hls playlist is written. // VariantPlaylistWritten is called when a variant hls playlist is written.
@ -127,7 +138,8 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) {
// to make sure we're not referring to files in a playlist that don't // to make sure we're not referring to files in a playlist that don't
// yet exist. See SegmentWritten. // yet exist. See SegmentWritten.
if _, ok := s.queuedPlaylistUpdates[localFilePath]; ok { if _, ok := s.queuedPlaylistUpdates[localFilePath]; ok {
if _, err := s.Save(localFilePath, 0); err != nil { remoteDestinationPath := s.GetRemoteDestinationPathFromLocalFilePath(localFilePath)
if _, err := s.Save(localFilePath, remoteDestinationPath, 0); err != nil {
log.Errorln(err) log.Errorln(err)
s.queuedPlaylistUpdates[localFilePath] = localFilePath s.queuedPlaylistUpdates[localFilePath] = localFilePath
} }
@ -138,21 +150,25 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) {
// MasterPlaylistWritten is called when the master hls playlist is written. // MasterPlaylistWritten is called when the master hls playlist is written.
func (s *S3Storage) MasterPlaylistWritten(localFilePath string) { func (s *S3Storage) MasterPlaylistWritten(localFilePath string) {
// Rewrite the playlist to use absolute remote S3 URLs // Rewrite the playlist to use absolute remote S3 URLs
if err := rewritePlaylistLocations(localFilePath, s.host, s.s3PathPrefix); err != nil { pathPrefix := filepath.Join("hls", s.streamId)
if s.s3PathPrefix != "" {
pathPrefix = filepath.Join(s.s3PathPrefix, pathPrefix)
}
if err := rewriteRemotePlaylist(localFilePath, s.host, pathPrefix); err != nil {
log.Warnln(err) log.Warnln(err)
} }
} }
// Save saves the file to the s3 bucket. // Save saves the file to the s3 bucket.
func (s *S3Storage) Save(filePath string, retryCount int) (string, error) { func (s *S3Storage) Save(localFilePath, remoteDestinationPath string, retryCount int) (string, error) {
file, err := os.Open(filePath) // nolint file, err := os.Open(localFilePath) // nolint
if err != nil { if err != nil {
return "", err return "", err
} }
defer file.Close() defer file.Close()
// Convert the local path to the variant/file path by stripping the local storage location. // Convert the local path to the variant/file path by stripping the local storage location.
normalizedPath := strings.TrimPrefix(filePath, config.HLSStoragePath) normalizedPath := strings.TrimPrefix(localFilePath, config.HLSStoragePath)
// Build the remote path by adding the "hls" path prefix. // Build the remote path by adding the "hls" path prefix.
remotePath := strings.Join([]string{"hls", normalizedPath}, "") remotePath := strings.Join([]string{"hls", normalizedPath}, "")
@ -162,7 +178,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
remotePath = strings.Join([]string{prefix, remotePath}, "/") remotePath = strings.Join([]string{prefix, remotePath}, "/")
} }
maxAgeSeconds := utils.GetCacheDurationSecondsForPath(filePath) maxAgeSeconds := utils.GetCacheDurationSecondsForPath(localFilePath)
cacheControlHeader := fmt.Sprintf("max-age=%d", maxAgeSeconds) cacheControlHeader := fmt.Sprintf("max-age=%d", maxAgeSeconds)
uploadInput := &s3manager.UploadInput{ uploadInput := &s3manager.UploadInput{
@ -172,7 +188,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
CacheControl: &cacheControlHeader, CacheControl: &cacheControlHeader,
} }
if path.Ext(filePath) == ".m3u8" { if path.Ext(localFilePath) == ".m3u8" {
noCacheHeader := "no-cache, no-store, must-revalidate" noCacheHeader := "no-cache, no-store, must-revalidate"
contentType := "application/x-mpegURL" contentType := "application/x-mpegURL"
@ -192,22 +208,27 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
log.Traceln("error uploading segment", err.Error()) log.Traceln("error uploading segment", err.Error())
if retryCount < 4 { if retryCount < 4 {
log.Traceln("Retrying...") log.Traceln("Retrying...")
return s.Save(filePath, retryCount+1) return s.Save(localFilePath, remoteDestinationPath, retryCount+1)
} }
// Upload failure. Remove the local file. // Upload failure. Remove the local file.
s.removeLocalFile(filePath) s.removeLocalFile(localFilePath)
return "", fmt.Errorf("Giving up uploading %s to object storage %s", filePath, s.s3Endpoint) return "", fmt.Errorf("Giving up uploading %s to object storage %s", localFilePath, s.s3Endpoint)
} }
// Upload success. Remove the local file. // Upload success. Remove the local file.
s.removeLocalFile(filePath) s.removeLocalFile(localFilePath)
return response.Location, nil return response.Location, nil
} }
func (s *S3Storage) Cleanup() error { func (s *S3Storage) Cleanup() error {
// If we're recording, don't perform the cleanup.
if config.EnableReplayFeatures {
return nil
}
// Determine how many files we should keep on S3 storage // Determine how many files we should keep on S3 storage
maxNumber := data.GetStreamLatencyLevel().SegmentCount maxNumber := data.GetStreamLatencyLevel().SegmentCount
buffer := 20 buffer := 20
@ -273,7 +294,7 @@ func (s *S3Storage) removeLocalFile(filePath string) {
cleanFilepath := filepath.Clean(filePath) cleanFilepath := filepath.Clean(filePath)
if err := os.Remove(cleanFilepath); err != nil { if err := os.Remove(cleanFilepath); err != nil {
log.Errorln(err) log.Debugln(err)
} }
} }
@ -331,6 +352,16 @@ func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) {
return allObjects, nil return allObjects, nil
} }
func (s *S3Storage) GetRemoteDestinationPathFromLocalFilePath(localFilePath string) string {
// Convert the local path to the variant/file path by stripping the local storage location.
normalizedPath := strings.TrimPrefix(localFilePath, config.HLSStoragePath)
// Build the remote path by adding the "hls" path prefix.
remoteDestionationPath := strings.Join([]string{"hls", normalizedPath}, "")
return remoteDestionationPath
}
type s3object struct { type s3object struct {
lastModified time.Time lastModified time.Time
key string key string

33
core/streamState.go

@ -3,9 +3,11 @@ package core
import ( import (
"context" "context"
"io" "io"
"path/filepath"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
"github.com/owncast/owncast/activitypub" "github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/config" "github.com/owncast/owncast/config"
@ -39,36 +41,34 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
_stats.LastConnectTime = &now _stats.LastConnectTime = &now
_stats.SessionMaxViewerCount = 0 _stats.SessionMaxViewerCount = 0
streamId := shortid.MustGenerate()
fileWriter.SetStreamID(streamId)
_currentBroadcast = &models.CurrentBroadcast{ _currentBroadcast = &models.CurrentBroadcast{
StreamID: streamId,
LatencyLevel: data.GetStreamLatencyLevel(), LatencyLevel: data.GetStreamLatencyLevel(),
OutputSettings: data.GetStreamOutputVariants(), OutputSettings: data.GetStreamOutputVariants(),
} }
StopOfflineCleanupTimer() StopOfflineCleanupTimer()
startOnlineCleanupTimer()
if !config.EnableReplayFeatures {
startOnlineCleanupTimer()
}
if _yp != nil { if _yp != nil {
go _yp.Start() go _yp.Start()
} }
segmentPath := config.HLSStoragePath
if err := setupStorage(); err != nil { if err := setupStorage(); err != nil {
log.Fatalln("failed to setup the storage", err) log.Fatalln("failed to setup the storage", err)
} }
go func() { setupVideoComponentsForId(streamId)
_transcoder = transcoder.NewTranscoder() setupLiveTranscoderForId(streamId, rtmpOut)
_transcoder.TranscoderCompleted = func(error) {
SetStreamAsDisconnected()
_transcoder = nil
_currentBroadcast = nil
}
_transcoder.SetStdin(rtmpOut)
_transcoder.Start(true)
}()
go webhooks.SendStreamStatusEvent(models.StreamStarted) go webhooks.SendStreamStatusEvent(models.StreamStarted, streamId)
segmentPath := filepath.Join(config.HLSStoragePath, streamId)
transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings)) transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings))
_ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true) _ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true)
@ -100,6 +100,7 @@ func SetStreamAsDisconnected() {
return return
} }
handler.StreamEnded()
transcoder.StopThumbnailGenerator() transcoder.StopThumbnailGenerator()
rtmp.Disconnect() rtmp.Disconnect()
@ -118,14 +119,14 @@ func SetStreamAsDisconnected() {
} }
for index := range _currentBroadcast.OutputSettings { for index := range _currentBroadcast.OutputSettings {
makeVariantIndexOffline(index, offlineFilePath, offlineFilename) makeVariantIndexOffline(_currentBroadcast.StreamID, index, offlineFilePath, offlineFilename)
} }
StartOfflineCleanupTimer() StartOfflineCleanupTimer()
stopOnlineCleanupTimer() stopOnlineCleanupTimer()
saveStats() saveStats()
go webhooks.SendStreamStatusEvent(models.StreamStopped) go webhooks.SendStreamStatusEvent(models.StreamStopped, _currentBroadcast.StreamID)
} }
// StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected. // StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected.

7
core/transcoder/fileWriterReceiverService.go

@ -26,6 +26,7 @@ type FileWriterReceiverServiceCallback interface {
// as it can send HTTP requests to this service with the results. // as it can send HTTP requests to this service with the results.
type FileWriterReceiverService struct { type FileWriterReceiverService struct {
callbacks FileWriterReceiverServiceCallback callbacks FileWriterReceiverServiceCallback
streamId string
} }
// SetupFileWriterReceiverService will start listening for transcoder responses. // SetupFileWriterReceiverService will start listening for transcoder responses.
@ -53,6 +54,10 @@ func (s *FileWriterReceiverService) SetupFileWriterReceiverService(callbacks Fil
}() }()
} }
func (s *FileWriterReceiverService) SetStreamID(streamID string) {
s.streamId = streamID
}
func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http.Request) { func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" { if r.Method != "PUT" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@ -79,7 +84,7 @@ func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http
} }
func (s *FileWriterReceiverService) fileWritten(path string) { func (s *FileWriterReceiverService) fileWritten(path string) {
if utils.GetRelativePathFromAbsolutePath(path) == "hls/stream.m3u8" { if utils.GetRelativePathFromAbsolutePath(path) == s.streamId+"/stream.m3u8" {
s.callbacks.MasterPlaylistWritten(path) s.callbacks.MasterPlaylistWritten(path)
} else if strings.HasSuffix(path, ".ts") { } else if strings.HasSuffix(path, ".ts") {
s.callbacks.SegmentWritten(path) s.callbacks.SegmentWritten(path)

33
core/transcoder/hlsHandler.go

@ -1,17 +1,46 @@
package transcoder package transcoder
import ( import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/replays"
log "github.com/sirupsen/logrus"
) )
// HLSHandler gets told about available HLS playlists and segments. // HLSHandler gets told about available HLS playlists and segments.
type HLSHandler struct { type HLSHandler struct {
Storage models.StorageProvider Storage models.StorageProvider
Recorder *replays.HLSRecorder
}
// StreamEnded is called when a stream is ended so the end time can be noted
// in the stream's metadata.
func (h *HLSHandler) StreamEnded() {
if config.EnableReplayFeatures {
h.Recorder.StreamEnded()
}
}
func (h *HLSHandler) SetStreamId(streamId string) {
h.Storage.SetStreamId(streamId)
if config.EnableReplayFeatures {
h.Recorder = replays.NewRecording(streamId)
}
} }
// SegmentWritten is fired when a HLS segment is written to disk. // SegmentWritten is fired when a HLS segment is written to disk.
func (h *HLSHandler) SegmentWritten(localFilePath string) { func (h *HLSHandler) SegmentWritten(localFilePath string) {
h.Storage.SegmentWritten(localFilePath) remotePath, _, err := h.Storage.SegmentWritten(localFilePath)
if err != nil {
log.Debugln(err, localFilePath)
return
}
if h.Recorder != nil {
h.Recorder.SegmentWritten(remotePath)
} else {
log.Debugln("No HLS recorder available to notify of segment written.")
}
} }
// VariantPlaylistWritten is fired when a HLS variant playlist is written to disk. // VariantPlaylistWritten is fired when a HLS variant playlist is written to disk.

36
core/transcoder/transcoder.go

@ -27,12 +27,10 @@ type Transcoder struct {
stdin *io.PipeReader stdin *io.PipeReader
TranscoderCompleted func(error) TranscoderCompleted func(error)
playlistOutputPath string StreamID string
ffmpegPath string ffmpegPath string
segmentIdentifier string
internalListenerPort string internalListenerPort string
input string input string
segmentOutputPath string
variants []HLSVariant variants []HLSVariant
currentStreamOutputSettings []models.StreamOutputVariant currentStreamOutputSettings []models.StreamOutputVariant
@ -118,7 +116,9 @@ func (t *Transcoder) Start(shouldLog bool) {
if shouldLog { if shouldLog {
log.Infof("Processing video using codec %s with %d output qualities configured.", t.codec.DisplayName(), len(t.variants)) log.Infof("Processing video using codec %s with %d output qualities configured.", t.codec.DisplayName(), len(t.variants))
} }
createVariantDirectories()
// Make directory for this stream.
createVariantDirectories(t.StreamID)
if config.EnableDebugFeatures { if config.EnableDebugFeatures {
log.Println(command) log.Println(command)
@ -181,8 +181,8 @@ func (t *Transcoder) getString() string {
hlsOptionFlags = append(hlsOptionFlags, "append_list") hlsOptionFlags = append(hlsOptionFlags, "append_list")
} }
if t.segmentIdentifier == "" { if t.StreamID == "" {
t.segmentIdentifier = shortid.MustGenerate() t.StreamID = shortid.MustGenerate()
} }
hlsEventString := "" hlsEventString := ""
@ -197,6 +197,7 @@ func (t *Transcoder) getString() string {
if len(hlsOptionFlags) > 0 { if len(hlsOptionFlags) > 0 {
hlsOptionsString = "-hls_flags " + strings.Join(hlsOptionFlags, "+") hlsOptionsString = "-hls_flags " + strings.Join(hlsOptionFlags, "+")
} }
ffmpegFlags := []string{ ffmpegFlags := []string{
fmt.Sprintf(`FFREPORT=file="%s":level=32`, logging.GetTranscoderLogFilePath()), fmt.Sprintf(`FFREPORT=file="%s":level=32`, logging.GetTranscoderLogFilePath()),
t.ffmpegPath, t.ffmpegPath,
@ -226,11 +227,11 @@ func (t *Transcoder) getString() string {
// Filenames // Filenames
"-master_pl_name", "stream.m3u8", "-master_pl_name", "stream.m3u8",
"-hls_segment_filename", localListenerAddress + "/%v/stream-" + t.segmentIdentifier + "-%d.ts", // Send HLS segments back to us over HTTP "-hls_segment_filename", localListenerAddress + "/" + t.StreamID + "/%v/stream-" + t.StreamID + "-%d.ts", // Send HLS segments back to us over HTTP
"-max_muxing_queue_size", "400", // Workaround for Too many packets error: https://trac.ffmpeg.org/ticket/6375?cversion=0 "-max_muxing_queue_size", "400", // Workaround for Too many packets error: https://trac.ffmpeg.org/ticket/6375?cversion=0
"-method PUT", // HLS results sent back to us will be over PUTs "-method PUT", // HLS results sent back to us will be over PUTs
localListenerAddress + "/%v/stream.m3u8", // Send HLS playlists back to us over HTTP localListenerAddress + "/" + t.StreamID + "/%v/stream.m3u8", // Send HLS playlists back to us over HTTP
} }
return strings.Join(ffmpegFlags, " ") return strings.Join(ffmpegFlags, " ")
@ -272,19 +273,17 @@ func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int)
} }
// NewTranscoder will return a new Transcoder, populated by the config. // NewTranscoder will return a new Transcoder, populated by the config.
func NewTranscoder() *Transcoder { func NewTranscoder(streamID string) *Transcoder {
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.StreamID = streamID
transcoder.ffmpegPath = ffmpegPath transcoder.ffmpegPath = ffmpegPath
transcoder.internalListenerPort = config.InternalHLSListenerPort transcoder.internalListenerPort = config.InternalHLSListenerPort
transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants() transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants()
transcoder.currentLatencyLevel = data.GetStreamLatencyLevel() transcoder.currentLatencyLevel = data.GetStreamLatencyLevel()
transcoder.codec = getCodec(data.GetVideoCodec()) transcoder.codec = getCodec(data.GetVideoCodec())
transcoder.segmentOutputPath = config.HLSStoragePath
transcoder.playlistOutputPath = config.HLSStoragePath
transcoder.input = "pipe:0" // stdin transcoder.input = "pipe:0" // stdin
for index, quality := range transcoder.currentStreamOutputSettings { for index, quality := range transcoder.currentStreamOutputSettings {
@ -433,14 +432,9 @@ func (t *Transcoder) SetStdin(pipe *io.PipeReader) {
t.stdin = pipe t.stdin = pipe
} }
// SetOutputPath sets the root directory that should include playlists and video segments. // SetStreamID sets a unique identifier for the currently transcoding stream.
func (t *Transcoder) SetOutputPath(output string) { func (t *Transcoder) SetStreamID(id string) {
t.segmentOutputPath = output t.StreamID = id
}
// SetIdentifier enables appending a unique identifier to segment file name.
func (t *Transcoder) SetIdentifier(output string) {
t.segmentIdentifier = output
} }
// SetInternalHTTPPort will set the port to be used for internal communication. // SetInternalHTTPPort will set the port to be used for internal communication.

5
core/transcoder/transcoder_nvenc_test.go

@ -14,8 +14,7 @@ func TestFFmpegNvencCommand(t *testing.T) {
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg") transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv") transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput") transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetIdentifier("jdoieGg")
transcoder.SetInternalHTTPPort("8123") transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name()) transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegNvencCommand(t *testing.T) {
cmd := transcoder.getString() cmd := transcoder.getString()
expectedLogPath := filepath.Join("data", "logs", "transcoder.log") expectedLogPath := filepath.Join("data", "logs", "transcoder.log")
expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel cuda -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_nvenc -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -tune:v:0 ll -map a:0? -c:a:0 copy -preset p3 -map v:0 -c:v:1 h264_nvenc -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -tune:v:1 ll -map a:0? -c:a:1 copy -preset p5 -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset p1 -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdoieGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8` expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel cuda -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_nvenc -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -tune:v:0 ll -map a:0? -c:a:0 copy -preset p3 -map v:0 -c:v:1 h264_nvenc -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -tune:v:1 ll -map a:0? -c:a:1 copy -preset p5 -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset p1 -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/jdFsdfzGg/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/jdFsdfzGg/%v/stream.m3u8`
if cmd != expected { if cmd != expected {
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)

5
core/transcoder/transcoder_omx_test.go

@ -14,8 +14,7 @@ func TestFFmpegOmxCommand(t *testing.T) {
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg") transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv") transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput") transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetIdentifier("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123") transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name()) transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegOmxCommand(t *testing.T) {
cmd := transcoder.getString() cmd := transcoder.getString()
expectedLogPath := filepath.Join("data", "logs", "transcoder.log") expectedLogPath := filepath.Join("data", "logs", "transcoder.log")
expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_omx -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_omx -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8` expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_omx -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_omx -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/jdFsdfzGg/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/jdFsdfzGg/%v/stream.m3u8`
if cmd != expected { if cmd != expected {
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)

5
core/transcoder/transcoder_vaapi_test.go

@ -14,8 +14,7 @@ func TestFFmpegVaapiCommand(t *testing.T) {
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg") transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv") transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput") transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetIdentifier("jdofFGg")
transcoder.SetInternalHTTPPort("8123") transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name()) transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegVaapiCommand(t *testing.T) {
cmd := transcoder.getString() cmd := transcoder.getString()
expectedLogPath := filepath.Join("data", "logs", "transcoder.log") expectedLogPath := filepath.Join("data", "logs", "transcoder.log")
expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel vaapi -hwaccel_output_format vaapi -vaapi_device /dev/dri/renderD128 -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_vaapi -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_vaapi -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt vaapi_vld -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8` expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel vaapi -hwaccel_output_format vaapi -vaapi_device /dev/dri/renderD128 -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_vaapi -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_vaapi -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt vaapi_vld -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/jdFsdfzGg/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/jdFsdfzGg/%v/stream.m3u8`
if cmd != expected { if cmd != expected {
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)

5
core/transcoder/transcoder_videotoolbox_test.go

@ -14,8 +14,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) {
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg") transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv") transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput") transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetIdentifier("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123") transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name()) transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) {
cmd := transcoder.getString() cmd := transcoder.getString()
expectedLogPath := filepath.Join("data", "logs", "transcoder.log") expectedLogPath := filepath.Join("data", "logs", "transcoder.log")
expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_videotoolbox -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -realtime true -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_videotoolbox -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt nv12 -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8` expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_videotoolbox -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -realtime true -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_videotoolbox -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt nv12 -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/jdFsdfzGg/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/jdFsdfzGg/%v/stream.m3u8`
if cmd != expected { if cmd != expected {
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)

5
core/transcoder/transcoder_x264_test.go

@ -14,8 +14,7 @@ func TestFFmpegx264Command(t *testing.T) {
transcoder := new(Transcoder) transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg") transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv") transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput") transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetIdentifier("jdofFGg")
transcoder.SetInternalHTTPPort("8123") transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name()) transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegx264Command(t *testing.T) {
cmd := transcoder.getString() cmd := transcoder.getString()
expectedLogPath := filepath.Join("data", "logs", "transcoder.log") expectedLogPath := filepath.Join("data", "logs", "transcoder.log")
expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0" -bufsize:v:0 1088k -profile:v:0 high -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0" -bufsize:v:1 3572k -profile:v:1 high -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8` expected := `FFREPORT=file="` + expectedLogPath + `":level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0" -bufsize:v:0 1088k -profile:v:0 high -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0" -bufsize:v:1 3572k -profile:v:1 high -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/jdFsdfzGg/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/jdFsdfzGg/%v/stream.m3u8`
if cmd != expected { if cmd != expected {
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)

6
core/transcoder/utils.go

@ -96,13 +96,13 @@ func handleTranscoderMessage(message string) {
_lastTranscoderLogMessage = message _lastTranscoderLogMessage = message
} }
func createVariantDirectories() { func createVariantDirectories(streamID string) {
// Create private hls data dirs // Create private hls data dirs
utils.CleanupDirectory(config.HLSStoragePath) utils.CleanupDirectory(config.HLSStoragePath, config.EnableReplayFeatures)
if len(data.GetStreamOutputVariants()) != 0 { if len(data.GetStreamOutputVariants()) != 0 {
for index := range data.GetStreamOutputVariants() { for index := range data.GetStreamOutputVariants() {
if err := os.MkdirAll(path.Join(config.HLSStoragePath, strconv.Itoa(index)), 0o750); err != nil { if err := os.MkdirAll(path.Join(config.HLSStoragePath, streamID, strconv.Itoa(index)), 0o750); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
} }

26
core/video.go

@ -0,0 +1,26 @@
package core
import (
"io"
"github.com/owncast/owncast/core/transcoder"
)
func setupVideoComponentsForId(streamId string) {
}
func setupLiveTranscoderForId(streamId string, rtmpOut *io.PipeReader) {
_storage.SetStreamId(streamId)
handler.SetStreamId(streamId)
go func() {
_transcoder = transcoder.NewTranscoder(streamId)
_transcoder.TranscoderCompleted = func(error) {
SetStreamAsDisconnected()
_transcoder = nil
_currentBroadcast = nil
}
_transcoder.SetStdin(rtmpOut)
_transcoder.Start(true)
}()
}

7
core/webhooks/stream.go

@ -9,11 +9,11 @@ import (
) )
// SendStreamStatusEvent will send all webhook destinations the current stream status. // SendStreamStatusEvent will send all webhook destinations the current stream status.
func SendStreamStatusEvent(eventType models.EventType) { func SendStreamStatusEvent(eventType models.EventType, streamID string) {
sendStreamStatusEvent(eventType, shortid.MustGenerate(), time.Now()) sendStreamStatusEvent(eventType, shortid.MustGenerate(), streamID, time.Now())
} }
func sendStreamStatusEvent(eventType models.EventType, id string, timestamp time.Time) { func sendStreamStatusEvent(eventType models.EventType, id, streamID string, timestamp time.Time) {
SendEventToWebhooks(WebhookEvent{ SendEventToWebhooks(WebhookEvent{
Type: eventType, Type: eventType,
EventData: map[string]interface{}{ EventData: map[string]interface{}{
@ -23,6 +23,7 @@ func sendStreamStatusEvent(eventType models.EventType, id string, timestamp time
"streamTitle": data.GetStreamTitle(), "streamTitle": data.GetStreamTitle(),
"status": getStatus(), "status": getStatus(),
"timestamp": timestamp, "timestamp": timestamp,
"streamID": streamID,
}, },
}) })
} }

5
core/webhooks/stream_test.go

@ -14,14 +14,17 @@ func TestSendStreamStatusEvent(t *testing.T) {
data.SetServerSummary("my server where I stream") data.SetServerSummary("my server where I stream")
data.SetStreamTitle("my stream") data.SetStreamTitle("my stream")
streamID := "test-stream-id"
checkPayload(t, models.StreamStarted, func() { checkPayload(t, models.StreamStarted, func() {
sendStreamStatusEvent(events.StreamStarted, "id", time.Unix(72, 6).UTC()) sendStreamStatusEvent(events.StreamStarted, "id", streamID, time.Unix(72, 6).UTC())
}, `{ }, `{
"id": "id", "id": "id",
"name": "my server", "name": "my server",
"streamTitle": "my stream", "streamTitle": "my stream",
"summary": "my server where I stream", "summary": "my server where I stream",
"timestamp": "1970-01-01T00:01:12.000000006Z", "timestamp": "1970-01-01T00:01:12.000000006Z",
"streamID": "test-stream-id",
"status": { "status": {
"lastConnectTime": null, "lastConnectTime": null,
"lastDisconnectTime": null, "lastDisconnectTime": null,

2
db/db.go

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.15.0 // sqlc v1.19.1
package db package db

41
db/models.go

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.15.0 // sqlc v1.19.1
package db package db
@ -72,6 +72,23 @@ type Notification struct {
CreatedAt sql.NullTime CreatedAt sql.NullTime
} }
type ReplayClip struct {
ID string
StreamID string
ClippedBy sql.NullString
ClipTitle sql.NullString
RelativeStartTime sql.NullFloat64
RelativeEndTime sql.NullFloat64
Timestamp sql.NullTime
}
type Stream struct {
ID string
StreamTitle sql.NullString
StartTime sql.NullTime
EndTime sql.NullTime
}
type User struct { type User struct {
ID string ID string
DisplayName string DisplayName string
@ -91,3 +108,25 @@ type UserAccessToken struct {
UserID string UserID string
Timestamp time.Time Timestamp time.Time
} }
type VideoSegment struct {
ID string
StreamID string
OutputConfigurationID string
Path string
RelativeTimestamp float32
Timestamp sql.NullTime
}
type VideoSegmentOutputConfiguration struct {
ID string
VariantID string
Name string
StreamID string
SegmentDuration int32
Bitrate int32
Framerate int32
ResolutionWidth sql.NullInt32
ResolutionHeight sql.NullInt32
Timestamp sql.NullTime
}

58
db/query.sql

@ -108,3 +108,61 @@ UPDATE users SET display_name = $1, previous_names = previous_names || $2, namec
-- name: ChangeDisplayColor :exec -- name: ChangeDisplayColor :exec
UPDATE users SET display_color = $1 WHERE id = $2; UPDATE users SET display_color = $1 WHERE id = $2;
-- Recording and clip related queries.
-- name: GetStreams :many
SELECT id, stream_title, start_time, end_time FROM streams ORDER BY start_time DESC;
-- name: GetStreamById :one
SELECT id, stream_title, start_time, end_time FROM streams WHERE id = $1 LIMIT 1;
-- name: GetOutputConfigurationsForStreamId :many
SELECT id, stream_id, variant_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height FROM video_segment_output_configuration WHERE stream_id = $1;
-- name: GetOutputConfigurationForId :one
SELECT id, stream_id, variant_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height FROM video_segment_output_configuration WHERE id = $1;
-- name: GetSegmentsForOutputId :many
SELECT id, stream_id, output_configuration_id, path, timestamp FROM video_segments WHERE output_configuration_id = $1 ORDER BY timestamp ASC;
-- name: GetSegmentsForOutputIdAndWindow :many
SELECT id, stream_id, output_configuration_id, path, relative_timestamp, timestamp FROM video_segments WHERE output_configuration_id = $1 AND (cast ( relative_timestamp as int ) - ( relative_timestamp < cast ( relative_timestamp as int ))) >= @start_seconds::REAL AND (cast ( relative_timestamp as int ) + ( relative_timestamp > cast ( relative_timestamp as int ))) <= @end_seconds::REAL ORDER BY relative_timestamp ASC;
-- name: InsertStream :exec
INSERT INTO streams (id, stream_title, start_time, end_time) VALUES($1, $2, $3, $4);
-- name: InsertOutputConfiguration :exec
INSERT INTO video_segment_output_configuration (id, variant_id, stream_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height, timestamp) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: InsertSegment :exec
INSERT INTO video_segments (id, stream_id, output_configuration_id, path, relative_timestamp, timestamp) VALUES($1, $2, $3, $4, $5, $6);
-- name: SetStreamEnded :exec
UPDATE streams SET end_time = $1 WHERE id = $2;
-- name: InsertClip :exec
INSERT INTO replay_clips (id, stream_id, clip_title, relative_start_time, relative_end_time, timestamp) VALUES($1, $2, $3, $4, $5, $6);
-- name: GetAllClips :many
SELECT rc.id AS id, rc.clip_title, rc.stream_id, rc.relative_start_time, rc.relative_end_time, (rc.relative_end_time - rc.relative_start_time) AS duration_seconds, rc.timestamp, s.stream_title AS stream_title
FROM replay_clips rc
JOIN streams s ON rc.stream_id = s.id
ORDER BY timestamp DESC;
-- name: GetAllClipsForStream :many
SELECT rc.id AS clip_id, rc.stream_id, rc.clipped_by, rc.clip_title, rc.relative_start_time, rc.relative_end_time, rc.timestamp,
s.id AS stream_id, s.stream_title AS stream_title
FROM replay_clips rc
JOIN streams s ON rc.stream_id = s.id
WHERE rc.stream_id = $1
ORDER BY timestamp DESC;
-- name: GetClip :one
SELECT id AS clip_id, stream_id, clipped_by, clip_title, timestamp AS clip_timestamp, relative_start_time, relative_end_time FROM replay_clips WHERE id = $1;
-- name: GetFinalSegmentForStream :one
SELECT id, stream_id, output_configuration_id, path, relative_timestamp, timestamp FROM video_segments WHERE stream_id = $1 ORDER BY relative_timestamp DESC LIMIT 1;
-- name: FixUnfinishedStreams :exec
UPDATE streams SET end_time = (SELECT timestamp FROM video_segments WHERE stream_id = streams.id) WHERE end_time IS NULL;

491
db/query.sql.go

@ -205,6 +205,148 @@ func (q *Queries) DoesInboundActivityExist(ctx context.Context, arg DoesInboundA
return count, err return count, err
} }
const fixUnfinishedStreams = `-- name: FixUnfinishedStreams :exec
UPDATE streams SET end_time = (SELECT timestamp FROM video_segments WHERE stream_id = streams.id) WHERE end_time IS NULL
`
func (q *Queries) FixUnfinishedStreams(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, fixUnfinishedStreams)
return err
}
const getAllClips = `-- name: GetAllClips :many
SELECT rc.id AS id, rc.clip_title, rc.stream_id, rc.relative_start_time, rc.relative_end_time, (rc.relative_end_time - rc.relative_start_time) AS duration_seconds, rc.timestamp, s.stream_title AS stream_title
FROM replay_clips rc
JOIN streams s ON rc.stream_id = s.id
ORDER BY timestamp DESC
`
type GetAllClipsRow struct {
ID string
ClipTitle sql.NullString
StreamID string
RelativeStartTime sql.NullFloat64
RelativeEndTime sql.NullFloat64
DurationSeconds int32
Timestamp sql.NullTime
StreamTitle sql.NullString
}
func (q *Queries) GetAllClips(ctx context.Context) ([]GetAllClipsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllClips)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllClipsRow
for rows.Next() {
var i GetAllClipsRow
if err := rows.Scan(
&i.ID,
&i.ClipTitle,
&i.StreamID,
&i.RelativeStartTime,
&i.RelativeEndTime,
&i.DurationSeconds,
&i.Timestamp,
&i.StreamTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllClipsForStream = `-- name: GetAllClipsForStream :many
SELECT rc.id AS clip_id, rc.stream_id, rc.clipped_by, rc.clip_title, rc.relative_start_time, rc.relative_end_time, rc.timestamp,
s.id AS stream_id, s.stream_title AS stream_title
FROM replay_clips rc
JOIN streams s ON rc.stream_id = s.id
WHERE rc.stream_id = $1
ORDER BY timestamp DESC
`
type GetAllClipsForStreamRow struct {
ClipID string
StreamID string
ClippedBy sql.NullString
ClipTitle sql.NullString
RelativeStartTime sql.NullFloat64
RelativeEndTime sql.NullFloat64
Timestamp sql.NullTime
StreamID_2 string
StreamTitle sql.NullString
}
func (q *Queries) GetAllClipsForStream(ctx context.Context, streamID string) ([]GetAllClipsForStreamRow, error) {
rows, err := q.db.QueryContext(ctx, getAllClipsForStream, streamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllClipsForStreamRow
for rows.Next() {
var i GetAllClipsForStreamRow
if err := rows.Scan(
&i.ClipID,
&i.StreamID,
&i.ClippedBy,
&i.ClipTitle,
&i.RelativeStartTime,
&i.RelativeEndTime,
&i.Timestamp,
&i.StreamID_2,
&i.StreamTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getClip = `-- name: GetClip :one
SELECT id AS clip_id, stream_id, clipped_by, clip_title, timestamp AS clip_timestamp, relative_start_time, relative_end_time FROM replay_clips WHERE id = $1
`
type GetClipRow struct {
ClipID string
StreamID string
ClippedBy sql.NullString
ClipTitle sql.NullString
ClipTimestamp sql.NullTime
RelativeStartTime sql.NullFloat64
RelativeEndTime sql.NullFloat64
}
func (q *Queries) GetClip(ctx context.Context, id string) (GetClipRow, error) {
row := q.db.QueryRowContext(ctx, getClip, id)
var i GetClipRow
err := row.Scan(
&i.ClipID,
&i.StreamID,
&i.ClippedBy,
&i.ClipTitle,
&i.ClipTimestamp,
&i.RelativeStartTime,
&i.RelativeEndTime,
)
return i, err
}
const getFederationFollowerApprovalRequests = `-- name: GetFederationFollowerApprovalRequests :many const getFederationFollowerApprovalRequests = `-- name: GetFederationFollowerApprovalRequests :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null
` `
@ -296,6 +438,24 @@ func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetF
return items, nil return items, nil
} }
const getFinalSegmentForStream = `-- name: GetFinalSegmentForStream :one
SELECT id, stream_id, output_configuration_id, path, relative_timestamp, timestamp FROM video_segments WHERE stream_id = $1 ORDER BY relative_timestamp DESC LIMIT 1
`
func (q *Queries) GetFinalSegmentForStream(ctx context.Context, streamID string) (VideoSegment, error) {
row := q.db.QueryRowContext(ctx, getFinalSegmentForStream, streamID)
var i VideoSegment
err := row.Scan(
&i.ID,
&i.StreamID,
&i.OutputConfigurationID,
&i.Path,
&i.RelativeTimestamp,
&i.Timestamp,
)
return i, err
}
const getFollowerByIRI = `-- name: GetFollowerByIRI :one const getFollowerByIRI = `-- name: GetFollowerByIRI :one
SELECT iri, inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1 SELECT iri, inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1
` `
@ -541,6 +701,88 @@ func (q *Queries) GetOutboxWithOffset(ctx context.Context, arg GetOutboxWithOffs
return items, nil return items, nil
} }
const getOutputConfigurationForId = `-- name: GetOutputConfigurationForId :one
SELECT id, stream_id, variant_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height FROM video_segment_output_configuration WHERE id = $1
`
type GetOutputConfigurationForIdRow struct {
ID string
StreamID string
VariantID string
Name string
SegmentDuration int32
Bitrate int32
Framerate int32
ResolutionWidth sql.NullInt32
ResolutionHeight sql.NullInt32
}
func (q *Queries) GetOutputConfigurationForId(ctx context.Context, id string) (GetOutputConfigurationForIdRow, error) {
row := q.db.QueryRowContext(ctx, getOutputConfigurationForId, id)
var i GetOutputConfigurationForIdRow
err := row.Scan(
&i.ID,
&i.StreamID,
&i.VariantID,
&i.Name,
&i.SegmentDuration,
&i.Bitrate,
&i.Framerate,
&i.ResolutionWidth,
&i.ResolutionHeight,
)
return i, err
}
const getOutputConfigurationsForStreamId = `-- name: GetOutputConfigurationsForStreamId :many
SELECT id, stream_id, variant_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height FROM video_segment_output_configuration WHERE stream_id = $1
`
type GetOutputConfigurationsForStreamIdRow struct {
ID string
StreamID string
VariantID string
Name string
SegmentDuration int32
Bitrate int32
Framerate int32
ResolutionWidth sql.NullInt32
ResolutionHeight sql.NullInt32
}
func (q *Queries) GetOutputConfigurationsForStreamId(ctx context.Context, streamID string) ([]GetOutputConfigurationsForStreamIdRow, error) {
rows, err := q.db.QueryContext(ctx, getOutputConfigurationsForStreamId, streamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetOutputConfigurationsForStreamIdRow
for rows.Next() {
var i GetOutputConfigurationsForStreamIdRow
if err := rows.Scan(
&i.ID,
&i.StreamID,
&i.VariantID,
&i.Name,
&i.SegmentDuration,
&i.Bitrate,
&i.Framerate,
&i.ResolutionWidth,
&i.ResolutionHeight,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRejectedAndBlockedFollowers = `-- name: GetRejectedAndBlockedFollowers :many const getRejectedAndBlockedFollowers = `-- name: GetRejectedAndBlockedFollowers :many
SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null
` `
@ -584,6 +826,137 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje
return items, nil return items, nil
} }
const getSegmentsForOutputId = `-- name: GetSegmentsForOutputId :many
SELECT id, stream_id, output_configuration_id, path, timestamp FROM video_segments WHERE output_configuration_id = $1 ORDER BY timestamp ASC
`
type GetSegmentsForOutputIdRow struct {
ID string
StreamID string
OutputConfigurationID string
Path string
Timestamp sql.NullTime
}
func (q *Queries) GetSegmentsForOutputId(ctx context.Context, outputConfigurationID string) ([]GetSegmentsForOutputIdRow, error) {
rows, err := q.db.QueryContext(ctx, getSegmentsForOutputId, outputConfigurationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSegmentsForOutputIdRow
for rows.Next() {
var i GetSegmentsForOutputIdRow
if err := rows.Scan(
&i.ID,
&i.StreamID,
&i.OutputConfigurationID,
&i.Path,
&i.Timestamp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getSegmentsForOutputIdAndWindow = `-- name: GetSegmentsForOutputIdAndWindow :many
SELECT id, stream_id, output_configuration_id, path, relative_timestamp, timestamp FROM video_segments WHERE output_configuration_id = $1 AND (cast ( relative_timestamp as int ) - ( relative_timestamp < cast ( relative_timestamp as int ))) >= $2::REAL AND (cast ( relative_timestamp as int ) + ( relative_timestamp > cast ( relative_timestamp as int ))) <= $3::REAL ORDER BY relative_timestamp ASC
`
type GetSegmentsForOutputIdAndWindowParams struct {
OutputConfigurationID string
StartSeconds float32
EndSeconds float32
}
func (q *Queries) GetSegmentsForOutputIdAndWindow(ctx context.Context, arg GetSegmentsForOutputIdAndWindowParams) ([]VideoSegment, error) {
rows, err := q.db.QueryContext(ctx, getSegmentsForOutputIdAndWindow, arg.OutputConfigurationID, arg.StartSeconds, arg.EndSeconds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []VideoSegment
for rows.Next() {
var i VideoSegment
if err := rows.Scan(
&i.ID,
&i.StreamID,
&i.OutputConfigurationID,
&i.Path,
&i.RelativeTimestamp,
&i.Timestamp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getStreamById = `-- name: GetStreamById :one
SELECT id, stream_title, start_time, end_time FROM streams WHERE id = $1 LIMIT 1
`
func (q *Queries) GetStreamById(ctx context.Context, id string) (Stream, error) {
row := q.db.QueryRowContext(ctx, getStreamById, id)
var i Stream
err := row.Scan(
&i.ID,
&i.StreamTitle,
&i.StartTime,
&i.EndTime,
)
return i, err
}
const getStreams = `-- name: GetStreams :many
SELECT id, stream_title, start_time, end_time FROM streams ORDER BY start_time DESC
`
// Recording and clip related queries.
func (q *Queries) GetStreams(ctx context.Context) ([]Stream, error) {
rows, err := q.db.QueryContext(ctx, getStreams)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Stream
for rows.Next() {
var i Stream
if err := rows.Scan(
&i.ID,
&i.StreamTitle,
&i.StartTime,
&i.EndTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserByAccessToken = `-- name: GetUserByAccessToken :one const getUserByAccessToken = `-- name: GetUserByAccessToken :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id
` `
@ -666,6 +1039,110 @@ func (q *Queries) GetUserDisplayNameByToken(ctx context.Context, token string) (
return display_name, err return display_name, err
} }
const insertClip = `-- name: InsertClip :exec
INSERT INTO replay_clips (id, stream_id, clip_title, relative_start_time, relative_end_time, timestamp) VALUES($1, $2, $3, $4, $5, $6)
`
type InsertClipParams struct {
ID string
StreamID string
ClipTitle sql.NullString
RelativeStartTime sql.NullFloat64
RelativeEndTime sql.NullFloat64
Timestamp sql.NullTime
}
func (q *Queries) InsertClip(ctx context.Context, arg InsertClipParams) error {
_, err := q.db.ExecContext(ctx, insertClip,
arg.ID,
arg.StreamID,
arg.ClipTitle,
arg.RelativeStartTime,
arg.RelativeEndTime,
arg.Timestamp,
)
return err
}
const insertOutputConfiguration = `-- name: InsertOutputConfiguration :exec
INSERT INTO video_segment_output_configuration (id, variant_id, stream_id, name, segment_duration, bitrate, framerate, resolution_width, resolution_height, timestamp) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertOutputConfigurationParams struct {
ID string
VariantID string
StreamID string
Name string
SegmentDuration int32
Bitrate int32
Framerate int32
ResolutionWidth sql.NullInt32
ResolutionHeight sql.NullInt32
Timestamp sql.NullTime
}
func (q *Queries) InsertOutputConfiguration(ctx context.Context, arg InsertOutputConfigurationParams) error {
_, err := q.db.ExecContext(ctx, insertOutputConfiguration,
arg.ID,
arg.VariantID,
arg.StreamID,
arg.Name,
arg.SegmentDuration,
arg.Bitrate,
arg.Framerate,
arg.ResolutionWidth,
arg.ResolutionHeight,
arg.Timestamp,
)
return err
}
const insertSegment = `-- name: InsertSegment :exec
INSERT INTO video_segments (id, stream_id, output_configuration_id, path, relative_timestamp, timestamp) VALUES($1, $2, $3, $4, $5, $6)
`
type InsertSegmentParams struct {
ID string
StreamID string
OutputConfigurationID string
Path string
RelativeTimestamp float32
Timestamp sql.NullTime
}
func (q *Queries) InsertSegment(ctx context.Context, arg InsertSegmentParams) error {
_, err := q.db.ExecContext(ctx, insertSegment,
arg.ID,
arg.StreamID,
arg.OutputConfigurationID,
arg.Path,
arg.RelativeTimestamp,
arg.Timestamp,
)
return err
}
const insertStream = `-- name: InsertStream :exec
INSERT INTO streams (id, stream_title, start_time, end_time) VALUES($1, $2, $3, $4)
`
type InsertStreamParams struct {
ID string
StreamTitle sql.NullString
StartTime sql.NullTime
EndTime sql.NullTime
}
func (q *Queries) InsertStream(ctx context.Context, arg InsertStreamParams) error {
_, err := q.db.ExecContext(ctx, insertStream,
arg.ID,
arg.StreamTitle,
arg.StartTime,
arg.EndTime,
)
return err
}
const isDisplayNameAvailable = `-- name: IsDisplayNameAvailable :one const isDisplayNameAvailable = `-- name: IsDisplayNameAvailable :one
SELECT count(*) FROM users WHERE display_name = $1 AND ( type='API' OR authenticated_at IS NOT NULL ) AND disabled_at IS NULL SELECT count(*) FROM users WHERE display_name = $1 AND ( type='API' OR authenticated_at IS NOT NULL ) AND disabled_at IS NULL
` `
@ -748,6 +1225,20 @@ func (q *Queries) SetAccessTokenToOwner(ctx context.Context, arg SetAccessTokenT
return err return err
} }
const setStreamEnded = `-- name: SetStreamEnded :exec
UPDATE streams SET end_time = $1 WHERE id = $2
`
type SetStreamEndedParams struct {
EndTime sql.NullTime
ID string
}
func (q *Queries) SetStreamEnded(ctx context.Context, arg SetStreamEndedParams) error {
_, err := q.db.ExecContext(ctx, setStreamEnded, arg.EndTime, arg.ID)
return err
}
const setUserAsAuthenticated = `-- name: SetUserAsAuthenticated :exec const setUserAsAuthenticated = `-- name: SetUserAsAuthenticated :exec
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1 UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1
` `

58
db/schema.sql

@ -97,3 +97,61 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX user_id ON messages (user_id); CREATE INDEX user_id ON messages (user_id);
CREATE INDEX hidden_at ON messages (hidden_at); CREATE INDEX hidden_at ON messages (hidden_at);
CREATE INDEX timestamp ON messages (timestamp); CREATE INDEX timestamp ON messages (timestamp);
-- Record the high level details of each stream.
CREATE TABLE IF NOT EXISTS streams (
"id" string NOT NULL PRIMARY KEY,
"stream_title" TEXT,
"start_time" DATE,
"end_time" DATE,
PRIMARY KEY (id)
);
CREATE INDEX streams_id ON streams (id);
CREATE INDEX streams_start_time ON streams (start_time);
CREATE INDEX streams_start_end_time ON streams (start_time,end_time);
-- Record the output configuration of a stream.
CREATE TABLE IF NOT EXISTS video_segment_output_configuration (
"id" string NOT NULL PRIMARY KEY,
"variant_id" string NOT NULL,
"name" string NOT NULL,
"stream_id" string NOT NULL,
"segment_duration" INTEGER NOT NULL,
"bitrate" INTEGER NOT NULL,
"framerate" INTEGER NOT NULL,
"resolution_width" INTEGER,
"resolution_height" INTEGER,
"timestamp" DATE,
PRIMARY KEY (id)
);
CREATE INDEX video_segment_output_configuration_stream_id ON video_segment_output_configuration (stream_id);
-- Support querying all segments for a single stream as well
-- as segments for a time window.
CREATE TABLE IF NOT EXISTS video_segments (
"id" string NOT NULL PRIMARY KEY,
"stream_id" string NOT NULL,
"output_configuration_id" string NOT NULL,
"path" TEXT NOT NULL,
"relative_timestamp" REAL NOT NULL,
"timestamp" DATE,
PRIMARY KEY (id)
);
CREATE INDEX video_segments_stream_id ON video_segments (stream_id);
CREATE INDEX video_segments_stream_id_timestamp ON video_segments (stream_id,timestamp);
-- Record the details of a replayable clip.
CREATE TABLE IF NOT EXISTS replay_clips (
"id" string NOT NULL,
"stream_id" string NOT NULL,
"clipped_by" string,
"clip_title" TEXT,
"relative_start_time" REAL,
"relative_end_time" REAL,
"timestamp" DATE,
PRIMARY KEY (id),
FOREIGN KEY(stream_id) REFERENCES streams(id)
);
CREATE INDEX clip_id ON replay_clips (id);
CREATE INDEX clip_stream_id ON replay_clips (stream_id);
CREATE INDEX clip_start_end_time ON replay_clips (start_time,end_time);

3
main.go

@ -28,6 +28,7 @@ var (
webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port") webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port")
webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address") webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address")
rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server") rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server")
enableReplayFeatures = flag.Bool("enableReplayFeatures", false, "Enable experimental replay features")
) )
// nolint:cyclop // nolint:cyclop
@ -42,6 +43,8 @@ func main() {
config.BackupDirectory = *backupDirectory config.BackupDirectory = *backupDirectory
} }
config.EnableReplayFeatures = *enableReplayFeatures
// Create the data directory if needed // Create the data directory if needed
if !utils.DoesFileExists("data") { if !utils.DoesFileExists("data") {
if err := os.Mkdir("./data", 0o700); err != nil { if err := os.Mkdir("./data", 0o700); err != nil {

1
models/currentBroadcast.go

@ -2,6 +2,7 @@ package models
// CurrentBroadcast represents the configuration associated with the currently active stream. // CurrentBroadcast represents the configuration associated with the currently active stream.
type CurrentBroadcast struct { type CurrentBroadcast struct {
StreamID string `json:"streamId"`
OutputSettings []StreamOutputVariant `json:"outputSettings"` OutputSettings []StreamOutputVariant `json:"outputSettings"`
LatencyLevel LatencyLevel `json:"latencyLevel"` LatencyLevel LatencyLevel `json:"latencyLevel"`
} }

63
models/flexibledate.go

@ -0,0 +1,63 @@
package models
import (
"database/sql"
"errors"
"time"
)
type FlexibleDate struct {
time.Time
}
func (self *FlexibleDate) UnmarshalJSON(b []byte) (err error) {
s := string(b)
// Get rid of the quotes "" around the value.
s = s[1 : len(s)-1]
result, err := FlexibleDateParse(s)
if err != nil {
return err
}
self.Time = result
return
}
// FlexibleDateParse is a convinience function to parse a date that could be
// a string, a time.Time, or a sql.NullTime.
func FlexibleDateParse(date interface{}) (time.Time, error) {
// If it's within a sql.NullTime wrapper, return the time from that.
nulltime, ok := date.(sql.NullTime)
if ok {
return nulltime.Time, nil
}
// Parse as string
datestring, ok := date.(string)
if ok {
t, err := time.Parse(time.RFC3339Nano, datestring)
if err == nil {
return t, nil
}
t, err = time.Parse("2006-01-02T15:04:05.999999999Z0700", datestring)
if err == nil {
return t, nil
}
t, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", datestring)
if err == nil {
return t, nil
}
}
dateobject, ok := date.(time.Time)
if ok {
return dateobject, nil
}
return time.Time{}, errors.New("unable to parse date")
}

42
models/flexibledate_test.go

@ -0,0 +1,42 @@
package models
import (
"database/sql"
"encoding/json"
"testing"
"time"
)
func TestFlexibleDateParsing(t *testing.T) {
type testJson struct {
Testdate FlexibleDate `json:"testdate"`
}
nullTime := sql.NullTime{Time: time.Unix(1591614434, 0), Valid: true}
testNullTime, err := FlexibleDateParse(nullTime)
if err != nil {
t.Error(err)
}
if testNullTime.Unix() != nullTime.Time.Unix() {
t.Errorf("Expected %d but got %d", nullTime.Time.Unix(), testNullTime.Unix())
}
testStrings := map[string]time.Time{
"2023-08-10 17:40:15.376736475-07:00": time.Unix(1691714415, 0),
}
for testString, expectedTime := range testStrings {
testJsonString := `{"testdate":"` + testString + `"}`
response := testJson{}
err := json.Unmarshal([]byte(testJsonString), &response)
if err != nil {
t.Error(err)
}
if response.Testdate.Time.Unix() != expectedTime.Unix() {
t.Errorf("Expected %d but got %d", expectedTime.Unix(), response.Testdate.Time.Unix())
}
}
}

7
models/storageProvider.go

@ -3,11 +3,12 @@ package models
// StorageProvider is how a chunk storage provider should be implemented. // StorageProvider is how a chunk storage provider should be implemented.
type StorageProvider interface { type StorageProvider interface {
Setup() error Setup() error
Save(filePath string, retryCount int) (string, error) Save(localFilePath, destinationPath string, retryCount int) (string, error)
SetStreamId(streamID string)
SegmentWritten(localFilePath string) SegmentWritten(localFilePath string) (string, int, error)
VariantPlaylistWritten(localFilePath string) VariantPlaylistWritten(localFilePath string)
MasterPlaylistWritten(localFilePath string) MasterPlaylistWritten(localFilePath string)
GetRemoteDestinationPathFromLocalFilePath(localFilePath string) string
Cleanup() error Cleanup() error
} }

141
replays/clips.go

@ -0,0 +1,141 @@
package replays
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
)
// Clip represents a clip that has been created from a stream.
// A clip is a subset of a stream that has has start and end seconds
// relative to the start of the stream.
type Clip struct {
ID string `json:"id"`
StreamId string `json:"stream_id"`
ClippedBy string `json:"clipped_by,omitempty"`
ClipTitle string `json:"title,omitempty"`
StreamTitle string `json:"stream_title,omitempty"`
RelativeStartTime float32 `json:"relativeStartTime"`
RelativeEndTime float32 `json:"relativeEndTime"`
DurationSeconds int `json:"durationSeconds"`
Manifest string `json:"manifest,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// GetClips will return all clips that have been recorded.
func GetAllClips() ([]*Clip, error) {
clips, err := data.GetDatastore().GetQueries().GetAllClips(context.Background())
if err != nil {
return nil, errors.WithMessage(err, "failure to get clips")
}
response := []*Clip{}
for _, clip := range clips {
s := Clip{
ID: clip.ID,
ClipTitle: clip.ClipTitle.String,
StreamId: clip.StreamID,
StreamTitle: clip.StreamTitle.String,
RelativeStartTime: float32(clip.RelativeStartTime.Float64),
RelativeEndTime: float32(clip.RelativeEndTime.Float64),
DurationSeconds: int(clip.DurationSeconds),
Timestamp: clip.Timestamp.Time,
Manifest: fmt.Sprintf("/clip/%s", clip.ID),
}
response = append(response, &s)
}
return response, nil
}
// GetAllClipsForStream will return all clips that have been recorded for a stream.
func GetAllClipsForStream(streamId string) ([]*Clip, error) {
clips, err := data.GetDatastore().GetQueries().GetAllClipsForStream(context.Background(), streamId)
if err != nil {
return nil, errors.WithMessage(err, "failure to get clips")
}
response := []*Clip{}
for _, clip := range clips {
s := Clip{
ID: clip.ClipID,
ClipTitle: clip.ClipTitle.String,
StreamTitle: clip.StreamTitle.String,
RelativeStartTime: float32(clip.RelativeStartTime.Float64),
RelativeEndTime: float32(clip.RelativeEndTime.Float64),
Timestamp: clip.Timestamp.Time,
Manifest: fmt.Sprintf("/clips/%s", clip.ClipID),
}
response = append(response, &s)
}
return response, nil
}
// AddClipForStream will save a new clip for a stream.
func AddClipForStream(streamId, clipTitle, clippedBy string, relativeStartTimeSeconds, relativeEndTimeSeconds float32) (string, int, error) {
playlistGenerator := NewPlaylistGenerator()
// Verify this stream exists
if _, err := playlistGenerator.GetStream(streamId); err != nil {
return "", 0, errors.WithMessage(err, "stream not found")
}
// Verify this stream has at least one output configuration.
configs, err := playlistGenerator.GetConfigurationsForStream(streamId)
if err != nil {
return "", 0, errors.WithMessage(err, "unable to get configurations for stream")
}
if len(configs) == 0 {
return "", 0, errors.New("no configurations found for stream")
}
// We want the start and end seconds to be aligned to the segment so
// round up and down the values to get a fully inclusive segment range.
config := configs[0]
segmentDuration := int(config.SegmentDuration)
updatedRelativeStartTimeSeconds := utils.RoundDownToNearest(relativeStartTimeSeconds, segmentDuration)
updatedRelativeEndTimeSeconds := utils.RoundUpToNearest(relativeEndTimeSeconds, segmentDuration)
clipId := shortid.MustGenerate()
duration := updatedRelativeEndTimeSeconds - updatedRelativeStartTimeSeconds
err = data.GetDatastore().GetQueries().InsertClip(context.Background(), db.InsertClipParams{
ID: clipId,
StreamID: streamId,
ClipTitle: sql.NullString{String: clipTitle, Valid: clipTitle != ""},
RelativeStartTime: sql.NullFloat64{Float64: float64(updatedRelativeStartTimeSeconds), Valid: true},
RelativeEndTime: sql.NullFloat64{Float64: float64(updatedRelativeEndTimeSeconds), Valid: true},
Timestamp: sql.NullTime{Time: time.Now(), Valid: true},
})
if err != nil {
return "", 0, errors.WithMessage(err, "failure to add clip")
}
return clipId, duration, nil
}
// GetFinalSegmentForStream will return the final known segment for a stream.
func GetFinalSegmentForStream(streamId string) (*HLSSegment, error) {
segmentResponse, err := data.GetDatastore().GetQueries().GetFinalSegmentForStream(context.Background(), streamId)
if err != nil {
return nil, errors.Wrap(err, "unable to get final segment for stream")
}
segment := HLSSegment{
ID: segmentResponse.ID,
StreamID: segmentResponse.StreamID,
OutputConfigurationID: segmentResponse.OutputConfigurationID,
Path: segmentResponse.Path,
RelativeTimestamp: segmentResponse.RelativeTimestamp,
Timestamp: segmentResponse.Timestamp.Time,
}
return &segment, nil
}

122
replays/hlsRecorder.go

@ -0,0 +1,122 @@
package replays
import (
"context"
"database/sql"
"strconv"
"strings"
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils"
"github.com/teris-io/shortid"
log "github.com/sirupsen/logrus"
)
type HLSRecorder struct {
streamID string
startTime time.Time
// The video variant configurations that were used for this stream.
outputConfigurations []HLSOutputConfiguration
datastore *data.Datastore
}
// NewRecording returns a new instance of the HLS recorder.
func NewRecording(streamID string) *HLSRecorder {
// We don't support replaying offline clips.
if streamID == "offline" {
return nil
}
log.Infoln("Recording replay of this stream:", streamID)
h := HLSRecorder{
streamID: streamID,
startTime: time.Now(),
datastore: data.GetDatastore(),
}
outputs := data.GetStreamOutputVariants()
latency := data.GetStreamLatencyLevel()
streamTitle := data.GetStreamTitle()
validTitle := streamTitle != ""
if err := h.datastore.GetQueries().InsertStream(context.Background(), db.InsertStreamParams{
ID: streamID,
StartTime: sql.NullTime{Time: h.startTime, Valid: true},
StreamTitle: sql.NullString{String: streamTitle, Valid: validTitle},
}); err != nil {
log.Panicln(err)
}
// Create a reference of the output configurations that were used for this stream.
for variantId, o := range outputs {
configId := shortid.MustGenerate()
if err := h.datastore.GetQueries().InsertOutputConfiguration(context.Background(), db.InsertOutputConfigurationParams{
ID: configId,
Name: o.Name,
StreamID: streamID,
VariantID: strconv.Itoa(variantId),
SegmentDuration: int32(latency.SecondsPerSegment),
Bitrate: int32(o.VideoBitrate),
Framerate: int32(o.Framerate),
ResolutionWidth: sql.NullInt32{Int32: int32(o.ScaledWidth), Valid: true},
ResolutionHeight: sql.NullInt32{Int32: int32(o.ScaledHeight), Valid: true},
Timestamp: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
log.Panicln(err)
}
h.outputConfigurations = append(h.outputConfigurations, HLSOutputConfiguration{
ID: configId,
Name: o.Name,
VideoBitrate: o.VideoBitrate,
ScaledWidth: o.ScaledWidth,
ScaledHeight: o.ScaledHeight,
Framerate: o.Framerate,
SegmentDuration: float64(latency.SegmentCount),
})
}
return &h
}
// SegmentWritten is called when a segment is written to disk.
func (h *HLSRecorder) SegmentWritten(path string) {
outputConfigurationIndexString := utils.GetIndexFromFilePath(path)
outputConfigurationIndex, err := strconv.Atoi(outputConfigurationIndexString)
if err != nil {
log.Errorln("HLSRecorder segmentWritten error:", err)
return
}
p := strings.ReplaceAll(path, "data/", "")
relativeTimestamp := time.Since(h.startTime)
if err := h.datastore.GetQueries().InsertSegment(context.Background(), db.InsertSegmentParams{
ID: shortid.MustGenerate(),
StreamID: h.streamID,
OutputConfigurationID: h.outputConfigurations[outputConfigurationIndex].ID,
Path: p,
RelativeTimestamp: float32(relativeTimestamp.Seconds()),
Timestamp: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorln(err)
}
}
// StreamEnded is called when a stream is ended so the end time can be noted
// in the stream's metadata.
func (h *HLSRecorder) StreamEnded() {
if err := h.datastore.GetQueries().SetStreamEnded(context.Background(), db.SetStreamEndedParams{
ID: h.streamID,
EndTime: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorln(err)
}
}

13
replays/hlsSegment.go

@ -0,0 +1,13 @@
package replays
import "time"
// HLSSegment represents a single HLS segment.
type HLSSegment struct {
ID string
StreamID string
Timestamp time.Time
RelativeTimestamp float32
OutputConfigurationID string
Path string
}

49
replays/mediaPlaylistAllowCacheTag.go

@ -0,0 +1,49 @@
package replays
import (
"bytes"
"fmt"
"github.com/grafov/m3u8"
)
// MediaPlaylistAllowCacheTag is a custom tag to explicitly state that this
// playlist is allowed to be cached.
type MediaPlaylistAllowCacheTag struct {
Type string
}
// TagName should return the full tag identifier including the leading
// '#' and trailing ':' if the tag also contains a value or attribute
// list.
func (tag *MediaPlaylistAllowCacheTag) TagName() string {
return "#EXT-X-ALLOW-CACHE"
}
// Decode decodes the input line. The line will be the entire matched
// line, including the identifier.
func (tag *MediaPlaylistAllowCacheTag) Decode(line string) (m3u8.CustomTag, error) {
_, err := fmt.Sscanf(line, "#EXT-X-ALLOW-CACHE")
return tag, err
}
// SegmentTag specifies that this tag is not for segments.
func (tag *MediaPlaylistAllowCacheTag) SegmentTag() bool {
return false
}
// Encode formats the structure to the text result.
func (tag *MediaPlaylistAllowCacheTag) Encode() *bytes.Buffer {
buf := new(bytes.Buffer)
buf.WriteString(tag.TagName())
buf.WriteString(tag.Type)
return buf
}
// String implements Stringer interface.
func (tag *MediaPlaylistAllowCacheTag) String() string {
return tag.Encode().String()
}

27
replays/outputConfiguration.go

@ -0,0 +1,27 @@
package replays
import "github.com/pkg/errors"
type HLSOutputConfiguration struct {
ID string
StreamId string
VariantId string
Name string
VideoBitrate int
ScaledWidth int
ScaledHeight int
Framerate int
SegmentDuration float64
}
func (config *HLSOutputConfiguration) Validate() error {
if config.VideoBitrate == 0 {
return errors.New("video bitrate is unavailable")
}
if config.Framerate == 0 {
return errors.New("video framerate is unavailable")
}
return nil
}

146
replays/playlistGenerator.go

@ -0,0 +1,146 @@
package replays
import (
"context"
"fmt"
"strings"
"time"
"github.com/grafov/m3u8"
"github.com/pkg/errors"
)
// GetConfigurationsForStream returns the output configurations for a given stream.
func (p *PlaylistGenerator) GetConfigurationsForStream(streamId string) ([]*HLSOutputConfiguration, error) {
outputConfigRows, err := p.datastore.GetQueries().GetOutputConfigurationsForStreamId(context.Background(), streamId)
if err != nil {
return nil, errors.Wrap(err, "failed to get output configurations for stream")
}
outputConfigs := []*HLSOutputConfiguration{}
for _, row := range outputConfigRows {
config := &HLSOutputConfiguration{
ID: row.ID,
StreamId: streamId,
VariantId: row.VariantID,
Name: row.Name,
VideoBitrate: int(row.Bitrate),
Framerate: int(row.Framerate),
ScaledHeight: int(row.ResolutionWidth.Int32),
ScaledWidth: int(row.ResolutionHeight.Int32),
SegmentDuration: float64(row.SegmentDuration),
}
outputConfigs = append(outputConfigs, config)
}
return outputConfigs, nil
}
func (p *PlaylistGenerator) createMediaPlaylistForConfigurationAndSegments(configuration *HLSOutputConfiguration, startTime time.Time, inProgress bool, segments []HLSSegment) (*m3u8.MediaPlaylist, error) {
playlistSize := len(segments)
segmentDuration := configuration.SegmentDuration
playlist, err := m3u8.NewMediaPlaylist(0, uint(playlistSize))
playlist.TargetDuration = configuration.SegmentDuration
if !inProgress {
playlist.MediaType = m3u8.VOD
} else {
playlist.MediaType = m3u8.EVENT
}
// Add the segments to the playlist.
for index, segment := range segments {
// If it's a URL leave it as is, if it's a local path then append a slash.
path := segment.Path
if !strings.HasPrefix(path, "http") {
path = "/" + path
}
mediaSegment := m3u8.MediaSegment{
URI: path,
Duration: segmentDuration,
SeqId: uint64(index),
ProgramDateTime: segment.Timestamp,
}
if err := playlist.AppendSegment(&mediaSegment); err != nil {
return nil, errors.Wrap(err, "failed to append segment to recording playlist")
}
}
if err != nil {
return nil, err
}
// Configure the properties of this media playlist.
if err := playlist.SetProgramDateTime(startTime); err != nil {
return nil, errors.Wrap(err, "failed to set media playlist program date time")
}
// Our live output is specified as v6, so let's match it to be as close as
// possible to what we're doing for live streams.
playlist.SetVersion(6)
if !inProgress {
// Specify explicitly that the playlist content is allowed to be cached.
// However, if in-progress recordings are supported this should not be enabled
// in order for the playlist to be updated with new segments. inProgress is
// determined by seeing if the stream has an endTime or not.
playlist.SetCustomTag(&MediaPlaylistAllowCacheTag{})
// Set the ENDLIST tag and close the playlist for writing if the stream is
// not still in progress.
playlist.Close()
}
return playlist, nil
}
func (p *PlaylistGenerator) createNewMasterPlaylist() *m3u8.MasterPlaylist {
playlist := m3u8.NewMasterPlaylist()
playlist.SetIndependentSegments(true)
playlist.SetVersion(6)
return playlist
}
// GetAllSegmentsForOutputConfiguration returns all the segments for a given output config.
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfiguration(outputId string) ([]HLSSegment, error) {
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputId(context.Background(), outputId)
if err != nil {
return nil, errors.Wrap(err, "failed to get segments for output config")
}
segments := []HLSSegment{}
for _, row := range segmentRows {
segment := HLSSegment{
ID: row.ID,
StreamID: row.StreamID,
OutputConfigurationID: row.OutputConfigurationID,
Timestamp: row.Timestamp.Time,
Path: row.Path,
}
segments = append(segments, segment)
}
return segments, nil
}
func (p *PlaylistGenerator) getMediaPlaylistParamsForConfig(config *HLSOutputConfiguration) m3u8.VariantParams {
params := m3u8.VariantParams{
ProgramId: 1,
Name: config.Name,
FrameRate: float64(config.Framerate),
Bandwidth: uint32(config.VideoBitrate * 1000),
// Match what is generated in our live playlists.
Codecs: "avc1.64001f,mp4a.40.2",
}
// If both the width and height are set then we can set that as
// the resolution in the media playlist.
if config.ScaledHeight > 0 && config.ScaledWidth > 0 {
params.Resolution = fmt.Sprintf("%dx%d", config.ScaledWidth, config.ScaledHeight)
}
return params
}

136
replays/playlistGenerator_test.go

@ -0,0 +1,136 @@
package replays
import (
"testing"
"time"
"github.com/grafov/m3u8"
)
var (
generator = NewPlaylistGenerator()
config = []HLSOutputConfiguration{
{
ID: "1",
VideoBitrate: 1000,
Framerate: 30,
},
{
ID: "2",
VideoBitrate: 2000,
Framerate: 30,
},
}
)
var segments = []HLSSegment{
{
ID: "testSegmentId",
StreamID: "testStreamId",
Timestamp: time.Now(),
OutputConfigurationID: "testOutputConfigId",
Path: "hls/testStreamId/testOutputConfigId/testSegmentId.ts",
},
}
func TestMasterPlaylist(t *testing.T) {
playlist := generator.createNewMasterPlaylist()
mediaPlaylists, err := generator.createMediaPlaylistForConfigurationAndSegments(&config[0], time.Now(), false, segments)
playlist.Append("test", mediaPlaylists, m3u8.VariantParams{
Bandwidth: uint32(config[0].VideoBitrate),
FrameRate: float64(config[0].Framerate),
})
mediaPlaylists.Close()
if err != nil {
t.Error(err)
}
if playlist.Version() != 6 {
t.Error("expected version 6, got", playlist.Version())
}
if !playlist.IndependentSegments() {
t.Error("expected independent segments")
}
if playlist.Variants[0].Bandwidth != uint32(config[0].VideoBitrate) {
t.Error("expected bandwidth", config[0].VideoBitrate, "got", playlist.Variants[0].Bandwidth)
}
if playlist.Variants[0].FrameRate != float64(config[0].Framerate) {
t.Error("expected framerate", config[0].Framerate, "got", playlist.Variants[0].FrameRate)
}
}
func TestCompletedMediaPlaylist(t *testing.T) {
startTime := segments[0].Timestamp
conf := config[0]
// Create a completed media playlist.
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, false, segments)
if err != nil {
t.Error(err)
}
if playlist.TargetDuration != conf.SegmentDuration {
t.Error("expected target duration", conf.SegmentDuration, "got", playlist.TargetDuration)
}
// Verify it's marked as cachable.
if playlist.Custom["#EXT-X-ALLOW-CACHE"].String() != "#EXT-X-ALLOW-CACHE" {
t.Error("expected cachable playlist, tag not set")
}
// Verify it has the correct number of segments in the media playlist.
if int(playlist.Count()) != len(segments) {
t.Error("expected", len(segments), "segments, got", playlist.Count())
}
// Test the playlist version.
if playlist.Version() != 6 {
t.Error("expected version 6, got", playlist.Version())
}
// Verify the playlist type
if playlist.MediaType != m3u8.VOD {
t.Error("expected VOD playlist type, got type", playlist.MediaType)
}
// Verify the first segment URI.
if playlist.Segments[0].URI != "/"+segments[0].Path {
t.Error("expected segment URI", segments[0].Path, "got", playlist.Segments[0].URI)
}
}
func TestInProgressMediaPlaylist(t *testing.T) {
startTime := segments[0].Timestamp
conf := config[0]
// Create a completed media playlist.
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, true, segments)
if err != nil {
t.Error(err)
}
// Verify it's marked as cachable.
if playlist.Custom != nil && playlist.Custom["#EXT-X-ALLOW-CACHE"].String() == "#EXT-X-ALLOW-CACHE" {
t.Error("expected non-achable playlist when stream is still in progress")
}
// Verify it has the correct number of segments in the media playlist.
if int(playlist.Count()) != len(segments) {
t.Error("expected", len(segments), "segments, got", playlist.Count())
}
// Test the playlist version.
if playlist.Version() != 6 {
t.Error("expected version 6, got", playlist.Version())
}
// Verify the playlist type
if playlist.MediaType != m3u8.EVENT {
t.Error("expected EVENT playlist type, got type", playlist.MediaType)
}
}

154
replays/replay_test.go

@ -0,0 +1,154 @@
package replays
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
var (
fakeStreamId = shortid.MustGenerate()
fakeSegmentCount = 300
fakeSegmentDuration = 0
fakeStreamStartTime = time.Now()
fakeConfigId = ""
fakeClipper = shortid.MustGenerate()
fakeClipStartTime = 10
fakeClipEndTime = 15
)
func TestMain(m *testing.M) {
if err := data.SetupPersistence(":memory:"); err != nil {
panic(err)
}
Setup()
populateFakeStream()
m.Run()
}
func populateFakeStream() {
queries := data.GetDatastore().GetQueries()
recording := NewRecording(fakeStreamId)
fakeConfigId = recording.outputConfigurations[0].ID
fakeSegmentDuration = data.GetStreamLatencyLevel().SecondsPerSegment // Seconds
for i := 0; i < fakeSegmentCount; i++ {
fakeSegmentName := fmt.Sprintf("%s-%d.ts", fakeStreamId, i)
if err := queries.InsertSegment(context.Background(), db.InsertSegmentParams{
ID: shortid.MustGenerate(),
StreamID: fakeStreamId,
OutputConfigurationID: fakeConfigId,
Path: filepath.Join(fakeStreamId, fakeConfigId, "0", fakeSegmentName),
RelativeTimestamp: float32(i * fakeSegmentDuration),
Timestamp: sql.NullTime{Time: fakeStreamStartTime.Add(time.Duration(fakeSegmentDuration * i)), Valid: true},
}); err != nil {
log.Errorln(err)
}
}
if err := queries.SetStreamEnded(context.Background(), db.SetStreamEndedParams{
ID: fakeStreamId,
EndTime: sql.NullTime{Time: fakeStreamStartTime.Add(time.Duration(fakeSegmentDuration * fakeSegmentCount)), Valid: true},
}); err != nil {
log.Errorln(err)
}
}
func TestStream(t *testing.T) {
playlist := NewPlaylistGenerator()
stream, err := playlist.GetStream(fakeStreamId)
if err != nil {
t.Error(err)
}
if stream.ID != fakeStreamId {
t.Error("expected stream id", fakeStreamId, "got", stream.ID)
}
}
func TestPlaylist(t *testing.T) {
playlist := NewPlaylistGenerator()
p, err := playlist.GenerateMediaPlaylistForStreamAndConfiguration(fakeStreamId, fakeConfigId)
if p == nil {
t.Error("expected playlist")
}
if err != nil {
t.Error(err)
}
if len(p.Segments) != fakeSegmentCount {
t.Error("expected", fakeSegmentCount, "segments, got", len(p.Segments))
}
}
func TestClip(t *testing.T) {
segmentDuration := data.GetStreamLatencyLevel().SecondsPerSegment
playlist := NewPlaylistGenerator()
clipId, _, err := AddClipForStream(fakeStreamId, "test clip", fakeClipper, float32(fakeClipStartTime), float32(fakeClipEndTime))
if err != nil {
t.Error(err)
}
clips, err := GetAllClips()
if err != nil {
t.Error(err)
}
if len(clips) != 1 {
t.Error("expected 1 clip, got", len(clips))
}
clip := clips[0]
if clip.ID != clipId {
t.Error("expected clip id", clipId, "got", clip.ID)
}
if clip.Manifest != fmt.Sprintf("/clip/%s", clipId) {
t.Error("expected manifest id", fmt.Sprintf("/clip/%s", clipId), "got", clip.Manifest)
}
expectedStartTime := float32(utils.RoundDownToNearest(float32(fakeClipStartTime), segmentDuration))
if clip.RelativeStartTime != expectedStartTime {
t.Error("expected clip start time", fakeClipStartTime, "got", clip.RelativeStartTime)
}
expectedEndTime := float32(utils.RoundUpToNearest(float32(fakeClipEndTime), segmentDuration))
if clip.RelativeEndTime != expectedEndTime {
t.Error("expected clip end time", fakeClipEndTime, "got", clip.RelativeEndTime)
}
expectedDuration := expectedEndTime - expectedStartTime
if float32(clip.DurationSeconds) != expectedDuration {
t.Error("expected clip duration", expectedDuration, "got", clip.DurationSeconds)
}
p, err := playlist.GenerateMediaPlaylistForClipAndConfiguration(clipId, fakeConfigId)
if err != nil {
t.Error(err)
}
if p == nil {
t.Error("expected playlist")
}
expectedSegmentCount := 3
if len(p.Segments) != expectedSegmentCount {
t.Error("expected", expectedSegmentCount, "segments, got", len(p.Segments))
}
if p.TargetDuration != float64(fakeSegmentDuration) {
t.Error("expected target duration of", fakeSegmentDuration, "got", p.TargetDuration)
}
}

21
replays/replays.go

@ -0,0 +1,21 @@
package replays
import (
"context"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// Setup will setup the replay package.
func Setup() {
fixUnfinishedStreams()
}
// fixUnfinishedStreams will find streams with no end time and attempt to
// give them an end time based on the last segment assigned to that stream.
func fixUnfinishedStreams() {
if err := data.GetDatastore().GetQueries().FixUnfinishedStreams(context.Background()); err != nil {
log.Warnln(err)
}
}

6
replays/storageProvider.go

@ -0,0 +1,6 @@
package replays
type StorageProvider interface {
Setup() error
Save(localFilePath, destinationPath string, retryCount int) (string, error)
}

41
replays/stream.go

@ -0,0 +1,41 @@
package replays
import (
"context"
"fmt"
"time"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
)
type Stream struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime,omitempty"`
InProgress bool `json:"inProgress,omitempty"`
Manifest string `json:"manifest,omitempty"`
}
// GetStreams will return all streams that have been recorded.
func GetStreams() ([]*Stream, error) {
streams, err := data.GetDatastore().GetQueries().GetStreams(context.Background())
if err != nil {
return nil, errors.WithMessage(err, "failure to get streams")
}
response := []*Stream{}
for _, stream := range streams {
s := Stream{
ID: stream.ID,
Title: stream.StreamTitle.String,
StartTime: stream.StartTime.Time,
EndTime: stream.EndTime.Time,
InProgress: !stream.EndTime.Valid,
Manifest: fmt.Sprintf("/replay/%s", stream.ID),
}
response = append(response, &s)
}
return response, nil
}

142
replays/streamClipPlaylistGenerator.go

@ -0,0 +1,142 @@
package replays
import (
"context"
"strings"
"github.com/grafov/m3u8"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/pkg/errors"
)
// GenerateMasterPlaylistForClip returns a master playlist for a given clip Id.
// It includes references to the media playlists for each output configuration.
func (p *PlaylistGenerator) GenerateMasterPlaylistForClip(clipId string) (*m3u8.MasterPlaylist, error) {
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId)
if err != nil {
return nil, errors.Wrap(err, "unable to fetch requested clip")
}
streamId := clip.StreamID
configs, err := p.GetConfigurationsForStream(streamId)
if err != nil {
return nil, errors.Wrap(err, "failed to get configurations for stream")
}
// Create the master playlist that will hold the different media playlists.
masterPlaylist := p.createNewMasterPlaylist()
// Create the media playlists for each output configuration.
for _, config := range configs {
// Verify the validity of the configuration.
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid output configuration")
}
mediaPlaylist, err := p.GenerateMediaPlaylistForClipAndConfiguration(clipId, config.ID)
if err != nil {
return nil, errors.Wrap(err, "failed to create clip media playlist")
}
// Append the media playlist to the master playlist.
params := p.getMediaPlaylistParamsForConfig(config)
// Add the media playlist to the master playlist.
publicPlaylistPath := strings.Join([]string{"/clip", clipId, config.ID}, "/")
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params)
}
// Return the final master playlist that contains all the media playlists.
return masterPlaylist, nil
}
// GenerateMediaPlaylistForClipAndConfiguration returns a media playlist for a
// given clip Id and output configuration.
func (p *PlaylistGenerator) GenerateMediaPlaylistForClipAndConfiguration(clipId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) {
clip, err := p.GetClip(clipId)
if err != nil {
return nil, errors.Wrap(err, "failed to get stream")
}
config, err := p.GetOutputConfig(outputConfigurationId)
if err != nil {
return nil, errors.Wrap(err, "failed to get output configuration")
}
clipStartSeconds := clip.RelativeStartTime
clipEndSeconds := clip.RelativeEndTime
// Fetch all the segments for this configuration.
segments, err := p.GetAllSegmentsForOutputConfigurationAndWindow(outputConfigurationId, clipStartSeconds, clipEndSeconds)
if err != nil {
return nil, errors.Wrap(err, "failed to get all clip segments for output configuration")
}
// Create the media playlist for this configuration and add the segments.
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, clip.Timestamp, false, segments)
if err != nil {
return nil, errors.Wrap(err, "failed to create clip media playlist")
}
return mediaPlaylist, nil
}
// GetClip returns a clip by its ID.
func (p *PlaylistGenerator) GetClip(clipId string) (*Clip, error) {
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId)
if err != nil {
return nil, errors.Wrap(err, "failed to get clip")
}
if clip.ClipID == "" {
return nil, errors.Wrap(err, "failed to get clip")
}
if !clip.RelativeEndTime.Valid {
return nil, errors.Wrap(err, "failed to get clip")
}
timestamp, err := models.FlexibleDateParse(clip.ClipTimestamp)
if err != nil {
return nil, errors.Wrap(err, "failed to parse clip timestamp")
}
c := Clip{
ID: clip.ClipID,
StreamId: clip.StreamID,
ClipTitle: clip.ClipTitle.String,
RelativeStartTime: float32(clip.RelativeStartTime.Float64),
RelativeEndTime: float32(clip.RelativeEndTime.Float64),
Timestamp: timestamp,
}
return &c, nil
}
// GetAllSegmentsForOutputConfigurationAndWindow returns all the segments for a
// given output config and time window.
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfigurationAndWindow(configId string, startSeconds, endSeconds float32) ([]HLSSegment, error) {
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputIdAndWindow(context.Background(), db.GetSegmentsForOutputIdAndWindowParams{
OutputConfigurationID: configId,
StartSeconds: startSeconds,
EndSeconds: endSeconds,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get clip segments for output config")
}
segments := []HLSSegment{}
for _, row := range segmentRows {
segment := HLSSegment{
ID: row.ID,
StreamID: row.StreamID,
OutputConfigurationID: row.OutputConfigurationID,
Timestamp: row.Timestamp.Time,
Path: row.Path,
}
segments = append(segments, segment)
}
return segments, nil
}

129
replays/streamReplayPlaylistGenerator.go

@ -0,0 +1,129 @@
package replays
import (
"context"
"strings"
"github.com/grafov/m3u8"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/pkg/errors"
)
/*
The PlaylistGenerator is responsible for creating the master and media
playlists, in order to replay a stream in whole, or part. It requires detailed
metadata about how the initial live stream was configured, as well as a
access to every segment that was created during the live stream.
*/
type PlaylistGenerator struct {
datastore *data.Datastore
}
func NewPlaylistGenerator() *PlaylistGenerator {
return &PlaylistGenerator{
datastore: data.GetDatastore(),
}
}
func (p *PlaylistGenerator) GenerateMasterPlaylistForStream(streamId string) (*m3u8.MasterPlaylist, error) {
// Determine the different output configurations for this stream.
configs, err := p.GetConfigurationsForStream(streamId)
if err != nil {
return nil, errors.Wrap(err, "failed to get configurations for stream")
}
// Create the master playlist that will hold the different media playlists.
masterPlaylist := p.createNewMasterPlaylist()
// Create the media playlists for each output configuration.
for _, config := range configs {
// Verify the validity of the configuration.
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid output configuration")
}
mediaPlaylist, err := p.GenerateMediaPlaylistForStreamAndConfiguration(streamId, config.ID)
if err != nil {
return nil, errors.Wrap(err, "failed to create media playlist")
}
// Append the media playlist to the master playlist.
params := p.getMediaPlaylistParamsForConfig(config)
// Add the media playlist to the master playlist.
publicPlaylistPath := strings.Join([]string{"/replay", streamId, config.ID}, "/")
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params)
}
// Return the final master playlist that contains all the media playlists.
return masterPlaylist, nil
}
func (p *PlaylistGenerator) GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) {
stream, err := p.GetStream(streamId)
if err != nil {
return nil, errors.Wrap(err, "failed to get stream")
}
config, err := p.GetOutputConfig(outputConfigurationId)
if err != nil {
return nil, errors.Wrap(err, "failed to get output configuration")
}
// Fetch all the segments for this configuration.
segments, err := p.GetAllSegmentsForOutputConfiguration(outputConfigurationId)
if err != nil {
return nil, errors.Wrap(err, "failed to get all segments for output configuration")
}
// Create the media playlist for this configuration and add the segments.
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, stream.StartTime, stream.InProgress, segments)
if err != nil {
return nil, errors.Wrap(err, "failed to create media playlist")
}
return mediaPlaylist, nil
}
func (p *PlaylistGenerator) GetStream(streamId string) (*Stream, error) {
stream, err := p.datastore.GetQueries().GetStreamById(context.Background(), streamId)
if stream.ID == "" {
return nil, errors.Wrap(err, "failed to get stream")
}
s := Stream{
ID: stream.ID,
Title: stream.StreamTitle.String,
StartTime: stream.StartTime.Time,
EndTime: stream.EndTime.Time,
InProgress: !stream.EndTime.Valid,
}
return &s, nil
}
func (p *PlaylistGenerator) GetOutputConfig(outputConfigId string) (*HLSOutputConfiguration, error) {
config, err := p.datastore.GetQueries().GetOutputConfigurationForId(context.Background(), outputConfigId)
if err != nil {
return nil, errors.Wrap(err, "failed to get output configuration")
}
return createConfigFromConfigRow(config), nil
}
func createConfigFromConfigRow(row db.GetOutputConfigurationForIdRow) *HLSOutputConfiguration {
config := HLSOutputConfiguration{
ID: row.ID,
StreamId: row.StreamID,
VariantId: row.VariantID,
Name: row.Name,
VideoBitrate: int(row.Bitrate),
Framerate: int(row.Framerate),
ScaledHeight: int(row.ResolutionWidth.Int32),
ScaledWidth: int(row.ResolutionHeight.Int32),
SegmentDuration: float64(row.SegmentDuration),
}
return &config
}

9
router/router.go

@ -392,6 +392,15 @@ func Start() error {
http.HandleFunc("/api/auth/fediverse", middleware.RequireUserAccessToken(fediverseauth.RegisterFediverseOTPRequest)) http.HandleFunc("/api/auth/fediverse", middleware.RequireUserAccessToken(fediverseauth.RegisterFediverseOTPRequest))
http.HandleFunc("/api/auth/fediverse/verify", fediverseauth.VerifyFediverseOTPRequest) http.HandleFunc("/api/auth/fediverse/verify", fediverseauth.VerifyFediverseOTPRequest)
// Replay functionality. This route handles both /replay/{streamId} (master)
// and /replay/{streamId}/{outputConfigId} (media) routes.
http.HandleFunc("/api/replays", controllers.GetReplays)
http.HandleFunc("/replay/", controllers.GetReplay)
http.HandleFunc("/api/clips", controllers.GetAllClips)
http.HandleFunc("/api/clip", controllers.AddClip)
http.HandleFunc("/clip/", controllers.GetClip)
// ActivityPub has its own router // ActivityPub has its own router
activitypub.Start(data.GetDatastore()) activitypub.Start(data.GetDatastore())

1
static/web/_next/static/QNBMvcxExtLzshYBDiHgr/_buildManifest.js

File diff suppressed because one or more lines are too long

1
static/web/_next/static/QNBMvcxExtLzshYBDiHgr/_ssgManifest.js

@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set,self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB();

35
static/web/_next/static/chunks/5888-0b1d0f03187c2ed1.js vendored

File diff suppressed because one or more lines are too long

10985
test/automated/replays/package-lock.json generated

File diff suppressed because it is too large Load Diff

18
test/automated/replays/package.json

@ -0,0 +1,18 @@
{
"name": "owncast-test-automation",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest --bail"
},
"author": "",
"license": "ISC",
"dependencies": {
"m3u8-parser": "^4.7.0",
"node-fetch": "^2.6.7"
},
"devDependencies": {
"jest": "^26.6.3"
}
}

130
test/automated/replays/replays.test.js

@ -0,0 +1,130 @@
const m3u8Parser = require('m3u8-parser');
const fetch = require('node-fetch');
const url = require('url');
const { test } = require('@jest/globals');
const REPLAYS_API = '/api/replays';
const TEST_OWNCAST_INSTANCE = 'http://localhost:8080';
const HLS_FETCH_ITERATIONS = 5;
jest.setTimeout(40000);
async function getPlaylist(urlString) {
const response = await fetch(urlString);
expect(response.status).toBe(200);
const body = await response.text();
var parser = new m3u8Parser.Parser();
parser.push(body);
parser.end();
return parser.manifest;
}
async function getReplaysAPI(urlString) {
const response = await fetch(urlString);
expect(response.status).toBe(200);
const body = await response.text();
return body;
}
function normalizeUrl(urlString, baseUrl) {
let parsedString = url.parse(urlString);
if (!parsedString.host) {
const testInstanceRoot = url.parse(baseUrl);
parsedString.protocol = testInstanceRoot.protocol;
parsedString.host = testInstanceRoot.host;
const filename = baseUrl.substring(baseUrl.lastIndexOf('/') + 1);
parsedString.pathname =
testInstanceRoot.pathname.replace(filename, '') + urlString;
}
return url.format(parsedString).toString();
}
// Iterate over an array of video segments and make sure they return back
// valid status.
async function validateSegments(segments) {
for (let segment of segments) {
const res = await fetch(segment);
expect(res.status).toBe(200);
}
}
describe('fetch list of clips', () => {
const replaysAPIEndpoint = `${TEST_OWNCAST_INSTANCE}${REPLAYS_API}`;
// var masterPlaylist;
// var mediaPlaylistUrl;
test('fetch replay list', async (done) => {
console.log(replaysAPIEndpoint);
try {
const response = await getReplaysAPI(replaysAPIEndpoint);
console.log(response);
} catch (e) {
console.error('error fetching and parsing master playlist', e);
}
done();
});
// test('verify there is a media playlist', () => {
// // Master playlist should have at least one media playlist.
// expect(masterPlaylist.playlists.length).toBe(1);
// try {
// mediaPlaylistUrl = normalizeUrl(
// masterPlaylist.playlists[0].uri,
// masterPlaylistUrl
// );
// } catch (e) {
// console.error('error fetching and parsing media playlist', e);
// }
// });
// test('verify there are segments', async (done) => {
// let playlist;
// try {
// playlist = await getPlaylist(mediaPlaylistUrl);
// } catch (e) {
// console.error('error verifying segments in media playlist', e);
// }
// const segments = playlist.segments;
// expect(segments.length).toBeGreaterThan(0);
// done();
// });
// // Iterate over segments and make sure they change.
// // Use the reported duration of the segment to wait to
// // fetch another just like a real HLS player would do.
// var lastSegmentUrl;
// for (let i = 0; i < HLS_FETCH_ITERATIONS; i++) {
// test('fetch and monitor media playlist segments ' + i, async (done) => {
// await new Promise((r) => setTimeout(r, 5000));
// try {
// var playlist = await getPlaylist(mediaPlaylistUrl);
// } catch (e) {
// console.error('error updating media playlist', mediaPlaylistUrl, e);
// }
// const segments = playlist.segments;
// const segment = segments[segments.length - 1];
// expect(segment.uri).not.toBe(lastSegmentUrl);
// try {
// var segmentUrl = normalizeUrl(segment.uri, mediaPlaylistUrl);
// await validateSegments([segmentUrl]);
// } catch (e) {
// console.error('unable to validate HLS segment', segmentUrl, e);
// }
// lastSegmentUrl = segment.uri;
// done();
// });
// }
});

19
test/automated/replays/run.sh

@ -0,0 +1,19 @@
#!/bin/bash
set -e
source ../tools.sh
# Install the node test framework
npm install --silent >/dev/null
install_ffmpeg
start_owncast "--enableReplayFeatures"
start_stream
sleep 10
# Run tests against a fresh install with no settings.
npm test

121
test/automated/tools.sh

@ -3,91 +3,96 @@
set -e set -e
function install_ffmpeg() { function install_ffmpeg() {
# install a specific version of ffmpeg # install a specific version of ffmpeg
FFMPEG_VER="4.4.1" FFMPEG_VER="4.4.1"
FFMPEG_PATH="$(pwd)/ffmpeg-$FFMPEG_VER" FFMPEG_PATH="$(pwd)/ffmpeg-$FFMPEG_VER"
PATH=$FFMPEG_PATH:$PATH PATH=$FFMPEG_PATH:$PATH
if ! [[ -d "$FFMPEG_PATH" ]]; then if ! [[ -d "$FFMPEG_PATH" ]]; then
mkdir "$FFMPEG_PATH" mkdir "$FFMPEG_PATH"
fi fi
pushd "$FFMPEG_PATH" >/dev/null pushd "$FFMPEG_PATH" >/dev/null
if [[ -x "$FFMPEG_PATH/ffmpeg" ]]; then if [[ -x "$FFMPEG_PATH/ffmpeg" ]]; then
ffmpeg_version=$("$FFMPEG_PATH/ffmpeg" -version | awk -F 'ffmpeg version' '{print $2}' | awk 'NR==1{print $1}')
if [[ "$ffmpeg_version" == "$FFMPEG_VER-static" ]]; then ffmpeg_version=$("$FFMPEG_PATH/ffmpeg" -version | awk -F 'ffmpeg version' '{print $2}' | awk 'NR==1{print $1}')
popd >/dev/null
return 0
else
mv "$FFMPEG_PATH/ffmpeg" "$FFMPEG_PATH/ffmpeg.bk" || rm -f "$FFMPEG_PATH/ffmpeg"
fi
fi
rm -f ffmpeg.zip if [[ "$ffmpeg_version" == "$FFMPEG_VER-static" ]]; then
curl -sL --fail https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v${FFMPEG_VER}/ffmpeg-${FFMPEG_VER}-linux-64.zip --output ffmpeg.zip >/dev/null popd >/dev/null
unzip -o ffmpeg.zip >/dev/null && rm -f ffmpeg.zip return 0
chmod +x ffmpeg else
PATH=$FFMPEG_PATH:$PATH mv "$FFMPEG_PATH/ffmpeg" "$FFMPEG_PATH/ffmpeg.bk" || rm -f "$FFMPEG_PATH/ffmpeg"
fi
fi
popd >/dev/null rm -f ffmpeg.zip
curl -sL --fail https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v${FFMPEG_VER}/ffmpeg-${FFMPEG_VER}-linux-64.zip --output ffmpeg.zip >/dev/null
unzip -o ffmpeg.zip >/dev/null && rm -f ffmpeg.zip
chmod +x ffmpeg
PATH=$FFMPEG_PATH:$PATH
popd >/dev/null
} }
function start_owncast() { function start_owncast() {
# Build and run owncast from source # Build and run owncast from source
echo "Building owncast..." echo "Building owncast..."
pushd "$(git rev-parse --show-toplevel)" >/dev/null pushd "$(git rev-parse --show-toplevel)" >/dev/null
go build -o owncast main.go go build -o owncast main.go
if [ -z "$1" ]; then
echo "Running owncast..."
else
echo "Running owncast with flags: $1"
fi
echo "Running owncast..." ./owncast -database "$TEMP_DB" $1 &
./owncast -database "$TEMP_DB" & SERVER_PID=$!
SERVER_PID=$! popd >/dev/null
popd >/dev/null
sleep 5 sleep 5
} }
function start_stream() { function start_stream() {
# Start streaming the test file over RTMP to the local owncast instance. # Start streaming the test file over RTMP to the local owncast instance.
../../ocTestStream.sh & ../../ocTestStream.sh &
STREAM_PID=$! STREAM_PID=$!
echo "Waiting for stream to start..." echo "Waiting for stream to start..."
sleep 12 sleep 12
} }
function update_storage_config() { function update_storage_config() {
echo "Configuring external storage to use ${S3_BUCKET}..." echo "Configuring external storage to use ${S3_BUCKET}..."
# Hard-coded to admin:abc123 for auth # Hard-coded to admin:abc123 for auth
curl --fail 'http://localhost:8080/api/admin/config/s3' \ curl --fail 'http://localhost:8080/api/admin/config/s3' \
-H 'Authorization: Basic YWRtaW46YWJjMTIz' \ -H 'Authorization: Basic YWRtaW46YWJjMTIz' \
--data-raw "{\"value\":{\"accessKey\":\"${S3_ACCESS_KEY}\",\"acl\":\"\",\"bucket\":\"${S3_BUCKET}\",\"enabled\":true,\"endpoint\":\"${S3_ENDPOINT}\",\"region\":\"${S3_REGION}\",\"secret\":\"${S3_SECRET}\",\"servingEndpoint\":\"\"}}" --data-raw "{\"value\":{\"accessKey\":\"${S3_ACCESS_KEY}\",\"acl\":\"\",\"bucket\":\"${S3_BUCKET}\",\"enabled\":true,\"endpoint\":\"${S3_ENDPOINT}\",\"region\":\"${S3_REGION}\",\"secret\":\"${S3_SECRET}\",\"servingEndpoint\":\"\"}}"
} }
function kill_with_kids() { function kill_with_kids() {
# kill a process and all its children (by pid)! return no error. # kill a process and all its children (by pid)! return no error.
if [[ -n $1 ]]; then if [[ -n $1 ]]; then
mapfile -t CHILDREN_PID_LIST < <(ps --ppid "$1" -o pid= &>/dev/null || true) mapfile -t CHILDREN_PID_LIST < <(ps --ppid "$1" -o pid= &>/dev/null || true)
for child_pid in "${CHILDREN_PID_LIST[@]}"; do for child_pid in "${CHILDREN_PID_LIST[@]}"; do
kill "$child_pid" &>/dev/null || true kill "$child_pid" &>/dev/null || true
wait "$child_pid" &>/dev/null || true wait "$child_pid" &>/dev/null || true
done done
kill "$1" &>/dev/null || true kill "$1" &>/dev/null || true
wait "$1" &>/dev/null || true wait "$1" &>/dev/null || true
fi fi
} }
function finish() { function finish() {
echo "Cleaning up..." echo "Cleaning up..."
kill_with_kids "$STREAM_PID" kill_with_kids "$STREAM_PID"
kill "$SERVER_PID" &>/dev/null || true kill "$SERVER_PID" &>/dev/null || true
wait "$SERVER_PID" &>/dev/null || true wait "$SERVER_PID" &>/dev/null || true
rm -fr "$TEMP_DB" rm -fr "$TEMP_DB"
} }

33
utils/utils.go

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"math/rand" "math/rand"
"net/url" "net/url"
"os" "os"
@ -67,7 +68,7 @@ func Copy(source, destination string) error {
func Move(source, destination string) error { func Move(source, destination string) error {
err := os.Rename(source, destination) err := os.Rename(source, destination)
if err != nil { if err != nil {
log.Warnln("Moving with os.Rename failed, falling back to copy and delete!", err) log.Debugln("Moving with os.Rename failed, falling back to copy and delete!", err)
return moveFallback(source, destination) return moveFallback(source, destination)
} }
return nil return nil
@ -303,10 +304,12 @@ func VerifyFFMpegPath(path string) error {
} }
// CleanupDirectory removes the directory and makes it fresh again. Throws fatal error on failure. // CleanupDirectory removes the directory and makes it fresh again. Throws fatal error on failure.
func CleanupDirectory(path string) { func CleanupDirectory(path string, keepOldFiles bool) {
log.Traceln("Cleaning", path) if !keepOldFiles {
if err := os.RemoveAll(path); err != nil { log.Traceln("Cleaning", path)
log.Fatalln("Unable to remove directory. Please check the ownership and permissions", err) if err := os.RemoveAll(path); err != nil {
log.Fatalln("Unable to remove directory. Please check the ownership and permissions", err)
}
} }
if err := os.MkdirAll(path, 0o750); err != nil { if err := os.MkdirAll(path, 0o750); err != nil {
log.Fatalln("Unable to create directory. Please check the ownership and permissions", err) log.Fatalln("Unable to create directory. Please check the ownership and permissions", err)
@ -448,3 +451,23 @@ func DecodeBase64Image(url string) (bytes []byte, extension string, err error) {
return bytes, extension, nil return bytes, extension, nil
} }
// RoundUpToNearest rounds up to the nearest number divisible.
func RoundUpToNearest(x float32, to int) int {
xInt := int(math.Ceil(float64(x)))
if xInt%to == 0 {
return xInt
}
return xInt + to - xInt%to
}
// RoundDownToNearest rounds down to the nearest number divisible.
func RoundDownToNearest(x float32, to int) int {
xInt := int(math.Floor(float64(x)))
if xInt%to == 0 {
return xInt
}
return xInt - xInt%to
}

Loading…
Cancel
Save