From bc2caadb74c0d2c33d86c7da1d370cdc39684b10 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 18 Feb 2021 23:05:52 -0800 Subject: [PATCH] 0.0.6 -> Master (#731) * Implement webhook events for external integrations (#574) * Implement webhook events for external integrations Reference #556 * move message type to models and remove duplicate * add json header so content type can be determined * Pass at migrating webhooks to datastore + management apis (#589) * Pass at migrating webhooks to datastore + management apis * Support nil lastUsed timestamps and return back the new webhook on create * Cleanup from review feedback * Simplify a bit Co-authored-by: Aaron Ogle Co-authored-by: Gabe Kangas * Webhook query cleanup * Access tokens + Send system message external API (#585) * New add, get and delete access token APIs * Create auth token middleware * Update last_used timestamp when using an access token * Add auth'ed endpoint for sending system messages * Cleanup * Update api spec for new apis * Commit updated API documentation * Add auth'ed endpoint for sending user chat messages * Return access token string * Commit updated API documentation * Fix route * Support nil lastUsed time * Commit updated Javascript packages * Remove duplicate function post rebase * Fix msg id generation * Update controllers/admin/chat.go Co-authored-by: Aaron Ogle * Webhook query cleanup * Add SystemMessageSent to EventType Co-authored-by: Owncast Co-authored-by: Aaron Ogle * Set webhook as used on completion. Closes #610 * Display webhook errors as errors * Commit updated API documentation * Add user joined chat event * Change integration API paths. Update API spec * Update development version of admin that supports integration apis * Commit updated API documentation * Add automated tests for external integration APIs * check error * quiet this test for now * Route up some additional 3rd party apis. #638 * Commit updated API documentation * Save username on user joined event * Add missing scope to valid scopes list * Add generic chat action event API for 3rd parties. Closes #666 * Commit updated API documentation * First pass at moving WIP config framework into project for #234 * Only support exported fields in custom types * Using YP get/set key as a first pass at using the data layer. Fixes + integration. * Ignore test db * Start adding getters and setters for config values * More get/set config work. Starting to populate api with data * Wire up some config edit endpoints * More endpoints * Disable cors middleware * Add more endpoints and add test to test them * Remove the in-memory change APIs * Add endpoint for changing tags * Add more config endpoints * Starting to point more things away from config file and to the datastore * Populate YP with db data * Create new util method for parsing page body markdown and return it in api * Verify proposed path to ffmpeg * For development purposes show the config key in logs * Move stats values to datastore * Moving over more values to the datastore * Move S3 config to datastore * First pass the config -> db migrator * Add the start of the video config apis * It builds pointing everything away from the config * Tweak ffmpeg path error message * Backup database every hour. Closes #549 * Config + defaults + migration work for db * Cleanup logging * Remove all the old config structs * Add descriptive info about migration * Tweak ffmpeg validation logic * Fix db backup path. backup on db version migration * Set video and s3 configurations * Update api spec with new config endpoints * Add migrator for stats file * Commit updated API documentation * Use a dynamic system port for internal HLS writes. Closes #577 (#626) * Use a dynamic system port for internal HLS writes. Closes #577 * Cleanup * YP key migration to datastore * Create a backup directory if needed before migrations * Remove config test that no longer makes sense. Cleanup. * Change number types from float32 to float64 * Update automated test suite * Allow restoring a database backup via command line flags. Closes #549 * Add new hls segment config api * Commit updated API documentation * Update apis to require a value container property * add socialHandles api * Commit updated API documentation * Add new latancy level setting to replace segment settings * Commit updated API documentation * Fix spelling * Commit updated API documentation * hardcode a json api of available social platforms * Add additional icons * Return social handles in server config api * Add socialhandles validation to test * Move list of hard coded social platforms to an api * Remove audio only code from transcoder since we do not use it * Add latency levels api + snapshot of video settings as current broadcast * Add config/serverurl endpoint * Return 404 on YP api if disabled * Surface stream title in YP response * Add stream title to web ui * Cleanup log message. Closes #520 * Rename ffmpeg package to transcoder * Add ws package for testing * Reduce chat backlog to past 5hrs, max 50. Closes #548 * Fix error formatting * Add endpoint for resetting yp registration * Add yp/reset to api spec. return status in response * Return zero viewer count if stream is offline. Closes #422 * Post-rebase fixes * Fix merge conflict in openapi file * Commit updated API documentation * Standardize controller names * Support setting the stream key via the command line. Closes #665 * Return social handles with YP data. First half of https://github.com/owncast/owncast-yp/issues/28 * Give the YP package access to server status regardless if enabled or not * Change delay in automated tests * Add stream title integration API. For #638 * Commit updated API documentation * Add storage to the migrator * Missing returning NSFW value in server config * Add flag to ignore websocket client. Closes #537 * Add error for parsing broadcaster metadata * Add support for a cli specified http server port. Closes #674 * Add cpu usage levels and a temporary mapping between it and libx264 presets * Test for valid url endpoint when saving s3 config * Re-configure storage on every stream to allow changing storage providers * After 5 minutes of a stream being stopped clear the stream title * Hide viewer count once stream goes offline instead of when player stops * Pull steamTitle from the status that gets updated instead of the config * Commit updated API documentation * Optionally show stream title in the header * Reset stream title when server starts * Show chat action when stream title is updated * Allow system messages to come back in persistence * Split out getting chat history for moderation + fix tests * Remove server title and standardize on name only * Commit updated API documentation * Bump github.com/aws/aws-sdk-go from 1.37.1 to 1.37.2 (#680) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.1 to 1.37.2. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.1...v1.37.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add video variant and stream latency config file migrator * Remove mostly unused disable upgrade check bool * Commit updated API documentation * Allow bundling the admin from the 0.0.6 branch * Fix saving port numbers * Use name instead of old title on window focus * Work on latency levels. Fix test to use levels. Clean up transcoder to only reference levels * Another place where title -> name * Fix test * Bump github.com/aws/aws-sdk-go from 1.37.2 to 1.37.3 (#690) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.2 to 1.37.3. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.2...v1.37.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update dependabot config * Bump github.com/aws/aws-sdk-go from 1.37.3 to 1.37.5 (#693) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.3 to 1.37.5. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.3...v1.37.5) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript (#694) * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript Bumps [video.js](https://github.com/videojs/video.js) from 7.10.2 to 7.11.4. - [Release notes](https://github.com/videojs/video.js/releases) - [Changelog](https://github.com/videojs/video.js/blob/main/CHANGELOG.md) - [Commits](https://github.com/videojs/video.js/compare/v7.10.2...v7.11.4) Signed-off-by: dependabot[bot] * Commit updated Javascript packages Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Owncast * Make the latency migrator dynamic so I can tweak values easier * Split out fetching ffmpeg path from validating the path so it can be changed in the admin * Some commenting and linter cleanup * Validate the path for a logo change and throw an error if it does not exist * Logo change requests have to be a real file now * Cleanup, making linter happy * Format javascript on push * Only format js in master * Tweak latency level values * Remove unused config file examples * Fix thumbnail generation after messing with the ffmpeg path getter * Reduce how often we report high hardware utilization warnings * Bundle the 0.0.6 branch version of the admin * Return validated ffmpeg path in admin server config * Change the logo to be stored in the data directory instead of webroot * Bump postcss from 8.2.4 to 8.2.5 in /build/javascript (#702) Bumps [postcss](https://github.com/postcss/postcss) from 8.2.4 to 8.2.5. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.2.4...8.2.5) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Default config file no longer used * don't show stream title when offline addresses https://github.com/owncast/owncast/issues/677 * Remove auto-clearing stream title. #677 * webroot -> data when using logo as thumbnail * Do not list websocket/access token create/delete as integration APIs * Commit updated API documentation * Bundle updated admin * Remove pointing to the 0.0.6 admin branch * Linter cleanup * Linter cleanup * Add donations and follow links to show up under social handles * Prettified Code! * More linter cleanup * Update admin bundle * Remove use of platforms.js and return icons with social handles. Closes #732 * Update admin bundle * Support custom config path for use in migration * Remove unused platform-logos.gif * Reduce log level of message * Remove unused logo files in static dir * Handle dev vs. release build info * Restore logo.png for initial thumbnail * Cleanup some files from the build process that are not needed * Fix incorrect build-time injection var * Fix missing file getting copied to the build * Remove console directory message. * Update admin bundle * Fix comment * Report storage setup error * add some value set error checking * Use validated dynamic ffmpeg path for animated gif preview * Make chat message links be white so they don't hide in the bg. Closes #599 * Restore conditional that was accidentally removed Co-authored-by: Aaron Ogle Co-authored-by: Owncast Co-authored-by: Ginger Wong Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: nebunez Co-authored-by: gabek --- .github/workflows/javascript-formatting.yml | 4 +- .gitignore | 3 +- .vscode/settings.json | 22 + build/admin/bundleAdmin.sh | 4 +- build/release/build.sh | 12 +- config/config.go | 325 +----- config/configUtils.go | 48 - config/config_test.go | 19 - config/constants.go | 19 +- config/defaults.go | 71 +- config/verifyInstall.go | 10 +- controllers/admin/accessToken.go | 100 ++ controllers/admin/changePageContent.go | 36 - controllers/admin/changeStreamKey.go | 35 - controllers/admin/changeStreamName.go | 35 - controllers/admin/changeStreamTags.go | 35 - controllers/admin/changeStreamTitle.go | 35 - controllers/admin/chat.go | 107 +- controllers/admin/config.go | 475 ++++++++ controllers/admin/serverConfig.go | 83 +- controllers/admin/status.go | 22 +- controllers/admin/webhooks.go | 84 ++ controllers/admin/yp.go | 20 + controllers/chat.go | 21 +- controllers/config.go | 52 +- controllers/connectedClients.go | 2 +- controllers/constants.go | 7 + controllers/controllers.go | 38 +- controllers/emoji.go | 2 +- controllers/index.go | 30 +- controllers/logo.go | 65 ++ controllers/status.go | 2 +- core/chat/chat.go | 8 +- core/chat/client.go | 57 +- core/chat/messages.go | 6 +- core/chat/persistence.go | 24 +- core/chat/server.go | 29 +- core/chatListener.go | 14 +- core/core.go | 52 +- core/data/accessTokens.go | 199 ++++ core/data/cache.go | 18 + core/data/config.go | 450 ++++++++ core/data/configEntry.go | 46 + core/data/data.go | 28 +- core/data/data_test.go | 115 ++ core/data/defaults.go | 43 + core/data/migrator.go | 266 +++++ core/data/persistence.go | 152 +++ core/data/types.go | 46 + core/data/webhooks.go | 220 ++++ core/rtmp/broadcaster.go | 2 +- core/rtmp/rtmp.go | 9 +- core/stats.go | 59 +- core/status.go | 15 +- core/storage.go | 8 +- core/storageproviders/local.go | 4 +- core/storageproviders/s3Storage.go | 24 +- core/streamState.go | 43 +- .../fileWriterReceiverService.go | 23 +- .../hlsFilesystemCleanup.go | 6 +- core/{ffmpeg => transcoder}/hlsHandler.go | 2 +- .../thumbnailGenerator.go | 13 +- core/{ffmpeg => transcoder}/transcoder.go | 79 +- .../{ffmpeg => transcoder}/transcoder_test.go | 13 +- core/webhooks/chat.go | 39 + core/webhooks/stream.go | 7 + core/webhooks/webhooks.go | 67 ++ data/content-example.md | 4 - doc/api/index.html | 171 ++- examples/config-example.yaml | 50 - geoip/geoip.go | 4 +- go.mod | 1 + go.sum | 3 + main.go | 86 +- metrics/alerting.go | 41 +- models/accessToken.go | 53 + models/broadcaster.go | 1 + models/chatActionEvent.go | 12 + models/chatMessage.go | 17 +- models/client.go | 3 + models/currentBroadcast.go | 7 + models/eventType.go | 27 + models/latencyLevels.go | 25 + models/nameChangeEvent.go | 10 +- models/pingMessage.go | 2 +- models/s3Storage.go | 13 + models/socialHandle.go | 110 ++ models/status.go | 1 + models/streamOutputVariant.go | 89 ++ models/userJoinedEvent.go | 11 + models/webhook.go | 33 + openapi.yaml | 1024 +++++++++++++---- pkged.go | 2 +- router/middleware/auth.go | 36 +- router/router.go | 120 +- static/logo-900x720.png | Bin 47288 -> 0 bytes static/metadata.html | 26 +- test/automated/admin.test.js | 38 +- test/automated/chat.test.js | 18 +- test/automated/chatmoderation.test.js | 2 +- test/automated/configmanagement.test.js | 191 +++ test/automated/index.test.js | 11 - test/automated/integrations.test.js | 109 ++ test/automated/run.sh | 6 +- test/package-lock.json | 27 +- utils/accessTokens.go | 37 + utils/accessTokens_test.go | 12 + utils/backup.go | 99 ++ utils/utils.go | 100 ++ webroot/img/platform-logos.gif | Bin 13779 -> 0 bytes webroot/img/platformlogos/donate.svg | 71 ++ webroot/img/platformlogos/follow.svg | 48 + webroot/img/platformlogos/link.svg | 1 + webroot/js/app-standalone-chat.js | 3 +- webroot/js/app.js | 32 +- webroot/js/components/chat/chat.js | 27 +- webroot/js/components/chat/message.js | 27 + webroot/js/components/chat/username.js | 4 +- webroot/js/components/platform-logos-list.js | 8 +- webroot/js/utils/constants.js | 1 + webroot/js/utils/platforms.js | 83 -- webroot/js/utils/websocket.js | 13 +- webroot/styles/chat.css | 4 +- yp/api.go | 45 +- yp/yp.go | 46 +- 125 files changed, 5544 insertions(+), 1510 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 config/config_test.go create mode 100644 controllers/admin/accessToken.go delete mode 100644 controllers/admin/changePageContent.go delete mode 100644 controllers/admin/changeStreamKey.go delete mode 100644 controllers/admin/changeStreamName.go delete mode 100644 controllers/admin/changeStreamTags.go delete mode 100644 controllers/admin/changeStreamTitle.go create mode 100644 controllers/admin/config.go create mode 100644 controllers/admin/webhooks.go create mode 100644 controllers/admin/yp.go create mode 100644 controllers/constants.go create mode 100644 controllers/logo.go create mode 100644 core/data/accessTokens.go create mode 100644 core/data/cache.go create mode 100644 core/data/config.go create mode 100644 core/data/configEntry.go create mode 100644 core/data/data_test.go create mode 100644 core/data/defaults.go create mode 100644 core/data/migrator.go create mode 100644 core/data/persistence.go create mode 100644 core/data/types.go create mode 100644 core/data/webhooks.go rename core/{ffmpeg => transcoder}/fileWriterReceiverService.go (79%) rename core/{ffmpeg => transcoder}/hlsFilesystemCleanup.go (93%) rename core/{ffmpeg => transcoder}/hlsHandler.go (97%) rename core/{ffmpeg => transcoder}/thumbnailGenerator.go (90%) rename core/{ffmpeg => transcoder}/transcoder.go (85%) rename core/{ffmpeg => transcoder}/transcoder_test.go (50%) create mode 100644 core/webhooks/chat.go create mode 100644 core/webhooks/stream.go create mode 100644 core/webhooks/webhooks.go delete mode 100644 data/content-example.md delete mode 100644 examples/config-example.yaml create mode 100644 models/accessToken.go create mode 100644 models/chatActionEvent.go create mode 100644 models/currentBroadcast.go create mode 100644 models/eventType.go create mode 100644 models/latencyLevels.go create mode 100644 models/s3Storage.go create mode 100644 models/socialHandle.go create mode 100644 models/streamOutputVariant.go create mode 100644 models/userJoinedEvent.go create mode 100644 models/webhook.go delete mode 100644 static/logo-900x720.png create mode 100644 test/automated/configmanagement.test.js create mode 100644 test/automated/integrations.test.js create mode 100644 utils/accessTokens.go create mode 100644 utils/accessTokens_test.go create mode 100644 utils/backup.go delete mode 100644 webroot/img/platform-logos.gif create mode 100644 webroot/img/platformlogos/donate.svg create mode 100644 webroot/img/platformlogos/follow.svg create mode 100644 webroot/img/platformlogos/link.svg delete mode 100644 webroot/js/utils/platforms.js diff --git a/.github/workflows/javascript-formatting.yml b/.github/workflows/javascript-formatting.yml index 0149a2870..040f490df 100644 --- a/.github/workflows/javascript-formatting.yml +++ b/.github/workflows/javascript-formatting.yml @@ -4,8 +4,8 @@ name: Format Javascript on: pull_request: push: - # branches: - # - master + branches: + - master jobs: prettier: diff --git a/.gitignore b/.gitignore index 2bab666eb..726b97962 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ data/ transcoder.log chat.db .yp.key - +backup/ !webroot/js/web_modules/**/dist !core/data +test/test.db diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fcc2990fc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "cSpell.words": [ + "Debugln", + "Errorln", + "Ffmpeg", + "Mbps", + "Owncast", + "RTMP", + "Tracef", + "Traceln", + "Warnf", + "Warnln", + "ffmpegpath", + "ffmpg", + "mattn", + "nolint", + "preact", + "rtmpserverport", + "sqlite", + "videojs" + ] +} \ No newline at end of file diff --git a/build/admin/bundleAdmin.sh b/build/admin/bundleAdmin.sh index 9324de1c7..e2b11a96e 100755 --- a/build/admin/bundleAdmin.sh +++ b/build/admin/bundleAdmin.sh @@ -16,9 +16,11 @@ shutdown () { trap shutdown INT TERM ABRT EXIT echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..." -git clone --depth 1 https://github.com/owncast/owncast-admin 2> /dev/null +git clone https://github.com/owncast/owncast-admin 2> /dev/null cd owncast-admin +git checkout 0.0.6 + echo "Installing npm modules for the owncast admin..." npm --silent install 2> /dev/null diff --git a/build/release/build.sh b/build/release/build.sh index 5150a177b..d228ba64f 100755 --- a/build/release/build.sh +++ b/build/release/build.sh @@ -24,7 +24,7 @@ BUILD_TEMP_DIRECTORY="$(mktemp -d)" cd $BUILD_TEMP_DIRECTORY echo "Cloning owncast into $BUILD_TEMP_DIRECTORY..." -git clone --depth 1 https://github.com/owncast/owncast 2> /dev/null +git clone https://github.com/owncast/owncast 2> /dev/null cd owncast echo "Changing to branch: $GIT_BRANCH" @@ -58,26 +58,22 @@ build() { VERSION=$4 GIT_COMMIT=$5 - echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH}..." + echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH} ${GIT_COMMIT}..." mkdir -p dist/${NAME} - mkdir -p dist/${NAME}/webroot/static mkdir -p dist/${NAME}/data - # Default files - cp config-default.yaml dist/${NAME}/config.yaml - cp data/content-example.md dist/${NAME}/data/content.md - cp -R webroot/ dist/${NAME}/webroot/ # Copy the production pruned+minified css to the build's directory. cp "${TMPDIR}tailwind.min.css" ./dist/${NAME}/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css cp -R static/ dist/${NAME}/static cp README.md dist/${NAME} + cp webroot/img/logo.svg dist/${NAME}/data/logo.svg pushd dist/${NAME} >> /dev/null - CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildType=${NAME}" -targets "${OS}/${ARCH}" github.com/owncast/owncast + CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildPlatform=${NAME}" -targets "${OS}/${ARCH}" github.com/owncast/owncast mv owncast-*-${ARCH} owncast zip -r -q -8 ../owncast-$VERSION-$NAME.zip . diff --git a/config/config.go b/config/config.go index 815c30dcf..7e4055234 100644 --- a/config/config.go +++ b/config/config.go @@ -1,299 +1,40 @@ package config import ( - "errors" - "io/ioutil" - "os/exec" - "strings" - - "github.com/owncast/owncast/utils" - log "github.com/sirupsen/logrus" - "gopkg.in/yaml.v2" + "fmt" ) -// Config contains a reference to the configuration. -var Config *config -var _default config +// These are runtime-set values used for configuration. -type config struct { - DatabaseFilePath string `yaml:"databaseFile"` - EnableDebugFeatures bool `yaml:"-"` - FFMpegPath string `yaml:"ffmpegPath"` - Files files `yaml:"files"` - InstanceDetails InstanceDetails `yaml:"instanceDetails"` - S3 S3 `yaml:"s3"` - VersionInfo string `yaml:"-"` // For storing the version/build number - VersionNumber string `yaml:"-"` - VideoSettings videoSettings `yaml:"videoSettings"` - WebServerPort int `yaml:"webServerPort"` - RTMPServerPort int `yaml:"rtmpServerPort"` - DisableUpgradeChecks bool `yaml:"disableUpgradeChecks"` - YP YP `yaml:"yp"` -} - -// InstanceDetails defines the user-visible information about this particular instance. -type InstanceDetails struct { - Name string `yaml:"name" json:"name"` - Title string `yaml:"title" json:"title"` - Summary string `yaml:"summary" json:"summary"` - // Logo logo `yaml:"logo" json:"logo"` - Logo string `yaml:"logo" json:"logo"` - Tags []string `yaml:"tags" json:"tags"` - SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"` - Version string `json:"version"` - NSFW bool `yaml:"nsfw" json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` -} - -// type logo struct { -// Large string `yaml:"large" json:"large"` -// Small string `yaml:"small" json:"small"` -// } - -type socialHandle struct { - Platform string `yaml:"platform" json:"platform"` - URL string `yaml:"url" json:"url"` - Icon string `yaml:"icon" json:"icon"` -} - -type videoSettings struct { - ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"` - StreamingKey string `yaml:"streamingKey"` - StreamQualities []StreamQuality `yaml:"streamQualities"` - HighestQualityStreamIndex int `yaml:"-"` -} - -// YP allows registration to the central Owncast YP (Yellow pages) service operating as a directory. -type YP struct { - Enabled bool `yaml:"enabled" json:"enabled"` - InstanceURL string `yaml:"instanceURL" json:"instanceUrl"` // The public URL the directory should link to - YPServiceURL string `yaml:"ypServiceURL" json:"-"` // The base URL to the YP API to register with (optional) -} - -// StreamQuality defines the specifics of a single HLS stream variant. -type StreamQuality struct { - // Enable passthrough to copy the video and/or audio directly from the - // incoming stream and disable any transcoding. It will ignore any of - // the below settings. - IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"` - IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"` - - VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"` - AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"` - - // Set only one of these in order to keep your current aspect ratio. - // Or set neither to not scale the video. - ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"` - ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"` - - Framerate int `yaml:"framerate" json:"framerate"` - EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"` -} - -type files struct { - MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"` -} - -// S3 is for configuring the S3 integration. -type S3 struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` - ServingEndpoint string `yaml:"servingEndpoint" json:"servingEndpoint,omitempty"` - AccessKey string `yaml:"accessKey" json:"accessKey,omitempty"` - Secret string `yaml:"secret" json:"secret,omitempty"` - Bucket string `yaml:"bucket" json:"bucket,omitempty"` - Region string `yaml:"region" json:"region,omitempty"` - ACL string `yaml:"acl" json:"acl,omitempty"` -} - -func (c *config) load(filePath string) error { - if !utils.DoesFileExists(filePath) { - log.Fatal("ERROR: valid config.yaml is required. Copy config-default.yaml to config.yaml and edit") - } - - yamlFile, err := ioutil.ReadFile(filePath) - if err != nil { - log.Printf("yamlFile.Get err #%v ", err) - return err - } - - if err := yaml.Unmarshal(yamlFile, c); err != nil { - log.Fatalf("Error reading the config file.\nHave you recently updated your version of Owncast?\nIf so there may be changes to the config.\nPlease read the change log for your version at https://owncast.online/posts/\n%v", err) - return err - } - - c.VideoSettings.HighestQualityStreamIndex = findHighestQuality(c.VideoSettings.StreamQualities) - - // Add custom page content to the instance details. - customContentMarkdownData, err := ioutil.ReadFile(ExtraInfoFile) - if err == nil { - customContentMarkdownString := string(customContentMarkdownData) - c.InstanceDetails.ExtraPageContent = utils.RenderSimpleMarkdown(customContentMarkdownString) - } - - return nil -} - -func (c *config) verifySettings() error { - if c.VideoSettings.StreamingKey == "" { - return errors.New("No stream key set. Please set one in your config file.") - } - - if c.S3.Enabled { - if c.S3.AccessKey == "" || c.S3.Secret == "" { - return errors.New("s3 support requires an access key and secret") - } - - if c.S3.Region == "" || c.S3.Endpoint == "" { - return errors.New("s3 support requires a region and endpoint") - } - - if c.S3.Bucket == "" { - return errors.New("s3 support requires a bucket created for storing public video segments") - } - } - - if c.YP.Enabled && c.YP.InstanceURL == "" { - return errors.New("YP is enabled but instance url is not set") - } - - return nil -} - -func (c *config) GetVideoSegmentSecondsLength() int { - if c.VideoSettings.ChunkLengthInSeconds != 0 { - return c.VideoSettings.ChunkLengthInSeconds - } - - return _default.GetVideoSegmentSecondsLength() -} - -func (c *config) GetPublicWebServerPort() int { - if c.WebServerPort != 0 { - return c.WebServerPort - } - - return _default.WebServerPort -} - -func (c *config) GetRTMPServerPort() int { - if c.RTMPServerPort != 0 { - return c.RTMPServerPort - } - - return _default.RTMPServerPort -} - -func (c *config) GetMaxNumberOfReferencedSegmentsInPlaylist() int { - if c.Files.MaxNumberInPlaylist > 0 { - return c.Files.MaxNumberInPlaylist - } - - return _default.GetMaxNumberOfReferencedSegmentsInPlaylist() -} - -func (c *config) GetFFMpegPath() string { - if c.FFMpegPath != "" { - if err := verifyFFMpegPath(c.FFMpegPath); err == nil { - return c.FFMpegPath - } else { - log.Errorln(c.FFMpegPath, "is an invalid path to ffmpeg. Will try to use a copy in your path, if possible.") - } - } - - // First look to see if ffmpeg is in the current working directory - localCopy := "./ffmpeg" - hasLocalCopyError := verifyFFMpegPath(localCopy) - if hasLocalCopyError == nil { - // No error, so all is good. Use the local copy. - return localCopy - } - - cmd := exec.Command("which", "ffmpeg") - out, err := cmd.CombinedOutput() - if err != nil { - log.Fatalln("Unable to determine path to ffmpeg. Please specify it in the config file.") - } - - path := strings.TrimSpace(string(out)) - if err := verifyFFMpegPath(path); err != nil { - log.Warnln(err) - } - return path -} - -func (c *config) GetYPServiceHost() string { - if c.YP.YPServiceURL != "" { - return c.YP.YPServiceURL - } - - return _default.YP.YPServiceURL -} - -func (c *config) GetDataFilePath() string { - if c.DatabaseFilePath != "" { - return c.DatabaseFilePath - } - - return _default.DatabaseFilePath -} - -func (c *config) GetVideoStreamQualities() []StreamQuality { - if len(c.VideoSettings.StreamQualities) > 0 { - return c.VideoSettings.StreamQualities - } - - return _default.VideoSettings.StreamQualities -} - -// GetFramerate returns the framerate or default. -func (q *StreamQuality) GetFramerate() int { - if q.IsVideoPassthrough { - return 0 - } - - if q.Framerate > 0 { - return q.Framerate - } - - return _default.VideoSettings.StreamQualities[0].Framerate -} - -// GetEncoderPreset returns the preset or default. -func (q *StreamQuality) GetEncoderPreset() string { - if q.IsVideoPassthrough { - return "" - } - - if q.EncoderPreset != "" { - return q.EncoderPreset - } - - return _default.VideoSettings.StreamQualities[0].EncoderPreset -} - -func (q *StreamQuality) GetIsAudioPassthrough() bool { - if q.IsAudioPassthrough { - return true - } - - if q.AudioBitrate == 0 { - return true - } - - return false -} - -// Load tries to load the configuration file. -func Load(filePath string, versionInfo string, versionNumber string) error { - Config = new(config) - _default = getDefaults() - - if err := Config.load(filePath); err != nil { - return err - } - - Config.VersionInfo = versionInfo - Config.VersionNumber = versionNumber - return Config.verifySettings() +// DatabaseFilePath is the path to the file ot be used as the global database for this run of the application. +var DatabaseFilePath = "data/owncast.db" + +// EnableDebugFeatures will print additional data to help in debugging. +var EnableDebugFeatures = false + +// VersionNumber is the current version string. +var VersionNumber = StaticVersionNumber + +// WebServerPort is the port for Owncast's webserver that is used for this execution of the service. +var WebServerPort = 8080 + +// InternalHLSListenerPort is the port for HLS writes that is used for this execution of the service. +var InternalHLSListenerPort = "8927" + +// ConfigFilePath is the path to the config file for migration. +var ConfigFilePath = "config.yaml" + +// GitCommit is an optional commit this build was made from. +var GitCommit = "" + +// BuildPlatform is the optional platform this release was built for. +var BuildPlatform = "local" + +// GetReleaseString gets the version string. +func GetReleaseString() string { + var versionNumber = VersionNumber + var buildPlatform = BuildPlatform + var gitCommit = GitCommit + + return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit) } diff --git a/config/configUtils.go b/config/configUtils.go index 636e31cb9..d912156be 100644 --- a/config/configUtils.go +++ b/config/configUtils.go @@ -1,49 +1 @@ package config - -import ( - "encoding/json" - "sort" -) - -func findHighestQuality(qualities []StreamQuality) int { - type IndexedQuality struct { - index int - quality StreamQuality - } - - if len(qualities) < 2 { - return 0 - } - - indexedQualities := make([]IndexedQuality, 0) - for index, quality := range qualities { - indexedQuality := IndexedQuality{index, quality} - indexedQualities = append(indexedQualities, indexedQuality) - } - - sort.Slice(indexedQualities, func(a, b int) bool { - if indexedQualities[a].quality.IsVideoPassthrough && !indexedQualities[b].quality.IsVideoPassthrough { - return true - } - - if !indexedQualities[a].quality.IsVideoPassthrough && indexedQualities[b].quality.IsVideoPassthrough { - return false - } - - return indexedQualities[a].quality.VideoBitrate > indexedQualities[b].quality.VideoBitrate - }) - - return indexedQualities[0].index -} - -// MarshalJSON is a custom JSON marshal function for video stream qualities. -func (q *StreamQuality) MarshalJSON() ([]byte, error) { - type Alias StreamQuality - return json.Marshal(&struct { - Framerate int `json:"framerate"` - *Alias - }{ - Framerate: q.GetFramerate(), - Alias: (*Alias)(q), - }) -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 64e76c88a..000000000 --- a/config/config_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package config - -import "testing" - -func TestDefaults(t *testing.T) { - _default = getDefaults() - - encoderPreset := "veryfast" - framerate := 24 - - quality := StreamQuality{} - if quality.GetEncoderPreset() != encoderPreset { - t.Errorf("default encoder preset does not match expected. Got %s, want: %s", quality.GetEncoderPreset(), encoderPreset) - } - - if quality.GetFramerate() != framerate { - t.Errorf("default framerate does not match expected. Got %d, want: %d", quality.GetFramerate(), framerate) - } -} diff --git a/config/constants.go b/config/constants.go index cedc203d2..65a4eec8e 100644 --- a/config/constants.go +++ b/config/constants.go @@ -3,14 +3,23 @@ package config import "path/filepath" const ( - WebRoot = "webroot" - PrivateHLSStoragePath = "hls" - GeoIPDatabasePath = "data/GeoLite2-City.mmdb" - ExtraInfoFile = "data/content.md" - StatsFile = "data/stats.json" + // StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings. + StaticVersionNumber = "0.0.6" // Shown when you build from master + // WebRoot is the web server root directory. + WebRoot = "webroot" + // PrivateHLSStoragePath is the HLS write directory. + PrivateHLSStoragePath = "hls" + // ExtraInfoFile is the markdown file for page content. Remove this after the migrator is removed. + ExtraInfoFile = "data/content.md" + // StatsFile is the json file we used to save stats in. Remove this after the migrator is removed. + StatsFile = "data/stats.json" + // FfmpegSuggestedVersion is the version of ffmpeg we suggest. FfmpegSuggestedVersion = "v4.1.5" // Requires the v + // BackupDirectory is the directory we write backup files to. + BackupDirectory = "backup" ) var ( + // PublicHLSStoragePath is the directory we write public HLS files to for distribution. PublicHLSStoragePath = filepath.Join(WebRoot, "hls") ) diff --git a/config/defaults.go b/config/defaults.go index 1ff2c1a64..d46bf41d9 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -1,22 +1,59 @@ package config -func getDefaults() config { - defaults := config{} - defaults.WebServerPort = 8080 - defaults.RTMPServerPort = 1935 - defaults.VideoSettings.ChunkLengthInSeconds = 4 - defaults.Files.MaxNumberInPlaylist = 5 - defaults.YP.Enabled = false - defaults.YP.YPServiceURL = "https://yp.owncast.online" - defaults.DatabaseFilePath = "data/owncast.db" +import "github.com/owncast/owncast/models" - defaultQuality := StreamQuality{ - IsAudioPassthrough: true, - VideoBitrate: 1200, - EncoderPreset: "veryfast", - Framerate: 24, - } - defaults.VideoSettings.StreamQualities = []StreamQuality{defaultQuality} +// Defaults will hold default configuration values. +type Defaults struct { + Name string + Title string + Summary string + Logo string + Tags []string + PageBodyContent string - return defaults + DatabaseFilePath string + WebServerPort int + RTMPServerPort int + StreamKey string + + YPEnabled bool + YPServer string + + SegmentLengthSeconds int + SegmentsInPlaylist int + StreamVariants []models.StreamOutputVariant +} + +// GetDefaults will return default configuration values. +func GetDefaults() Defaults { + return Defaults{ + Name: "Owncast", + Title: "My Owncast Server", + Summary: "This is brief summary of whom you are or what your stream is. You can edit this description in the admin.", + Logo: "logo.svg", + Tags: []string{ + "owncast", + "streaming", + }, + + PageBodyContent: "# This is your page content that can be edited from the admin.", + + DatabaseFilePath: "data/owncast.db", + + YPEnabled: false, + YPServer: "https://yp.owncast.online", + + WebServerPort: 8080, + RTMPServerPort: 1935, + StreamKey: "abc123", + + StreamVariants: []models.StreamOutputVariant{ + { + IsAudioPassthrough: true, + VideoBitrate: 1200, + EncoderPreset: "veryfast", + Framerate: 24, + }, + }, + } } diff --git a/config/verifyInstall.go b/config/verifyInstall.go index bfde2a8f6..5a8250a05 100644 --- a/config/verifyInstall.go +++ b/config/verifyInstall.go @@ -12,8 +12,8 @@ import ( "golang.org/x/mod/semver" ) -// verifyFFMpegPath verifies that the path exists, is a file, and is executable. -func verifyFFMpegPath(path string) error { +// VerifyFFMpegPath verifies that the path exists, is a file, and is executable. +func VerifyFFMpegPath(path string) error { stat, err := os.Stat(path) if os.IsNotExist(err) { @@ -39,12 +39,12 @@ func verifyFFMpegPath(path string) error { response := string(out) if response == "" { - return fmt.Errorf("unable to determine the version of your ffmpeg installation at %s. you may experience issues with video.", path) + return fmt.Errorf("unable to determine the version of your ffmpeg installation at %s you may experience issues with video", path) } responseComponents := strings.Split(response, " ") if len(responseComponents) < 3 { - log.Debugf("unable to determine the version of your ffmpeg installation at %s. you may experience issues with video.", path) + log.Debugf("unable to determine the version of your ffmpeg installation at %s you may experience issues with video", path) return nil } @@ -59,7 +59,7 @@ func verifyFFMpegPath(path string) error { } if semver.Compare(versionString, FfmpegSuggestedVersion) == -1 { - return fmt.Errorf("your %s version of ffmpeg at %s may be older than the suggested version of %s. you may experience issues with video.", versionString, path, FfmpegSuggestedVersion) + return fmt.Errorf("your %s version of ffmpeg at %s may be older than the suggested version of %s you may experience issues with video", versionString, path, FfmpegSuggestedVersion) } return nil diff --git a/controllers/admin/accessToken.go b/controllers/admin/accessToken.go new file mode 100644 index 000000000..077004273 --- /dev/null +++ b/controllers/admin/accessToken.go @@ -0,0 +1,100 @@ +package admin + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/utils" +) + +type deleteTokenRequest struct { + Token string `json:"token"` +} + +type createTokenRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` +} + +// CreateAccessToken will generate a 3rd party access token. +func CreateAccessToken(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var request createTokenRequest + if err := decoder.Decode(&request); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + // Verify all the scopes provided are valid + if !models.HasValidScopes(request.Scopes) { + controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided")) + return + } + + token, err := utils.GenerateAccessToken() + if err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + if err := data.InsertToken(token, request.Name, request.Scopes); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + controllers.WriteResponse(w, models.AccessToken{ + Token: token, + Name: request.Name, + Scopes: request.Scopes, + Timestamp: time.Now(), + LastUsed: nil, + }) +} + +// GetAccessTokens will return all 3rd party access tokens. +func GetAccessTokens(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + tokens, err := data.GetAccessTokens() + if err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + controllers.WriteResponse(w, tokens) +} + +// DeleteAccessToken will return a single 3rd party access token. +func DeleteAccessToken(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != controllers.POST { + controllers.WriteSimpleResponse(w, false, r.Method+" not supported") + return + } + + decoder := json.NewDecoder(r.Body) + var request deleteTokenRequest + if err := decoder.Decode(&request); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + if request.Token == "" { + controllers.BadRequestHandler(w, errors.New("must provide a token")) + return + } + + if err := data.DeleteToken(request.Token); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + controllers.WriteSimpleResponse(w, true, "deleted token") +} diff --git a/controllers/admin/changePageContent.go b/controllers/admin/changePageContent.go deleted file mode 100644 index 04e0d0a41..000000000 --- a/controllers/admin/changePageContent.go +++ /dev/null @@ -1,36 +0,0 @@ -package admin - -import ( - "encoding/json" - "net/http" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/controllers" - "github.com/owncast/owncast/utils" - - log "github.com/sirupsen/logrus" -) - -// ChangeExtraPageContent will change the optional page content. -func ChangeExtraPageContent(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - controllers.WriteSimpleResponse(w, false, r.Method+" not supported") - return - } - - decoder := json.NewDecoder(r.Body) - var request changeExtraPageContentRequest - err := decoder.Decode(&request) - if err != nil { - log.Errorln(err) - controllers.WriteSimpleResponse(w, false, "") - return - } - - config.Config.InstanceDetails.ExtraPageContent = utils.RenderSimpleMarkdown(request.Key) - controllers.WriteSimpleResponse(w, true, "changed") -} - -type changeExtraPageContentRequest struct { - Key string `json:"content"` -} diff --git a/controllers/admin/changeStreamKey.go b/controllers/admin/changeStreamKey.go deleted file mode 100644 index 06ed67c9f..000000000 --- a/controllers/admin/changeStreamKey.go +++ /dev/null @@ -1,35 +0,0 @@ -package admin - -import ( - "encoding/json" - "net/http" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/controllers" - - log "github.com/sirupsen/logrus" -) - -// ChangeStreamKey will change the stream key (in memory). -func ChangeStreamKey(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - controllers.WriteSimpleResponse(w, false, r.Method+" not supported") - return - } - - decoder := json.NewDecoder(r.Body) - var request changeStreamKeyRequest - err := decoder.Decode(&request) - if err != nil { - log.Errorln(err) - controllers.WriteSimpleResponse(w, false, "") - return - } - - config.Config.VideoSettings.StreamingKey = request.Key - controllers.WriteSimpleResponse(w, true, "changed") -} - -type changeStreamKeyRequest struct { - Key string `json:"key"` -} diff --git a/controllers/admin/changeStreamName.go b/controllers/admin/changeStreamName.go deleted file mode 100644 index 10384e7b5..000000000 --- a/controllers/admin/changeStreamName.go +++ /dev/null @@ -1,35 +0,0 @@ -package admin - -import ( - "encoding/json" - "net/http" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/controllers" - - log "github.com/sirupsen/logrus" -) - -// ChangeStreamName will change the stream key (in memory). -func ChangeStreamName(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - controllers.WriteSimpleResponse(w, false, r.Method+" not supported") - return - } - - decoder := json.NewDecoder(r.Body) - var request changeStreamNameRequest - err := decoder.Decode(&request) - if err != nil { - log.Errorln(err) - controllers.WriteSimpleResponse(w, false, "") - return - } - - config.Config.InstanceDetails.Name = request.Name - controllers.WriteSimpleResponse(w, true, "changed") -} - -type changeStreamNameRequest struct { - Name string `json:"name"` -} diff --git a/controllers/admin/changeStreamTags.go b/controllers/admin/changeStreamTags.go deleted file mode 100644 index 38bb1b105..000000000 --- a/controllers/admin/changeStreamTags.go +++ /dev/null @@ -1,35 +0,0 @@ -package admin - -import ( - "encoding/json" - "net/http" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/controllers" - - log "github.com/sirupsen/logrus" -) - -// ChangeStreamTags will change the stream key (in memory). -func ChangeStreamTags(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - controllers.WriteSimpleResponse(w, false, r.Method+" not supported") - return - } - - decoder := json.NewDecoder(r.Body) - var request changeStreamTagsRequest - err := decoder.Decode(&request) - if err != nil { - log.Errorln(err) - controllers.WriteSimpleResponse(w, false, "") - return - } - - config.Config.InstanceDetails.Tags = request.Tags - controllers.WriteSimpleResponse(w, true, "changed") -} - -type changeStreamTagsRequest struct { - Tags []string `json:"tags"` -} diff --git a/controllers/admin/changeStreamTitle.go b/controllers/admin/changeStreamTitle.go deleted file mode 100644 index 2a3a95e14..000000000 --- a/controllers/admin/changeStreamTitle.go +++ /dev/null @@ -1,35 +0,0 @@ -package admin - -import ( - "encoding/json" - "net/http" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/controllers" - - log "github.com/sirupsen/logrus" -) - -// ChangeStreamTitle will change the stream key (in memory). -func ChangeStreamTitle(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - controllers.WriteSimpleResponse(w, false, r.Method+" not supported") - return - } - - decoder := json.NewDecoder(r.Body) - var request changeStreamTitleRequest - err := decoder.Decode(&request) - if err != nil { - log.Errorln(err) - controllers.WriteSimpleResponse(w, false, "") - return - } - - config.Config.InstanceDetails.Title = request.Title - controllers.WriteSimpleResponse(w, true, "changed") -} - -type changeStreamTitleRequest struct { - Title string `json:"title"` -} diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index 3c2ba2265..656481b92 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -4,36 +4,36 @@ package admin import ( "encoding/json" + "errors" + "fmt" "net/http" "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/models" log "github.com/sirupsen/logrus" + "github.com/teris-io/shortid" ) // UpdateMessageVisibility updates an array of message IDs to have the same visiblity. func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { + if r.Method != controllers.POST { controllers.WriteSimpleResponse(w, false, r.Method+" not supported") return } decoder := json.NewDecoder(r.Body) - var request messageVisibilityUpdateRequest // creates an empty struc + var request messageVisibilityUpdateRequest - err := decoder.Decode(&request) // decode the json into `request` + err := decoder.Decode(&request) if err != nil { log.Errorln(err) controllers.WriteSimpleResponse(w, false, "") return } - // // make sql update call here. - // // := means create a new var - // _db := data.GetDatabase() - // updateMessageVisibility(_db, request) - if err := chat.SetMessagesVisibility(request.IDArray, request.Visible); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return @@ -49,12 +49,99 @@ type messageVisibilityUpdateRequest struct { // GetChatMessages returns all of the chat messages, unfiltered. func GetChatMessages(w http.ResponseWriter, r *http.Request) { - // middleware.EnableCors(&w) w.Header().Set("Content-Type", "application/json") - messages := core.GetAllChatMessages(false) + messages := core.GetModerationChatMessages() if err := json.NewEncoder(w).Encode(messages); err != nil { log.Errorln(err) } } + +// SendSystemMessage will send an official "SYSTEM" message +// to chat on behalf of your server. +func SendSystemMessage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var message models.ChatEvent + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + message.MessageType = models.SystemMessageSent + message.Author = data.GetServerName() + message.ClientID = "owncast-server" + message.ID = shortid.MustGenerate() + message.Visible = true + + message.SetDefaults() + message.RenderAndSanitizeMessageBody() + + if err := core.SendMessageToChat(message); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + controllers.WriteSimpleResponse(w, true, "sent") +} + +// SendUserMessage will send a message to chat on behalf of a user. +func SendUserMessage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var message models.ChatEvent + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + if !message.Valid() { + controllers.BadRequestHandler(w, errors.New("invalid chat message; id, author, and body are required")) + return + } + + message.MessageType = models.MessageSent + message.ClientID = "external-request" + message.ID = shortid.MustGenerate() + message.Visible = true + + message.SetDefaults() + message.RenderAndSanitizeMessageBody() + + if err := core.SendMessageToChat(message); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + controllers.WriteSimpleResponse(w, true, "sent") +} + +// SendChatAction will send a generic chat action. +func SendChatAction(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var message models.ChatEvent + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + message.MessageType = models.ChatActionSent + message.ClientID = "external-request" + message.ID = shortid.MustGenerate() + message.Visible = true + + if message.Author != "" { + message.Body = fmt.Sprintf("%s %s", message.Author, message.Body) + } + + message.SetDefaults() + + if err := core.SendMessageToChat(message); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + controllers.WriteSimpleResponse(w, true, "sent") +} diff --git a/controllers/admin/config.go b/controllers/admin/config.go new file mode 100644 index 000000000..a34930f60 --- /dev/null +++ b/controllers/admin/config.go @@ -0,0 +1,475 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "reflect" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" +) + +// ConfigValue is a container object that holds a value, is encoded, and saved to the database. +type ConfigValue struct { + Value interface{} `json:"value"` +} + +// SetTags will handle the web config request to set tags. +func SetTags(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValues, success := getValuesFromRequest(w, r) + if !success { + return + } + + var tagStrings []string + for _, tag := range configValues { + tagStrings = append(tagStrings, tag.Value.(string)) + } + + if err := data.SetServerMetadataTags(tagStrings); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetStreamTitle will handle the web config request to set the current stream title. +func SetStreamTitle(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + value := configValue.Value.(string) + + if err := data.SetStreamTitle(value); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + if value != "" { + sendSystemChatAction(fmt.Sprintf("Stream title changed to **%s**", value)) + } + controllers.WriteSimpleResponse(w, true, "changed") +} + +func sendSystemChatAction(messageText string) { + message := models.ChatEvent{} + message.Body = messageText + message.MessageType = models.ChatActionSent + message.ClientID = "internal-server" + message.SetDefaults() + + if err := core.SendMessageToChat(message); err != nil { + log.Errorln(err) + } +} + +// SetServerName will handle the web config request to set the server's name. +func SetServerName(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetServerName(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetServerSummary will handle the web config request to set the about/summary text. +func SetServerSummary(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetServerSummary(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetExtraPageContent will handle the web config request to set the page markdown content. +func SetExtraPageContent(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetExtraPageBodyContent(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetStreamKey will handle the web config request to set the server stream key. +func SetStreamKey(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetStreamKey(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetLogoPath will handle the web config request to validate and set the logo path. +func SetLogoPath(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + imgPath := configValue.Value.(string) + fullPath := filepath.Join("data", imgPath) + if !utils.DoesFileExists(fullPath) { + controllers.WriteSimpleResponse(w, false, fmt.Sprintf("%s does not exist", fullPath)) + return + } + + if err := data.SetLogoPath(imgPath); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetNSFW will handle the web config request to set the NSFW flag. +func SetNSFW(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetNSFW(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetFfmpegPath will handle the web config request to validate and set an updated copy of ffmpg. +func SetFfmpegPath(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + path := configValue.Value.(string) + if err := utils.VerifyFFMpegPath(path); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + if err := data.SetFfmpegPath(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "changed") +} + +// SetWebServerPort will handle the web config request to set the server's HTTP port. +func SetWebServerPort(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetHTTPPortNumber(configValue.Value.(float64)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "http port set") +} + +// SetRTMPServerPort will handle the web config request to set the inbound RTMP port. +func SetRTMPServerPort(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetRTMPPortNumber(configValue.Value.(float64)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "rtmp port set") +} + +// SetServerURL will handle the web config request to set the full server URL. +func SetServerURL(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetServerURL(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "server url set") +} + +// SetDirectoryEnabled will handle the web config request to enable or disable directory registration. +func SetDirectoryEnabled(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetDirectoryEnabled(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + controllers.WriteSimpleResponse(w, true, "directory state changed") +} + +// SetStreamLatencyLevel will handle the web config request to set the stream latency level. +func SetStreamLatencyLevel(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetStreamLatencyLevel(configValue.Value.(float64)); err != nil { + controllers.WriteSimpleResponse(w, false, "error setting stream latency "+err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "set stream latency") +} + +// SetS3Configuration will handle the web config request to set the storage configuration. +func SetS3Configuration(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type s3ConfigurationRequest struct { + Value models.S3 `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var newS3Config s3ConfigurationRequest + if err := decoder.Decode(&newS3Config); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update s3 config with provided values") + return + } + + if newS3Config.Value.Enabled { + if newS3Config.Value.Endpoint == "" || !utils.IsValidUrl((newS3Config.Value.Endpoint)) { + controllers.WriteSimpleResponse(w, false, "s3 support requires an endpoint") + return + + } + + if newS3Config.Value.AccessKey == "" || newS3Config.Value.Secret == "" { + controllers.WriteSimpleResponse(w, false, "s3 support requires an access key and secret") + return + } + + if newS3Config.Value.Region == "" { + controllers.WriteSimpleResponse(w, false, "s3 support requires a region and endpoint") + return + } + + if newS3Config.Value.Bucket == "" { + controllers.WriteSimpleResponse(w, false, "s3 support requires a bucket created for storing public video segments") + return + } + } + + data.SetS3Config(newS3Config.Value) + controllers.WriteSimpleResponse(w, true, "storage configuration changed") + +} + +// SetStreamOutputVariants will handle the web config request to set the video output stream variants. +func SetStreamOutputVariants(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type streamOutputVariantRequest struct { + Value []models.StreamOutputVariant `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var videoVariants streamOutputVariantRequest + if err := decoder.Decode(&videoVariants); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update video config with provided values") + return + } + + // Temporary: Convert the cpuUsageLevel to a preset. In the future we will have + // different codec models that will handle this for us and we won't + // be keeping track of presets at all. But for now... + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + for i, variant := range videoVariants.Value { + preset := "superfast" + if variant.CPUUsageLevel > 0 && variant.CPUUsageLevel <= len(presetMapping) { + preset = presetMapping[variant.CPUUsageLevel-1] + } + variant.EncoderPreset = preset + videoVariants.Value[i] = variant + } + + if err := data.SetStreamOutputVariants(videoVariants.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update video config with provided values") + return + } + + controllers.WriteSimpleResponse(w, true, "stream output variants updated") +} + +// SetSocialHandles will handle the web config request to set the external social profile links. +func SetSocialHandles(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type socialHandlesRequest struct { + Value []models.SocialHandle `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var socialHandles socialHandlesRequest + if err := decoder.Decode(&socialHandles); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update social handles with provided values") + return + } + + if err := data.SetSocialHandles(socialHandles.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update social handles with provided values") + return + } + + controllers.WriteSimpleResponse(w, true, "social handles updated") +} + +func requirePOST(w http.ResponseWriter, r *http.Request) bool { + if r.Method != controllers.POST { + controllers.WriteSimpleResponse(w, false, r.Method+" not supported") + return false + } + + return true +} + +func getValueFromRequest(w http.ResponseWriter, r *http.Request) (ConfigValue, bool) { + decoder := json.NewDecoder(r.Body) + var configValue ConfigValue + if err := decoder.Decode(&configValue); err != nil { + log.Warnln(err) + controllers.WriteSimpleResponse(w, false, "unable to parse new value") + return configValue, false + } + + return configValue, true +} + +func getValuesFromRequest(w http.ResponseWriter, r *http.Request) ([]ConfigValue, bool) { + var values []ConfigValue + + decoder := json.NewDecoder(r.Body) + var configValue ConfigValue + if err := decoder.Decode(&configValue); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to parse array of values") + return values, false + } + + object := reflect.ValueOf(configValue.Value) + + for i := 0; i < object.Len(); i++ { + values = append(values, ConfigValue{Value: object.Index(i).Interface()}) + } + + return values, true +} diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 80713d0a4..8fcd970ce 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -5,35 +5,50 @@ import ( "net/http" "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) // GetServerConfig gets the config details of the server. func GetServerConfig(w http.ResponseWriter, r *http.Request) { - var videoQualityVariants = make([]config.StreamQuality, 0) - for _, variant := range config.Config.GetVideoStreamQualities() { - videoQualityVariants = append(videoQualityVariants, config.StreamQuality{ + var videoQualityVariants = make([]models.StreamOutputVariant, 0) + for _, variant := range data.GetStreamOutputVariants() { + videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{ IsAudioPassthrough: variant.GetIsAudioPassthrough(), IsVideoPassthrough: variant.IsVideoPassthrough, Framerate: variant.GetFramerate(), EncoderPreset: variant.GetEncoderPreset(), VideoBitrate: variant.VideoBitrate, AudioBitrate: variant.AudioBitrate, + CPUUsageLevel: variant.GetCPUUsageLevel(), }) } response := serverConfigAdminResponse{ - InstanceDetails: config.Config.InstanceDetails, - FFmpegPath: config.Config.GetFFMpegPath(), - StreamKey: config.Config.VideoSettings.StreamingKey, - WebServerPort: config.Config.GetPublicWebServerPort(), - RTMPServerPort: config.Config.GetRTMPServerPort(), - VideoSettings: videoSettings{ - VideoQualityVariants: videoQualityVariants, - SegmentLengthSeconds: config.Config.GetVideoSegmentSecondsLength(), - NumberOfPlaylistItems: config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist(), + InstanceDetails: webConfigResponse{ + Name: data.GetServerName(), + Summary: data.GetServerSummary(), + Tags: data.GetServerMetadataTags(), + ExtraPageContent: data.GetExtraPageBodyContent(), + StreamTitle: data.GetStreamTitle(), + Logo: data.GetLogoPath(), + SocialHandles: data.GetSocialHandles(), + NSFW: data.GetNSFW(), }, - YP: config.Config.YP, - S3: config.Config.S3, + FFmpegPath: utils.ValidatedFfmpegPath(data.GetFfMpegPath()), + StreamKey: data.GetStreamKey(), + WebServerPort: config.WebServerPort, + RTMPServerPort: data.GetRTMPPortNumber(), + VideoSettings: videoSettings{ + VideoQualityVariants: videoQualityVariants, + LatencyLevel: data.GetStreamLatencyLevel().Level, + }, + YP: yp{ + Enabled: data.GetDirectoryEnabled(), + InstanceURL: data.GetServerURL(), + }, + S3: data.GetS3Config(), } w.Header().Set("Content-Type", "application/json") @@ -44,18 +59,36 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { } type serverConfigAdminResponse struct { - InstanceDetails config.InstanceDetails `json:"instanceDetails"` - FFmpegPath string `json:"ffmpegPath"` - StreamKey string `json:"streamKey"` - WebServerPort int `json:"webServerPort"` - RTMPServerPort int `json:"rtmpServerPort"` - S3 config.S3 `json:"s3"` - VideoSettings videoSettings `json:"videoSettings"` - YP config.YP `json:"yp"` + InstanceDetails webConfigResponse `json:"instanceDetails"` + FFmpegPath string `json:"ffmpegPath"` + StreamKey string `json:"streamKey"` + WebServerPort int `json:"webServerPort"` + RTMPServerPort int `json:"rtmpServerPort"` + S3 models.S3 `json:"s3"` + VideoSettings videoSettings `json:"videoSettings"` + LatencyLevel int `json:"latencyLevel"` + YP yp `json:"yp"` } type videoSettings struct { - VideoQualityVariants []config.StreamQuality `json:"videoQualityVariants"` - SegmentLengthSeconds int `json:"segmentLengthSeconds"` - NumberOfPlaylistItems int `json:"numberOfPlaylistItems"` + VideoQualityVariants []models.StreamOutputVariant `json:"videoQualityVariants"` + LatencyLevel int `json:"latencyLevel"` +} + +type webConfigResponse struct { + Name string `json:"name"` + Summary string `json:"summary"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` +} + +type yp struct { + Enabled bool `json:"enabled"` + InstanceURL string `json:"instanceUrl"` // The public URL the directory should link to + YPServiceURL string `json:"-"` // The base URL to the YP API to register with (optional) } diff --git a/controllers/admin/status.go b/controllers/admin/status.go index 2755276e3..e02f1fbd0 100644 --- a/controllers/admin/status.go +++ b/controllers/admin/status.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" - "github.com/owncast/owncast/config" "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" ) @@ -14,15 +14,17 @@ import ( func Status(w http.ResponseWriter, r *http.Request) { broadcaster := core.GetBroadcaster() status := core.GetStatus() + currentBroadcast := core.GetCurrentBroadcast() response := adminStatusResponse{ Broadcaster: broadcaster, + CurrentBroadcast: currentBroadcast, Online: status.Online, ViewerCount: status.ViewerCount, OverallPeakViewerCount: status.OverallMaxViewerCount, SessionPeakViewerCount: status.SessionMaxViewerCount, VersionNumber: status.VersionNumber, - DisableUpgradeChecks: config.Config.DisableUpgradeChecks, + StreamTitle: data.GetStreamTitle(), } w.Header().Set("Content-Type", "application/json") @@ -33,12 +35,12 @@ func Status(w http.ResponseWriter, r *http.Request) { } type adminStatusResponse struct { - Broadcaster *models.Broadcaster `json:"broadcaster"` - Online bool `json:"online"` - ViewerCount int `json:"viewerCount"` - OverallPeakViewerCount int `json:"overallPeakViewerCount"` - SessionPeakViewerCount int `json:"sessionPeakViewerCount"` - - VersionNumber string `json:"versionNumber"` - DisableUpgradeChecks bool `json:"disableUpgradeChecks"` + Broadcaster *models.Broadcaster `json:"broadcaster"` + CurrentBroadcast *models.CurrentBroadcast `json:"currentBroadcast"` + Online bool `json:"online"` + ViewerCount int `json:"viewerCount"` + OverallPeakViewerCount int `json:"overallPeakViewerCount"` + SessionPeakViewerCount int `json:"sessionPeakViewerCount"` + StreamTitle string `json:"streamTitle"` + VersionNumber string `json:"versionNumber"` } diff --git a/controllers/admin/webhooks.go b/controllers/admin/webhooks.go new file mode 100644 index 000000000..9407b1996 --- /dev/null +++ b/controllers/admin/webhooks.go @@ -0,0 +1,84 @@ +package admin + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" +) + +type deleteWebhookRequest struct { + ID int `json:"id"` +} + +type createWebhookRequest struct { + URL string `json:"url"` + Events []models.EventType `json:"events"` +} + +// CreateWebhook will add a single webhook. +func CreateWebhook(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var request createWebhookRequest + if err := decoder.Decode(&request); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + // Verify all the scopes provided are valid + if !models.HasValidEvents(request.Events) { + controllers.BadRequestHandler(w, errors.New("one or more invalid event provided")) + return + } + + newWebhookID, err := data.InsertWebhook(request.URL, request.Events) + if err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + controllers.WriteResponse(w, models.Webhook{ + ID: newWebhookID, + URL: request.URL, + Events: request.Events, + Timestamp: time.Now(), + LastUsed: nil, + }) +} + +// GetWebhooks will return all webhooks. +func GetWebhooks(w http.ResponseWriter, r *http.Request) { + webhooks, err := data.GetWebhooks() + if err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + controllers.WriteResponse(w, webhooks) +} + +// DeleteWebhook will delete a single webhook. +func DeleteWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != controllers.POST { + controllers.WriteSimpleResponse(w, false, r.Method+" not supported") + return + } + + decoder := json.NewDecoder(r.Body) + var request deleteWebhookRequest + if err := decoder.Decode(&request); err != nil { + controllers.BadRequestHandler(w, err) + return + } + + if err := data.DeleteWebhook(request.ID); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + controllers.WriteSimpleResponse(w, true, "deleted webhook") +} diff --git a/controllers/admin/yp.go b/controllers/admin/yp.go new file mode 100644 index 000000000..c6595b86e --- /dev/null +++ b/controllers/admin/yp.go @@ -0,0 +1,20 @@ +package admin + +import ( + "net/http" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/data" + log "github.com/sirupsen/logrus" +) + +// ResetYPRegistration will clear the YP protocol registration key. +func ResetYPRegistration(w http.ResponseWriter, r *http.Request) { + log.Traceln("Resetting YP registration key") + if err := data.SetDirectoryRegistrationKey(""); err != nil { + log.Errorln(err) + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + controllers.WriteSimpleResponse(w, true, "reset") +} diff --git a/controllers/chat.go b/controllers/chat.go index 5ecff7902..0b604e737 100644 --- a/controllers/chat.go +++ b/controllers/chat.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/owncast/owncast/core" - "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" log "github.com/sirupsen/logrus" ) @@ -17,32 +16,16 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - messages := core.GetAllChatMessages(true) + messages := core.GetAllChatMessages() err := json.NewEncoder(w).Encode(messages) if err != nil { log.Errorln(err) } - case http.MethodPost: - var message models.ChatEvent - if err := json.NewDecoder(r.Body).Decode(&message); err != nil { - internalErrorHandler(w, err) - return - } - - if err := core.SendMessageToChat(message); err != nil { - badRequestHandler(w, err) - return - } - - if err := json.NewEncoder(w).Encode(j{"success": true}); err != nil { - internalErrorHandler(w, err) - return - } default: w.WriteHeader(http.StatusNotImplemented) if err := json.NewEncoder(w).Encode(j{"error": "method not implemented (PRs are accepted)"}); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } } diff --git a/controllers/config.go b/controllers/config.go index 65b0bb95f..cffb8d16d 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -5,17 +5,63 @@ import ( "net/http" "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" + "github.com/owncast/owncast/utils" ) +type webConfigResponse struct { + Name string `json:"name"` + Summary string `json:"summary"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` +} + // GetWebConfig gets the status of the server. func GetWebConfig(w http.ResponseWriter, r *http.Request) { middleware.EnableCors(&w) w.Header().Set("Content-Type", "application/json") - configuration := config.Config.InstanceDetails - configuration.Version = config.Config.VersionInfo + pageContent := utils.RenderPageContentMarkdown(data.GetExtraPageBodyContent()) + socialHandles := data.GetSocialHandles() + for i, handle := range socialHandles { + platform := models.GetSocialHandle(handle.Platform) + if platform != nil { + handle.Icon = platform.Icon + socialHandles[i] = handle + } + } + + configuration := webConfigResponse{ + Name: data.GetServerName(), + Summary: data.GetServerSummary(), + Logo: "/logo", + Tags: data.GetServerMetadataTags(), + Version: config.GetReleaseString(), + NSFW: data.GetNSFW(), + ExtraPageContent: pageContent, + StreamTitle: data.GetStreamTitle(), + SocialHandles: socialHandles, + } + if err := json.NewEncoder(w).Encode(configuration); err != nil { - badRequestHandler(w, err) + BadRequestHandler(w, err) + } +} + +// GetAllSocialPlatforms will return a list of all social platform types. +func GetAllSocialPlatforms(w http.ResponseWriter, r *http.Request) { + middleware.EnableCors(&w) + w.Header().Set("Content-Type", "application/json") + + platforms := models.GetAllSocialHandles() + if err := json.NewEncoder(w).Encode(platforms); err != nil { + InternalErrorHandler(w, err) } } diff --git a/controllers/connectedClients.go b/controllers/connectedClients.go index 5d9a7c6f2..b30bf0957 100644 --- a/controllers/connectedClients.go +++ b/controllers/connectedClients.go @@ -13,6 +13,6 @@ func GetConnectedClients(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(clients); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } diff --git a/controllers/constants.go b/controllers/constants.go new file mode 100644 index 000000000..29b08442a --- /dev/null +++ b/controllers/constants.go @@ -0,0 +1,7 @@ +package controllers + +// POST is the HTTP POST method. +const POST = "POST" + +// GET is the HTTP GET method. +const GET = "GET" diff --git a/controllers/controllers.go b/controllers/controllers.go index a92c6fad8..4d5e26b3a 100644 --- a/controllers/controllers.go +++ b/controllers/controllers.go @@ -5,39 +5,65 @@ import ( "net/http" "github.com/owncast/owncast/models" + log "github.com/sirupsen/logrus" ) type j map[string]interface{} -func internalErrorHandler(w http.ResponseWriter, err error) { +// InternalErrorHandler will return an error message as an HTTP response. +func InternalErrorHandler(w http.ResponseWriter, err error) { if err == nil { return } + log.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } -func badRequestHandler(w http.ResponseWriter, err error) { +// BadRequestHandler will return an HTTP 500 as an HTTP response. +func BadRequestHandler(w http.ResponseWriter, err error) { if err == nil { return } + log.Debugln(err) + w.WriteHeader(http.StatusBadRequest) if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } +// WriteSimpleResponse will return a message as a response. func WriteSimpleResponse(w http.ResponseWriter, success bool, message string) { response := models.BaseAPIResponse{ Success: success, Message: message, } - w.WriteHeader(http.StatusOK) + + w.Header().Set("Content-Type", "application/json") + + if success { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusBadRequest) + } + if err := json.NewEncoder(w).Encode(response); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) + } +} + +// WriteResponse will return an object as a JSON encoded response. +func WriteResponse(w http.ResponseWriter, response interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(response); err != nil { + InternalErrorHandler(w, err) } } diff --git a/controllers/emoji.go b/controllers/emoji.go index 9eb8e95fc..486b1f0f6 100644 --- a/controllers/emoji.go +++ b/controllers/emoji.go @@ -40,6 +40,6 @@ func GetCustomEmoji(w http.ResponseWriter, r *http.Request) { } if err := json.NewEncoder(w).Encode(emojiList); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } diff --git a/controllers/index.go b/controllers/index.go index 058ef121f..ed520d2d8 100644 --- a/controllers/index.go +++ b/controllers/index.go @@ -14,16 +14,23 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/utils" ) +// MetadataPage represents a server-rendered web page for bots and web scrapers. type MetadataPage struct { - Config config.InstanceDetails - RequestedURL string - Image string - Thumbnail string - TagsString string + RequestedURL string + Image string + Thumbnail string + TagsString string + Title string + Summary string + Name string + Tags []string + SocialHandles []models.SocialHandle } // IndexHandler handles the default index route. @@ -70,7 +77,7 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) { if err != nil { log.Panicln(err) } - imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, config.Config.InstanceDetails.Logo)) + imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, data.GetLogoPath())) if err != nil { log.Panicln(err) } @@ -91,8 +98,15 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) { thumbnailURL = imageURL.String() } - tagsString := strings.Join(config.Config.InstanceDetails.Tags, ",") - metadata := MetadataPage{config.Config.InstanceDetails, fullURL.String(), imageURL.String(), thumbnailURL, tagsString} + tagsString := strings.Join(data.GetServerMetadataTags(), ",") + metadata := MetadataPage{ + RequestedURL: fullURL.String(), + Image: imageURL.String(), + Thumbnail: thumbnailURL, + TagsString: tagsString, + Tags: data.GetServerMetadataTags(), + SocialHandles: data.GetSocialHandles(), + } w.Header().Set("Content-Type", "text/html") err = tmpl.Execute(w, metadata) diff --git a/controllers/logo.go b/controllers/logo.go new file mode 100644 index 000000000..4b3821a74 --- /dev/null +++ b/controllers/logo.go @@ -0,0 +1,65 @@ +package controllers + +import ( + "io/ioutil" + "net/http" + "path/filepath" + "strconv" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" +) + +// GetLogo will return the logo image as a response. +func GetLogo(w http.ResponseWriter, r *http.Request) { + imageFilename := data.GetLogoPath() + if imageFilename == "" { + returnDefault(w) + return + } + imagePath := filepath.Join("data", imageFilename) + imageBytes, err := getImage(imagePath) + if err != nil { + returnDefault(w) + return + } + + contentType := "image/jpeg" + if filepath.Ext(imageFilename) == ".svg" { + contentType = "image/svg+xml" + } else if filepath.Ext(imageFilename) == ".gif" { + contentType = "image/gif" + } else if filepath.Ext(imageFilename) == ".png" { + contentType = "image/png" + } + + cacheTime := utils.GetCacheDurationSecondsForPath(imagePath) + writeBytesAsImage(imageBytes, contentType, w, cacheTime) +} + +func returnDefault(w http.ResponseWriter) { + imagePath := filepath.Join(config.WebRoot, "img", "logo.svg") + imageBytes, err := getImage(imagePath) + if err != nil { + log.Errorln(err) + return + } + cacheTime := utils.GetCacheDurationSecondsForPath(imagePath) + writeBytesAsImage(imageBytes, "image/svg+xml", w, cacheTime) +} + +func writeBytesAsImage(data []byte, contentType string, w http.ResponseWriter, cacheSeconds int) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(cacheSeconds)) + + if _, err := w.Write(data); err != nil { + log.Println("unable to write image.") + } +} + +func getImage(path string) ([]byte, error) { + return ioutil.ReadFile(path) +} diff --git a/controllers/status.go b/controllers/status.go index 5f89f5145..fe51374b7 100644 --- a/controllers/status.go +++ b/controllers/status.go @@ -16,6 +16,6 @@ func GetStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(status); err != nil { - internalErrorHandler(w, err) + InternalErrorHandler(w, err) } } diff --git a/core/chat/chat.go b/core/chat/chat.go index 8b829d2d8..1498ec165 100644 --- a/core/chat/chat.go +++ b/core/chat/chat.go @@ -60,12 +60,16 @@ func SendMessage(message models.ChatEvent) { } // GetMessages gets all of the messages. -func GetMessages(filtered bool) []models.ChatEvent { +func GetMessages() []models.ChatEvent { if _server == nil { return []models.ChatEvent{} } - return getChatHistory(filtered) + return getChatHistory() +} + +func GetModerationChatMessages() []models.ChatEvent { + return getChatModerationHistory() } func GetClient(clientID string) *Client { diff --git a/core/chat/client.go b/core/chat/client.go index 9c8d50b6a..916506c19 100644 --- a/core/chat/client.go +++ b/core/chat/client.go @@ -28,36 +28,38 @@ type Client struct { Username *string ClientID string // How we identify unique viewers when counting viewer counts. Geo *geoip.GeoDetails `json:"geo"` + Ignore bool // If set to true this will not be treated as a viewer socketID string // How we identify a single websocket client. ws *websocket.Conn ch chan models.ChatEvent pingch chan models.PingMessage usernameChangeChannel chan models.NameChangeEvent + userJoinedChannel chan models.UserJoinedEvent doneCh chan bool rateLimiter *rate.Limiter } -const ( - CHAT = "CHAT" - NAMECHANGE = "NAME_CHANGE" - PING = "PING" - PONG = "PONG" - VISIBILITYUPDATE = "VISIBILITY-UPDATE" -) - // NewClient creates a new chat client. func NewClient(ws *websocket.Conn) *Client { if ws == nil { log.Panicln("ws cannot be nil") } + var ignoreClient = false + for _, extraData := range ws.Config().Protocol { + if extraData == "IGNORE_CLIENT" { + ignoreClient = true + } + } + ch := make(chan models.ChatEvent, channelBufSize) doneCh := make(chan bool) pingch := make(chan models.PingMessage) usernameChangeChannel := make(chan models.NameChangeEvent) + userJoinedChannel := make(chan models.UserJoinedEvent) ipAddress := utils.GetIPAddressFromRequest(ws.Request()) userAgent := ws.Request().UserAgent() @@ -66,7 +68,7 @@ func NewClient(ws *websocket.Conn) *Client { rateLimiter := rate.NewLimiter(0.6, 5) - return &Client{time.Now(), 0, userAgent, ipAddress, nil, clientID, nil, socketID, ws, ch, pingch, usernameChangeChannel, doneCh, rateLimiter} + return &Client{time.Now(), 0, userAgent, ipAddress, nil, clientID, nil, ignoreClient, socketID, ws, ch, pingch, usernameChangeChannel, userJoinedChannel, doneCh, rateLimiter} } func (c *Client) write(msg models.ChatEvent) { @@ -105,6 +107,12 @@ func (c *Client) listenWrite() { if err != nil { c.handleClientSocketError(err) } + case msg := <-c.userJoinedChannel: + err := websocket.JSON.Send(c.ws, msg) + if err != nil { + c.handleClientSocketError(err) + } + // receive done request case <-c.doneCh: _server.removeClient(c) @@ -157,28 +165,46 @@ func (c *Client) listenRead() { log.Errorln(err) } - messageType := messageTypeCheck["type"] + messageType := messageTypeCheck["type"].(string) if !c.passesRateLimit() { continue } - if messageType == CHAT { + if messageType == models.MessageSent { c.chatMessageReceived(data) - } else if messageType == NAMECHANGE { + } else if messageType == models.UserNameChanged { c.userChangedName(data) + } else if messageType == models.UserJoined { + c.userJoined(data) } } } } +func (c *Client) userJoined(data []byte) { + var msg models.UserJoinedEvent + if err := json.Unmarshal(data, &msg); err != nil { + log.Errorln(err) + return + } + + msg.ID = shortid.MustGenerate() + msg.Type = models.UserJoined + msg.Timestamp = time.Now() + + c.Username = &msg.Username + + _server.userJoined(msg) +} + func (c *Client) userChangedName(data []byte) { var msg models.NameChangeEvent err := json.Unmarshal(data, &msg) if err != nil { log.Errorln(err) } - msg.Type = NAMECHANGE + msg.Type = models.UserNameChanged msg.ID = shortid.MustGenerate() _server.usernameChanged(msg) c.Username = &msg.NewName @@ -191,10 +217,7 @@ func (c *Client) chatMessageReceived(data []byte) { log.Errorln(err) } - id, _ := shortid.Generate() - msg.ID = id - msg.Timestamp = time.Now() - msg.Visible = true + msg.SetDefaults() c.MessageCount++ c.Username = &msg.Author diff --git a/core/chat/messages.go b/core/chat/messages.go index 5cf5dcd43..8d5dfa3e9 100644 --- a/core/chat/messages.go +++ b/core/chat/messages.go @@ -1,6 +1,8 @@ package chat import ( + "github.com/owncast/owncast/core/webhooks" + "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" ) @@ -20,8 +22,10 @@ func SetMessagesVisibility(messageIDs []string, visibility bool) error { log.Errorln(err) continue } - message.MessageType = VISIBILITYUPDATE + message.MessageType = models.VisibiltyToggled _server.sendAll(message) + + go webhooks.SendChatEvent(message) } return nil diff --git a/core/chat/persistence.go b/core/chat/persistence.go index d49943af9..b302cee70 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -61,15 +61,8 @@ func addMessage(message models.ChatEvent) { } } -func getChatHistory(filtered bool) []models.ChatEvent { +func getChat(query string) []models.ChatEvent { history := make([]models.ChatEvent, 0) - - // Get all messages sent within the past day - var query = "SELECT * FROM messages WHERE messageType != 'SYSTEM' AND datetime(timestamp) >=datetime('now', '-1 Day')" - if filtered { - query = query + " AND visible = 1" - } - rows, err := _db.Query(query) if err != nil { log.Fatal(err) @@ -80,7 +73,7 @@ func getChatHistory(filtered bool) []models.ChatEvent { var id string var author string var body string - var messageType string + var messageType models.EventType var visible int var timestamp time.Time @@ -109,6 +102,17 @@ func getChatHistory(filtered bool) []models.ChatEvent { return history } +func getChatModerationHistory() []models.ChatEvent { + var query = "SELECT * FROM messages WHERE messageType == 'CHAT' AND datetime(timestamp) >=datetime('now', '-5 Hour')" + return getChat(query) +} + +func getChatHistory() []models.ChatEvent { + // Get all messages sent within the past 5hrs, max 50 + var query = "SELECT * FROM messages WHERE datetime(timestamp) >=datetime('now', '-5 Hour') AND visible = 1 LIMIT 50" + return getChat(query) +} + func saveMessageVisibility(messageIDs []string, visible bool) error { tx, err := _db.Begin() if err != nil { @@ -150,7 +154,7 @@ func getMessageById(messageID string) (models.ChatEvent, error) { var id string var author string var body string - var messageType string + var messageType models.EventType var visible int var timestamp time.Time diff --git a/core/chat/server.go b/core/chat/server.go index 33dbe1bfb..5f1d94935 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -9,7 +9,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/net/websocket" - "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/models" ) @@ -61,7 +62,7 @@ func (s *server) sendAll(msg models.ChatEvent) { } func (s *server) ping() { - ping := models.PingMessage{MessageType: PING} + ping := models.PingMessage{MessageType: models.PING} for _, c := range s.Clients { c.pingch <- ping } @@ -71,6 +72,16 @@ func (s *server) usernameChanged(msg models.NameChangeEvent) { for _, c := range s.Clients { c.usernameChangeChannel <- msg } + + go webhooks.SendChatEventUsernameChanged(msg) +} + +func (s *server) userJoined(msg models.UserJoinedEvent) { + for _, c := range s.Clients { + c.userJoinedChannel <- msg + } + + go webhooks.SendChatEventUserJoined(msg) } func (s *server) onConnection(ws *websocket.Conn) { @@ -103,8 +114,10 @@ func (s *server) Listen() { s.Clients[c.socketID] = c l.Unlock() - s.listener.ClientAdded(c.GetViewerClientFromChatClient()) - s.sendWelcomeMessageToClient(c) + if !c.Ignore { + s.listener.ClientAdded(c.GetViewerClientFromChatClient()) + s.sendWelcomeMessageToClient(c) + } // remove a client case c := <-s.delCh: @@ -122,7 +135,11 @@ func (s *server) Listen() { s.sendAll(msg) // Store in the message history + msg.SetDefaults() addMessage(msg) + + // Send webhooks + go webhooks.SendChatEvent(msg) } case ping := <-s.pingCh: fmt.Println("PING?", ping) @@ -154,8 +171,8 @@ func (s *server) sendWelcomeMessageToClient(c *Client) { // Add an artificial delay so people notice this message come in. time.Sleep(7 * time.Second) - initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary) - initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: config.Config.InstanceDetails.Name, Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()} + initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", data.GetServerName(), data.GetServerSummary()) + initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: data.GetServerName(), Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()} c.write(initialMessage) }() } diff --git a/core/chatListener.go b/core/chatListener.go index feec2f10f..b6bcd8357 100644 --- a/core/chatListener.go +++ b/core/chatListener.go @@ -1,8 +1,6 @@ package core import ( - "errors" - "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/models" ) @@ -26,16 +24,16 @@ func (cl ChatListenerImpl) MessageSent(message models.ChatEvent) { // SendMessageToChat sends a message to the chat server. func SendMessageToChat(message models.ChatEvent) error { - if !message.Valid() { - return errors.New("invalid chat message; id, author, and body are required") - } - chat.SendMessage(message) return nil } // GetAllChatMessages gets all of the chat messages. -func GetAllChatMessages(filtered bool) []models.ChatEvent { - return chat.GetMessages(filtered) +func GetAllChatMessages() []models.ChatEvent { + return chat.GetMessages() +} + +func GetModerationChatMessages() []models.ChatEvent { + return chat.GetModerationChatMessages() } diff --git a/core/core.go b/core/core.go index 1f911fcc4..6a83913aa 100644 --- a/core/core.go +++ b/core/core.go @@ -10,8 +10,9 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat" - "github.com/owncast/owncast/core/ffmpeg" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/rtmp" + "github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" "github.com/owncast/owncast/yp" @@ -20,33 +21,41 @@ import ( var ( _stats *models.Stats _storage models.StorageProvider - _transcoder *ffmpeg.Transcoder + _transcoder *transcoder.Transcoder _yp *yp.YP _broadcaster *models.Broadcaster ) -var handler ffmpeg.HLSHandler -var fileWriter = ffmpeg.FileWriterReceiverService{} +var handler transcoder.HLSHandler +var fileWriter = transcoder.FileWriterReceiverService{} // Start starts up the core processing. func Start() error { resetDirectories() + data.PopulateDefaults() + // Once a couple versions pass we can remove the old data migrators. + data.RunMigrations() + + if err := data.VerifySettings(); err != nil { + log.Error(err) + return err + } + if err := setupStats(); err != nil { log.Error("failed to setup the stats") return err } - if err := setupStorage(); err != nil { - log.Error("failed to setup the storage") - return err - } - // The HLS handler takes the written HLS playlists and segments // and makes storage decisions. It's rather simple right now // but will play more useful when recordings come into play. - handler = ffmpeg.HLSHandler{} - handler.Storage = _storage + handler = transcoder.HLSHandler{} + + if err := setupStorage(); err != nil { + log.Errorln("storage error", err) + } + fileWriter.SetupFileWriterReceiverService(&handler) if err := createInitialOfflineState(); err != nil { @@ -54,10 +63,8 @@ func Start() error { return err } - if config.Config.YP.Enabled { + if data.GetDirectoryEnabled() { _yp = yp.NewYP(GetStatus) - } else { - yp.DisplayInstructions() } chat.Setup(ChatListenerImpl{}) @@ -65,8 +72,8 @@ func Start() error { // start the rtmp server go rtmp.Start(setStreamAsConnected, setBroadcaster) - port := config.Config.GetPublicWebServerPort() - rtmpPort := config.Config.GetRTMPServerPort() + port := config.WebServerPort + rtmpPort := data.GetRTMPPortNumber() log.Infof("Web server is listening on port %d, RTMP is accepting inbound streams on port %d.", port, rtmpPort) log.Infoln("The web admin interface is available at /admin.") @@ -94,13 +101,13 @@ func transitionToOfflineVideoStreamContent() { offlineFilename := "offline.ts" offlineFilePath := "static/" + offlineFilename - _transcoder := ffmpeg.NewTranscoder() - _transcoder.SetSegmentLength(10) + _transcoder := transcoder.NewTranscoder() _transcoder.SetInput(offlineFilePath) _transcoder.Start() // Copy the logo to be the thumbnail - err := utils.Copy(filepath.Join("webroot", config.Config.InstanceDetails.Logo), "webroot/thumbnail.jpg") + logo := data.GetLogoPath() + err := utils.Copy(filepath.Join("data", logo), "webroot/thumbnail.jpg") if err != nil { log.Warnln(err) } @@ -129,8 +136,8 @@ func resetDirectories() { os.Remove(filepath.Join(config.WebRoot, "thumbnail.jpg")) // Create private hls data dirs - if len(config.Config.VideoSettings.StreamQualities) != 0 { - for index := range config.Config.VideoSettings.StreamQualities { + if len(data.GetStreamOutputVariants()) != 0 { + for index := range data.GetStreamOutputVariants() { err = os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777) if err != nil { log.Fatalln(err) @@ -154,7 +161,8 @@ func resetDirectories() { } // Remove the previous thumbnail - err = utils.Copy(path.Join(config.WebRoot, config.Config.InstanceDetails.Logo), "webroot/thumbnail.jpg") + logo := data.GetLogoPath() + err = utils.Copy(path.Join("data", logo), "webroot/thumbnail.jpg") if err != nil { log.Warnln(err) } diff --git a/core/data/accessTokens.go b/core/data/accessTokens.go new file mode 100644 index 000000000..237252b8a --- /dev/null +++ b/core/data/accessTokens.go @@ -0,0 +1,199 @@ +package data + +import ( + "errors" + "strings" + "time" + + "github.com/owncast/owncast/models" + log "github.com/sirupsen/logrus" +) + +func createAccessTokensTable() { + log.Traceln("Creating access_tokens table...") + + createTableSQL := `CREATE TABLE IF NOT EXISTS access_tokens ( + "token" string NOT NULL PRIMARY KEY, + "name" string, + "scopes" TEXT, + "timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP, + "last_used" DATETIME + );` + + stmt, err := _db.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln(err) + } +} + +// InsertToken will add a new token to the database. +func InsertToken(token string, name string, scopes []string) error { + log.Println("Adding new access token:", name) + + scopesString := strings.Join(scopes, ",") + + tx, err := _db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("INSERT INTO access_tokens(token, name, scopes) values(?, ?, ?)") + + if err != nil { + return err + } + defer stmt.Close() + + if _, err = stmt.Exec(token, name, scopesString); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} + +// DeleteToken will delete a token from the database. +func DeleteToken(token string) error { + log.Println("Deleting access token:", token) + + tx, err := _db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("DELETE FROM access_tokens WHERE token = ?") + + if err != nil { + return err + } + defer stmt.Close() + + result, err := stmt.Exec(token) + if err != nil { + return err + } + + if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { + tx.Rollback() //nolint + return errors.New(token + " not found") + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} + +// DoesTokenSupportScope will determine if a specific token has access to perform a scoped action. +func DoesTokenSupportScope(token string, scope string) (bool, error) { + // This will split the scopes from comma separated to individual rows + // so we can efficiently find if a token supports a single scope. + // This is SQLite specific, so if we ever support other database + // backends we need to support other methods. + var query = `SELECT count(*) FROM ( + WITH RECURSIVE split(token, scope, rest) AS ( + SELECT token, '', scopes || ',' FROM access_tokens + UNION ALL + SELECT token, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',')+1) + FROM split + WHERE rest <> '') + SELECT token, scope + FROM split + WHERE scope <> '' + ORDER BY token, scope + ) AS token WHERE token.token = ? AND token.scope = ?;` + + row := _db.QueryRow(query, token, scope) + + var count = 0 + err := row.Scan(&count) + + return count > 0, err +} + +// GetAccessTokens will return all access tokens. +func GetAccessTokens() ([]models.AccessToken, error) { //nolint + tokens := make([]models.AccessToken, 0) + + // Get all messages sent within the past day + var query = "SELECT * FROM access_tokens" + + rows, err := _db.Query(query) + if err != nil { + return tokens, err + } + defer rows.Close() + + for rows.Next() { + var token string + var name string + var scopes string + var timestampString string + var lastUsedString *string + + if err := rows.Scan(&token, &name, &scopes, ×tampString, &lastUsedString); err != nil { + log.Error("There is a problem reading the database.", err) + return tokens, err + } + + timestamp, err := time.Parse(time.RFC3339, timestampString) + if err != nil { + return tokens, err + } + + var lastUsed *time.Time = nil + if lastUsedString != nil { + lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString) + lastUsed = &lastUsedTime + } + + singleToken := models.AccessToken{ + Name: name, + Token: token, + Scopes: strings.Split(scopes, ","), + Timestamp: timestamp, + LastUsed: lastUsed, + } + + tokens = append(tokens, singleToken) + } + + if err := rows.Err(); err != nil { + return tokens, err + } + + return tokens, nil +} + +// SetAccessTokenAsUsed will update the last used timestamp for a token. +func SetAccessTokenAsUsed(token string) error { + tx, err := _db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("UPDATE access_tokens SET last_used = CURRENT_TIMESTAMP WHERE token = ?") + + if err != nil { + return err + } + defer stmt.Close() + + if _, err := stmt.Exec(token); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} diff --git a/core/data/cache.go b/core/data/cache.go new file mode 100644 index 000000000..784999814 --- /dev/null +++ b/core/data/cache.go @@ -0,0 +1,18 @@ +package data + +import "errors" + +// GetCachedValue will return a value for key from the cache. +func (ds *Datastore) GetCachedValue(key string) ([]byte, error) { + // Check for a cached value + if val, ok := ds.cache[key]; ok { + return val, nil + } + + return nil, errors.New(key + " not found in cache") +} + +// SetCachedValue will set a value for key in the cache. +func (ds *Datastore) SetCachedValue(key string, b []byte) { + ds.cache[key] = b +} diff --git a/core/data/config.go b/core/data/config.go new file mode 100644 index 000000000..31ed87c6f --- /dev/null +++ b/core/data/config.go @@ -0,0 +1,450 @@ +package data + +import ( + "errors" + "sort" + "strings" + "time" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/models" + log "github.com/sirupsen/logrus" +) + +const extraContentKey = "extra_page_content" +const streamTitleKey = "stream_title" +const streamKeyKey = "stream_key" +const logoPathKey = "logo_path" +const serverSummaryKey = "server_summary" +const serverNameKey = "server_name" +const serverURLKey = "server_url" +const httpPortNumberKey = "http_port_number" +const rtmpPortNumberKey = "rtmp_port_number" +const serverMetadataTagsKey = "server_metadata_tags" +const directoryEnabledKey = "directory_enabled" +const directoryRegistrationKeyKey = "directory_registration_key" +const socialHandlesKey = "social_handles" +const peakViewersSessionKey = "peak_viewers_session" +const peakViewersOverallKey = "peak_viewers_overall" +const lastDisconnectTimeKey = "last_disconnect_time" +const ffmpegPathKey = "ffmpeg_path" +const nsfwKey = "nsfw" +const s3StorageEnabledKey = "s3_storage_enabled" +const s3StorageConfigKey = "s3_storage_config" +const videoLatencyLevel = "video_latency_level" +const videoStreamOutputVariantsKey = "video_stream_output_variants" + +// GetExtraPageBodyContent will return the user-supplied body content. +func GetExtraPageBodyContent() string { + content, err := _datastore.GetString(extraContentKey) + if err != nil { + log.Errorln(extraContentKey, err) + return config.GetDefaults().PageBodyContent + } + + return content +} + +// SetExtraPageBodyContent will set the user-supplied body content. +func SetExtraPageBodyContent(content string) error { + return _datastore.SetString(extraContentKey, content) +} + +// GetStreamTitle will return the name of the current stream. +func GetStreamTitle() string { + title, err := _datastore.GetString(streamTitleKey) + if err != nil { + return "" + } + + return title +} + +// SetStreamTitle will set the name of the current stream. +func SetStreamTitle(title string) error { + return _datastore.SetString(streamTitleKey, title) +} + +// GetStreamKey will return the inbound streaming password. +func GetStreamKey() string { + key, err := _datastore.GetString(streamKeyKey) + if err != nil { + log.Errorln(streamKeyKey, err) + return "" + } + + return key +} + +// SetStreamKey will set the inbound streaming password. +func SetStreamKey(key string) error { + return _datastore.SetString(streamKeyKey, key) +} + +// GetLogoPath will return the path for the logo, relative to webroot. +func GetLogoPath() string { + logo, err := _datastore.GetString(logoPathKey) + if err != nil { + log.Errorln(logoPathKey, err) + return config.GetDefaults().Logo + } + + if logo == "" { + return config.GetDefaults().Logo + } + + return logo +} + +// SetLogoPath will set the path for the logo, relative to webroot. +func SetLogoPath(logo string) error { + return _datastore.SetString(logoPathKey, logo) +} + +// GetServerSummary will return the server summary text. +func GetServerSummary() string { + summary, err := _datastore.GetString(serverSummaryKey) + if err != nil { + log.Errorln(serverSummaryKey, err) + return "" + } + + return summary +} + +// SetServerSummary will set the server summary text. +func SetServerSummary(summary string) error { + return _datastore.SetString(serverSummaryKey, summary) +} + +// GetServerName will return the server name text. +func GetServerName() string { + name, err := _datastore.GetString(serverNameKey) + if err != nil { + log.Errorln(serverNameKey, err) + return "" + } + + return name +} + +// SetServerName will set the server name text. +func SetServerName(name string) error { + return _datastore.SetString(serverNameKey, name) +} + +// GetServerURL will return the server URL. +func GetServerURL() string { + url, err := _datastore.GetString(serverURLKey) + if err != nil { + return "" + } + + return url +} + +// SetServerURL will set the server URL. +func SetServerURL(url string) error { + return _datastore.SetString(serverURLKey, url) +} + +// GetHTTPPortNumber will return the server HTTP port. +func GetHTTPPortNumber() int { + port, err := _datastore.GetNumber(httpPortNumberKey) + if err != nil { + log.Errorln(httpPortNumberKey, err) + return config.GetDefaults().WebServerPort + } + + if port == 0 { + return config.GetDefaults().WebServerPort + } + return int(port) +} + +// SetHTTPPortNumber will set the server HTTP port. +func SetHTTPPortNumber(port float64) error { + return _datastore.SetNumber(httpPortNumberKey, port) +} + +// GetRTMPPortNumber will return the server RTMP port. +func GetRTMPPortNumber() int { + port, err := _datastore.GetNumber(rtmpPortNumberKey) + if err != nil { + log.Errorln(rtmpPortNumberKey, err) + return config.GetDefaults().RTMPServerPort + } + + if port == 0 { + return config.GetDefaults().RTMPServerPort + } + + return int(port) +} + +// SetRTMPPortNumber will set the server RTMP port. +func SetRTMPPortNumber(port float64) error { + return _datastore.SetNumber(rtmpPortNumberKey, port) +} + +// GetServerMetadataTags will return the metadata tags. +func GetServerMetadataTags() []string { + tagsString, err := _datastore.GetString(serverMetadataTagsKey) + if err != nil { + log.Errorln(serverMetadataTagsKey, err) + return []string{} + } + + return strings.Split(tagsString, ",") +} + +// SetServerMetadataTags will return the metadata tags. +func SetServerMetadataTags(tags []string) error { + tagString := strings.Join(tags, ",") + return _datastore.SetString(serverMetadataTagsKey, tagString) +} + +// GetDirectoryEnabled will return if this server should register to YP. +func GetDirectoryEnabled() bool { + enabled, err := _datastore.GetBool(directoryEnabledKey) + if err != nil { + return config.GetDefaults().YPEnabled + } + + return enabled +} + +// SetDirectoryEnabled will set if this server should register to YP. +func SetDirectoryEnabled(enabled bool) error { + return _datastore.SetBool(directoryEnabledKey, enabled) +} + +// SetDirectoryRegistrationKey will set the YP protocol registration key. +func SetDirectoryRegistrationKey(key string) error { + return _datastore.SetString(directoryRegistrationKeyKey, key) +} + +// GetDirectoryRegistrationKey will return the YP protocol registration key. +func GetDirectoryRegistrationKey() string { + key, _ := _datastore.GetString(directoryRegistrationKeyKey) + return key +} + +// GetSocialHandles will return the external social links. +func GetSocialHandles() []models.SocialHandle { + var socialHandles []models.SocialHandle + + configEntry, err := _datastore.Get(socialHandlesKey) + if err != nil { + log.Errorln(socialHandlesKey, err) + return socialHandles + } + + if err := configEntry.getObject(&socialHandles); err != nil { + log.Errorln(err) + return socialHandles + } + + return socialHandles +} + +// SetSocialHandles will set the external social links. +func SetSocialHandles(socialHandles []models.SocialHandle) error { + var configEntry = ConfigEntry{Key: socialHandlesKey, Value: socialHandles} + return _datastore.Save(configEntry) +} + +// GetPeakSessionViewerCount will return the max number of viewers for this stream. +func GetPeakSessionViewerCount() int { + count, err := _datastore.GetNumber(peakViewersSessionKey) + if err != nil { + return 0 + } + return int(count) +} + +// SetPeakSessionViewerCount will set the max number of viewers for this stream. +func SetPeakSessionViewerCount(count int) error { + return _datastore.SetNumber(peakViewersSessionKey, float64(count)) +} + +// GetPeakOverallViewerCount will return the overall max number of viewers. +func GetPeakOverallViewerCount() int { + count, err := _datastore.GetNumber(peakViewersOverallKey) + if err != nil { + return 0 + } + return int(count) +} + +// SetPeakOverallViewerCount will set the overall max number of viewers. +func SetPeakOverallViewerCount(count int) error { + return _datastore.SetNumber(peakViewersOverallKey, float64(count)) +} + +// GetLastDisconnectTime will return the time the last stream ended. +func GetLastDisconnectTime() (time.Time, error) { + var disconnectTime time.Time + configEntry, err := _datastore.Get(lastDisconnectTimeKey) + if err != nil { + return disconnectTime, err + } + + if err := configEntry.getObject(disconnectTime); err != nil { + return disconnectTime, err + } + + return disconnectTime, nil +} + +// SetLastDisconnectTime will set the time the last stream ended. +func SetLastDisconnectTime(disconnectTime time.Time) error { + var configEntry = ConfigEntry{Key: lastDisconnectTimeKey, Value: disconnectTime} + return _datastore.Save(configEntry) +} + +// SetNSFW will set if this stream has NSFW content. +func SetNSFW(isNSFW bool) error { + return _datastore.SetBool(nsfwKey, isNSFW) +} + +// GetNSFW will return if this stream has NSFW content. +func GetNSFW() bool { + nsfw, err := _datastore.GetBool(nsfwKey) + if err != nil { + return false + } + return nsfw +} + +// SetFfmpegPath will set the custom ffmpeg path. +func SetFfmpegPath(path string) error { + return _datastore.SetString(ffmpegPathKey, path) +} + +// GetFfMpegPath will return the ffmpeg path. +func GetFfMpegPath() string { + path, err := _datastore.GetString(ffmpegPathKey) + if err != nil { + return "" + } + return path +} + +// GetS3Config will return the external storage configuration. +func GetS3Config() models.S3 { + configEntry, err := _datastore.Get(s3StorageConfigKey) + if err != nil { + return models.S3{Enabled: false} + } + + var s3Config models.S3 + if err := configEntry.getObject(&s3Config); err != nil { + return models.S3{Enabled: false} + } + + return s3Config +} + +// SetS3Config will set the external storage configuration. +func SetS3Config(config models.S3) error { + var configEntry = ConfigEntry{Key: s3StorageConfigKey, Value: config} + return _datastore.Save(configEntry) +} + +// GetS3StorageEnabled will return if external storage is enabled. +func GetS3StorageEnabled() bool { + enabled, err := _datastore.GetBool(s3StorageEnabledKey) + if err != nil { + log.Errorln(err) + return false + } + + return enabled +} + +// SetS3StorageEnabled will enable or disable external storage. +func SetS3StorageEnabled(enabled bool) error { + return _datastore.SetBool(s3StorageEnabledKey, enabled) +} + +// GetStreamLatencyLevel will return the stream latency level. +func GetStreamLatencyLevel() models.LatencyLevel { + level, err := _datastore.GetNumber(videoLatencyLevel) + if err != nil || level == 0 { + level = 4 + } + + return models.GetLatencyLevel(int(level)) +} + +// SetStreamLatencyLevel will set the stream latency level. +func SetStreamLatencyLevel(level float64) error { + return _datastore.SetNumber(videoLatencyLevel, level) +} + +// GetStreamOutputVariants will return all of the stream output variants. +func GetStreamOutputVariants() []models.StreamOutputVariant { + configEntry, err := _datastore.Get(videoStreamOutputVariantsKey) + if err != nil { + return config.GetDefaults().StreamVariants + } + + var streamOutputVariants []models.StreamOutputVariant + if err := configEntry.getObject(&streamOutputVariants); err != nil { + return config.GetDefaults().StreamVariants + } + + if len(streamOutputVariants) == 0 { + return config.GetDefaults().StreamVariants + } + + return streamOutputVariants +} + +// SetStreamOutputVariants will set the stream output variants. +func SetStreamOutputVariants(variants []models.StreamOutputVariant) error { + var configEntry = ConfigEntry{Key: videoStreamOutputVariantsKey, Value: variants} + return _datastore.Save(configEntry) +} + +// VerifySettings will perform a sanity check for specific settings values. +func VerifySettings() error { + if GetStreamKey() == "" { + return errors.New("no stream key set. Please set one in your config file") + } + + return nil +} + +// FindHighestVideoQualityIndex will return the highest quality from a slice of variants. +func FindHighestVideoQualityIndex(qualities []models.StreamOutputVariant) int { + type IndexedQuality struct { + index int + quality models.StreamOutputVariant + } + + if len(qualities) < 2 { + return 0 + } + + indexedQualities := make([]IndexedQuality, 0) + for index, quality := range qualities { + indexedQuality := IndexedQuality{index, quality} + indexedQualities = append(indexedQualities, indexedQuality) + } + + sort.Slice(indexedQualities, func(a, b int) bool { + if indexedQualities[a].quality.IsVideoPassthrough && !indexedQualities[b].quality.IsVideoPassthrough { + return true + } + + if !indexedQualities[a].quality.IsVideoPassthrough && indexedQualities[b].quality.IsVideoPassthrough { + return false + } + + return indexedQualities[a].quality.VideoBitrate > indexedQualities[b].quality.VideoBitrate + }) + + return indexedQualities[0].index +} diff --git a/core/data/configEntry.go b/core/data/configEntry.go new file mode 100644 index 000000000..6e4afb17d --- /dev/null +++ b/core/data/configEntry.go @@ -0,0 +1,46 @@ +package data + +import ( + "bytes" + "encoding/gob" +) + +// ConfigEntry is the actual object saved to the database. +// The Value is encoded using encoding/gob. +type ConfigEntry struct { + Key string + Value interface{} +} + +func (c *ConfigEntry) getString() (string, error) { + decoder := c.getDecoder() + var result string + err := decoder.Decode(&result) + return result, err +} + +func (c *ConfigEntry) getNumber() (float64, error) { + decoder := c.getDecoder() + var result float64 + err := decoder.Decode(&result) + return result, err +} + +func (c *ConfigEntry) getBool() (bool, error) { + decoder := c.getDecoder() + var result bool + err := decoder.Decode(&result) + return result, err +} + +func (c *ConfigEntry) getObject(result interface{}) error { + decoder := c.getDecoder() + err := decoder.Decode(result) + return err +} + +func (c *ConfigEntry) getDecoder() *gob.Decoder { + valueBytes := c.Value.([]byte) + decoder := gob.NewDecoder(bytes.NewBuffer(valueBytes)) + return decoder +} diff --git a/core/data/data.go b/core/data/data.go index 6b8634763..74321dbd2 100644 --- a/core/data/data.go +++ b/core/data/data.go @@ -8,25 +8,32 @@ import ( "database/sql" "fmt" "os" + "time" - "github.com/owncast/owncast/config" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) const ( schemaVersion = 0 + backupFile = "backup/owncastdb.bak" ) var _db *sql.DB +var _datastore *Datastore +// GetDatabase will return the shared instance of the actual database. func GetDatabase() *sql.DB { return _db } -func SetupPersistence() error { - file := config.Config.DatabaseFilePath +// GetStore will return the shared instance of the read/write datastore. +func GetStore() *Datastore { + return _datastore +} +// SetupPersistence will open the datastore and make it available. +func SetupPersistence(file string) error { // Create empty DB file if it doesn't exist. if !utils.DoesFileExists(file) { log.Traceln("Creating new database at", file) @@ -79,11 +86,26 @@ func SetupPersistence() error { } _db = db + + createWebhooksTable() + createAccessTokensTable() + + _datastore = &Datastore{} + _datastore.Setup() + + dbBackupTicker := time.NewTicker(1 * time.Hour) + go func() { + for range dbBackupTicker.C { + utils.Backup(_db, backupFile) + } + }() + return nil } func migrateDatabase(db *sql.DB, from, to int) error { log.Printf("Migrating database from version %d to %d\n", from, to) + utils.Backup(db, fmt.Sprintf("backup/owncast-v%d.bak", from)) for v := from; v < to; v++ { switch v { case 0: diff --git a/core/data/data_test.go b/core/data/data_test.go new file mode 100644 index 000000000..2b7891933 --- /dev/null +++ b/core/data/data_test.go @@ -0,0 +1,115 @@ +package data + +import ( + "fmt" + "testing" +) + +func TestMain(m *testing.M) { + dbFile := "../../test/test.db" + + SetupPersistence(dbFile) + m.Run() +} + +func TestString(t *testing.T) { + const testKey = "test string key" + const testValue = "test string value" + + err := _datastore.SetString(testKey, testValue) + if err != nil { + panic(err) + } + + // Get the config entry from the database + stringTestResult, err := _datastore.GetString(testKey) + if err != nil { + panic(err) + } + + if stringTestResult != testValue { + t.Error("expected", testValue, "but test returned", stringTestResult) + } +} + +func TestNumber(t *testing.T) { + const testKey = "test number key" + const testValue = 42 + + err := _datastore.SetNumber(testKey, testValue) + if err != nil { + panic(err) + } + + // Get the config entry from the database + numberTestResult, err := _datastore.GetNumber(testKey) + if err != nil { + panic(err) + } + fmt.Println(numberTestResult) + + if numberTestResult != testValue { + t.Error("expected", testValue, "but test returned", numberTestResult) + } +} + +func TestBool(t *testing.T) { + const testKey = "test bool key" + const testValue = true + + err := _datastore.SetBool(testKey, testValue) + if err != nil { + panic(err) + } + + // Get the config entry from the database + numberTestResult, err := _datastore.GetBool(testKey) + if err != nil { + panic(err) + } + fmt.Println(numberTestResult) + + if numberTestResult != testValue { + t.Error("expected", testValue, "but test returned", numberTestResult) + } +} + +func TestCustomType(t *testing.T) { + const testKey = "test custom type key" + + // Test an example struct with a slice + testStruct := TestStruct{ + Test: "Test string 123 in test struct", + TestSlice: []string{"test string 1", "test string 2"}, + } + + // Save config entry to the database + if err := _datastore.Save(ConfigEntry{testKey, &testStruct}); err != nil { + t.Error(err) + } + + // Get the config entry from the database + entryResult, err := _datastore.Get(testKey) + if err != nil { + t.Error(err) + } + + // Get a typed struct out of it + var testResult TestStruct + if err := entryResult.getObject(&testResult); err != nil { + t.Error(err) + } + + fmt.Printf("%+v", testResult) + + if testResult.TestSlice[0] != testStruct.TestSlice[0] { + t.Error("expected", testStruct.TestSlice[0], "but test returned", testResult.TestSlice[0]) + } +} + +// Custom type for testing +type TestStruct struct { + Test string + TestSlice []string + privateProperty string +} diff --git a/core/data/defaults.go b/core/data/defaults.go new file mode 100644 index 000000000..4519b6736 --- /dev/null +++ b/core/data/defaults.go @@ -0,0 +1,43 @@ +package data + +import ( + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/models" +) + +// HasPopulatedDefaults will determine if the defaults have been inserted into the database. +func HasPopulatedDefaults() bool { + hasPopulated, err := _datastore.GetBool("HAS_POPULATED_DEFAULTS") + if err != nil { + return false + } + return hasPopulated +} + +// PopulateDefaults will set default values in the database. +func PopulateDefaults() { + defaults := config.GetDefaults() + + if HasPopulatedDefaults() { + return + } + + _ = SetStreamKey(defaults.StreamKey) + _ = SetHTTPPortNumber(float64(defaults.WebServerPort)) + _ = SetRTMPPortNumber(float64(defaults.RTMPServerPort)) + _ = SetLogoPath(defaults.Logo) + _ = SetServerMetadataTags([]string{"owncast", "streaming"}) + _ = SetServerSummary("Welcome to your new Owncast server! This description can be changed in the admin") + _ = SetServerName("Owncast") + _ = SetStreamKey(defaults.StreamKey) + _ = SetExtraPageBodyContent("This is your page's content that can be edited in the admin.") + _ = SetSocialHandles([]models.SocialHandle{ + { + Platform: "github", + URL: "https://github.com/owncast/owncast", + }, + }) + + _datastore.warmCache() + _ = _datastore.SetBool("HAS_POPULATED_DEFAULTS", true) +} diff --git a/core/data/migrator.go b/core/data/migrator.go new file mode 100644 index 000000000..640356dc6 --- /dev/null +++ b/core/data/migrator.go @@ -0,0 +1,266 @@ +package data + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +// RunMigrations will start the migration process from the config file. +func RunMigrations() { + if !utils.DoesFileExists(config.BackupDirectory) { + if err := os.Mkdir(config.BackupDirectory, 0700); err != nil { + log.Errorln("Unable to create backup directory", err) + return + } + } + + migrateConfigFile() + migrateStatsFile() + migrateYPKey() +} + +func migrateStatsFile() { + oldStats := models.Stats{ + Clients: make(map[string]models.Client), + } + + if !utils.DoesFileExists(config.StatsFile) { + return + } + + log.Infoln("Migrating", config.StatsFile, "to new datastore") + + jsonFile, err := ioutil.ReadFile(config.StatsFile) + if err != nil { + log.Errorln(err) + return + } + + if err := json.Unmarshal(jsonFile, &oldStats); err != nil { + log.Errorln(err) + return + } + + _ = SetPeakSessionViewerCount(oldStats.SessionMaxViewerCount) + _ = SetPeakOverallViewerCount(oldStats.OverallMaxViewerCount) + + if err := utils.Move(config.StatsFile, "backup/stats.old"); err != nil { + log.Warnln(err) + } +} + +func migrateYPKey() { + filePath := ".yp.key" + + if !utils.DoesFileExists(filePath) { + return + } + + log.Infoln("Migrating", filePath, "to new datastore") + + keyFile, err := ioutil.ReadFile(filePath) + if err != nil { + log.Errorln("Unable to migrate", keyFile, "there may be issues registering with the directory") + } + + if err := SetDirectoryRegistrationKey(string(keyFile)); err != nil { + log.Errorln("Unable to migrate", keyFile, "there may be issues registering with the directory") + return + } + + if err := utils.Move(filePath, "backup/yp.key.old"); err != nil { + log.Warnln(err) + } +} + +func migrateConfigFile() { + filePath := config.ConfigFilePath + + if !utils.DoesFileExists(filePath) { + return + } + + log.Infoln("Migrating", filePath, "to new datastore") + + var oldConfig configFile + + yamlFile, err := ioutil.ReadFile(filePath) + if err != nil { + log.Errorln("config file err", err) + return + } + + if err := yaml.Unmarshal(yamlFile, &oldConfig); err != nil { + log.Errorln("Error reading the config file.", err) + return + } + + _ = SetServerName(oldConfig.InstanceDetails.Name) + _ = SetServerSummary(oldConfig.InstanceDetails.Summary) + _ = SetServerMetadataTags(oldConfig.InstanceDetails.Tags) + _ = SetStreamKey(oldConfig.VideoSettings.StreamingKey) + _ = SetNSFW(oldConfig.InstanceDetails.NSFW) + _ = SetServerURL(oldConfig.YP.InstanceURL) + _ = SetDirectoryEnabled(oldConfig.YP.Enabled) + _ = SetSocialHandles(oldConfig.InstanceDetails.SocialHandles) + _ = SetFfmpegPath(oldConfig.FFMpegPath) + _ = SetHTTPPortNumber(float64(oldConfig.WebServerPort)) + _ = SetRTMPPortNumber(float64(oldConfig.RTMPServerPort)) + + // Migrate logo + if logo := oldConfig.InstanceDetails.Logo; logo != "" { + filename := filepath.Base(logo) + newPath := filepath.Join("data", filename) + err := utils.Copy(filepath.Join("webroot", logo), newPath) + log.Traceln("Copying logo from", logo, "to", newPath) + if err != nil { + log.Errorln("Error moving logo", logo, err) + } else { + _ = SetLogoPath(filename) + } + } + + // Migrate video variants + variants := []models.StreamOutputVariant{} + for _, variant := range oldConfig.VideoSettings.StreamQualities { + migratedVariant := models.StreamOutputVariant{} + migratedVariant.IsAudioPassthrough = true + migratedVariant.IsVideoPassthrough = variant.IsVideoPassthrough + migratedVariant.Framerate = variant.Framerate + migratedVariant.VideoBitrate = variant.VideoBitrate + migratedVariant.ScaledHeight = variant.ScaledHeight + migratedVariant.ScaledWidth = variant.ScaledWidth + + presetMapping := map[string]int{ + "ultrafast": 1, + "superfast": 2, + "veryfast": 3, + "faster": 4, + "fast": 5, + } + migratedVariant.CPUUsageLevel = presetMapping[variant.EncoderPreset] + variants = append(variants, migratedVariant) + } + _ = SetStreamOutputVariants(variants) + + // Migrate latency level + level := 4 + oldSegmentLength := oldConfig.VideoSettings.ChunkLengthInSeconds + oldNumberOfSegments := oldConfig.Files.MaxNumberInPlaylist + latencyLevels := models.GetLatencyConfigs() + + if oldSegmentLength == latencyLevels[1].SecondsPerSegment && oldNumberOfSegments == latencyLevels[1].SegmentCount { + level = 1 + } else if oldSegmentLength == latencyLevels[2].SecondsPerSegment && oldNumberOfSegments == latencyLevels[2].SegmentCount { + level = 2 + } else if oldSegmentLength == latencyLevels[3].SecondsPerSegment && oldNumberOfSegments == latencyLevels[3].SegmentCount { + level = 3 + } else if oldSegmentLength == latencyLevels[5].SecondsPerSegment && oldNumberOfSegments == latencyLevels[5].SegmentCount { + level = 5 + } else if oldSegmentLength >= latencyLevels[6].SecondsPerSegment && oldNumberOfSegments >= latencyLevels[6].SegmentCount { + level = 6 + } + + _ = SetStreamLatencyLevel(float64(level)) + + // Migrate storage config + _ = SetS3Config(models.S3(oldConfig.Storage)) + + // Migrate the old content.md file + content, err := ioutil.ReadFile(config.ExtraInfoFile) + if err == nil && len(content) > 0 { + _ = SetExtraPageBodyContent(string(content)) + } + + if err := utils.Move(filePath, "backup/config.old"); err != nil { + log.Warnln(err) + } + + log.Infoln("Your old config file can be found in the backup directory for reference. For all future configuration use the web admin.") +} + +type configFile struct { + DatabaseFilePath string `yaml:"databaseFile"` + EnableDebugFeatures bool `yaml:"-"` + FFMpegPath string + Files files `yaml:"files"` + InstanceDetails instanceDetails `yaml:"instanceDetails"` + VersionInfo string `yaml:"-"` // For storing the version/build number + VersionNumber string `yaml:"-"` + VideoSettings videoSettings `yaml:"videoSettings"` + WebServerPort int + RTMPServerPort int + YP yp `yaml:"yp"` + Storage s3 `yaml:"s3"` +} + +// instanceDetails defines the user-visible information about this particular instance. +type instanceDetails struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Logo string `yaml:"logo"` + Tags []string `yaml:"tags"` + Version string `yaml:"version"` + NSFW bool `yaml:"nsfw"` + ExtraPageContent string `yaml:"extraPageContent"` + StreamTitle string `yaml:"streamTitle"` + SocialHandles []models.SocialHandle `yaml:"socialHandles"` +} + +type videoSettings struct { + ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"` + StreamingKey string `yaml:"streamingKey"` + StreamQualities []streamQuality `yaml:"streamQualities"` + HighestQualityStreamIndex int `yaml:"-"` +} + +// yp allows registration to the central Owncast yp (Yellow pages) service operating as a directory. +type yp struct { + Enabled bool `yaml:"enabled"` + InstanceURL string `yaml:"instanceUrl"` // The public URL the directory should link to +} + +// streamQuality defines the specifics of a single HLS stream variant. +type streamQuality struct { + // Enable passthrough to copy the video and/or audio directly from the + // incoming stream and disable any transcoding. It will ignore any of + // the below settings. + IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"` + IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"` + + VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"` + AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"` + + // Set only one of these in order to keep your current aspect ratio. + // Or set neither to not scale the video. + ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"` + ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"` + + Framerate int `yaml:"framerate" json:"framerate"` + EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"` +} + +type files struct { + MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"` +} + +// s3 is for configuring the s3 integration. +type s3 struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` + ServingEndpoint string `yaml:"servingEndpoint" json:"servingEndpoint,omitempty"` + AccessKey string `yaml:"accessKey" json:"accessKey,omitempty"` + Secret string `yaml:"secret" json:"secret,omitempty"` + Bucket string `yaml:"bucket" json:"bucket,omitempty"` + Region string `yaml:"region" json:"region,omitempty"` + ACL string `yaml:"acl" json:"acl,omitempty"` +} diff --git a/core/data/persistence.go b/core/data/persistence.go new file mode 100644 index 000000000..de9482141 --- /dev/null +++ b/core/data/persistence.go @@ -0,0 +1,152 @@ +package data + +import ( + "bytes" + "database/sql" + "encoding/gob" + + // sqlite requires a blank import. + _ "github.com/mattn/go-sqlite3" + log "github.com/sirupsen/logrus" +) + +// Datastore is the global key/value store for configuration values. +type Datastore struct { + db *sql.DB + cache map[string][]byte +} + +func (ds *Datastore) warmCache() { + log.Traceln("Warming config value cache") + + res, err := ds.db.Query("SELECT key, value FROM datastore") + if err != nil || res.Err() != nil { + log.Errorln("error warming config cache", err, res.Err()) + } + defer res.Close() + + for res.Next() { + var rowKey string + var rowValue []byte + if err := res.Scan(&rowKey, &rowValue); err != nil { + log.Errorln("error pre-caching config row", err) + } + ds.cache[rowKey] = rowValue + } +} + +// Get will query the database for the key and return the entry. +func (ds *Datastore) Get(key string) (ConfigEntry, error) { + cachedValue, err := ds.GetCachedValue(key) + if err == nil { + return ConfigEntry{ + Key: key, + Value: cachedValue, + }, nil + } + + var resultKey string + var resultValue []byte + + row := ds.db.QueryRow("SELECT key, value FROM datastore WHERE key = ? LIMIT 1", key) + if err := row.Scan(&resultKey, &resultValue); err != nil { + return ConfigEntry{}, err + } + + result := ConfigEntry{ + Key: resultKey, + Value: resultValue, + } + + return result, nil +} + +// Save will save the ConfigEntry to the database. +func (ds *Datastore) Save(e ConfigEntry) error { + var dataGob bytes.Buffer + enc := gob.NewEncoder(&dataGob) + if err := enc.Encode(e.Value); err != nil { + return err + } + + tx, err := ds.db.Begin() + if err != nil { + return err + } + var stmt *sql.Stmt + var count int + row := ds.db.QueryRow("SELECT COUNT(*) FROM datastore WHERE key = ? LIMIT 1", e.Key) + if err := row.Scan(&count); err != nil { + return err + } + + if count == 0 { + stmt, err = tx.Prepare("INSERT INTO datastore(key, value) values(?, ?)") + if err != nil { + return err + } + _, err = stmt.Exec(e.Key, dataGob.Bytes()) + } else { + stmt, err = tx.Prepare("UPDATE datastore SET value=? WHERE key=?") + if err != nil { + return err + } + _, err = stmt.Exec(dataGob.Bytes(), e.Key) + } + if err != nil { + return err + } + defer stmt.Close() + + if err = tx.Commit(); err != nil { + log.Fatalln(err) + } + + ds.SetCachedValue(e.Key, dataGob.Bytes()) + + return nil +} + +// Setup will create the datastore table and perform initial initialization. +func (ds *Datastore) Setup() { + ds.cache = make(map[string][]byte) + ds.db = GetDatabase() + + createTableSQL := `CREATE TABLE IF NOT EXISTS datastore ( + "key" string NOT NULL PRIMARY KEY, + "value" BLOB, + "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL + );` + + stmt, err := ds.db.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + + _, err = stmt.Exec() + if err != nil { + log.Fatalln(err) + } + + if !HasPopulatedDefaults() { + PopulateDefaults() + } +} + +// Reset will delete all config entries in the datastore and start over. +func (ds *Datastore) Reset() { + sql := "DELETE FROM datastore" + stmt, err := ds.db.Prepare(sql) + if err != nil { + log.Fatalln(err) + } + + defer stmt.Close() + + if _, err = stmt.Exec(); err != nil { + log.Fatalln(err) + } + + PopulateDefaults() +} diff --git a/core/data/types.go b/core/data/types.go new file mode 100644 index 000000000..75e85cb3d --- /dev/null +++ b/core/data/types.go @@ -0,0 +1,46 @@ +package data + +// GetString will return the string value for a key. +func (ds *Datastore) GetString(key string) (string, error) { + configEntry, err := ds.Get(key) + if err != nil { + return "", err + } + return configEntry.getString() +} + +// SetString will set the string value for a key. +func (ds *Datastore) SetString(key string, value string) error { + configEntry := ConfigEntry{key, value} + return ds.Save(configEntry) +} + +// GetNumber will return the numeric value for a key. +func (ds *Datastore) GetNumber(key string) (float64, error) { + configEntry, err := ds.Get(key) + if err != nil { + return 0, err + } + return configEntry.getNumber() +} + +// SetNumber will set the numeric value for a key. +func (ds *Datastore) SetNumber(key string, value float64) error { + configEntry := ConfigEntry{key, value} + return ds.Save(configEntry) +} + +// GetBool will return the boolean value for a key. +func (ds *Datastore) GetBool(key string) (bool, error) { + configEntry, err := ds.Get(key) + if err != nil { + return false, err + } + return configEntry.getBool() +} + +// SetBool will set the boolean value for a key. +func (ds *Datastore) SetBool(key string, value bool) error { + configEntry := ConfigEntry{key, value} + return ds.Save(configEntry) +} diff --git a/core/data/webhooks.go b/core/data/webhooks.go new file mode 100644 index 000000000..7e1c1ec91 --- /dev/null +++ b/core/data/webhooks.go @@ -0,0 +1,220 @@ +package data + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/owncast/owncast/models" + log "github.com/sirupsen/logrus" +) + +func createWebhooksTable() { + log.Traceln("Creating webhooks table...") + + createTableSQL := `CREATE TABLE IF NOT EXISTS webhooks ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "url" string NOT NULL, + "events" TEXT NOT NULL, + "timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP, + "last_used" DATETIME + );` + + stmt, err := _db.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln(err) + } +} + +// InsertWebhook will add a new webhook to the database. +func InsertWebhook(url string, events []models.EventType) (int, error) { + log.Println("Adding new webhook:", url) + + eventsString := strings.Join(events, ",") + + tx, err := _db.Begin() + if err != nil { + return 0, err + } + stmt, err := tx.Prepare("INSERT INTO webhooks(url, events) values(?, ?)") + + if err != nil { + return 0, err + } + defer stmt.Close() + + insertResult, err := stmt.Exec(url, eventsString) + if err != nil { + return 0, err + } + + if err = tx.Commit(); err != nil { + return 0, err + } + + newID, err := insertResult.LastInsertId() + if err != nil { + return 0, err + } + + return int(newID), err +} + +// DeleteWebhook will delete a webhook from the database. +func DeleteWebhook(id int) error { + log.Println("Deleting webhook:", id) + + tx, err := _db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("DELETE FROM webhooks WHERE id = ?") + + if err != nil { + return err + } + defer stmt.Close() + + result, err := stmt.Exec(id) + if err != nil { + return err + } + + if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { + tx.Rollback() //nolint + return errors.New(fmt.Sprint(id) + " not found") + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} + +// GetWebhooksForEvent will return all of the webhooks that want to be notified about an event type. +func GetWebhooksForEvent(event models.EventType) []models.Webhook { + webhooks := make([]models.Webhook, 0) + + var query = `SELECT * FROM ( + WITH RECURSIVE split(url, event, rest) AS ( + SELECT url, '', events || ',' FROM webhooks + UNION ALL + SELECT url, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',')+1) + FROM split + WHERE rest <> '') + SELECT url, event + FROM split + WHERE event <> '' + ) AS webhook WHERE event IS "` + event + `"` + + rows, err := _db.Query(query) + + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + for rows.Next() { + var url string + + err = rows.Scan(&url, &event) + if err != nil { + log.Debugln(err) + log.Error("There is a problem with the database.") + break + } + + singleWebhook := models.Webhook{ + URL: url, + } + + webhooks = append(webhooks, singleWebhook) + } + return webhooks +} + +// GetWebhooks will return all the webhooks. +func GetWebhooks() ([]models.Webhook, error) { //nolint + webhooks := make([]models.Webhook, 0) + + var query = "SELECT * FROM webhooks" + + rows, err := _db.Query(query) + if err != nil { + return webhooks, err + } + defer rows.Close() + + for rows.Next() { + var id int + var url string + var events string + var timestampString string + var lastUsedString *string + + if err := rows.Scan(&id, &url, &events, ×tampString, &lastUsedString); err != nil { + log.Error("There is a problem reading the database.", err) + return webhooks, err + } + + timestamp, err := time.Parse(time.RFC3339, timestampString) + if err != nil { + return webhooks, err + } + + var lastUsed *time.Time = nil + if lastUsedString != nil { + lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString) + lastUsed = &lastUsedTime + } + + singleWebhook := models.Webhook{ + ID: id, + URL: url, + Events: strings.Split(events, ","), + Timestamp: timestamp, + LastUsed: lastUsed, + } + + webhooks = append(webhooks, singleWebhook) + } + + if err := rows.Err(); err != nil { + return webhooks, err + } + + return webhooks, nil +} + +// SetWebhookAsUsed will update the last used time for a webhook. +func SetWebhookAsUsed(id string) error { + tx, err := _db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("UPDATE webhooks SET last_used = CURRENT_TIMESTAMP WHERE id = ?") + + if err != nil { + return err + } + defer stmt.Close() + + if _, err := stmt.Exec(id); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} diff --git a/core/rtmp/broadcaster.go b/core/rtmp/broadcaster.go index 25b600166..62a120295 100644 --- a/core/rtmp/broadcaster.go +++ b/core/rtmp/broadcaster.go @@ -11,7 +11,7 @@ import ( func setCurrentBroadcasterInfo(t flvio.Tag, remoteAddr string) { data, err := getInboundDetailsFromMetadata(t.DebugFields()) if err != nil { - log.Traceln("RTMP meadata:", err) + log.Warnln("Unable to parse inbound broadcaster details:", err) } broadcaster := models.Broadcaster{ diff --git a/core/rtmp/rtmp.go b/core/rtmp/rtmp.go index 4af1998d4..aaba031ee 100644 --- a/core/rtmp/rtmp.go +++ b/core/rtmp/rtmp.go @@ -8,14 +8,13 @@ import ( "strings" "syscall" "time" - "unsafe" "github.com/nareix/joy5/format/flv" "github.com/nareix/joy5/format/flv/flvio" log "github.com/sirupsen/logrus" "github.com/nareix/joy5/format/rtmp" - "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" ) @@ -35,7 +34,7 @@ func Start(setStreamAsConnected func(), setBroadcaster func(models.Broadcaster)) _setStreamAsConnected = setStreamAsConnected _setBroadcaster = setBroadcaster - port := config.Config.GetRTMPServerPort() + port := data.GetRTMPPortNumber() s := rtmp.NewServer() var lis net.Listener var err error @@ -45,7 +44,7 @@ func Start(setStreamAsConnected func(), setBroadcaster func(models.Broadcaster)) s.LogEvent = func(c *rtmp.Conn, nc net.Conn, e int) { es := rtmp.EventString[e] - log.Traceln(unsafe.Pointer(c), nc.LocalAddr(), nc.RemoteAddr(), es) + log.Traceln("RTMP", nc.LocalAddr(), nc.RemoteAddr(), es) } s.HandleConn = HandleConn @@ -81,7 +80,7 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) { streamingKeyComponents := strings.Split(c.URL.Path, "/") streamingKey := streamingKeyComponents[len(streamingKeyComponents)-1] - if streamingKey != config.Config.VideoSettings.StreamingKey { + if streamingKey != data.GetStreamKey() { log.Errorln("invalid streaming key; rejecting incoming stream") nc.Close() return diff --git a/core/stats.go b/core/stats.go index cc017cc59..2e4f21f87 100644 --- a/core/stats.go +++ b/core/stats.go @@ -1,17 +1,14 @@ package core import ( - "encoding/json" - "io/ioutil" "math" - "os" "sync" "time" log "github.com/sirupsen/logrus" - "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/geoip" "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" @@ -20,17 +17,13 @@ import ( var l = sync.Mutex{} func setupStats() error { - s, err := getSavedStats() - if err != nil { - return err - } - + s := getSavedStats() _stats = &s statsSaveTimer := time.NewTicker(1 * time.Minute) go func() { for range statsSaveTimer.C { - if err := saveStatsToFile(); err != nil { + if err := saveStats(); err != nil { panic(err) } } @@ -48,7 +41,8 @@ func IsStreamConnected() bool { // Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available. // So account for that with an artificial buffer of four segments. timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds() - if timeSinceLastConnected < float64(config.Config.GetVideoSegmentSecondsLength())*2.3 { + waitTime := math.Max(float64(data.GetStreamLatencyLevel().SecondsPerSegment)*3.0, 7) + if timeSinceLastConnected < waitTime { return false } @@ -103,42 +97,27 @@ func GetClients() []models.Client { return clients } -func saveStatsToFile() error { - jsonData, err := json.Marshal(_stats) - if err != nil { - return err +func saveStats() error { + if err := data.SetPeakOverallViewerCount(_stats.OverallMaxViewerCount); err != nil { + log.Errorln("error saving viewer count", err) } - - f, err := os.Create(config.StatsFile) - if err != nil { - return err + if err := data.SetPeakSessionViewerCount(_stats.SessionMaxViewerCount); err != nil { + log.Errorln("error saving viewer count", err) } - - defer f.Close() - - if _, err := f.Write(jsonData); err != nil { - return err + if err := data.SetLastDisconnectTime(_stats.LastConnectTime.Time); err != nil { + log.Errorln("error saving disconnect time", err) } return nil } -func getSavedStats() (models.Stats, error) { +func getSavedStats() models.Stats { + savedLastDisconnectTime, savedLastDisconnectTimeErr := data.GetLastDisconnectTime() result := models.Stats{ - Clients: make(map[string]models.Client), - } - - if !utils.DoesFileExists(config.StatsFile) { - return result, nil - } - - jsonFile, err := ioutil.ReadFile(config.StatsFile) - if err != nil { - return result, err - } - - if err := json.Unmarshal(jsonFile, &result); err != nil { - return result, err + Clients: make(map[string]models.Client), + SessionMaxViewerCount: data.GetPeakSessionViewerCount(), + OverallMaxViewerCount: data.GetPeakOverallViewerCount(), + LastDisconnectTime: utils.NullTime{Time: savedLastDisconnectTime, Valid: savedLastDisconnectTimeErr == nil}, } // If the stats were saved > 5min ago then ignore the @@ -147,5 +126,5 @@ func getSavedStats() (models.Stats, error) { result.SessionMaxViewerCount = 0 } - return result, err + return result } diff --git a/core/status.go b/core/status.go index 99b162c87..b6e81cec2 100644 --- a/core/status.go +++ b/core/status.go @@ -2,6 +2,7 @@ package core import ( "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/models" ) @@ -11,17 +12,27 @@ func GetStatus() models.Status { return models.Status{} } + viewerCount := 0 + if IsStreamConnected() { + viewerCount = len(_stats.Clients) + } + return models.Status{ Online: IsStreamConnected(), - ViewerCount: len(_stats.Clients), + ViewerCount: viewerCount, OverallMaxViewerCount: _stats.OverallMaxViewerCount, SessionMaxViewerCount: _stats.SessionMaxViewerCount, LastDisconnectTime: _stats.LastDisconnectTime, LastConnectTime: _stats.LastConnectTime, - VersionNumber: config.Config.VersionNumber, + VersionNumber: config.VersionNumber, + StreamTitle: data.GetStreamTitle(), } } +func GetCurrentBroadcast() *models.CurrentBroadcast { + return _currentBroadcast +} + // setBroadcaster will store the current inbound broadcasting details. func setBroadcaster(broadcaster models.Broadcaster) { _broadcaster = &broadcaster diff --git a/core/storage.go b/core/storage.go index b0dedc9d9..962a1ce6a 100644 --- a/core/storage.go +++ b/core/storage.go @@ -1,14 +1,14 @@ package core import ( - "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/storageproviders" ) func setupStorage() error { - handler.Storage = _storage + s3Config := data.GetS3Config() - if config.Config.S3.Enabled { + if s3Config.Enabled { _storage = &storageproviders.S3Storage{} } else { _storage = &storageproviders.LocalStorage{} @@ -18,5 +18,7 @@ func setupStorage() error { return err } + handler.Storage = _storage + return nil } diff --git a/core/storageproviders/local.go b/core/storageproviders/local.go index 67e9d56c2..1da93d6b4 100644 --- a/core/storageproviders/local.go +++ b/core/storageproviders/local.go @@ -7,7 +7,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/owncast/owncast/config" - "github.com/owncast/owncast/core/ffmpeg" + "github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/utils" ) @@ -24,7 +24,7 @@ func (s *LocalStorage) Setup() error { _onlineCleanupTicker = time.NewTicker(1 * time.Minute) go func() { for range _onlineCleanupTicker.C { - ffmpeg.CleanupOldContent(config.PublicHLSStoragePath) + transcoder.CleanupOldContent(config.PublicHLSStoragePath) } }() return nil diff --git a/core/storageproviders/s3Storage.go b/core/storageproviders/s3Storage.go index b689b2184..6d5d2fc17 100644 --- a/core/storageproviders/s3Storage.go +++ b/core/storageproviders/s3Storage.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/playlist" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" @@ -44,19 +45,20 @@ var _uploader *s3manager.Uploader func (s *S3Storage) Setup() error { log.Trace("Setting up S3 for external storage of video...") - if config.Config.S3.ServingEndpoint != "" { - s.host = config.Config.S3.ServingEndpoint + s3Config := data.GetS3Config() + if s3Config.ServingEndpoint != "" { + s.host = s3Config.ServingEndpoint } else { - s.host = fmt.Sprintf("%s/%s", config.Config.S3.Endpoint, config.Config.S3.Bucket) + s.host = fmt.Sprintf("%s/%s", s3Config.Endpoint, s3Config.Bucket) } - s.s3Endpoint = config.Config.S3.Endpoint - s.s3ServingEndpoint = config.Config.S3.ServingEndpoint - s.s3Region = config.Config.S3.Region - s.s3Bucket = config.Config.S3.Bucket - s.s3AccessKey = config.Config.S3.AccessKey - s.s3Secret = config.Config.S3.Secret - s.s3ACL = config.Config.S3.ACL + s.s3Endpoint = s3Config.Endpoint + s.s3ServingEndpoint = s3Config.ServingEndpoint + s.s3Region = s3Config.Region + s.s3Bucket = s3Config.Bucket + s.s3AccessKey = s3Config.AccessKey + s.s3Secret = s3Config.Secret + s.s3ACL = s3Config.ACL s.sess = s.connectAWS() @@ -81,7 +83,7 @@ func (s *S3Storage) SegmentWritten(localFilePath string) { // Warn the user about long-running save operations if averagePerformance != 0 { - if averagePerformance > float64(config.Config.GetVideoSegmentSecondsLength())*0.9 { + if averagePerformance > float64(data.GetStreamLatencyLevel().SecondsPerSegment)*0.9 { log.Warnln("Possible slow uploads: average upload S3 save duration", averagePerformance, "s. troubleshoot this issue by visiting https://owncast.online/docs/troubleshooting/") } } diff --git a/core/streamState.go b/core/streamState.go index 7a4c72d4e..8920b937b 100644 --- a/core/streamState.go +++ b/core/streamState.go @@ -10,8 +10,11 @@ import ( log "github.com/sirupsen/logrus" "github.com/owncast/owncast/config" - "github.com/owncast/owncast/core/ffmpeg" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/rtmp" + "github.com/owncast/owncast/core/transcoder" + "github.com/owncast/owncast/core/webhooks" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" "github.com/grafov/m3u8" @@ -23,12 +26,20 @@ var _offlineCleanupTimer *time.Timer // While a stream takes place cleanup old HLS content every N min. var _onlineCleanupTicker *time.Ticker +var _currentBroadcast *models.CurrentBroadcast + // setStreamAsConnected sets the stream as connected. func setStreamAsConnected() { + _stats.StreamConnected = true _stats.LastConnectTime = utils.NullTime{Time: time.Now(), Valid: true} _stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: false} + _currentBroadcast = &models.CurrentBroadcast{ + LatencyLevel: data.GetStreamLatencyLevel(), + OutputSettings: data.GetStreamOutputVariants(), + } + StopOfflineCleanupTimer() startOnlineCleanupTimer() @@ -37,23 +48,28 @@ func setStreamAsConnected() { } segmentPath := config.PublicHLSStoragePath - if config.Config.S3.Enabled { + s3Config := data.GetS3Config() + + if err := setupStorage(); err != nil { + log.Fatalln("failed to setup the storage", err) + } + + if s3Config.Enabled { segmentPath = config.PrivateHLSStoragePath } go func() { - _transcoder = ffmpeg.NewTranscoder() - if _broadcaster != nil { - _transcoder.SetVideoOnly(_broadcaster.StreamDetails.VideoOnly) - } - + _transcoder = transcoder.NewTranscoder() _transcoder.TranscoderCompleted = func(error) { SetStreamAsDisconnected() + _transcoder = nil + _currentBroadcast = nil } _transcoder.Start() }() - ffmpeg.StartThumbnailGenerator(segmentPath, config.Config.VideoSettings.HighestQualityStreamIndex) + go webhooks.SendStreamStatusEvent(models.StreamStarted) + transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings)) } // SetStreamAsDisconnected sets the stream as disconnected. @@ -65,14 +81,14 @@ func SetStreamAsDisconnected() { offlineFilename := "offline.ts" offlineFilePath := "static/" + offlineFilename - ffmpeg.StopThumbnailGenerator() + transcoder.StopThumbnailGenerator() rtmp.Disconnect() if _yp != nil { _yp.Stop() } - for index := range config.Config.GetVideoStreamQualities() { + for index := range data.GetStreamOutputVariants() { playlistFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/stream.m3u8"), index) segmentFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/%s"), index, offlineFilename) @@ -97,7 +113,7 @@ func SetStreamAsDisconnected() { } variantPlaylist := playlist.(*m3u8.MediaPlaylist) - if len(variantPlaylist.Segments) > config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist() { + if len(variantPlaylist.Segments) > int(data.GetStreamLatencyLevel().SegmentCount) { variantPlaylist.Segments = variantPlaylist.Segments[:len(variantPlaylist.Segments)] } @@ -144,6 +160,8 @@ func SetStreamAsDisconnected() { StartOfflineCleanupTimer() stopOnlineCleanupTimer() + + go webhooks.SendStreamStatusEvent(models.StreamStopped) } // StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected. @@ -153,6 +171,7 @@ func StartOfflineCleanupTimer() { for range _offlineCleanupTimer.C { // Reset the session count since the session is over _stats.SessionMaxViewerCount = 0 + // Set video to offline state resetDirectories() transitionToOfflineVideoStreamContent() } @@ -170,7 +189,7 @@ func startOnlineCleanupTimer() { _onlineCleanupTicker = time.NewTicker(1 * time.Minute) go func() { for range _onlineCleanupTicker.C { - ffmpeg.CleanupOldContent(config.PrivateHLSStoragePath) + transcoder.CleanupOldContent(config.PrivateHLSStoragePath) } }() } diff --git a/core/ffmpeg/fileWriterReceiverService.go b/core/transcoder/fileWriterReceiverService.go similarity index 79% rename from core/ffmpeg/fileWriterReceiverService.go rename to core/transcoder/fileWriterReceiverService.go index ed9ce93e5..449fcaaa4 100644 --- a/core/ffmpeg/fileWriterReceiverService.go +++ b/core/transcoder/fileWriterReceiverService.go @@ -1,11 +1,11 @@ -package ffmpeg +package transcoder import ( "bytes" "io" + "net" "os" "path/filepath" - "strconv" "strings" "net/http" @@ -34,15 +34,22 @@ func (s *FileWriterReceiverService) SetupFileWriterReceiverService(callbacks Fil httpServer := http.NewServeMux() httpServer.HandleFunc("/", s.uploadHandler) - localListenerAddress := "127.0.0.1:" + strconv.Itoa(config.Config.GetPublicWebServerPort()+1) + localListenerAddress := "127.0.0.1:0" go func() { - if err := http.ListenAndServe(localListenerAddress, httpServer); err != nil { - log.Fatal(err) + listener, err := net.Listen("tcp", localListenerAddress) + if err != nil { + log.Fatalln("Unable to start internal video writing service", err) + } + + listenerPort := strings.Split(listener.Addr().String(), ":")[1] + config.InternalHLSListenerPort = listenerPort + log.Traceln("Transcoder response service listening on: " + listenerPort) + + if err := http.Serve(listener, httpServer); err != nil { + log.Fatalln("Unable to start internal video writing service", err) } }() - - log.Traceln("Transcoder response listening on: " + localListenerAddress) } func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http.Request) { @@ -86,6 +93,6 @@ func (s *FileWriterReceiverService) fileWritten(path string) { } func returnError(err error, w http.ResponseWriter) { - log.Errorln(err) + log.Debugln(err) http.Error(w, http.StatusText(http.StatusInternalServerError)+": "+err.Error(), http.StatusInternalServerError) } diff --git a/core/ffmpeg/hlsFilesystemCleanup.go b/core/transcoder/hlsFilesystemCleanup.go similarity index 93% rename from core/ffmpeg/hlsFilesystemCleanup.go rename to core/transcoder/hlsFilesystemCleanup.go index 3e705c9a2..a13a43cff 100644 --- a/core/ffmpeg/hlsFilesystemCleanup.go +++ b/core/transcoder/hlsFilesystemCleanup.go @@ -1,4 +1,4 @@ -package ffmpeg +package transcoder import ( log "github.com/sirupsen/logrus" @@ -7,14 +7,14 @@ import ( "path/filepath" "sort" - "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" ) // CleanupOldContent will delete old files from the private dir that are no longer being referenced // in the stream. func CleanupOldContent(baseDirectory string) { // Determine how many files we should keep on disk - maxNumber := config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist() + maxNumber := int(data.GetStreamLatencyLevel().SegmentCount) buffer := 10 files, err := getAllFilesRecursive(baseDirectory) diff --git a/core/ffmpeg/hlsHandler.go b/core/transcoder/hlsHandler.go similarity index 97% rename from core/ffmpeg/hlsHandler.go rename to core/transcoder/hlsHandler.go index bf726bde5..0a819ff95 100644 --- a/core/ffmpeg/hlsHandler.go +++ b/core/transcoder/hlsHandler.go @@ -1,4 +1,4 @@ -package ffmpeg +package transcoder import ( "github.com/owncast/owncast/models" diff --git a/core/ffmpeg/thumbnailGenerator.go b/core/transcoder/thumbnailGenerator.go similarity index 90% rename from core/ffmpeg/thumbnailGenerator.go rename to core/transcoder/thumbnailGenerator.go index 10ecbd7f2..0df3cacc8 100644 --- a/core/ffmpeg/thumbnailGenerator.go +++ b/core/transcoder/thumbnailGenerator.go @@ -1,4 +1,4 @@ -package ffmpeg +package transcoder import ( "io/ioutil" @@ -11,6 +11,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/utils" ) var _timer *time.Ticker @@ -78,9 +80,10 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error { } mostRecentFile := path.Join(framePath, names[0]) + ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) thumbnailCmdFlags := []string{ - config.Config.GetFFMpegPath(), + ffmpegPath, "-y", // Overwrite file "-threads 1", // Low priority processing "-t 1", // Pull from frame 1 @@ -96,7 +99,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error { } // If YP support is enabled also create an animated GIF preview - if config.Config.YP.Enabled { + if data.GetDirectoryEnabled() { makeAnimatedGifPreview(mostRecentFile, previewGifFile) } @@ -104,9 +107,11 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error { } func makeAnimatedGifPreview(sourceFile string, outputFile string) { + ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) + // Filter is pulled from https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/ animatedGifFlags := []string{ - config.Config.GetFFMpegPath(), + ffmpegPath, "-y", // Overwrite file "-threads 1", // Low priority processing "-i", sourceFile, // Input diff --git a/core/ffmpeg/transcoder.go b/core/transcoder/transcoder.go similarity index 85% rename from core/ffmpeg/transcoder.go rename to core/transcoder/transcoder.go index abc984534..d10f4d000 100644 --- a/core/ffmpeg/transcoder.go +++ b/core/transcoder/transcoder.go @@ -1,4 +1,4 @@ -package ffmpeg +package transcoder import ( "fmt" @@ -10,6 +10,8 @@ import ( "github.com/teris-io/shortid" "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" ) @@ -21,14 +23,15 @@ type Transcoder struct { segmentOutputPath string playlistOutputPath string variants []HLSVariant - hlsPlaylistLength int - segmentLengthSeconds int appendToStream bool ffmpegPath string segmentIdentifier string - internalListenerPort int - videoOnly bool // If true ignore any audio, if any - TranscoderCompleted func(error) + internalListenerPort string + + currentStreamOutputSettings []models.StreamOutputVariant + currentLatencyLevel models.LatencyLevel + + TranscoderCompleted func(error) } // HLSVariant is a combination of settings that results in a single HLS stream. @@ -81,11 +84,8 @@ func (t *Transcoder) Start() { command := t.getString() log.Tracef("Video transcoder started with %d stream variants.", len(t.variants)) - if t.videoOnly { - log.Tracef("Transcoder requested to operate on video only, ignoring audio.") - } - if config.Config.EnableDebugFeatures { + if config.EnableDebugFeatures { log.Println(command) } @@ -103,16 +103,8 @@ func (t *Transcoder) Start() { } func (t *Transcoder) getString() string { - var port int - if config.Config != nil { - port = config.Config.GetPublicWebServerPort() + 1 - } else if t.internalListenerPort != 0 { - port = t.internalListenerPort - } else { - log.Panicln("A internal port must be set for transcoder callback") - } - - localListenerAddress := "http://127.0.0.1:" + strconv.Itoa(port) + var port = t.internalListenerPort + localListenerAddress := "http://127.0.0.1:" + port hlsOptionFlags := []string{} @@ -139,8 +131,8 @@ func (t *Transcoder) getString() string { // HLS Output "-f", "hls", - "-hls_time", strconv.Itoa(t.segmentLengthSeconds), // Length of each segment - "-hls_list_size", strconv.Itoa(t.hlsPlaylistLength), // Max # in variant playlist + "-hls_time", strconv.Itoa(t.currentLatencyLevel.SecondsPerSegment), // Length of each segment + "-hls_list_size", strconv.Itoa(t.currentLatencyLevel.SegmentCount), // Max # in variant playlist "-hls_delete_threshold", "10", // Start deleting files after hls_list_size + 10 hlsOptionsString, @@ -165,7 +157,7 @@ func (t *Transcoder) getString() string { return strings.Join(ffmpegFlags, " ") } -func getVariantFromConfigQuality(quality config.StreamQuality, index int) HLSVariant { +func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int) HLSVariant { variant := HLSVariant{} variant.index = index variant.isAudioPassthrough = quality.IsAudioPassthrough @@ -202,12 +194,17 @@ func getVariantFromConfigQuality(quality config.StreamQuality, index int) HLSVar // NewTranscoder will return a new Transcoder, populated by the config. func NewTranscoder() *Transcoder { + ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) + transcoder := new(Transcoder) - transcoder.ffmpegPath = config.Config.GetFFMpegPath() - transcoder.hlsPlaylistLength = config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist() + transcoder.ffmpegPath = ffmpegPath + transcoder.internalListenerPort = config.InternalHLSListenerPort + + transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants() + transcoder.currentLatencyLevel = data.GetStreamLatencyLevel() var outputPath string - if config.Config.S3.Enabled { + if data.GetS3Config().Enabled { // Segments are not available via the local HTTP server outputPath = config.PrivateHLSStoragePath } else { @@ -220,10 +217,8 @@ func NewTranscoder() *Transcoder { transcoder.playlistOutputPath = config.PublicHLSStoragePath transcoder.input = utils.GetTemporaryPipePath() - transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength() - qualities := config.Config.GetVideoStreamQualities() - for index, quality := range qualities { + for index, quality := range transcoder.currentStreamOutputSettings { variant := getVariantFromConfigQuality(quality, index) transcoder.AddVariant(variant) } @@ -257,12 +252,7 @@ func (t *Transcoder) getVariantsString() string { for _, variant := range t.variants { variantsCommandFlags = variantsCommandFlags + " " + variant.getVariantString(t) singleVariantMap := "" - if t.videoOnly { - singleVariantMap = fmt.Sprintf("v:%d ", variant.index) - } else { - singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index) - } - + singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index) variantsStreamMaps = variantsStreamMaps + singleVariantMap } variantsCommandFlags = variantsCommandFlags + " " + variantsStreamMaps + "\"" @@ -306,7 +296,7 @@ func (v *HLSVariant) getVideoQualityString(t *Transcoder) string { // -1 to work around segments being generated slightly larger than expected. // https://trac.ffmpeg.org/ticket/6915?replyto=58#comment:57 - gop := (t.segmentLengthSeconds * v.framerate) - 1 + gop := (t.currentLatencyLevel.SecondsPerSegment * v.framerate) - 1 // For limiting the output bitrate // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate @@ -374,16 +364,6 @@ func (t *Transcoder) SetOutputPath(output string) { t.segmentOutputPath = output } -// SetHLSPlaylistLength will set the max number of items in a HLS variant's playlist. -func (t *Transcoder) SetHLSPlaylistLength(length int) { - t.hlsPlaylistLength = length -} - -// SetSegmentLength Specifies the number of seconds each segment should be. -func (t *Transcoder) SetSegmentLength(seconds int) { - t.segmentLengthSeconds = seconds -} - // SetAppendToStream enables appending to the HLS stream instead of overwriting. func (t *Transcoder) SetAppendToStream(append bool) { t.appendToStream = append @@ -394,11 +374,6 @@ func (t *Transcoder) SetIdentifier(output string) { t.segmentIdentifier = output } -func (t *Transcoder) SetInternalHTTPPort(port int) { +func (t *Transcoder) SetInternalHTTPPort(port string) { t.internalListenerPort = port } - -// SetVideoOnly will ignore any audio streams, if any. -func (t *Transcoder) SetVideoOnly(videoOnly bool) { - t.videoOnly = videoOnly -} diff --git a/core/ffmpeg/transcoder_test.go b/core/transcoder/transcoder_test.go similarity index 50% rename from core/ffmpeg/transcoder_test.go rename to core/transcoder/transcoder_test.go index 14ccded0b..eec72ce13 100644 --- a/core/ffmpeg/transcoder_test.go +++ b/core/transcoder/transcoder_test.go @@ -1,18 +1,21 @@ -package ffmpeg +package transcoder import ( "testing" + + "github.com/owncast/owncast/models" ) func TestFFmpegCommand(t *testing.T) { + latencyLevel := models.GetLatencyLevel(3) + transcoder := new(Transcoder) transcoder.ffmpegPath = "/fake/path/ffmpeg" - transcoder.SetSegmentLength(4) transcoder.SetInput("fakecontent.flv") transcoder.SetOutputPath("fakeOutput") - transcoder.SetHLSPlaylistLength(10) transcoder.SetIdentifier("jdofFGg") - transcoder.SetInternalHTTPPort(8123) + transcoder.SetInternalHTTPPort("8123") + transcoder.currentLatencyLevel = latencyLevel variant := HLSVariant{} variant.videoBitrate = 1200 @@ -35,7 +38,7 @@ func TestFFmpegCommand(t *testing.T) { cmd := transcoder.getString() - expected := `/fake/path/ffmpeg -hide_banner -loglevel warning -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 119 -profile:v:0 high -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=119:keyint=119" -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -bufsize:v:1 4200k -g:v:1 95 -profile:v:1 high -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0:min-keyint=95:keyint=95" -map a:0? -c:a:1 copy -preset faster -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 4 -hls_list_size 10 -hls_delete_threshold 10 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 -fflags +genpts http://127.0.0.1:8123/%v/stream.m3u8 2> transcoder.log` + expected := `/fake/path/ffmpeg -hide_banner -loglevel warning -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 89 -profile:v:0 high -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=89:keyint=89" -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -bufsize:v:1 4200k -g:v:1 71 -profile:v:1 high -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0:min-keyint=71:keyint=71" -map a:0? -c:a:1 copy -preset faster -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 3 -hls_delete_threshold 10 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 -fflags +genpts http://127.0.0.1:8123/%v/stream.m3u8 2> transcoder.log` if cmd != expected { t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) diff --git a/core/webhooks/chat.go b/core/webhooks/chat.go new file mode 100644 index 000000000..b0c1bd6f8 --- /dev/null +++ b/core/webhooks/chat.go @@ -0,0 +1,39 @@ +package webhooks + +import ( + "github.com/owncast/owncast/models" +) + +func SendChatEvent(chatEvent models.ChatEvent) { + webhookEvent := WebhookEvent{ + Type: chatEvent.MessageType, + EventData: &WebhookChatMessage{ + Author: chatEvent.Author, + Body: chatEvent.Body, + RawBody: chatEvent.RawBody, + ID: chatEvent.ID, + Visible: chatEvent.Visible, + Timestamp: &chatEvent.Timestamp, + }, + } + + SendEventToWebhooks(webhookEvent) +} + +func SendChatEventUsernameChanged(event models.NameChangeEvent) { + webhookEvent := WebhookEvent{ + Type: models.UserNameChanged, + EventData: event, + } + + SendEventToWebhooks(webhookEvent) +} + +func SendChatEventUserJoined(event models.UserJoinedEvent) { + webhookEvent := WebhookEvent{ + Type: models.UserNameChanged, + EventData: event, + } + + SendEventToWebhooks(webhookEvent) +} diff --git a/core/webhooks/stream.go b/core/webhooks/stream.go new file mode 100644 index 000000000..bbbad0d0e --- /dev/null +++ b/core/webhooks/stream.go @@ -0,0 +1,7 @@ +package webhooks + +import "github.com/owncast/owncast/models" + +func SendStreamStatusEvent(eventType models.EventType) { + SendEventToWebhooks(WebhookEvent{Type: eventType}) +} diff --git a/core/webhooks/webhooks.go b/core/webhooks/webhooks.go new file mode 100644 index 000000000..377e689ef --- /dev/null +++ b/core/webhooks/webhooks.go @@ -0,0 +1,67 @@ +package webhooks + +import ( + "bytes" + "encoding/json" + "net/http" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" +) + +type WebhookEvent struct { + Type models.EventType `json:"type"` // messageSent | userJoined | userNameChange + EventData interface{} `json:"eventData,omitempty"` +} + +type WebhookChatMessage struct { + Author string `json:"author,omitempty"` + Body string `json:"body,omitempty"` + RawBody string `json:"rawBody,omitempty"` + ID string `json:"id,omitempty"` + Visible bool `json:"visible"` + Timestamp *time.Time `json:"timestamp,omitempty"` +} + +func SendEventToWebhooks(payload WebhookEvent) { + webhooks := data.GetWebhooksForEvent(payload.Type) + + for _, webhook := range webhooks { + log.Debugf("Event %s sent to Webhook %s", payload.Type, webhook.URL) + if err := sendWebhook(webhook.URL, payload); err != nil { + log.Errorf("Event: %s failed to send to webhook: %s Error: %s", payload.Type, webhook.URL, err) + } + } +} + +func sendWebhook(url string, payload WebhookEvent) error { + jsonText, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonText)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if err := data.SetWebhookAsUsed(url); err != nil { + log.Warnln(err) + } + + return nil +} diff --git a/data/content-example.md b/data/content-example.md deleted file mode 100644 index 6b8f2888b..000000000 --- a/data/content-example.md +++ /dev/null @@ -1,4 +0,0 @@ -# Stream description and content can go here. - -1. Edit `content.md` in markdown. -1. And it'll go here. \ No newline at end of file diff --git a/doc/api/index.html b/doc/api/index.html index 5074214b0..28c0c2c7c 100644 --- a/doc/api/index.html +++ b/doc/api/index.html @@ -15,7 +15,7 @@