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 @@ -43,6 +43,9 @@ var EnableAutoUpdate = false
// A temporary stream key that can be set via the command line.
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.
func GetCommit() string {
if GitCommit == "" {

5
controllers/admin/config.go

@ -13,6 +13,7 @@ import ( @@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
@ -76,8 +77,10 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) { @@ -76,8 +77,10 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
return
}
if value != "" {
streamID := core.GetCurrentBroadcast().StreamID
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")
}

167
controllers/clips.go

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

1
core/data/data.go

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

64
core/data/replays.go

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

63
core/storageproviders/local.go

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

35
core/storageproviders/rewriteLocalPlaylist.go

@ -25,14 +25,7 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s @@ -25,14 +25,7 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s
}
for _, item := range p.Variants {
// Determine the final path to this playlist.
var finalPath string
if pathPrefix != "" {
finalPath = filepath.Join(pathPrefix, "/hls")
} else {
finalPath = "/hls"
}
item.URI = remoteServingEndpoint + filepath.Join(finalPath, item.URI)
item.URI = filepath.Join(remoteServingEndpoint, pathPrefix, item.URI)
}
publicPath := filepath.Join(config.HLSStoragePath, filepath.Base(localFilePath))
@ -41,3 +34,29 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s @@ -41,3 +34,29 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix s
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 ( @@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
@ -20,12 +21,11 @@ import ( @@ -20,12 +21,11 @@ import (
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/owncast/owncast/config"
)
// S3Storage is the s3 implementation of a storage provider.
type S3Storage struct {
streamId string
sess *session.Session
s3Client *s3.S3
@ -56,6 +56,11 @@ func NewS3Storage() *S3Storage { @@ -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.
func (s *S3Storage) Setup() error {
log.Trace("Setting up S3 for external storage of video...")
@ -88,16 +93,19 @@ func (s *S3Storage) Setup() error { @@ -88,16 +93,19 @@ func (s *S3Storage) Setup() error {
}
// 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)
performanceMonitorKey := "s3upload-" + index
utils.StartPerformanceMonitor(performanceMonitorKey)
// 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)
return
return "", 0, err
}
averagePerformance := utils.GetAveragePerformance(performanceMonitorKey)
// Warn the user about long-running save operations
@ -111,14 +119,17 @@ func (s *S3Storage) SegmentWritten(localFilePath string) { @@ -111,14 +119,17 @@ func (s *S3Storage) SegmentWritten(localFilePath string) {
// so the segments and the HLS playlist referencing
// them are in sync.
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
if pErr, ok := err.(*os.PathError); ok {
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.
@ -127,7 +138,8 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) { @@ -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
// yet exist. See SegmentWritten.
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)
s.queuedPlaylistUpdates[localFilePath] = localFilePath
}
@ -138,21 +150,25 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) { @@ -138,21 +150,25 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) {
// MasterPlaylistWritten is called when the master hls playlist is written.
func (s *S3Storage) MasterPlaylistWritten(localFilePath string) {
// 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)
}
}
// Save saves the file to the s3 bucket.
func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
file, err := os.Open(filePath) // nolint
func (s *S3Storage) Save(localFilePath, remoteDestinationPath string, retryCount int) (string, error) {
file, err := os.Open(localFilePath) // nolint
if err != nil {
return "", err
}
defer file.Close()
// 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.
remotePath := strings.Join([]string{"hls", normalizedPath}, "")
@ -162,7 +178,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) { @@ -162,7 +178,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
remotePath = strings.Join([]string{prefix, remotePath}, "/")
}
maxAgeSeconds := utils.GetCacheDurationSecondsForPath(filePath)
maxAgeSeconds := utils.GetCacheDurationSecondsForPath(localFilePath)
cacheControlHeader := fmt.Sprintf("max-age=%d", maxAgeSeconds)
uploadInput := &s3manager.UploadInput{
@ -172,7 +188,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) { @@ -172,7 +188,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
CacheControl: &cacheControlHeader,
}
if path.Ext(filePath) == ".m3u8" {
if path.Ext(localFilePath) == ".m3u8" {
noCacheHeader := "no-cache, no-store, must-revalidate"
contentType := "application/x-mpegURL"
@ -192,22 +208,27 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) { @@ -192,22 +208,27 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
log.Traceln("error uploading segment", err.Error())
if retryCount < 4 {
log.Traceln("Retrying...")
return s.Save(filePath, retryCount+1)
return s.Save(localFilePath, remoteDestinationPath, retryCount+1)
}
// 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.
s.removeLocalFile(filePath)
s.removeLocalFile(localFilePath)
return response.Location, nil
}
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
maxNumber := data.GetStreamLatencyLevel().SegmentCount
buffer := 20
@ -273,7 +294,7 @@ func (s *S3Storage) removeLocalFile(filePath string) { @@ -273,7 +294,7 @@ func (s *S3Storage) removeLocalFile(filePath string) {
cleanFilepath := filepath.Clean(filePath)
if err := os.Remove(cleanFilepath); err != nil {
log.Errorln(err)
log.Debugln(err)
}
}
@ -331,6 +352,16 @@ func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) { @@ -331,6 +352,16 @@ func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) {
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 {
lastModified time.Time
key string

33
core/streamState.go

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

7
core/transcoder/fileWriterReceiverService.go

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

33
core/transcoder/hlsHandler.go

@ -1,17 +1,46 @@ @@ -1,17 +1,46 @@
package transcoder
import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/replays"
log "github.com/sirupsen/logrus"
)
// HLSHandler gets told about available HLS playlists and segments.
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.
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.

36
core/transcoder/transcoder.go

@ -27,12 +27,10 @@ type Transcoder struct { @@ -27,12 +27,10 @@ type Transcoder struct {
stdin *io.PipeReader
TranscoderCompleted func(error)
playlistOutputPath string
StreamID string
ffmpegPath string
segmentIdentifier string
internalListenerPort string
input string
segmentOutputPath string
variants []HLSVariant
currentStreamOutputSettings []models.StreamOutputVariant
@ -118,7 +116,9 @@ func (t *Transcoder) Start(shouldLog bool) { @@ -118,7 +116,9 @@ func (t *Transcoder) Start(shouldLog bool) {
if shouldLog {
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 {
log.Println(command)
@ -181,8 +181,8 @@ func (t *Transcoder) getString() string { @@ -181,8 +181,8 @@ func (t *Transcoder) getString() string {
hlsOptionFlags = append(hlsOptionFlags, "append_list")
}
if t.segmentIdentifier == "" {
t.segmentIdentifier = shortid.MustGenerate()
if t.StreamID == "" {
t.StreamID = shortid.MustGenerate()
}
hlsEventString := ""
@ -197,6 +197,7 @@ func (t *Transcoder) getString() string { @@ -197,6 +197,7 @@ func (t *Transcoder) getString() string {
if len(hlsOptionFlags) > 0 {
hlsOptionsString = "-hls_flags " + strings.Join(hlsOptionFlags, "+")
}
ffmpegFlags := []string{
fmt.Sprintf(`FFREPORT=file="%s":level=32`, logging.GetTranscoderLogFilePath()),
t.ffmpegPath,
@ -226,11 +227,11 @@ func (t *Transcoder) getString() string { @@ -226,11 +227,11 @@ func (t *Transcoder) getString() string {
// Filenames
"-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
"-method PUT", // HLS results sent back to us will be over PUTs
localListenerAddress + "/%v/stream.m3u8", // Send HLS playlists back to us over HTTP
"-method PUT", // HLS results sent back to us will be over PUTs
localListenerAddress + "/" + t.StreamID + "/%v/stream.m3u8", // Send HLS playlists back to us over HTTP
}
return strings.Join(ffmpegFlags, " ")
@ -272,19 +273,17 @@ func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int) @@ -272,19 +273,17 @@ func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int)
}
// NewTranscoder will return a new Transcoder, populated by the config.
func NewTranscoder() *Transcoder {
func NewTranscoder(streamID string) *Transcoder {
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
transcoder := new(Transcoder)
transcoder.StreamID = streamID
transcoder.ffmpegPath = ffmpegPath
transcoder.internalListenerPort = config.InternalHLSListenerPort
transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants()
transcoder.currentLatencyLevel = data.GetStreamLatencyLevel()
transcoder.codec = getCodec(data.GetVideoCodec())
transcoder.segmentOutputPath = config.HLSStoragePath
transcoder.playlistOutputPath = config.HLSStoragePath
transcoder.input = "pipe:0" // stdin
for index, quality := range transcoder.currentStreamOutputSettings {
@ -433,14 +432,9 @@ func (t *Transcoder) SetStdin(pipe *io.PipeReader) { @@ -433,14 +432,9 @@ func (t *Transcoder) SetStdin(pipe *io.PipeReader) {
t.stdin = pipe
}
// SetOutputPath sets the root directory that should include playlists and video segments.
func (t *Transcoder) SetOutputPath(output string) {
t.segmentOutputPath = output
}
// SetIdentifier enables appending a unique identifier to segment file name.
func (t *Transcoder) SetIdentifier(output string) {
t.segmentIdentifier = output
// SetStreamID sets a unique identifier for the currently transcoding stream.
func (t *Transcoder) SetStreamID(id string) {
t.StreamID = id
}
// 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) { @@ -14,8 +14,7 @@ func TestFFmpegNvencCommand(t *testing.T) {
transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput")
transcoder.SetIdentifier("jdoieGg")
transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegNvencCommand(t *testing.T) { @@ -42,7 +41,7 @@ func TestFFmpegNvencCommand(t *testing.T) {
cmd := transcoder.getString()
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 {
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) { @@ -14,8 +14,7 @@ func TestFFmpegOmxCommand(t *testing.T) {
transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput")
transcoder.SetIdentifier("jdFsdfzGg")
transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegOmxCommand(t *testing.T) { @@ -42,7 +41,7 @@ func TestFFmpegOmxCommand(t *testing.T) {
cmd := transcoder.getString()
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 {
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) { @@ -14,8 +14,7 @@ func TestFFmpegVaapiCommand(t *testing.T) {
transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput")
transcoder.SetIdentifier("jdofFGg")
transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegVaapiCommand(t *testing.T) { @@ -42,7 +41,7 @@ func TestFFmpegVaapiCommand(t *testing.T) {
cmd := transcoder.getString()
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 {
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) { @@ -14,8 +14,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) {
transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput")
transcoder.SetIdentifier("jdFsdfzGg")
transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) { @@ -42,7 +41,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) {
cmd := transcoder.getString()
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 {
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) { @@ -14,8 +14,7 @@ func TestFFmpegx264Command(t *testing.T) {
transcoder := new(Transcoder)
transcoder.ffmpegPath = filepath.Join("fake", "path", "ffmpeg")
transcoder.SetInput("fakecontent.flv")
transcoder.SetOutputPath("fakeOutput")
transcoder.SetIdentifier("jdofFGg")
transcoder.SetStreamID("jdFsdfzGg")
transcoder.SetInternalHTTPPort("8123")
transcoder.SetCodec(codec.Name())
transcoder.currentLatencyLevel = latencyLevel
@ -42,7 +41,7 @@ func TestFFmpegx264Command(t *testing.T) { @@ -42,7 +41,7 @@ func TestFFmpegx264Command(t *testing.T) {
cmd := transcoder.getString()
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 {
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) { @@ -96,13 +96,13 @@ func handleTranscoderMessage(message string) {
_lastTranscoderLogMessage = message
}
func createVariantDirectories() {
func createVariantDirectories(streamID string) {
// Create private hls data dirs
utils.CleanupDirectory(config.HLSStoragePath)
utils.CleanupDirectory(config.HLSStoragePath, config.EnableReplayFeatures)
if len(data.GetStreamOutputVariants()) != 0 {
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)
}
}

26
core/video.go

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

5
core/webhooks/stream_test.go

@ -14,14 +14,17 @@ func TestSendStreamStatusEvent(t *testing.T) { @@ -14,14 +14,17 @@ func TestSendStreamStatusEvent(t *testing.T) {
data.SetServerSummary("my server where I stream")
data.SetStreamTitle("my stream")
streamID := "test-stream-id"
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",
"name": "my server",
"streamTitle": "my stream",
"summary": "my server where I stream",
"timestamp": "1970-01-01T00:01:12.000000006Z",
"streamID": "test-stream-id",
"status": {
"lastConnectTime": null,
"lastDisconnectTime": null,

2
db/db.go

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

41
db/models.go

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.15.0
// sqlc v1.19.1
package db
@ -72,6 +72,23 @@ type Notification struct { @@ -72,6 +72,23 @@ type Notification struct {
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 {
ID string
DisplayName string
@ -91,3 +108,25 @@ type UserAccessToken struct { @@ -91,3 +108,25 @@ type UserAccessToken struct {
UserID string
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 @@ -108,3 +108,61 @@ UPDATE users SET display_name = $1, previous_names = previous_names || $2, namec
-- name: ChangeDisplayColor :exec
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 @@ -205,6 +205,148 @@ func (q *Queries) DoesInboundActivityExist(ctx context.Context, arg DoesInboundA
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
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 @@ -296,6 +438,24 @@ func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetF
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
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 @@ -541,6 +701,88 @@ func (q *Queries) GetOutboxWithOffset(ctx context.Context, arg GetOutboxWithOffs
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
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 @@ -584,6 +826,137 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje
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
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) ( @@ -666,6 +1039,110 @@ func (q *Queries) GetUserDisplayNameByToken(ctx context.Context, token string) (
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
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 @@ -748,6 +1225,20 @@ func (q *Queries) SetAccessTokenToOwner(ctx context.Context, arg SetAccessTokenT
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
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1
`

58
db/schema.sql

@ -97,3 +97,61 @@ CREATE TABLE IF NOT EXISTS messages ( @@ -97,3 +97,61 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX user_id ON messages (user_id);
CREATE INDEX hidden_at ON messages (hidden_at);
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 ( @@ -28,6 +28,7 @@ var (
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")
rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server")
enableReplayFeatures = flag.Bool("enableReplayFeatures", false, "Enable experimental replay features")
)
// nolint:cyclop
@ -42,6 +43,8 @@ func main() { @@ -42,6 +43,8 @@ func main() {
config.BackupDirectory = *backupDirectory
}
config.EnableReplayFeatures = *enableReplayFeatures
// Create the data directory if needed
if !utils.DoesFileExists("data") {
if err := os.Mkdir("./data", 0o700); err != nil {

1
models/currentBroadcast.go

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

63
models/flexibledate.go

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

141
replays/clips.go

@ -0,0 +1,141 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 { @@ -392,6 +392,15 @@ func Start() error {
http.HandleFunc("/api/auth/fediverse", middleware.RequireUserAccessToken(fediverseauth.RegisterFediverseOTPRequest))
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.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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -3,91 +3,96 @@
set -e
function install_ffmpeg() {
# install a specific version of ffmpeg
# install a specific version of ffmpeg
FFMPEG_VER="4.4.1"
FFMPEG_PATH="$(pwd)/ffmpeg-$FFMPEG_VER"
PATH=$FFMPEG_PATH:$PATH
FFMPEG_VER="4.4.1"
FFMPEG_PATH="$(pwd)/ffmpeg-$FFMPEG_VER"
PATH=$FFMPEG_PATH:$PATH
if ! [[ -d "$FFMPEG_PATH" ]]; then
mkdir "$FFMPEG_PATH"
fi
if ! [[ -d "$FFMPEG_PATH" ]]; then
mkdir "$FFMPEG_PATH"
fi
pushd "$FFMPEG_PATH" >/dev/null
pushd "$FFMPEG_PATH" >/dev/null
if [[ -x "$FFMPEG_PATH/ffmpeg" ]]; then
ffmpeg_version=$("$FFMPEG_PATH/ffmpeg" -version | awk -F 'ffmpeg version' '{print $2}' | awk 'NR==1{print $1}')
if [[ -x "$FFMPEG_PATH/ffmpeg" ]]; then
if [[ "$ffmpeg_version" == "$FFMPEG_VER-static" ]]; then
popd >/dev/null
return 0
else
mv "$FFMPEG_PATH/ffmpeg" "$FFMPEG_PATH/ffmpeg.bk" || rm -f "$FFMPEG_PATH/ffmpeg"
fi
fi
ffmpeg_version=$("$FFMPEG_PATH/ffmpeg" -version | awk -F 'ffmpeg version' '{print $2}' | awk 'NR==1{print $1}')
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
if [[ "$ffmpeg_version" == "$FFMPEG_VER-static" ]]; then
popd >/dev/null
return 0
else
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() {
# Build and run owncast from source
echo "Building owncast..."
pushd "$(git rev-parse --show-toplevel)" >/dev/null
go build -o owncast main.go
# Build and run owncast from source
echo "Building owncast..."
pushd "$(git rev-parse --show-toplevel)" >/dev/null
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" &
SERVER_PID=$!
popd >/dev/null
./owncast -database "$TEMP_DB" $1 &
SERVER_PID=$!
popd >/dev/null
sleep 5
sleep 5
}
function start_stream() {
# Start streaming the test file over RTMP to the local owncast instance.
../../ocTestStream.sh &
STREAM_PID=$!
# Start streaming the test file over RTMP to the local owncast instance.
../../ocTestStream.sh &
STREAM_PID=$!
echo "Waiting for stream to start..."
sleep 12
echo "Waiting for stream to start..."
sleep 12
}
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
curl --fail 'http://localhost:8080/api/admin/config/s3' \
-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\":\"\"}}"
# Hard-coded to admin:abc123 for auth
curl --fail 'http://localhost:8080/api/admin/config/s3' \
-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\":\"\"}}"
}
function kill_with_kids() {
# kill a process and all its children (by pid)! return no error.
if [[ -n $1 ]]; then
mapfile -t CHILDREN_PID_LIST < <(ps --ppid "$1" -o pid= &>/dev/null || true)
for child_pid in "${CHILDREN_PID_LIST[@]}"; do
kill "$child_pid" &>/dev/null || true
wait "$child_pid" &>/dev/null || true
done
kill "$1" &>/dev/null || true
wait "$1" &>/dev/null || true
fi
# kill a process and all its children (by pid)! return no error.
if [[ -n $1 ]]; then
mapfile -t CHILDREN_PID_LIST < <(ps --ppid "$1" -o pid= &>/dev/null || true)
for child_pid in "${CHILDREN_PID_LIST[@]}"; do
kill "$child_pid" &>/dev/null || true
wait "$child_pid" &>/dev/null || true
done
kill "$1" &>/dev/null || true
wait "$1" &>/dev/null || true
fi
}
function finish() {
echo "Cleaning up..."
kill_with_kids "$STREAM_PID"
kill "$SERVER_PID" &>/dev/null || true
wait "$SERVER_PID" &>/dev/null || true
echo "Cleaning up..."
kill_with_kids "$STREAM_PID"
kill "$SERVER_PID" &>/dev/null || true
wait "$SERVER_PID" &>/dev/null || true
rm -fr "$TEMP_DB"
}

33
utils/utils.go

@ -6,6 +6,7 @@ import ( @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/url"
"os"
@ -67,7 +68,7 @@ func Copy(source, destination string) error { @@ -67,7 +68,7 @@ func Copy(source, destination string) error {
func Move(source, destination string) error {
err := os.Rename(source, destination)
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 nil
@ -303,10 +304,12 @@ func VerifyFFMpegPath(path string) error { @@ -303,10 +304,12 @@ func VerifyFFMpegPath(path string) error {
}
// CleanupDirectory removes the directory and makes it fresh again. Throws fatal error on failure.
func CleanupDirectory(path string) {
log.Traceln("Cleaning", path)
if err := os.RemoveAll(path); err != nil {
log.Fatalln("Unable to remove directory. Please check the ownership and permissions", err)
func CleanupDirectory(path string, keepOldFiles bool) {
if !keepOldFiles {
log.Traceln("Cleaning", path)
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 {
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) { @@ -448,3 +451,23 @@ func DecodeBase64Image(url string) (bytes []byte, extension string, err error) {
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