mirror of
https://github.com/owncast/owncast.git
synced 2024-11-23 21:28:29 +03:00
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 <aaron@geekgonecrazy.com> Co-authored-by: Gabe Kangas <gabek@real-ity.com> * 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 <geekgonecrazy@users.noreply.github.com> * Webhook query cleanup * Add SystemMessageSent to EventType Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> * 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] <support@github.com> 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] <support@github.com> 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] <support@github.com> 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] <support@github.com> * Commit updated Javascript packages Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> * 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] <support@github.com> 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 <geekgonecrazy@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Ginger Wong <omqmail@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: nebunez <uoj2y7wak869@opayq.net> Co-authored-by: gabek <gabek@users.noreply.github.com>
This commit is contained in:
parent
05ec74a1e3
commit
bc2caadb74
125 changed files with 5544 additions and 1510 deletions
4
.github/workflows/javascript-formatting.yml
vendored
4
.github/workflows/javascript-formatting.yml
vendored
|
@ -4,8 +4,8 @@ name: Format Javascript
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
# branches:
|
branches:
|
||||||
# - master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prettier:
|
prettier:
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -32,6 +32,7 @@ data/
|
||||||
transcoder.log
|
transcoder.log
|
||||||
chat.db
|
chat.db
|
||||||
.yp.key
|
.yp.key
|
||||||
|
backup/
|
||||||
!webroot/js/web_modules/**/dist
|
!webroot/js/web_modules/**/dist
|
||||||
!core/data
|
!core/data
|
||||||
|
test/test.db
|
||||||
|
|
22
.vscode/settings.json
vendored
Normal file
22
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"Debugln",
|
||||||
|
"Errorln",
|
||||||
|
"Ffmpeg",
|
||||||
|
"Mbps",
|
||||||
|
"Owncast",
|
||||||
|
"RTMP",
|
||||||
|
"Tracef",
|
||||||
|
"Traceln",
|
||||||
|
"Warnf",
|
||||||
|
"Warnln",
|
||||||
|
"ffmpegpath",
|
||||||
|
"ffmpg",
|
||||||
|
"mattn",
|
||||||
|
"nolint",
|
||||||
|
"preact",
|
||||||
|
"rtmpserverport",
|
||||||
|
"sqlite",
|
||||||
|
"videojs"
|
||||||
|
]
|
||||||
|
}
|
|
@ -16,9 +16,11 @@ shutdown () {
|
||||||
trap shutdown INT TERM ABRT EXIT
|
trap shutdown INT TERM ABRT EXIT
|
||||||
|
|
||||||
echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..."
|
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
|
cd owncast-admin
|
||||||
|
|
||||||
|
git checkout 0.0.6
|
||||||
|
|
||||||
echo "Installing npm modules for the owncast admin..."
|
echo "Installing npm modules for the owncast admin..."
|
||||||
npm --silent install 2> /dev/null
|
npm --silent install 2> /dev/null
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ BUILD_TEMP_DIRECTORY="$(mktemp -d)"
|
||||||
cd $BUILD_TEMP_DIRECTORY
|
cd $BUILD_TEMP_DIRECTORY
|
||||||
|
|
||||||
echo "Cloning owncast into $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
|
cd owncast
|
||||||
|
|
||||||
echo "Changing to branch: $GIT_BRANCH"
|
echo "Changing to branch: $GIT_BRANCH"
|
||||||
|
@ -58,26 +58,22 @@ build() {
|
||||||
VERSION=$4
|
VERSION=$4
|
||||||
GIT_COMMIT=$5
|
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}
|
||||||
mkdir -p dist/${NAME}/webroot/static
|
|
||||||
mkdir -p dist/${NAME}/data
|
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/
|
cp -R webroot/ dist/${NAME}/webroot/
|
||||||
|
|
||||||
# Copy the production pruned+minified css to the build's directory.
|
# 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 "${TMPDIR}tailwind.min.css" ./dist/${NAME}/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
|
||||||
cp -R static/ dist/${NAME}/static
|
cp -R static/ dist/${NAME}/static
|
||||||
cp README.md dist/${NAME}
|
cp README.md dist/${NAME}
|
||||||
|
cp webroot/img/logo.svg dist/${NAME}/data/logo.svg
|
||||||
|
|
||||||
pushd dist/${NAME} >> /dev/null
|
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
|
mv owncast-*-${ARCH} owncast
|
||||||
|
|
||||||
zip -r -q -8 ../owncast-$VERSION-$NAME.zip .
|
zip -r -q -8 ../owncast-$VERSION-$NAME.zip .
|
||||||
|
|
325
config/config.go
325
config/config.go
|
@ -1,299 +1,40 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/owncast/owncast/utils"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config contains a reference to the configuration.
|
// These are runtime-set values used for configuration.
|
||||||
var Config *config
|
|
||||||
var _default config
|
|
||||||
|
|
||||||
type config struct {
|
// DatabaseFilePath is the path to the file ot be used as the global database for this run of the application.
|
||||||
DatabaseFilePath string `yaml:"databaseFile"`
|
var DatabaseFilePath = "data/owncast.db"
|
||||||
EnableDebugFeatures bool `yaml:"-"`
|
|
||||||
FFMpegPath string `yaml:"ffmpegPath"`
|
// EnableDebugFeatures will print additional data to help in debugging.
|
||||||
Files files `yaml:"files"`
|
var EnableDebugFeatures = false
|
||||||
InstanceDetails InstanceDetails `yaml:"instanceDetails"`
|
|
||||||
S3 S3 `yaml:"s3"`
|
// VersionNumber is the current version string.
|
||||||
VersionInfo string `yaml:"-"` // For storing the version/build number
|
var VersionNumber = StaticVersionNumber
|
||||||
VersionNumber string `yaml:"-"`
|
|
||||||
VideoSettings videoSettings `yaml:"videoSettings"`
|
// WebServerPort is the port for Owncast's webserver that is used for this execution of the service.
|
||||||
WebServerPort int `yaml:"webServerPort"`
|
var WebServerPort = 8080
|
||||||
RTMPServerPort int `yaml:"rtmpServerPort"`
|
|
||||||
DisableUpgradeChecks bool `yaml:"disableUpgradeChecks"`
|
// InternalHLSListenerPort is the port for HLS writes that is used for this execution of the service.
|
||||||
YP YP `yaml:"yp"`
|
var InternalHLSListenerPort = "8927"
|
||||||
}
|
|
||||||
|
// ConfigFilePath is the path to the config file for migration.
|
||||||
// InstanceDetails defines the user-visible information about this particular instance.
|
var ConfigFilePath = "config.yaml"
|
||||||
type InstanceDetails struct {
|
|
||||||
Name string `yaml:"name" json:"name"`
|
// GitCommit is an optional commit this build was made from.
|
||||||
Title string `yaml:"title" json:"title"`
|
var GitCommit = ""
|
||||||
Summary string `yaml:"summary" json:"summary"`
|
|
||||||
// Logo logo `yaml:"logo" json:"logo"`
|
// BuildPlatform is the optional platform this release was built for.
|
||||||
Logo string `yaml:"logo" json:"logo"`
|
var BuildPlatform = "local"
|
||||||
Tags []string `yaml:"tags" json:"tags"`
|
|
||||||
SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"`
|
// GetReleaseString gets the version string.
|
||||||
Version string `json:"version"`
|
func GetReleaseString() string {
|
||||||
NSFW bool `yaml:"nsfw" json:"nsfw"`
|
var versionNumber = VersionNumber
|
||||||
ExtraPageContent string `json:"extraPageContent"`
|
var buildPlatform = BuildPlatform
|
||||||
}
|
var gitCommit = GitCommit
|
||||||
|
|
||||||
// type logo struct {
|
return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit)
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,49 +1 @@
|
||||||
package config
|
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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,14 +3,23 @@ package config
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WebRoot = "webroot"
|
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
|
||||||
PrivateHLSStoragePath = "hls"
|
StaticVersionNumber = "0.0.6" // Shown when you build from master
|
||||||
GeoIPDatabasePath = "data/GeoLite2-City.mmdb"
|
// WebRoot is the web server root directory.
|
||||||
ExtraInfoFile = "data/content.md"
|
WebRoot = "webroot"
|
||||||
StatsFile = "data/stats.json"
|
// 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
|
FfmpegSuggestedVersion = "v4.1.5" // Requires the v
|
||||||
|
// BackupDirectory is the directory we write backup files to.
|
||||||
|
BackupDirectory = "backup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// PublicHLSStoragePath is the directory we write public HLS files to for distribution.
|
||||||
PublicHLSStoragePath = filepath.Join(WebRoot, "hls")
|
PublicHLSStoragePath = filepath.Join(WebRoot, "hls")
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,22 +1,59 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
func getDefaults() config {
|
import "github.com/owncast/owncast/models"
|
||||||
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"
|
|
||||||
|
|
||||||
defaultQuality := StreamQuality{
|
// Defaults will hold default configuration values.
|
||||||
IsAudioPassthrough: true,
|
type Defaults struct {
|
||||||
VideoBitrate: 1200,
|
Name string
|
||||||
EncoderPreset: "veryfast",
|
Title string
|
||||||
Framerate: 24,
|
Summary string
|
||||||
}
|
Logo string
|
||||||
defaults.VideoSettings.StreamQualities = []StreamQuality{defaultQuality}
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,8 +12,8 @@ import (
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// verifyFFMpegPath verifies that the path exists, is a file, and is executable.
|
// VerifyFFMpegPath verifies that the path exists, is a file, and is executable.
|
||||||
func verifyFFMpegPath(path string) error {
|
func VerifyFFMpegPath(path string) error {
|
||||||
stat, err := os.Stat(path)
|
stat, err := os.Stat(path)
|
||||||
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
@ -39,12 +39,12 @@ func verifyFFMpegPath(path string) error {
|
||||||
|
|
||||||
response := string(out)
|
response := string(out)
|
||||||
if response == "" {
|
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, " ")
|
responseComponents := strings.Split(response, " ")
|
||||||
if len(responseComponents) < 3 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ func verifyFFMpegPath(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if semver.Compare(versionString, FfmpegSuggestedVersion) == -1 {
|
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
|
return nil
|
||||||
|
|
100
controllers/admin/accessToken.go
Normal file
100
controllers/admin/accessToken.go
Normal file
|
@ -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")
|
||||||
|
}
|
|
@ -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"`
|
|
||||||
}
|
|
|
@ -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"`
|
|
||||||
}
|
|
|
@ -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"`
|
|
||||||
}
|
|
|
@ -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"`
|
|
||||||
}
|
|
|
@ -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"`
|
|
||||||
}
|
|
|
@ -4,36 +4,36 @@ package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/controllers"
|
"github.com/owncast/owncast/controllers"
|
||||||
"github.com/owncast/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/owncast/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/teris-io/shortid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateMessageVisibility updates an array of message IDs to have the same visiblity.
|
// UpdateMessageVisibility updates an array of message IDs to have the same visiblity.
|
||||||
func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
|
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")
|
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
decoder := json.NewDecoder(r.Body)
|
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 {
|
if err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
controllers.WriteSimpleResponse(w, false, "")
|
controllers.WriteSimpleResponse(w, false, "")
|
||||||
return
|
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 {
|
if err := chat.SetMessagesVisibility(request.IDArray, request.Visible); err != nil {
|
||||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||||
return
|
return
|
||||||
|
@ -49,12 +49,99 @@ type messageVisibilityUpdateRequest struct {
|
||||||
|
|
||||||
// GetChatMessages returns all of the chat messages, unfiltered.
|
// GetChatMessages returns all of the chat messages, unfiltered.
|
||||||
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
// middleware.EnableCors(&w)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
messages := core.GetAllChatMessages(false)
|
messages := core.GetModerationChatMessages()
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(messages); err != nil {
|
if err := json.NewEncoder(w).Encode(messages); err != nil {
|
||||||
log.Errorln(err)
|
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")
|
||||||
|
}
|
||||||
|
|
475
controllers/admin/config.go
Normal file
475
controllers/admin/config.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -5,35 +5,50 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"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"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetServerConfig gets the config details of the server.
|
// GetServerConfig gets the config details of the server.
|
||||||
func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
var videoQualityVariants = make([]config.StreamQuality, 0)
|
var videoQualityVariants = make([]models.StreamOutputVariant, 0)
|
||||||
for _, variant := range config.Config.GetVideoStreamQualities() {
|
for _, variant := range data.GetStreamOutputVariants() {
|
||||||
videoQualityVariants = append(videoQualityVariants, config.StreamQuality{
|
videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{
|
||||||
IsAudioPassthrough: variant.GetIsAudioPassthrough(),
|
IsAudioPassthrough: variant.GetIsAudioPassthrough(),
|
||||||
IsVideoPassthrough: variant.IsVideoPassthrough,
|
IsVideoPassthrough: variant.IsVideoPassthrough,
|
||||||
Framerate: variant.GetFramerate(),
|
Framerate: variant.GetFramerate(),
|
||||||
EncoderPreset: variant.GetEncoderPreset(),
|
EncoderPreset: variant.GetEncoderPreset(),
|
||||||
VideoBitrate: variant.VideoBitrate,
|
VideoBitrate: variant.VideoBitrate,
|
||||||
AudioBitrate: variant.AudioBitrate,
|
AudioBitrate: variant.AudioBitrate,
|
||||||
|
CPUUsageLevel: variant.GetCPUUsageLevel(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
response := serverConfigAdminResponse{
|
response := serverConfigAdminResponse{
|
||||||
InstanceDetails: config.Config.InstanceDetails,
|
InstanceDetails: webConfigResponse{
|
||||||
FFmpegPath: config.Config.GetFFMpegPath(),
|
Name: data.GetServerName(),
|
||||||
StreamKey: config.Config.VideoSettings.StreamingKey,
|
Summary: data.GetServerSummary(),
|
||||||
WebServerPort: config.Config.GetPublicWebServerPort(),
|
Tags: data.GetServerMetadataTags(),
|
||||||
RTMPServerPort: config.Config.GetRTMPServerPort(),
|
ExtraPageContent: data.GetExtraPageBodyContent(),
|
||||||
VideoSettings: videoSettings{
|
StreamTitle: data.GetStreamTitle(),
|
||||||
VideoQualityVariants: videoQualityVariants,
|
Logo: data.GetLogoPath(),
|
||||||
SegmentLengthSeconds: config.Config.GetVideoSegmentSecondsLength(),
|
SocialHandles: data.GetSocialHandles(),
|
||||||
NumberOfPlaylistItems: config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist(),
|
NSFW: data.GetNSFW(),
|
||||||
},
|
},
|
||||||
YP: config.Config.YP,
|
FFmpegPath: utils.ValidatedFfmpegPath(data.GetFfMpegPath()),
|
||||||
S3: config.Config.S3,
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -44,18 +59,36 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverConfigAdminResponse struct {
|
type serverConfigAdminResponse struct {
|
||||||
InstanceDetails config.InstanceDetails `json:"instanceDetails"`
|
InstanceDetails webConfigResponse `json:"instanceDetails"`
|
||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
StreamKey string `json:"streamKey"`
|
StreamKey string `json:"streamKey"`
|
||||||
WebServerPort int `json:"webServerPort"`
|
WebServerPort int `json:"webServerPort"`
|
||||||
RTMPServerPort int `json:"rtmpServerPort"`
|
RTMPServerPort int `json:"rtmpServerPort"`
|
||||||
S3 config.S3 `json:"s3"`
|
S3 models.S3 `json:"s3"`
|
||||||
VideoSettings videoSettings `json:"videoSettings"`
|
VideoSettings videoSettings `json:"videoSettings"`
|
||||||
YP config.YP `json:"yp"`
|
LatencyLevel int `json:"latencyLevel"`
|
||||||
|
YP yp `json:"yp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type videoSettings struct {
|
type videoSettings struct {
|
||||||
VideoQualityVariants []config.StreamQuality `json:"videoQualityVariants"`
|
VideoQualityVariants []models.StreamOutputVariant `json:"videoQualityVariants"`
|
||||||
SegmentLengthSeconds int `json:"segmentLengthSeconds"`
|
LatencyLevel int `json:"latencyLevel"`
|
||||||
NumberOfPlaylistItems int `json:"numberOfPlaylistItems"`
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
|
||||||
"github.com/owncast/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
@ -14,15 +14,17 @@ import (
|
||||||
func Status(w http.ResponseWriter, r *http.Request) {
|
func Status(w http.ResponseWriter, r *http.Request) {
|
||||||
broadcaster := core.GetBroadcaster()
|
broadcaster := core.GetBroadcaster()
|
||||||
status := core.GetStatus()
|
status := core.GetStatus()
|
||||||
|
currentBroadcast := core.GetCurrentBroadcast()
|
||||||
|
|
||||||
response := adminStatusResponse{
|
response := adminStatusResponse{
|
||||||
Broadcaster: broadcaster,
|
Broadcaster: broadcaster,
|
||||||
|
CurrentBroadcast: currentBroadcast,
|
||||||
Online: status.Online,
|
Online: status.Online,
|
||||||
ViewerCount: status.ViewerCount,
|
ViewerCount: status.ViewerCount,
|
||||||
OverallPeakViewerCount: status.OverallMaxViewerCount,
|
OverallPeakViewerCount: status.OverallMaxViewerCount,
|
||||||
SessionPeakViewerCount: status.SessionMaxViewerCount,
|
SessionPeakViewerCount: status.SessionMaxViewerCount,
|
||||||
VersionNumber: status.VersionNumber,
|
VersionNumber: status.VersionNumber,
|
||||||
DisableUpgradeChecks: config.Config.DisableUpgradeChecks,
|
StreamTitle: data.GetStreamTitle(),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -33,12 +35,12 @@ func Status(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminStatusResponse struct {
|
type adminStatusResponse struct {
|
||||||
Broadcaster *models.Broadcaster `json:"broadcaster"`
|
Broadcaster *models.Broadcaster `json:"broadcaster"`
|
||||||
Online bool `json:"online"`
|
CurrentBroadcast *models.CurrentBroadcast `json:"currentBroadcast"`
|
||||||
ViewerCount int `json:"viewerCount"`
|
Online bool `json:"online"`
|
||||||
OverallPeakViewerCount int `json:"overallPeakViewerCount"`
|
ViewerCount int `json:"viewerCount"`
|
||||||
SessionPeakViewerCount int `json:"sessionPeakViewerCount"`
|
OverallPeakViewerCount int `json:"overallPeakViewerCount"`
|
||||||
|
SessionPeakViewerCount int `json:"sessionPeakViewerCount"`
|
||||||
VersionNumber string `json:"versionNumber"`
|
StreamTitle string `json:"streamTitle"`
|
||||||
DisableUpgradeChecks bool `json:"disableUpgradeChecks"`
|
VersionNumber string `json:"versionNumber"`
|
||||||
}
|
}
|
||||||
|
|
84
controllers/admin/webhooks.go
Normal file
84
controllers/admin/webhooks.go
Normal file
|
@ -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")
|
||||||
|
}
|
20
controllers/admin/yp.go
Normal file
20
controllers/admin/yp.go
Normal file
|
@ -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")
|
||||||
|
}
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/owncast/owncast/models"
|
|
||||||
"github.com/owncast/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
@ -17,32 +16,16 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
messages := core.GetAllChatMessages(true)
|
messages := core.GetAllChatMessages()
|
||||||
|
|
||||||
err := json.NewEncoder(w).Encode(messages)
|
err := json.NewEncoder(w).Encode(messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln(err)
|
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:
|
default:
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
if err := json.NewEncoder(w).Encode(j{"error": "method not implemented (PRs are accepted)"}); err != nil {
|
if err := json.NewEncoder(w).Encode(j{"error": "method not implemented (PRs are accepted)"}); err != nil {
|
||||||
internalErrorHandler(w, err)
|
InternalErrorHandler(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,17 +5,63 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"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/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.
|
// GetWebConfig gets the status of the server.
|
||||||
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
middleware.EnableCors(&w)
|
middleware.EnableCors(&w)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
configuration := config.Config.InstanceDetails
|
pageContent := utils.RenderPageContentMarkdown(data.GetExtraPageBodyContent())
|
||||||
configuration.Version = config.Config.VersionInfo
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,6 @@ func GetConnectedClients(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(clients); err != nil {
|
if err := json.NewEncoder(w).Encode(clients); err != nil {
|
||||||
internalErrorHandler(w, err)
|
InternalErrorHandler(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
7
controllers/constants.go
Normal file
7
controllers/constants.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
// POST is the HTTP POST method.
|
||||||
|
const POST = "POST"
|
||||||
|
|
||||||
|
// GET is the HTTP GET method.
|
||||||
|
const GET = "GET"
|
|
@ -5,39 +5,65 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type j map[string]interface{}
|
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 {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Errorln(err)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil {
|
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 {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugln(err)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil {
|
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) {
|
func WriteSimpleResponse(w http.ResponseWriter, success bool, message string) {
|
||||||
response := models.BaseAPIResponse{
|
response := models.BaseAPIResponse{
|
||||||
Success: success,
|
Success: success,
|
||||||
Message: message,
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,6 @@ func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(emojiList); err != nil {
|
if err := json.NewEncoder(w).Encode(emojiList); err != nil {
|
||||||
internalErrorHandler(w, err)
|
InternalErrorHandler(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,16 +14,23 @@ import (
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/core"
|
"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/router/middleware"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MetadataPage represents a server-rendered web page for bots and web scrapers.
|
||||||
type MetadataPage struct {
|
type MetadataPage struct {
|
||||||
Config config.InstanceDetails
|
RequestedURL string
|
||||||
RequestedURL string
|
Image string
|
||||||
Image string
|
Thumbnail string
|
||||||
Thumbnail string
|
TagsString string
|
||||||
TagsString string
|
Title string
|
||||||
|
Summary string
|
||||||
|
Name string
|
||||||
|
Tags []string
|
||||||
|
SocialHandles []models.SocialHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
// IndexHandler handles the default index route.
|
// IndexHandler handles the default index route.
|
||||||
|
@ -70,7 +77,7 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panicln(err)
|
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 {
|
if err != nil {
|
||||||
log.Panicln(err)
|
log.Panicln(err)
|
||||||
}
|
}
|
||||||
|
@ -91,8 +98,15 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
|
||||||
thumbnailURL = imageURL.String()
|
thumbnailURL = imageURL.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsString := strings.Join(config.Config.InstanceDetails.Tags, ",")
|
tagsString := strings.Join(data.GetServerMetadataTags(), ",")
|
||||||
metadata := MetadataPage{config.Config.InstanceDetails, fullURL.String(), imageURL.String(), thumbnailURL, tagsString}
|
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")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = tmpl.Execute(w, metadata)
|
err = tmpl.Execute(w, metadata)
|
||||||
|
|
65
controllers/logo.go
Normal file
65
controllers/logo.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -16,6 +16,6 @@ func GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||||
internalErrorHandler(w, err)
|
InternalErrorHandler(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,12 +60,16 @@ func SendMessage(message models.ChatEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMessages gets all of the messages.
|
// GetMessages gets all of the messages.
|
||||||
func GetMessages(filtered bool) []models.ChatEvent {
|
func GetMessages() []models.ChatEvent {
|
||||||
if _server == nil {
|
if _server == nil {
|
||||||
return []models.ChatEvent{}
|
return []models.ChatEvent{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getChatHistory(filtered)
|
return getChatHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetModerationChatMessages() []models.ChatEvent {
|
||||||
|
return getChatModerationHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetClient(clientID string) *Client {
|
func GetClient(clientID string) *Client {
|
||||||
|
|
|
@ -28,36 +28,38 @@ type Client struct {
|
||||||
Username *string
|
Username *string
|
||||||
ClientID string // How we identify unique viewers when counting viewer counts.
|
ClientID string // How we identify unique viewers when counting viewer counts.
|
||||||
Geo *geoip.GeoDetails `json:"geo"`
|
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.
|
socketID string // How we identify a single websocket client.
|
||||||
ws *websocket.Conn
|
ws *websocket.Conn
|
||||||
ch chan models.ChatEvent
|
ch chan models.ChatEvent
|
||||||
pingch chan models.PingMessage
|
pingch chan models.PingMessage
|
||||||
usernameChangeChannel chan models.NameChangeEvent
|
usernameChangeChannel chan models.NameChangeEvent
|
||||||
|
userJoinedChannel chan models.UserJoinedEvent
|
||||||
|
|
||||||
doneCh chan bool
|
doneCh chan bool
|
||||||
|
|
||||||
rateLimiter *rate.Limiter
|
rateLimiter *rate.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
CHAT = "CHAT"
|
|
||||||
NAMECHANGE = "NAME_CHANGE"
|
|
||||||
PING = "PING"
|
|
||||||
PONG = "PONG"
|
|
||||||
VISIBILITYUPDATE = "VISIBILITY-UPDATE"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewClient creates a new chat client.
|
// NewClient creates a new chat client.
|
||||||
func NewClient(ws *websocket.Conn) *Client {
|
func NewClient(ws *websocket.Conn) *Client {
|
||||||
if ws == nil {
|
if ws == nil {
|
||||||
log.Panicln("ws cannot be 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)
|
ch := make(chan models.ChatEvent, channelBufSize)
|
||||||
doneCh := make(chan bool)
|
doneCh := make(chan bool)
|
||||||
pingch := make(chan models.PingMessage)
|
pingch := make(chan models.PingMessage)
|
||||||
usernameChangeChannel := make(chan models.NameChangeEvent)
|
usernameChangeChannel := make(chan models.NameChangeEvent)
|
||||||
|
userJoinedChannel := make(chan models.UserJoinedEvent)
|
||||||
|
|
||||||
ipAddress := utils.GetIPAddressFromRequest(ws.Request())
|
ipAddress := utils.GetIPAddressFromRequest(ws.Request())
|
||||||
userAgent := ws.Request().UserAgent()
|
userAgent := ws.Request().UserAgent()
|
||||||
|
@ -66,7 +68,7 @@ func NewClient(ws *websocket.Conn) *Client {
|
||||||
|
|
||||||
rateLimiter := rate.NewLimiter(0.6, 5)
|
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) {
|
func (c *Client) write(msg models.ChatEvent) {
|
||||||
|
@ -105,6 +107,12 @@ func (c *Client) listenWrite() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.handleClientSocketError(err)
|
c.handleClientSocketError(err)
|
||||||
}
|
}
|
||||||
|
case msg := <-c.userJoinedChannel:
|
||||||
|
err := websocket.JSON.Send(c.ws, msg)
|
||||||
|
if err != nil {
|
||||||
|
c.handleClientSocketError(err)
|
||||||
|
}
|
||||||
|
|
||||||
// receive done request
|
// receive done request
|
||||||
case <-c.doneCh:
|
case <-c.doneCh:
|
||||||
_server.removeClient(c)
|
_server.removeClient(c)
|
||||||
|
@ -157,28 +165,46 @@ func (c *Client) listenRead() {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
messageType := messageTypeCheck["type"]
|
messageType := messageTypeCheck["type"].(string)
|
||||||
|
|
||||||
if !c.passesRateLimit() {
|
if !c.passesRateLimit() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if messageType == CHAT {
|
if messageType == models.MessageSent {
|
||||||
c.chatMessageReceived(data)
|
c.chatMessageReceived(data)
|
||||||
} else if messageType == NAMECHANGE {
|
} else if messageType == models.UserNameChanged {
|
||||||
c.userChangedName(data)
|
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) {
|
func (c *Client) userChangedName(data []byte) {
|
||||||
var msg models.NameChangeEvent
|
var msg models.NameChangeEvent
|
||||||
err := json.Unmarshal(data, &msg)
|
err := json.Unmarshal(data, &msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
}
|
}
|
||||||
msg.Type = NAMECHANGE
|
msg.Type = models.UserNameChanged
|
||||||
msg.ID = shortid.MustGenerate()
|
msg.ID = shortid.MustGenerate()
|
||||||
_server.usernameChanged(msg)
|
_server.usernameChanged(msg)
|
||||||
c.Username = &msg.NewName
|
c.Username = &msg.NewName
|
||||||
|
@ -191,10 +217,7 @@ func (c *Client) chatMessageReceived(data []byte) {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := shortid.Generate()
|
msg.SetDefaults()
|
||||||
msg.ID = id
|
|
||||||
msg.Timestamp = time.Now()
|
|
||||||
msg.Visible = true
|
|
||||||
|
|
||||||
c.MessageCount++
|
c.MessageCount++
|
||||||
c.Username = &msg.Author
|
c.Username = &msg.Author
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/owncast/owncast/core/webhooks"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,8 +22,10 @@ func SetMessagesVisibility(messageIDs []string, visibility bool) error {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
message.MessageType = VISIBILITYUPDATE
|
message.MessageType = models.VisibiltyToggled
|
||||||
_server.sendAll(message)
|
_server.sendAll(message)
|
||||||
|
|
||||||
|
go webhooks.SendChatEvent(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -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)
|
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)
|
rows, err := _db.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -80,7 +73,7 @@ func getChatHistory(filtered bool) []models.ChatEvent {
|
||||||
var id string
|
var id string
|
||||||
var author string
|
var author string
|
||||||
var body string
|
var body string
|
||||||
var messageType string
|
var messageType models.EventType
|
||||||
var visible int
|
var visible int
|
||||||
var timestamp time.Time
|
var timestamp time.Time
|
||||||
|
|
||||||
|
@ -109,6 +102,17 @@ func getChatHistory(filtered bool) []models.ChatEvent {
|
||||||
return history
|
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 {
|
func saveMessageVisibility(messageIDs []string, visible bool) error {
|
||||||
tx, err := _db.Begin()
|
tx, err := _db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -150,7 +154,7 @@ func getMessageById(messageID string) (models.ChatEvent, error) {
|
||||||
var id string
|
var id string
|
||||||
var author string
|
var author string
|
||||||
var body string
|
var body string
|
||||||
var messageType string
|
var messageType models.EventType
|
||||||
var visible int
|
var visible int
|
||||||
var timestamp time.Time
|
var timestamp time.Time
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,8 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/net/websocket"
|
"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"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -61,7 +62,7 @@ func (s *server) sendAll(msg models.ChatEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) ping() {
|
func (s *server) ping() {
|
||||||
ping := models.PingMessage{MessageType: PING}
|
ping := models.PingMessage{MessageType: models.PING}
|
||||||
for _, c := range s.Clients {
|
for _, c := range s.Clients {
|
||||||
c.pingch <- ping
|
c.pingch <- ping
|
||||||
}
|
}
|
||||||
|
@ -71,6 +72,16 @@ func (s *server) usernameChanged(msg models.NameChangeEvent) {
|
||||||
for _, c := range s.Clients {
|
for _, c := range s.Clients {
|
||||||
c.usernameChangeChannel <- msg
|
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) {
|
func (s *server) onConnection(ws *websocket.Conn) {
|
||||||
|
@ -103,8 +114,10 @@ func (s *server) Listen() {
|
||||||
s.Clients[c.socketID] = c
|
s.Clients[c.socketID] = c
|
||||||
l.Unlock()
|
l.Unlock()
|
||||||
|
|
||||||
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
|
if !c.Ignore {
|
||||||
s.sendWelcomeMessageToClient(c)
|
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
|
||||||
|
s.sendWelcomeMessageToClient(c)
|
||||||
|
}
|
||||||
|
|
||||||
// remove a client
|
// remove a client
|
||||||
case c := <-s.delCh:
|
case c := <-s.delCh:
|
||||||
|
@ -122,7 +135,11 @@ func (s *server) Listen() {
|
||||||
s.sendAll(msg)
|
s.sendAll(msg)
|
||||||
|
|
||||||
// Store in the message history
|
// Store in the message history
|
||||||
|
msg.SetDefaults()
|
||||||
addMessage(msg)
|
addMessage(msg)
|
||||||
|
|
||||||
|
// Send webhooks
|
||||||
|
go webhooks.SendChatEvent(msg)
|
||||||
}
|
}
|
||||||
case ping := <-s.pingCh:
|
case ping := <-s.pingCh:
|
||||||
fmt.Println("PING?", ping)
|
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.
|
// Add an artificial delay so people notice this message come in.
|
||||||
time.Sleep(7 * time.Second)
|
time.Sleep(7 * time.Second)
|
||||||
|
|
||||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
|
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", data.GetServerName(), data.GetServerSummary())
|
||||||
initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: config.Config.InstanceDetails.Name, Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()}
|
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)
|
c.write(initialMessage)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/owncast/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
@ -26,16 +24,16 @@ func (cl ChatListenerImpl) MessageSent(message models.ChatEvent) {
|
||||||
|
|
||||||
// SendMessageToChat sends a message to the chat server.
|
// SendMessageToChat sends a message to the chat server.
|
||||||
func SendMessageToChat(message models.ChatEvent) error {
|
func SendMessageToChat(message models.ChatEvent) error {
|
||||||
if !message.Valid() {
|
|
||||||
return errors.New("invalid chat message; id, author, and body are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.SendMessage(message)
|
chat.SendMessage(message)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllChatMessages gets all of the chat messages.
|
// GetAllChatMessages gets all of the chat messages.
|
||||||
func GetAllChatMessages(filtered bool) []models.ChatEvent {
|
func GetAllChatMessages() []models.ChatEvent {
|
||||||
return chat.GetMessages(filtered)
|
return chat.GetMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetModerationChatMessages() []models.ChatEvent {
|
||||||
|
return chat.GetModerationChatMessages()
|
||||||
}
|
}
|
||||||
|
|
52
core/core.go
52
core/core.go
|
@ -10,8 +10,9 @@ import (
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/core/chat"
|
"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/rtmp"
|
||||||
|
"github.com/owncast/owncast/core/transcoder"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
"github.com/owncast/owncast/yp"
|
"github.com/owncast/owncast/yp"
|
||||||
|
@ -20,33 +21,41 @@ import (
|
||||||
var (
|
var (
|
||||||
_stats *models.Stats
|
_stats *models.Stats
|
||||||
_storage models.StorageProvider
|
_storage models.StorageProvider
|
||||||
_transcoder *ffmpeg.Transcoder
|
_transcoder *transcoder.Transcoder
|
||||||
_yp *yp.YP
|
_yp *yp.YP
|
||||||
_broadcaster *models.Broadcaster
|
_broadcaster *models.Broadcaster
|
||||||
)
|
)
|
||||||
|
|
||||||
var handler ffmpeg.HLSHandler
|
var handler transcoder.HLSHandler
|
||||||
var fileWriter = ffmpeg.FileWriterReceiverService{}
|
var fileWriter = transcoder.FileWriterReceiverService{}
|
||||||
|
|
||||||
// Start starts up the core processing.
|
// Start starts up the core processing.
|
||||||
func Start() error {
|
func Start() error {
|
||||||
resetDirectories()
|
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 {
|
if err := setupStats(); err != nil {
|
||||||
log.Error("failed to setup the stats")
|
log.Error("failed to setup the stats")
|
||||||
return err
|
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
|
// The HLS handler takes the written HLS playlists and segments
|
||||||
// and makes storage decisions. It's rather simple right now
|
// and makes storage decisions. It's rather simple right now
|
||||||
// but will play more useful when recordings come into play.
|
// but will play more useful when recordings come into play.
|
||||||
handler = ffmpeg.HLSHandler{}
|
handler = transcoder.HLSHandler{}
|
||||||
handler.Storage = _storage
|
|
||||||
|
if err := setupStorage(); err != nil {
|
||||||
|
log.Errorln("storage error", err)
|
||||||
|
}
|
||||||
|
|
||||||
fileWriter.SetupFileWriterReceiverService(&handler)
|
fileWriter.SetupFileWriterReceiverService(&handler)
|
||||||
|
|
||||||
if err := createInitialOfflineState(); err != nil {
|
if err := createInitialOfflineState(); err != nil {
|
||||||
|
@ -54,10 +63,8 @@ func Start() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Config.YP.Enabled {
|
if data.GetDirectoryEnabled() {
|
||||||
_yp = yp.NewYP(GetStatus)
|
_yp = yp.NewYP(GetStatus)
|
||||||
} else {
|
|
||||||
yp.DisplayInstructions()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chat.Setup(ChatListenerImpl{})
|
chat.Setup(ChatListenerImpl{})
|
||||||
|
@ -65,8 +72,8 @@ func Start() error {
|
||||||
// start the rtmp server
|
// start the rtmp server
|
||||||
go rtmp.Start(setStreamAsConnected, setBroadcaster)
|
go rtmp.Start(setStreamAsConnected, setBroadcaster)
|
||||||
|
|
||||||
port := config.Config.GetPublicWebServerPort()
|
port := config.WebServerPort
|
||||||
rtmpPort := config.Config.GetRTMPServerPort()
|
rtmpPort := data.GetRTMPPortNumber()
|
||||||
log.Infof("Web server is listening on port %d, RTMP is accepting inbound streams on port %d.", port, rtmpPort)
|
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.")
|
log.Infoln("The web admin interface is available at /admin.")
|
||||||
|
|
||||||
|
@ -94,13 +101,13 @@ func transitionToOfflineVideoStreamContent() {
|
||||||
|
|
||||||
offlineFilename := "offline.ts"
|
offlineFilename := "offline.ts"
|
||||||
offlineFilePath := "static/" + offlineFilename
|
offlineFilePath := "static/" + offlineFilename
|
||||||
_transcoder := ffmpeg.NewTranscoder()
|
_transcoder := transcoder.NewTranscoder()
|
||||||
_transcoder.SetSegmentLength(10)
|
|
||||||
_transcoder.SetInput(offlineFilePath)
|
_transcoder.SetInput(offlineFilePath)
|
||||||
_transcoder.Start()
|
_transcoder.Start()
|
||||||
|
|
||||||
// Copy the logo to be the thumbnail
|
// 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 {
|
if err != nil {
|
||||||
log.Warnln(err)
|
log.Warnln(err)
|
||||||
}
|
}
|
||||||
|
@ -129,8 +136,8 @@ func resetDirectories() {
|
||||||
os.Remove(filepath.Join(config.WebRoot, "thumbnail.jpg"))
|
os.Remove(filepath.Join(config.WebRoot, "thumbnail.jpg"))
|
||||||
|
|
||||||
// Create private hls data dirs
|
// Create private hls data dirs
|
||||||
if len(config.Config.VideoSettings.StreamQualities) != 0 {
|
if len(data.GetStreamOutputVariants()) != 0 {
|
||||||
for index := range config.Config.VideoSettings.StreamQualities {
|
for index := range data.GetStreamOutputVariants() {
|
||||||
err = os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777)
|
err = os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
|
@ -154,7 +161,8 @@ func resetDirectories() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the previous thumbnail
|
// 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 {
|
if err != nil {
|
||||||
log.Warnln(err)
|
log.Warnln(err)
|
||||||
}
|
}
|
||||||
|
|
199
core/data/accessTokens.go
Normal file
199
core/data/accessTokens.go
Normal file
|
@ -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
|
||||||
|
}
|
18
core/data/cache.go
Normal file
18
core/data/cache.go
Normal file
|
@ -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
|
||||||
|
}
|
450
core/data/config.go
Normal file
450
core/data/config.go
Normal file
|
@ -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
|
||||||
|
}
|
46
core/data/configEntry.go
Normal file
46
core/data/configEntry.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -8,25 +8,32 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
schemaVersion = 0
|
schemaVersion = 0
|
||||||
|
backupFile = "backup/owncastdb.bak"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _db *sql.DB
|
var _db *sql.DB
|
||||||
|
var _datastore *Datastore
|
||||||
|
|
||||||
|
// GetDatabase will return the shared instance of the actual database.
|
||||||
func GetDatabase() *sql.DB {
|
func GetDatabase() *sql.DB {
|
||||||
return _db
|
return _db
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetupPersistence() error {
|
// GetStore will return the shared instance of the read/write datastore.
|
||||||
file := config.Config.DatabaseFilePath
|
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.
|
// Create empty DB file if it doesn't exist.
|
||||||
if !utils.DoesFileExists(file) {
|
if !utils.DoesFileExists(file) {
|
||||||
log.Traceln("Creating new database at", file)
|
log.Traceln("Creating new database at", file)
|
||||||
|
@ -79,11 +86,26 @@ func SetupPersistence() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
_db = db
|
_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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateDatabase(db *sql.DB, from, to int) error {
|
func migrateDatabase(db *sql.DB, from, to int) error {
|
||||||
log.Printf("Migrating database from version %d to %d\n", from, to)
|
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++ {
|
for v := from; v < to; v++ {
|
||||||
switch v {
|
switch v {
|
||||||
case 0:
|
case 0:
|
||||||
|
|
115
core/data/data_test.go
Normal file
115
core/data/data_test.go
Normal file
|
@ -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
|
||||||
|
}
|
43
core/data/defaults.go
Normal file
43
core/data/defaults.go
Normal file
|
@ -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)
|
||||||
|
}
|
266
core/data/migrator.go
Normal file
266
core/data/migrator.go
Normal file
|
@ -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"`
|
||||||
|
}
|
152
core/data/persistence.go
Normal file
152
core/data/persistence.go
Normal file
|
@ -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()
|
||||||
|
}
|
46
core/data/types.go
Normal file
46
core/data/types.go
Normal file
|
@ -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)
|
||||||
|
}
|
220
core/data/webhooks.go
Normal file
220
core/data/webhooks.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import (
|
||||||
func setCurrentBroadcasterInfo(t flvio.Tag, remoteAddr string) {
|
func setCurrentBroadcasterInfo(t flvio.Tag, remoteAddr string) {
|
||||||
data, err := getInboundDetailsFromMetadata(t.DebugFields())
|
data, err := getInboundDetailsFromMetadata(t.DebugFields())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Traceln("RTMP meadata:", err)
|
log.Warnln("Unable to parse inbound broadcaster details:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcaster := models.Broadcaster{
|
broadcaster := models.Broadcaster{
|
||||||
|
|
|
@ -8,14 +8,13 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/nareix/joy5/format/flv"
|
"github.com/nareix/joy5/format/flv"
|
||||||
"github.com/nareix/joy5/format/flv/flvio"
|
"github.com/nareix/joy5/format/flv/flvio"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/nareix/joy5/format/rtmp"
|
"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/models"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
@ -35,7 +34,7 @@ func Start(setStreamAsConnected func(), setBroadcaster func(models.Broadcaster))
|
||||||
_setStreamAsConnected = setStreamAsConnected
|
_setStreamAsConnected = setStreamAsConnected
|
||||||
_setBroadcaster = setBroadcaster
|
_setBroadcaster = setBroadcaster
|
||||||
|
|
||||||
port := config.Config.GetRTMPServerPort()
|
port := data.GetRTMPPortNumber()
|
||||||
s := rtmp.NewServer()
|
s := rtmp.NewServer()
|
||||||
var lis net.Listener
|
var lis net.Listener
|
||||||
var err error
|
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) {
|
s.LogEvent = func(c *rtmp.Conn, nc net.Conn, e int) {
|
||||||
es := rtmp.EventString[e]
|
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
|
s.HandleConn = HandleConn
|
||||||
|
@ -81,7 +80,7 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
||||||
|
|
||||||
streamingKeyComponents := strings.Split(c.URL.Path, "/")
|
streamingKeyComponents := strings.Split(c.URL.Path, "/")
|
||||||
streamingKey := streamingKeyComponents[len(streamingKeyComponents)-1]
|
streamingKey := streamingKeyComponents[len(streamingKeyComponents)-1]
|
||||||
if streamingKey != config.Config.VideoSettings.StreamingKey {
|
if streamingKey != data.GetStreamKey() {
|
||||||
log.Errorln("invalid streaming key; rejecting incoming stream")
|
log.Errorln("invalid streaming key; rejecting incoming stream")
|
||||||
nc.Close()
|
nc.Close()
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,17 +1,14 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io/ioutil"
|
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
|
||||||
"github.com/owncast/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/geoip"
|
"github.com/owncast/owncast/geoip"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
|
@ -20,17 +17,13 @@ import (
|
||||||
var l = sync.Mutex{}
|
var l = sync.Mutex{}
|
||||||
|
|
||||||
func setupStats() error {
|
func setupStats() error {
|
||||||
s, err := getSavedStats()
|
s := getSavedStats()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_stats = &s
|
_stats = &s
|
||||||
|
|
||||||
statsSaveTimer := time.NewTicker(1 * time.Minute)
|
statsSaveTimer := time.NewTicker(1 * time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
for range statsSaveTimer.C {
|
for range statsSaveTimer.C {
|
||||||
if err := saveStatsToFile(); err != nil {
|
if err := saveStats(); err != nil {
|
||||||
panic(err)
|
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.
|
// 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.
|
// So account for that with an artificial buffer of four segments.
|
||||||
timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds()
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,42 +97,27 @@ func GetClients() []models.Client {
|
||||||
return clients
|
return clients
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveStatsToFile() error {
|
func saveStats() error {
|
||||||
jsonData, err := json.Marshal(_stats)
|
if err := data.SetPeakOverallViewerCount(_stats.OverallMaxViewerCount); err != nil {
|
||||||
if err != nil {
|
log.Errorln("error saving viewer count", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
if err := data.SetPeakSessionViewerCount(_stats.SessionMaxViewerCount); err != nil {
|
||||||
f, err := os.Create(config.StatsFile)
|
log.Errorln("error saving viewer count", err)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
if err := data.SetLastDisconnectTime(_stats.LastConnectTime.Time); err != nil {
|
||||||
defer f.Close()
|
log.Errorln("error saving disconnect time", err)
|
||||||
|
|
||||||
if _, err := f.Write(jsonData); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSavedStats() (models.Stats, error) {
|
func getSavedStats() models.Stats {
|
||||||
|
savedLastDisconnectTime, savedLastDisconnectTimeErr := data.GetLastDisconnectTime()
|
||||||
result := models.Stats{
|
result := models.Stats{
|
||||||
Clients: make(map[string]models.Client),
|
Clients: make(map[string]models.Client),
|
||||||
}
|
SessionMaxViewerCount: data.GetPeakSessionViewerCount(),
|
||||||
|
OverallMaxViewerCount: data.GetPeakOverallViewerCount(),
|
||||||
if !utils.DoesFileExists(config.StatsFile) {
|
LastDisconnectTime: utils.NullTime{Time: savedLastDisconnectTime, Valid: savedLastDisconnectTimeErr == nil},
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the stats were saved > 5min ago then ignore the
|
// If the stats were saved > 5min ago then ignore the
|
||||||
|
@ -147,5 +126,5 @@ func getSavedStats() (models.Stats, error) {
|
||||||
result.SessionMaxViewerCount = 0
|
result.SessionMaxViewerCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, err
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -11,17 +12,27 @@ func GetStatus() models.Status {
|
||||||
return models.Status{}
|
return models.Status{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewerCount := 0
|
||||||
|
if IsStreamConnected() {
|
||||||
|
viewerCount = len(_stats.Clients)
|
||||||
|
}
|
||||||
|
|
||||||
return models.Status{
|
return models.Status{
|
||||||
Online: IsStreamConnected(),
|
Online: IsStreamConnected(),
|
||||||
ViewerCount: len(_stats.Clients),
|
ViewerCount: viewerCount,
|
||||||
OverallMaxViewerCount: _stats.OverallMaxViewerCount,
|
OverallMaxViewerCount: _stats.OverallMaxViewerCount,
|
||||||
SessionMaxViewerCount: _stats.SessionMaxViewerCount,
|
SessionMaxViewerCount: _stats.SessionMaxViewerCount,
|
||||||
LastDisconnectTime: _stats.LastDisconnectTime,
|
LastDisconnectTime: _stats.LastDisconnectTime,
|
||||||
LastConnectTime: _stats.LastConnectTime,
|
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.
|
// setBroadcaster will store the current inbound broadcasting details.
|
||||||
func setBroadcaster(broadcaster models.Broadcaster) {
|
func setBroadcaster(broadcaster models.Broadcaster) {
|
||||||
_broadcaster = &broadcaster
|
_broadcaster = &broadcaster
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/core/storageproviders"
|
"github.com/owncast/owncast/core/storageproviders"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupStorage() error {
|
func setupStorage() error {
|
||||||
handler.Storage = _storage
|
s3Config := data.GetS3Config()
|
||||||
|
|
||||||
if config.Config.S3.Enabled {
|
if s3Config.Enabled {
|
||||||
_storage = &storageproviders.S3Storage{}
|
_storage = &storageproviders.S3Storage{}
|
||||||
} else {
|
} else {
|
||||||
_storage = &storageproviders.LocalStorage{}
|
_storage = &storageproviders.LocalStorage{}
|
||||||
|
@ -18,5 +18,7 @@ func setupStorage() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handler.Storage = _storage
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/core/ffmpeg"
|
"github.com/owncast/owncast/core/transcoder"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ func (s *LocalStorage) Setup() error {
|
||||||
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
for range _onlineCleanupTicker.C {
|
for range _onlineCleanupTicker.C {
|
||||||
ffmpeg.CleanupOldContent(config.PublicHLSStoragePath)
|
transcoder.CleanupOldContent(config.PublicHLSStoragePath)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/core/playlist"
|
"github.com/owncast/owncast/core/playlist"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
@ -44,19 +45,20 @@ var _uploader *s3manager.Uploader
|
||||||
func (s *S3Storage) Setup() error {
|
func (s *S3Storage) Setup() error {
|
||||||
log.Trace("Setting up S3 for external storage of video...")
|
log.Trace("Setting up S3 for external storage of video...")
|
||||||
|
|
||||||
if config.Config.S3.ServingEndpoint != "" {
|
s3Config := data.GetS3Config()
|
||||||
s.host = config.Config.S3.ServingEndpoint
|
if s3Config.ServingEndpoint != "" {
|
||||||
|
s.host = s3Config.ServingEndpoint
|
||||||
} else {
|
} 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.s3Endpoint = s3Config.Endpoint
|
||||||
s.s3ServingEndpoint = config.Config.S3.ServingEndpoint
|
s.s3ServingEndpoint = s3Config.ServingEndpoint
|
||||||
s.s3Region = config.Config.S3.Region
|
s.s3Region = s3Config.Region
|
||||||
s.s3Bucket = config.Config.S3.Bucket
|
s.s3Bucket = s3Config.Bucket
|
||||||
s.s3AccessKey = config.Config.S3.AccessKey
|
s.s3AccessKey = s3Config.AccessKey
|
||||||
s.s3Secret = config.Config.S3.Secret
|
s.s3Secret = s3Config.Secret
|
||||||
s.s3ACL = config.Config.S3.ACL
|
s.s3ACL = s3Config.ACL
|
||||||
|
|
||||||
s.sess = s.connectAWS()
|
s.sess = s.connectAWS()
|
||||||
|
|
||||||
|
@ -81,7 +83,7 @@ func (s *S3Storage) SegmentWritten(localFilePath string) {
|
||||||
|
|
||||||
// Warn the user about long-running save operations
|
// Warn the user about long-running save operations
|
||||||
if averagePerformance != 0 {
|
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/")
|
log.Warnln("Possible slow uploads: average upload S3 save duration", averagePerformance, "s. troubleshoot this issue by visiting https://owncast.online/docs/troubleshooting/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,11 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"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/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/owncast/owncast/utils"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
"github.com/grafov/m3u8"
|
||||||
|
@ -23,12 +26,20 @@ var _offlineCleanupTimer *time.Timer
|
||||||
// While a stream takes place cleanup old HLS content every N min.
|
// While a stream takes place cleanup old HLS content every N min.
|
||||||
var _onlineCleanupTicker *time.Ticker
|
var _onlineCleanupTicker *time.Ticker
|
||||||
|
|
||||||
|
var _currentBroadcast *models.CurrentBroadcast
|
||||||
|
|
||||||
// setStreamAsConnected sets the stream as connected.
|
// setStreamAsConnected sets the stream as connected.
|
||||||
func setStreamAsConnected() {
|
func setStreamAsConnected() {
|
||||||
|
|
||||||
_stats.StreamConnected = true
|
_stats.StreamConnected = true
|
||||||
_stats.LastConnectTime = utils.NullTime{Time: time.Now(), Valid: true}
|
_stats.LastConnectTime = utils.NullTime{Time: time.Now(), Valid: true}
|
||||||
_stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: false}
|
_stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: false}
|
||||||
|
|
||||||
|
_currentBroadcast = &models.CurrentBroadcast{
|
||||||
|
LatencyLevel: data.GetStreamLatencyLevel(),
|
||||||
|
OutputSettings: data.GetStreamOutputVariants(),
|
||||||
|
}
|
||||||
|
|
||||||
StopOfflineCleanupTimer()
|
StopOfflineCleanupTimer()
|
||||||
startOnlineCleanupTimer()
|
startOnlineCleanupTimer()
|
||||||
|
|
||||||
|
@ -37,23 +48,28 @@ func setStreamAsConnected() {
|
||||||
}
|
}
|
||||||
|
|
||||||
segmentPath := config.PublicHLSStoragePath
|
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
|
segmentPath = config.PrivateHLSStoragePath
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_transcoder = ffmpeg.NewTranscoder()
|
_transcoder = transcoder.NewTranscoder()
|
||||||
if _broadcaster != nil {
|
|
||||||
_transcoder.SetVideoOnly(_broadcaster.StreamDetails.VideoOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
_transcoder.TranscoderCompleted = func(error) {
|
_transcoder.TranscoderCompleted = func(error) {
|
||||||
SetStreamAsDisconnected()
|
SetStreamAsDisconnected()
|
||||||
|
_transcoder = nil
|
||||||
|
_currentBroadcast = nil
|
||||||
}
|
}
|
||||||
_transcoder.Start()
|
_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.
|
// SetStreamAsDisconnected sets the stream as disconnected.
|
||||||
|
@ -65,14 +81,14 @@ func SetStreamAsDisconnected() {
|
||||||
offlineFilename := "offline.ts"
|
offlineFilename := "offline.ts"
|
||||||
offlineFilePath := "static/" + offlineFilename
|
offlineFilePath := "static/" + offlineFilename
|
||||||
|
|
||||||
ffmpeg.StopThumbnailGenerator()
|
transcoder.StopThumbnailGenerator()
|
||||||
rtmp.Disconnect()
|
rtmp.Disconnect()
|
||||||
|
|
||||||
if _yp != nil {
|
if _yp != nil {
|
||||||
_yp.Stop()
|
_yp.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
for index := range config.Config.GetVideoStreamQualities() {
|
for index := range data.GetStreamOutputVariants() {
|
||||||
playlistFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/stream.m3u8"), index)
|
playlistFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/stream.m3u8"), index)
|
||||||
segmentFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/%s"), index, offlineFilename)
|
segmentFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/%s"), index, offlineFilename)
|
||||||
|
|
||||||
|
@ -97,7 +113,7 @@ func SetStreamAsDisconnected() {
|
||||||
}
|
}
|
||||||
|
|
||||||
variantPlaylist := playlist.(*m3u8.MediaPlaylist)
|
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)]
|
variantPlaylist.Segments = variantPlaylist.Segments[:len(variantPlaylist.Segments)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,6 +160,8 @@ func SetStreamAsDisconnected() {
|
||||||
|
|
||||||
StartOfflineCleanupTimer()
|
StartOfflineCleanupTimer()
|
||||||
stopOnlineCleanupTimer()
|
stopOnlineCleanupTimer()
|
||||||
|
|
||||||
|
go webhooks.SendStreamStatusEvent(models.StreamStopped)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected.
|
// StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected.
|
||||||
|
@ -153,6 +171,7 @@ func StartOfflineCleanupTimer() {
|
||||||
for range _offlineCleanupTimer.C {
|
for range _offlineCleanupTimer.C {
|
||||||
// Reset the session count since the session is over
|
// Reset the session count since the session is over
|
||||||
_stats.SessionMaxViewerCount = 0
|
_stats.SessionMaxViewerCount = 0
|
||||||
|
// Set video to offline state
|
||||||
resetDirectories()
|
resetDirectories()
|
||||||
transitionToOfflineVideoStreamContent()
|
transitionToOfflineVideoStreamContent()
|
||||||
}
|
}
|
||||||
|
@ -170,7 +189,7 @@ func startOnlineCleanupTimer() {
|
||||||
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
for range _onlineCleanupTicker.C {
|
for range _onlineCleanupTicker.C {
|
||||||
ffmpeg.CleanupOldContent(config.PrivateHLSStoragePath)
|
transcoder.CleanupOldContent(config.PrivateHLSStoragePath)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -34,15 +34,22 @@ func (s *FileWriterReceiverService) SetupFileWriterReceiverService(callbacks Fil
|
||||||
httpServer := http.NewServeMux()
|
httpServer := http.NewServeMux()
|
||||||
httpServer.HandleFunc("/", s.uploadHandler)
|
httpServer.HandleFunc("/", s.uploadHandler)
|
||||||
|
|
||||||
localListenerAddress := "127.0.0.1:" + strconv.Itoa(config.Config.GetPublicWebServerPort()+1)
|
localListenerAddress := "127.0.0.1:0"
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := http.ListenAndServe(localListenerAddress, httpServer); err != nil {
|
listener, err := net.Listen("tcp", localListenerAddress)
|
||||||
log.Fatal(err)
|
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) {
|
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) {
|
func returnError(err error, w http.ResponseWriter) {
|
||||||
log.Errorln(err)
|
log.Debugln(err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError)+": "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError)+": "+err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
@ -7,14 +7,14 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"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
|
// CleanupOldContent will delete old files from the private dir that are no longer being referenced
|
||||||
// in the stream.
|
// in the stream.
|
||||||
func CleanupOldContent(baseDirectory string) {
|
func CleanupOldContent(baseDirectory string) {
|
||||||
// Determine how many files we should keep on disk
|
// Determine how many files we should keep on disk
|
||||||
maxNumber := config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist()
|
maxNumber := int(data.GetStreamLatencyLevel().SegmentCount)
|
||||||
buffer := 10
|
buffer := 10
|
||||||
|
|
||||||
files, err := getAllFilesRecursive(baseDirectory)
|
files, err := getAllFilesRecursive(baseDirectory)
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
@ -11,6 +11,8 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _timer *time.Ticker
|
var _timer *time.Ticker
|
||||||
|
@ -78,9 +80,10 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
mostRecentFile := path.Join(framePath, names[0])
|
mostRecentFile := path.Join(framePath, names[0])
|
||||||
|
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
|
||||||
|
|
||||||
thumbnailCmdFlags := []string{
|
thumbnailCmdFlags := []string{
|
||||||
config.Config.GetFFMpegPath(),
|
ffmpegPath,
|
||||||
"-y", // Overwrite file
|
"-y", // Overwrite file
|
||||||
"-threads 1", // Low priority processing
|
"-threads 1", // Low priority processing
|
||||||
"-t 1", // Pull from frame 1
|
"-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 YP support is enabled also create an animated GIF preview
|
||||||
if config.Config.YP.Enabled {
|
if data.GetDirectoryEnabled() {
|
||||||
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,9 +107,11 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeAnimatedGifPreview(sourceFile string, outputFile string) {
|
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/
|
// Filter is pulled from https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/
|
||||||
animatedGifFlags := []string{
|
animatedGifFlags := []string{
|
||||||
config.Config.GetFFMpegPath(),
|
ffmpegPath,
|
||||||
"-y", // Overwrite file
|
"-y", // Overwrite file
|
||||||
"-threads 1", // Low priority processing
|
"-threads 1", // Low priority processing
|
||||||
"-i", sourceFile, // Input
|
"-i", sourceFile, // Input
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -10,6 +10,8 @@ import (
|
||||||
"github.com/teris-io/shortid"
|
"github.com/teris-io/shortid"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,14 +23,15 @@ type Transcoder struct {
|
||||||
segmentOutputPath string
|
segmentOutputPath string
|
||||||
playlistOutputPath string
|
playlistOutputPath string
|
||||||
variants []HLSVariant
|
variants []HLSVariant
|
||||||
hlsPlaylistLength int
|
|
||||||
segmentLengthSeconds int
|
|
||||||
appendToStream bool
|
appendToStream bool
|
||||||
ffmpegPath string
|
ffmpegPath string
|
||||||
segmentIdentifier string
|
segmentIdentifier string
|
||||||
internalListenerPort int
|
internalListenerPort string
|
||||||
videoOnly bool // If true ignore any audio, if any
|
|
||||||
TranscoderCompleted func(error)
|
currentStreamOutputSettings []models.StreamOutputVariant
|
||||||
|
currentLatencyLevel models.LatencyLevel
|
||||||
|
|
||||||
|
TranscoderCompleted func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLSVariant is a combination of settings that results in a single HLS stream.
|
// HLSVariant is a combination of settings that results in a single HLS stream.
|
||||||
|
@ -81,11 +84,8 @@ func (t *Transcoder) Start() {
|
||||||
command := t.getString()
|
command := t.getString()
|
||||||
|
|
||||||
log.Tracef("Video transcoder started with %d stream variants.", len(t.variants))
|
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)
|
log.Println(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,16 +103,8 @@ func (t *Transcoder) Start() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Transcoder) getString() string {
|
func (t *Transcoder) getString() string {
|
||||||
var port int
|
var port = t.internalListenerPort
|
||||||
if config.Config != nil {
|
localListenerAddress := "http://127.0.0.1:" + port
|
||||||
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)
|
|
||||||
|
|
||||||
hlsOptionFlags := []string{}
|
hlsOptionFlags := []string{}
|
||||||
|
|
||||||
|
@ -139,8 +131,8 @@ func (t *Transcoder) getString() string {
|
||||||
// HLS Output
|
// HLS Output
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
|
|
||||||
"-hls_time", strconv.Itoa(t.segmentLengthSeconds), // Length of each segment
|
"-hls_time", strconv.Itoa(t.currentLatencyLevel.SecondsPerSegment), // Length of each segment
|
||||||
"-hls_list_size", strconv.Itoa(t.hlsPlaylistLength), // Max # in variant playlist
|
"-hls_list_size", strconv.Itoa(t.currentLatencyLevel.SegmentCount), // Max # in variant playlist
|
||||||
"-hls_delete_threshold", "10", // Start deleting files after hls_list_size + 10
|
"-hls_delete_threshold", "10", // Start deleting files after hls_list_size + 10
|
||||||
hlsOptionsString,
|
hlsOptionsString,
|
||||||
|
|
||||||
|
@ -165,7 +157,7 @@ func (t *Transcoder) getString() string {
|
||||||
return strings.Join(ffmpegFlags, " ")
|
return strings.Join(ffmpegFlags, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getVariantFromConfigQuality(quality config.StreamQuality, index int) HLSVariant {
|
func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int) HLSVariant {
|
||||||
variant := HLSVariant{}
|
variant := HLSVariant{}
|
||||||
variant.index = index
|
variant.index = index
|
||||||
variant.isAudioPassthrough = quality.IsAudioPassthrough
|
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.
|
// NewTranscoder will return a new Transcoder, populated by the config.
|
||||||
func NewTranscoder() *Transcoder {
|
func NewTranscoder() *Transcoder {
|
||||||
|
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
|
||||||
|
|
||||||
transcoder := new(Transcoder)
|
transcoder := new(Transcoder)
|
||||||
transcoder.ffmpegPath = config.Config.GetFFMpegPath()
|
transcoder.ffmpegPath = ffmpegPath
|
||||||
transcoder.hlsPlaylistLength = config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist()
|
transcoder.internalListenerPort = config.InternalHLSListenerPort
|
||||||
|
|
||||||
|
transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants()
|
||||||
|
transcoder.currentLatencyLevel = data.GetStreamLatencyLevel()
|
||||||
|
|
||||||
var outputPath string
|
var outputPath string
|
||||||
if config.Config.S3.Enabled {
|
if data.GetS3Config().Enabled {
|
||||||
// Segments are not available via the local HTTP server
|
// Segments are not available via the local HTTP server
|
||||||
outputPath = config.PrivateHLSStoragePath
|
outputPath = config.PrivateHLSStoragePath
|
||||||
} else {
|
} else {
|
||||||
|
@ -220,10 +217,8 @@ func NewTranscoder() *Transcoder {
|
||||||
transcoder.playlistOutputPath = config.PublicHLSStoragePath
|
transcoder.playlistOutputPath = config.PublicHLSStoragePath
|
||||||
|
|
||||||
transcoder.input = utils.GetTemporaryPipePath()
|
transcoder.input = utils.GetTemporaryPipePath()
|
||||||
transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength()
|
|
||||||
|
|
||||||
qualities := config.Config.GetVideoStreamQualities()
|
for index, quality := range transcoder.currentStreamOutputSettings {
|
||||||
for index, quality := range qualities {
|
|
||||||
variant := getVariantFromConfigQuality(quality, index)
|
variant := getVariantFromConfigQuality(quality, index)
|
||||||
transcoder.AddVariant(variant)
|
transcoder.AddVariant(variant)
|
||||||
}
|
}
|
||||||
|
@ -257,12 +252,7 @@ func (t *Transcoder) getVariantsString() string {
|
||||||
for _, variant := range t.variants {
|
for _, variant := range t.variants {
|
||||||
variantsCommandFlags = variantsCommandFlags + " " + variant.getVariantString(t)
|
variantsCommandFlags = variantsCommandFlags + " " + variant.getVariantString(t)
|
||||||
singleVariantMap := ""
|
singleVariantMap := ""
|
||||||
if t.videoOnly {
|
singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index)
|
||||||
singleVariantMap = fmt.Sprintf("v:%d ", variant.index)
|
|
||||||
} else {
|
|
||||||
singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
variantsStreamMaps = variantsStreamMaps + singleVariantMap
|
variantsStreamMaps = variantsStreamMaps + singleVariantMap
|
||||||
}
|
}
|
||||||
variantsCommandFlags = variantsCommandFlags + " " + variantsStreamMaps + "\""
|
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.
|
// -1 to work around segments being generated slightly larger than expected.
|
||||||
// https://trac.ffmpeg.org/ticket/6915?replyto=58#comment:57
|
// 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
|
// For limiting the output bitrate
|
||||||
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||||
|
@ -374,16 +364,6 @@ func (t *Transcoder) SetOutputPath(output string) {
|
||||||
t.segmentOutputPath = output
|
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.
|
// SetAppendToStream enables appending to the HLS stream instead of overwriting.
|
||||||
func (t *Transcoder) SetAppendToStream(append bool) {
|
func (t *Transcoder) SetAppendToStream(append bool) {
|
||||||
t.appendToStream = append
|
t.appendToStream = append
|
||||||
|
@ -394,11 +374,6 @@ func (t *Transcoder) SetIdentifier(output string) {
|
||||||
t.segmentIdentifier = output
|
t.segmentIdentifier = output
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Transcoder) SetInternalHTTPPort(port int) {
|
func (t *Transcoder) SetInternalHTTPPort(port string) {
|
||||||
t.internalListenerPort = port
|
t.internalListenerPort = port
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetVideoOnly will ignore any audio streams, if any.
|
|
||||||
func (t *Transcoder) SetVideoOnly(videoOnly bool) {
|
|
||||||
t.videoOnly = videoOnly
|
|
||||||
}
|
|
|
@ -1,18 +1,21 @@
|
||||||
package ffmpeg
|
package transcoder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFFmpegCommand(t *testing.T) {
|
func TestFFmpegCommand(t *testing.T) {
|
||||||
|
latencyLevel := models.GetLatencyLevel(3)
|
||||||
|
|
||||||
transcoder := new(Transcoder)
|
transcoder := new(Transcoder)
|
||||||
transcoder.ffmpegPath = "/fake/path/ffmpeg"
|
transcoder.ffmpegPath = "/fake/path/ffmpeg"
|
||||||
transcoder.SetSegmentLength(4)
|
|
||||||
transcoder.SetInput("fakecontent.flv")
|
transcoder.SetInput("fakecontent.flv")
|
||||||
transcoder.SetOutputPath("fakeOutput")
|
transcoder.SetOutputPath("fakeOutput")
|
||||||
transcoder.SetHLSPlaylistLength(10)
|
|
||||||
transcoder.SetIdentifier("jdofFGg")
|
transcoder.SetIdentifier("jdofFGg")
|
||||||
transcoder.SetInternalHTTPPort(8123)
|
transcoder.SetInternalHTTPPort("8123")
|
||||||
|
transcoder.currentLatencyLevel = latencyLevel
|
||||||
|
|
||||||
variant := HLSVariant{}
|
variant := HLSVariant{}
|
||||||
variant.videoBitrate = 1200
|
variant.videoBitrate = 1200
|
||||||
|
@ -35,7 +38,7 @@ func TestFFmpegCommand(t *testing.T) {
|
||||||
|
|
||||||
cmd := transcoder.getString()
|
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 {
|
if cmd != expected {
|
||||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
39
core/webhooks/chat.go
Normal file
39
core/webhooks/chat.go
Normal file
|
@ -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)
|
||||||
|
}
|
7
core/webhooks/stream.go
Normal file
7
core/webhooks/stream.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import "github.com/owncast/owncast/models"
|
||||||
|
|
||||||
|
func SendStreamStatusEvent(eventType models.EventType) {
|
||||||
|
SendEventToWebhooks(WebhookEvent{Type: eventType})
|
||||||
|
}
|
67
core/webhooks/webhooks.go
Normal file
67
core/webhooks/webhooks.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,4 +0,0 @@
|
||||||
# Stream description and content can go here.
|
|
||||||
|
|
||||||
1. Edit `content.md` in markdown.
|
|
||||||
1. And it'll go here.
|
|
File diff suppressed because one or more lines are too long
|
@ -1,50 +0,0 @@
|
||||||
# See https://owncast.online/docs/configuration/ for more details
|
|
||||||
|
|
||||||
instanceDetails:
|
|
||||||
name: Owncast
|
|
||||||
title: Owncast
|
|
||||||
summary: "This is brief summary of whom you are or what your stream is. You can read more about it at owncast.online. You can edit this description in your config file."
|
|
||||||
|
|
||||||
logo: /img/logo.svg
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- music
|
|
||||||
- software
|
|
||||||
- streaming
|
|
||||||
|
|
||||||
nsfw: false
|
|
||||||
|
|
||||||
# https://owncast.online/docs/configuration/#external-links
|
|
||||||
# for full list of supported social links. All optional.
|
|
||||||
socialHandles:
|
|
||||||
- platform: github
|
|
||||||
url: http://github.com/owncast/owncast
|
|
||||||
- platform: mastodon
|
|
||||||
url: http://mastodon.something/owncast
|
|
||||||
|
|
||||||
videoSettings:
|
|
||||||
# Change this value and keep it secure. Treat it like a password to your live stream.
|
|
||||||
streamingKey: abc123
|
|
||||||
|
|
||||||
streamQualities:
|
|
||||||
- low:
|
|
||||||
videoBitrate: 800
|
|
||||||
scaledWidth: 640
|
|
||||||
encoderPreset: veryfast
|
|
||||||
|
|
||||||
- medium:
|
|
||||||
videoBitrate: 1200
|
|
||||||
encoderPreset: veryfast
|
|
||||||
|
|
||||||
- high:
|
|
||||||
videoBitrate: 1600
|
|
||||||
encoderPreset: veryfast
|
|
||||||
|
|
||||||
# Set to true if you don't want the service checking for future releases.
|
|
||||||
disableUpgradeChecks: false
|
|
||||||
|
|
||||||
# Off by default. You can optionally list yourself in the Owncast directory.
|
|
||||||
# Make sure your instanceURL is the public URL to your Owncast instance.
|
|
||||||
yp:
|
|
||||||
enabled: true
|
|
||||||
instanceURL: https://stream.myserver.org
|
|
|
@ -8,12 +8,12 @@ import (
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/oschwald/geoip2-golang"
|
"github.com/oschwald/geoip2-golang"
|
||||||
"github.com/owncast/owncast/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _geoIPCache = map[string]GeoDetails{}
|
var _geoIPCache = map[string]GeoDetails{}
|
||||||
var _enabled = true // Try to use GeoIP support it by default.
|
var _enabled = true // Try to use GeoIP support it by default.
|
||||||
|
var geoIPDatabasePath = "data/GeoLite2-City.mmdb"
|
||||||
|
|
||||||
// GeoDetails stores details about a location.
|
// GeoDetails stores details about a location.
|
||||||
type GeoDetails struct {
|
type GeoDetails struct {
|
||||||
|
@ -53,7 +53,7 @@ func FetchGeoForIP(ip string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
db, err := geoip2.Open(config.GeoIPDatabasePath)
|
db, err := geoip2.Open(geoIPDatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Traceln("GeoIP support is disabled. visit http://owncast.online/docs/geoip to learn how to enable.", err)
|
log.Traceln("GeoIP support is disabled. visit http://owncast.online/docs/geoip to learn how to enable.", err)
|
||||||
_enabled = false
|
_enabled = false
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -16,6 +16,7 @@ require (
|
||||||
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||||
github.com/oschwald/geoip2-golang v1.4.0
|
github.com/oschwald/geoip2-golang v1.4.0
|
||||||
|
github.com/schollz/sqlite3dump v1.2.4
|
||||||
github.com/shirou/gopsutil v2.20.9+incompatible
|
github.com/shirou/gopsutil v2.20.9+incompatible
|
||||||
github.com/sirupsen/logrus v1.8.0
|
github.com/sirupsen/logrus v1.8.0
|
||||||
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
|
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
|
||||||
|
|
3
go.sum
3
go.sum
|
@ -31,6 +31,7 @@ github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g=
|
||||||
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
|
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
|
||||||
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
||||||
|
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||||
|
@ -51,6 +52,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/schollz/sqlite3dump v1.2.4 h1:b3dgcKLsHZhF6OsB2EK+e/oA77vh4P/45TAh2R35OFI=
|
||||||
|
github.com/schollz/sqlite3dump v1.2.4/go.mod h1:SEajZA5udi52Taht5xQYlFfHwr7AIrqPrLDrAoFv17o=
|
||||||
github.com/shirou/gopsutil v2.20.9+incompatible h1:msXs2frUV+O/JLva9EDLpuJ84PrFsdCTCQex8PUdtkQ=
|
github.com/shirou/gopsutil v2.20.9+incompatible h1:msXs2frUV+O/JLva9EDLpuJ84PrFsdCTCQex8PUdtkQ=
|
||||||
github.com/shirou/gopsutil v2.20.9+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
github.com/shirou/gopsutil v2.20.9+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||||
github.com/sirupsen/logrus v1.8.0 h1:nfhvjKcUMhBMVqbKHJlk5RPrrfYr/NMo3692g0dwfWU=
|
github.com/sirupsen/logrus v1.8.0 h1:nfhvjKcUMhBMVqbKHJlk5RPrrfYr/NMo3692g0dwfWU=
|
||||||
|
|
86
main.go
86
main.go
|
@ -2,7 +2,8 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/markbates/pkger"
|
"github.com/markbates/pkger"
|
||||||
"github.com/owncast/owncast/logging"
|
"github.com/owncast/owncast/logging"
|
||||||
|
@ -14,32 +15,61 @@ import (
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/metrics"
|
"github.com/owncast/owncast/metrics"
|
||||||
"github.com/owncast/owncast/router"
|
"github.com/owncast/owncast/router"
|
||||||
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// the following are injected at build-time.
|
// the following are injected at build-time.
|
||||||
var (
|
var (
|
||||||
// GitCommit is the commit which this version of owncast is running.
|
// GitCommit is the commit which this version of owncast is running.
|
||||||
GitCommit = "unknown"
|
GitCommit = ""
|
||||||
// BuildVersion is the version.
|
// BuildVersion is the version.
|
||||||
BuildVersion = "0.0.0"
|
BuildVersion = config.StaticVersionNumber
|
||||||
// BuildType is the type of build.
|
// BuildPlatform is the type of build.
|
||||||
BuildType = "localdev"
|
BuildPlatform = "dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configureLogging()
|
configureLogging()
|
||||||
|
|
||||||
log.Infoln(getReleaseString())
|
|
||||||
// Enable bundling of admin assets
|
// Enable bundling of admin assets
|
||||||
_ = pkger.Include("/admin")
|
_ = pkger.Include("/admin")
|
||||||
|
|
||||||
configFile := flag.String("configFile", "config.yaml", "Config File full path. Defaults to current folder")
|
configFile := flag.String("configFile", "config.yaml", "Config file path to migrate to the new database")
|
||||||
dbFile := flag.String("database", "", "Path to the database file.")
|
dbFile := flag.String("database", "", "Path to the database file.")
|
||||||
enableDebugOptions := flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
|
enableDebugOptions := flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
|
||||||
enableVerboseLogging := flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
|
enableVerboseLogging := flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
|
||||||
|
restoreDatabaseFile := flag.String("restoreDatabase", "", "Restore an Owncast database backup")
|
||||||
|
newStreamKey := flag.String("streamkey", "", "Set your stream key/admin password")
|
||||||
|
webServerPortOverride := flag.String("webserverport", "", "Force the web server to listen on a specific port")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
config.ConfigFilePath = *configFile
|
||||||
|
config.VersionNumber = BuildVersion
|
||||||
|
if GitCommit != "" {
|
||||||
|
config.GitCommit = GitCommit
|
||||||
|
} else {
|
||||||
|
config.GitCommit = time.Now().Format("20060102")
|
||||||
|
}
|
||||||
|
config.BuildPlatform = BuildPlatform
|
||||||
|
|
||||||
|
log.Infoln(config.GetReleaseString())
|
||||||
|
|
||||||
|
// Allows a user to restore a specific database backup
|
||||||
|
if *restoreDatabaseFile != "" {
|
||||||
|
databaseFile := config.DatabaseFilePath
|
||||||
|
if *dbFile != "" {
|
||||||
|
databaseFile = *dbFile
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := utils.Restore(*restoreDatabaseFile, databaseFile); err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Database has been restored. Restart Owncast.")
|
||||||
|
log.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
if *enableDebugOptions {
|
if *enableDebugOptions {
|
||||||
logrus.SetReportCaller(true)
|
logrus.SetReportCaller(true)
|
||||||
}
|
}
|
||||||
|
@ -50,41 +80,51 @@ func main() {
|
||||||
log.SetLevel(log.InfoLevel)
|
log.SetLevel(log.InfoLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := config.Load(*configFile, getReleaseString(), getVersionNumber()); err != nil {
|
config.EnableDebugFeatures = *enableDebugOptions
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
config.Config.EnableDebugFeatures = *enableDebugOptions
|
|
||||||
|
|
||||||
if *dbFile != "" {
|
if *dbFile != "" {
|
||||||
config.Config.DatabaseFilePath = *dbFile
|
config.DatabaseFilePath = *dbFile
|
||||||
} else if config.Config.DatabaseFilePath == "" {
|
|
||||||
config.Config.DatabaseFilePath = config.Config.GetDataFilePath()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go metrics.Start()
|
go metrics.Start()
|
||||||
|
|
||||||
err := data.SetupPersistence()
|
err := data.SetupPersistence(config.DatabaseFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("failed to open database", err)
|
log.Fatalln("failed to open database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *newStreamKey != "" {
|
||||||
|
if err := data.SetStreamKey(*newStreamKey); err != nil {
|
||||||
|
log.Errorln("Error setting your stream key.", err)
|
||||||
|
} else {
|
||||||
|
log.Infoln("Stream key changed to", *newStreamKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
// starts the core
|
// starts the core
|
||||||
if err := core.Start(); err != nil {
|
if err := core.Start(); err != nil {
|
||||||
log.Fatalln("failed to start the core package", err)
|
log.Fatalln("failed to start the core package", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the web server port
|
||||||
|
if *webServerPortOverride != "" {
|
||||||
|
portNumber, err := strconv.Atoi(*webServerPortOverride)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnln(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.WebServerPort = portNumber
|
||||||
|
} else {
|
||||||
|
config.WebServerPort = data.GetHTTPPortNumber()
|
||||||
|
}
|
||||||
|
|
||||||
if err := router.Start(); err != nil {
|
if err := router.Start(); err != nil {
|
||||||
log.Fatalln("failed to start/run the router", err)
|
log.Fatalln("failed to start/run the router", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// getReleaseString gets the version string.
|
|
||||||
func getReleaseString() string {
|
|
||||||
return fmt.Sprintf("Owncast v%s-%s (%s)", BuildVersion, BuildType, GitCommit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getVersionNumber() string {
|
|
||||||
return BuildVersion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureLogging() {
|
func configureLogging() {
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxCPUAlertingThresholdPCT = 80
|
const maxCPUAlertingThresholdPCT = 85
|
||||||
const maxRAMAlertingThresholdPCT = 80
|
const maxRAMAlertingThresholdPCT = 85
|
||||||
const maxDiskAlertingThresholdPCT = 90
|
const maxDiskAlertingThresholdPCT = 90
|
||||||
|
|
||||||
const alertingError = "The %s utilization of %d%% can cause issues with video generation and delivery. Please visit the documentation at http://owncast.online/docs/troubleshooting/ to help troubleshoot this issue."
|
var inCpuAlertingState = false
|
||||||
|
var inRamAlertingState = false
|
||||||
|
var inDiskAlertingState = false
|
||||||
|
|
||||||
|
var errorResetDuration = time.Minute * 5
|
||||||
|
|
||||||
|
const alertingError = "The %s utilization of %d%% could cause problems with video generation and delivery. Visit the documentation at http://owncast.online/docs/troubleshooting/ if you are experiencing issues."
|
||||||
|
|
||||||
func handleAlerting() {
|
func handleAlerting() {
|
||||||
handleCPUAlerting()
|
handleCPUAlerting()
|
||||||
|
@ -22,8 +30,15 @@ func handleCPUAlerting() {
|
||||||
}
|
}
|
||||||
|
|
||||||
avg := recentAverage(Metrics.CPUUtilizations)
|
avg := recentAverage(Metrics.CPUUtilizations)
|
||||||
if avg > maxCPUAlertingThresholdPCT {
|
if avg > maxCPUAlertingThresholdPCT && !inCpuAlertingState {
|
||||||
log.Warnf(alertingError, "CPU", maxCPUAlertingThresholdPCT)
|
log.Warnf(alertingError, "CPU", maxCPUAlertingThresholdPCT)
|
||||||
|
inCpuAlertingState = true
|
||||||
|
|
||||||
|
resetTimer := time.NewTimer(errorResetDuration)
|
||||||
|
go func() {
|
||||||
|
<-resetTimer.C
|
||||||
|
inCpuAlertingState = false
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,8 +48,15 @@ func handleRAMAlerting() {
|
||||||
}
|
}
|
||||||
|
|
||||||
avg := recentAverage(Metrics.RAMUtilizations)
|
avg := recentAverage(Metrics.RAMUtilizations)
|
||||||
if avg > maxRAMAlertingThresholdPCT {
|
if avg > maxRAMAlertingThresholdPCT && !inRamAlertingState {
|
||||||
log.Warnf(alertingError, "memory", maxRAMAlertingThresholdPCT)
|
log.Warnf(alertingError, "memory", maxRAMAlertingThresholdPCT)
|
||||||
|
inRamAlertingState = true
|
||||||
|
|
||||||
|
resetTimer := time.NewTimer(errorResetDuration)
|
||||||
|
go func() {
|
||||||
|
<-resetTimer.C
|
||||||
|
inRamAlertingState = false
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,8 +67,15 @@ func handleDiskAlerting() {
|
||||||
|
|
||||||
avg := recentAverage(Metrics.DiskUtilizations)
|
avg := recentAverage(Metrics.DiskUtilizations)
|
||||||
|
|
||||||
if avg > maxDiskAlertingThresholdPCT {
|
if avg > maxDiskAlertingThresholdPCT && !inDiskAlertingState {
|
||||||
log.Warnf(alertingError, "disk", maxRAMAlertingThresholdPCT)
|
log.Warnf(alertingError, "disk", maxRAMAlertingThresholdPCT)
|
||||||
|
inDiskAlertingState = true
|
||||||
|
|
||||||
|
resetTimer := time.NewTimer(errorResetDuration)
|
||||||
|
go func() {
|
||||||
|
<-resetTimer.C
|
||||||
|
inDiskAlertingState = false
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
53
models/accessToken.go
Normal file
53
models/accessToken.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ScopeCanSendUserMessages will allow sending chat messages as users.
|
||||||
|
ScopeCanSendUserMessages = "CAN_SEND_MESSAGES"
|
||||||
|
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
|
||||||
|
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
|
||||||
|
// ScopeHasAdminAccess will allow performing administrative actions on the server.
|
||||||
|
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// For a scope to be seen as "valid" it must live in this slice.
|
||||||
|
var validAccessTokenScopes = []string{
|
||||||
|
ScopeCanSendUserMessages,
|
||||||
|
ScopeCanSendSystemMessages,
|
||||||
|
ScopeHasAdminAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessToken gives access to 3rd party code to access specific Owncast APIs.
|
||||||
|
type AccessToken struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
LastUsed *time.Time `json:"lastUsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasValidScopes will verify that all the scopes provided are valid.
|
||||||
|
// This is not a efficient method.
|
||||||
|
func HasValidScopes(scopes []string) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if !findItemInSlice(validAccessTokenScopes, scope) {
|
||||||
|
log.Errorln("Invalid scope", scope)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findItemInSlice(slice []string, value string) bool {
|
||||||
|
for _, item := range slice {
|
||||||
|
if item == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ type Broadcaster struct {
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundStreamDetails represents an inbound broadcast stream.
|
||||||
type InboundStreamDetails struct {
|
type InboundStreamDetails struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
|
|
12
models/chatActionEvent.go
Normal file
12
models/chatActionEvent.go
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ChatActionEvent represents a generic action that took place by a chat user.
|
||||||
|
type ChatActionEvent struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"github.com/teris-io/shortid"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
@ -18,8 +19,9 @@ type ChatEvent struct {
|
||||||
|
|
||||||
Author string `json:"author,omitempty"`
|
Author string `json:"author,omitempty"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
|
RawBody string `json:"-"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MessageType string `json:"type"`
|
MessageType EventType `json:"type"`
|
||||||
Visible bool `json:"visible"`
|
Visible bool `json:"visible"`
|
||||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||||
}
|
}
|
||||||
|
@ -29,15 +31,24 @@ func (m ChatEvent) Valid() bool {
|
||||||
return m.Author != "" && m.Body != "" && m.ID != ""
|
return m.Author != "" && m.Body != "" && m.ID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaults will set default values on a chat event object.
|
||||||
|
func (m *ChatEvent) SetDefaults() {
|
||||||
|
id, _ := shortid.Generate()
|
||||||
|
m.ID = id
|
||||||
|
m.Timestamp = time.Now()
|
||||||
|
m.Visible = true
|
||||||
|
}
|
||||||
|
|
||||||
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
|
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
|
||||||
// the message into something safe and renderable for clients.
|
// the message into something safe and renderable for clients.
|
||||||
func (m *ChatEvent) RenderAndSanitizeMessageBody() {
|
func (m *ChatEvent) RenderAndSanitizeMessageBody() {
|
||||||
raw := m.Body
|
m.RawBody = m.Body
|
||||||
|
|
||||||
// Set the new, sanitized and rendered message body
|
// Set the new, sanitized and rendered message body
|
||||||
m.Body = RenderAndSanitize(raw)
|
m.Body = RenderAndSanitize(m.RawBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty will return if this message's contents is empty.
|
||||||
func (m *ChatEvent) Empty() bool {
|
func (m *ChatEvent) Empty() bool {
|
||||||
return m.Body == ""
|
return m.Body == ""
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,12 @@ import (
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ConnectedClientsResponse is the response of the currently connected chat clients.
|
||||||
type ConnectedClientsResponse struct {
|
type ConnectedClientsResponse struct {
|
||||||
Clients []Client `json:"clients"`
|
Clients []Client `json:"clients"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client represents a single chat client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ConnectedAt time.Time `json:"connectedAt"`
|
ConnectedAt time.Time `json:"connectedAt"`
|
||||||
LastSeen time.Time `json:"-"`
|
LastSeen time.Time `json:"-"`
|
||||||
|
@ -23,6 +25,7 @@ type Client struct {
|
||||||
Geo *geoip.GeoDetails `json:"geo"`
|
Geo *geoip.GeoDetails `json:"geo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateClientFromRequest will return a chat client from a http request.
|
||||||
func GenerateClientFromRequest(req *http.Request) Client {
|
func GenerateClientFromRequest(req *http.Request) Client {
|
||||||
return Client{
|
return Client{
|
||||||
ConnectedAt: time.Now(),
|
ConnectedAt: time.Now(),
|
||||||
|
|
7
models/currentBroadcast.go
Normal file
7
models/currentBroadcast.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
// CurrentBroadcast represents the configuration associated with the currently active stream.
|
||||||
|
type CurrentBroadcast struct {
|
||||||
|
OutputSettings []StreamOutputVariant `json:"outputSettings"`
|
||||||
|
LatencyLevel LatencyLevel `json:"latencyLevel"`
|
||||||
|
}
|
27
models/eventType.go
Normal file
27
models/eventType.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
// EventType is the type of a websocket event.
|
||||||
|
type EventType = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MessageSent is the event sent when a chat event takes place.
|
||||||
|
MessageSent EventType = "CHAT"
|
||||||
|
// UserJoined is the event sent when a chat user join action takes place.
|
||||||
|
UserJoined EventType = "USER_JOINED"
|
||||||
|
// UserNameChanged is the event sent when a chat username change takes place.
|
||||||
|
UserNameChanged EventType = "NAME_CHANGE"
|
||||||
|
// VisibiltyToggled is the event sent when a chat message's visibility changes.
|
||||||
|
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
|
||||||
|
// PING is a ping message.
|
||||||
|
PING EventType = "PING"
|
||||||
|
// PONG is a pong message.
|
||||||
|
PONG EventType = "PONG"
|
||||||
|
// StreamStarted represents a stream started event.
|
||||||
|
StreamStarted EventType = "STREAM_STARTED"
|
||||||
|
// StreamStopped represents a stream stopped event.
|
||||||
|
StreamStopped EventType = "STREAM_STOPPED"
|
||||||
|
// SystemMessageSent is the event sent when a system message is sent.
|
||||||
|
SystemMessageSent EventType = "SYSTEM"
|
||||||
|
// ChatActionSent is a generic chat action that can be used for anything that doesn't need specific handling or formatting.
|
||||||
|
ChatActionSent EventType = "CHAT_ACTION"
|
||||||
|
)
|
25
models/latencyLevels.go
Normal file
25
models/latencyLevels.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
// LatencyLevel is a representation of HLS configuration values.
|
||||||
|
type LatencyLevel struct {
|
||||||
|
Level int `json:"level"`
|
||||||
|
SecondsPerSegment int `json:"-"`
|
||||||
|
SegmentCount int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatencyConfigs will return the available latency level options.
|
||||||
|
func GetLatencyConfigs() map[int]LatencyLevel {
|
||||||
|
return map[int]LatencyLevel{
|
||||||
|
1: {Level: 1, SecondsPerSegment: 1, SegmentCount: 2},
|
||||||
|
2: {Level: 2, SecondsPerSegment: 2, SegmentCount: 2},
|
||||||
|
3: {Level: 3, SecondsPerSegment: 3, SegmentCount: 3},
|
||||||
|
4: {Level: 4, SecondsPerSegment: 3, SegmentCount: 4}, // Default
|
||||||
|
5: {Level: 5, SecondsPerSegment: 4, SegmentCount: 5},
|
||||||
|
6: {Level: 6, SecondsPerSegment: 6, SegmentCount: 10},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatencyLevel will return the latency level at index.
|
||||||
|
func GetLatencyLevel(index int) LatencyLevel {
|
||||||
|
return GetLatencyConfigs()[index]
|
||||||
|
}
|
|
@ -2,9 +2,9 @@ package models
|
||||||
|
|
||||||
// NameChangeEvent represents a user changing their name in chat.
|
// NameChangeEvent represents a user changing their name in chat.
|
||||||
type NameChangeEvent struct {
|
type NameChangeEvent struct {
|
||||||
OldName string `json:"oldName"`
|
OldName string `json:"oldName"`
|
||||||
NewName string `json:"newName"`
|
NewName string `json:"newName"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Type string `json:"type"`
|
Type EventType `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,5 +2,5 @@ package models
|
||||||
|
|
||||||
// PingMessage represents a ping message between the client and server.
|
// PingMessage represents a ping message between the client and server.
|
||||||
type PingMessage struct {
|
type PingMessage struct {
|
||||||
MessageType string `json:"type"`
|
MessageType EventType `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
13
models/s3Storage.go
Normal file
13
models/s3Storage.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
// S3 is the storage configuration.
|
||||||
|
type S3 struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
ServingEndpoint string `json:"servingEndpoint,omitempty"`
|
||||||
|
AccessKey string `json:"accessKey,omitempty"`
|
||||||
|
Secret string `json:"secret,omitempty"`
|
||||||
|
Bucket string `json:"bucket,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
ACL string `json:"acl,omitempty"`
|
||||||
|
}
|
110
models/socialHandle.go
Normal file
110
models/socialHandle.go
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
// SocialHandle represents an external link.
|
||||||
|
type SocialHandle struct {
|
||||||
|
Platform string `yaml:"platform" json:"platform,omitempty"`
|
||||||
|
URL string `yaml:"url" json:"url,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSocialHandle will return the details for a supported platform.
|
||||||
|
func GetSocialHandle(platform string) *SocialHandle {
|
||||||
|
allPlatforms := GetAllSocialHandles()
|
||||||
|
if platform, ok := allPlatforms[platform]; ok {
|
||||||
|
return &platform
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllSocialHandles will return a list of all the social platforms we support.
|
||||||
|
func GetAllSocialHandles() map[string]SocialHandle {
|
||||||
|
socialHandlePlatforms := map[string]SocialHandle{
|
||||||
|
"bandcamp": {
|
||||||
|
Platform: "Bandcamp",
|
||||||
|
Icon: "/img/platformlogos/bandcamp.svg",
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
Platform: "Discord",
|
||||||
|
Icon: "/img/platformlogos/discord.svg",
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
Platform: "Facebook",
|
||||||
|
Icon: "/img/platformlogos/facebook.svg",
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
Platform: "GitHub",
|
||||||
|
Icon: "/img/platformlogos/github.svg",
|
||||||
|
},
|
||||||
|
"gitlab": {
|
||||||
|
Platform: "GitLab",
|
||||||
|
Icon: "/img/platformlogos/gitlab.svg",
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
Platform: "Instagram",
|
||||||
|
Icon: "/img/platformlogos/instagram.svg",
|
||||||
|
},
|
||||||
|
"keyoxide": {
|
||||||
|
Platform: "Keyoxide",
|
||||||
|
Icon: "/img/platformlogos/keyoxide.png",
|
||||||
|
},
|
||||||
|
"kofi": {
|
||||||
|
Platform: "Ko-Fi",
|
||||||
|
Icon: "/img/platformlogos/ko-fi.svg",
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
Platform: "LinkedIn",
|
||||||
|
Icon: "/img/platformlogos/linkedin.svg",
|
||||||
|
},
|
||||||
|
"mastodon": {
|
||||||
|
Platform: "Mastodon",
|
||||||
|
Icon: "/img/platformlogos/mastodon.svg",
|
||||||
|
},
|
||||||
|
"patreon": {
|
||||||
|
Platform: "Patreon",
|
||||||
|
Icon: "/img/platformlogos/patreon.svg",
|
||||||
|
},
|
||||||
|
"paypal": {
|
||||||
|
Platform: "Paypal",
|
||||||
|
Icon: "/img/platformlogos/paypal.svg",
|
||||||
|
},
|
||||||
|
"snapchat": {
|
||||||
|
Platform: "Snapchat",
|
||||||
|
Icon: "/img/platformlogos/snapchat.svg",
|
||||||
|
},
|
||||||
|
"soundcloud": {
|
||||||
|
Platform: "Soundcloud",
|
||||||
|
Icon: "/img/platformlogos/soundcloud.svg",
|
||||||
|
},
|
||||||
|
"spotify": {
|
||||||
|
Platform: "Spotify",
|
||||||
|
Icon: "/img/platformlogos/spotify.svg",
|
||||||
|
},
|
||||||
|
"tiktok": {
|
||||||
|
Platform: "TikTok",
|
||||||
|
Icon: "/img/platformlogos/tiktok.svg",
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
Platform: "Twitch",
|
||||||
|
Icon: "/img/platformlogos/twitch.svg",
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
Platform: "Twitter",
|
||||||
|
Icon: "/img/platformlogos/twitter.svg",
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
Platform: "YouTube",
|
||||||
|
Icon: "/img/platformlogos/youtube.svg",
|
||||||
|
},
|
||||||
|
"donate": {
|
||||||
|
Platform: "Donations",
|
||||||
|
Icon: "/img/platformlogos/donate.svg",
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
Platform: "Follow",
|
||||||
|
Icon: "/img/platformlogos/follow.svg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return socialHandlePlatforms
|
||||||
|
}
|
|
@ -13,4 +13,5 @@ type Status struct {
|
||||||
LastDisconnectTime utils.NullTime `json:"lastDisconnectTime"`
|
LastDisconnectTime utils.NullTime `json:"lastDisconnectTime"`
|
||||||
|
|
||||||
VersionNumber string `json:"versionNumber"`
|
VersionNumber string `json:"versionNumber"`
|
||||||
|
StreamTitle string `json:"streamTitle"`
|
||||||
}
|
}
|
||||||
|
|
89
models/streamOutputVariant.go
Normal file
89
models/streamOutputVariant.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// StreamOutputVariant defines the output specifics of a single HLS stream variant.
|
||||||
|
type StreamOutputVariant 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"` // Remove after migration is no longer used
|
||||||
|
// CPUUsageLevel represents a codec preset to configure CPU usage.
|
||||||
|
CPUUsageLevel int `json:"cpuUsageLevel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFramerate returns the framerate or default.
|
||||||
|
func (q *StreamOutputVariant) GetFramerate() int {
|
||||||
|
if q.IsVideoPassthrough {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.Framerate > 0 {
|
||||||
|
return q.Framerate
|
||||||
|
}
|
||||||
|
|
||||||
|
return 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEncoderPreset returns the preset or default.
|
||||||
|
func (q *StreamOutputVariant) GetEncoderPreset() string {
|
||||||
|
if q.IsVideoPassthrough {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.EncoderPreset != "" {
|
||||||
|
return q.EncoderPreset
|
||||||
|
}
|
||||||
|
|
||||||
|
return "veryfast"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCPUUsageLevel will return the libx264 codec encoder preset that maps to a level.
|
||||||
|
func (q *StreamOutputVariant) GetCPUUsageLevel() int {
|
||||||
|
presetMapping := map[string]int{
|
||||||
|
"ultrafast": 1,
|
||||||
|
"superfast": 2,
|
||||||
|
"veryfast": 3,
|
||||||
|
"faster": 4,
|
||||||
|
"fast": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
return presetMapping[q.GetEncoderPreset()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIsAudioPassthrough will return if this variant audio is passthrough.
|
||||||
|
func (q *StreamOutputVariant) GetIsAudioPassthrough() bool {
|
||||||
|
if q.IsAudioPassthrough {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.AudioBitrate == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON is a custom JSON marshal function for video stream qualities.
|
||||||
|
func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias StreamOutputVariant
|
||||||
|
return json.Marshal(&struct {
|
||||||
|
Framerate int `json:"framerate"`
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Framerate: q.GetFramerate(),
|
||||||
|
Alias: (*Alias)(q),
|
||||||
|
})
|
||||||
|
}
|
11
models/userJoinedEvent.go
Normal file
11
models/userJoinedEvent.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserJoinedEvent represents an event when a user joins the chat.
|
||||||
|
type UserJoinedEvent struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||||
|
}
|
33
models/webhook.go
Normal file
33
models/webhook.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Webhook is an event that is sent to 3rd party, external services with details about something that took place within an Owncast server.
|
||||||
|
type Webhook struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Events []EventType `json:"events"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
LastUsed *time.Time `json:"lastUsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// For an event to be seen as "valid" it must live in this slice.
|
||||||
|
var validEvents = []EventType{
|
||||||
|
MessageSent,
|
||||||
|
UserJoined,
|
||||||
|
UserNameChanged,
|
||||||
|
VisibiltyToggled,
|
||||||
|
StreamStarted,
|
||||||
|
StreamStopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasValidEvents will verify that all the events provided are valid.
|
||||||
|
// This is not a efficient method.
|
||||||
|
func HasValidEvents(events []EventType) bool {
|
||||||
|
for _, event := range events {
|
||||||
|
if !findItemInSlice(validEvents, event) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
1024
openapi.yaml
1024
openapi.yaml
File diff suppressed because it is too large
Load diff
2
pkged.go
2
pkged.go
File diff suppressed because one or more lines are too long
|
@ -3,8 +3,9 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/core/data"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -13,7 +14,7 @@ import (
|
||||||
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
username := "admin"
|
username := "admin"
|
||||||
password := config.Config.VideoSettings.StreamingKey
|
password := data.GetStreamKey()
|
||||||
realm := "Owncast Authenticated Request"
|
realm := "Owncast Authenticated Request"
|
||||||
|
|
||||||
// The following line is kind of a work around.
|
// The following line is kind of a work around.
|
||||||
|
@ -43,3 +44,34 @@ func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
handler(w, r)
|
handler(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequireAccessToken(scope string, handler http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
token := strings.Join(authHeader, "")
|
||||||
|
|
||||||
|
if len(authHeader) == 0 || token == "" {
|
||||||
|
log.Warnln("invalid access token")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized) //nolint
|
||||||
|
w.Write([]byte("invalid access token")) //nolint
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if accepted, err := data.DoesTokenSupportScope(token, scope); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError) //nolint
|
||||||
|
w.Write([]byte(err.Error())) //nolint
|
||||||
|
return
|
||||||
|
} else if !accepted {
|
||||||
|
log.Warnln("invalid access token")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized) //nolint
|
||||||
|
w.Write([]byte("invalid access token")) //nolint
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(w, r)
|
||||||
|
|
||||||
|
if err := data.SetAccessTokenAsUsed(token); err != nil {
|
||||||
|
log.Debugln(token, "not found when updating last_used timestamp")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
120
router/router.go
120
router/router.go
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/owncast/owncast/controllers"
|
"github.com/owncast/owncast/controllers"
|
||||||
"github.com/owncast/owncast/controllers/admin"
|
"github.com/owncast/owncast/controllers/admin"
|
||||||
"github.com/owncast/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
"github.com/owncast/owncast/yp"
|
"github.com/owncast/owncast/yp"
|
||||||
)
|
)
|
||||||
|
@ -48,8 +49,15 @@ func Start() error {
|
||||||
// video embed
|
// video embed
|
||||||
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
||||||
|
|
||||||
|
// return the YP protocol data
|
||||||
http.HandleFunc("/api/yp", yp.GetYPResponse)
|
http.HandleFunc("/api/yp", yp.GetYPResponse)
|
||||||
|
|
||||||
|
// list of all social platforms
|
||||||
|
http.HandleFunc("/api/socialplatforms", controllers.GetAllSocialPlatforms)
|
||||||
|
|
||||||
|
// return the logo
|
||||||
|
http.HandleFunc("/logo", controllers.GetLogo)
|
||||||
|
|
||||||
// Authenticated admin requests
|
// Authenticated admin requests
|
||||||
|
|
||||||
// Current inbound broadcaster
|
// Current inbound broadcaster
|
||||||
|
@ -58,21 +66,6 @@ func Start() error {
|
||||||
// Disconnect inbound stream
|
// Disconnect inbound stream
|
||||||
http.HandleFunc("/api/admin/disconnect", middleware.RequireAdminAuth(admin.DisconnectInboundConnection))
|
http.HandleFunc("/api/admin/disconnect", middleware.RequireAdminAuth(admin.DisconnectInboundConnection))
|
||||||
|
|
||||||
// Change the current streaming key in memory
|
|
||||||
http.HandleFunc("/api/admin/changekey", middleware.RequireAdminAuth(admin.ChangeStreamKey))
|
|
||||||
|
|
||||||
// Change the current streaming name in memory
|
|
||||||
http.HandleFunc("/api/admin/changename", middleware.RequireAdminAuth(admin.ChangeStreamName))
|
|
||||||
|
|
||||||
// Change the current streaming name in memory
|
|
||||||
http.HandleFunc("/api/admin/changetitle", middleware.RequireAdminAuth(admin.ChangeStreamTitle))
|
|
||||||
|
|
||||||
// Change the current streaming name in memory
|
|
||||||
http.HandleFunc("/api/admin/changetags", middleware.RequireAdminAuth(admin.ChangeStreamTags))
|
|
||||||
|
|
||||||
// Change the extra page content in memory
|
|
||||||
http.HandleFunc("/api/admin/changeextrapagecontent", middleware.RequireAdminAuth(admin.ChangeExtraPageContent))
|
|
||||||
|
|
||||||
// Server config
|
// Server config
|
||||||
http.HandleFunc("/api/admin/serverconfig", middleware.RequireAdminAuth(admin.GetServerConfig))
|
http.HandleFunc("/api/admin/serverconfig", middleware.RequireAdminAuth(admin.GetServerConfig))
|
||||||
|
|
||||||
|
@ -94,10 +87,103 @@ func Start() error {
|
||||||
// Get all chat messages for the admin, unfiltered.
|
// Get all chat messages for the admin, unfiltered.
|
||||||
http.HandleFunc("/api/admin/chat/messages", middleware.RequireAdminAuth(admin.GetChatMessages))
|
http.HandleFunc("/api/admin/chat/messages", middleware.RequireAdminAuth(admin.GetChatMessages))
|
||||||
|
|
||||||
// Update chat message visibilty
|
// Update chat message visibility
|
||||||
http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility))
|
http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility))
|
||||||
|
// Update config values
|
||||||
|
|
||||||
port := config.Config.GetPublicWebServerPort()
|
// Change the current streaming key in memory
|
||||||
|
http.HandleFunc("/api/admin/config/key", middleware.RequireAdminAuth(admin.SetStreamKey))
|
||||||
|
|
||||||
|
// Change the extra page content in memory
|
||||||
|
http.HandleFunc("/api/admin/config/pagecontent", middleware.RequireAdminAuth(admin.SetExtraPageContent))
|
||||||
|
|
||||||
|
// Stream title
|
||||||
|
http.HandleFunc("/api/admin/config/streamtitle", middleware.RequireAdminAuth(admin.SetStreamTitle))
|
||||||
|
|
||||||
|
// Server name
|
||||||
|
http.HandleFunc("/api/admin/config/name", middleware.RequireAdminAuth(admin.SetServerName))
|
||||||
|
|
||||||
|
// Server summary
|
||||||
|
http.HandleFunc("/api/admin/config/serversummary", middleware.RequireAdminAuth(admin.SetServerSummary))
|
||||||
|
|
||||||
|
// Return all webhooks
|
||||||
|
http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks))
|
||||||
|
|
||||||
|
// Delete a single webhook
|
||||||
|
http.HandleFunc("/api/admin/webhooks/delete", middleware.RequireAdminAuth(admin.DeleteWebhook))
|
||||||
|
|
||||||
|
// Create a single webhook
|
||||||
|
http.HandleFunc("/api/admin/webhooks/create", middleware.RequireAdminAuth(admin.CreateWebhook))
|
||||||
|
|
||||||
|
// Get all access tokens
|
||||||
|
http.HandleFunc("/api/admin/accesstokens", middleware.RequireAdminAuth(admin.GetAccessTokens))
|
||||||
|
|
||||||
|
// Delete a single access token
|
||||||
|
http.HandleFunc("/api/admin/accesstokens/delete", middleware.RequireAdminAuth(admin.DeleteAccessToken))
|
||||||
|
|
||||||
|
// Create a single access token
|
||||||
|
http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateAccessToken))
|
||||||
|
|
||||||
|
// Send a system message to chat
|
||||||
|
http.HandleFunc("/api/integrations/chat/system", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessage))
|
||||||
|
|
||||||
|
// Send a user message to chat
|
||||||
|
http.HandleFunc("/api/integrations/chat/user", middleware.RequireAccessToken(models.ScopeCanSendUserMessages, admin.SendUserMessage))
|
||||||
|
|
||||||
|
// Send a user action to chat
|
||||||
|
http.HandleFunc("/api/integrations/chat/action", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendChatAction))
|
||||||
|
|
||||||
|
// Hide chat message
|
||||||
|
http.HandleFunc("/api/integrations/chat/messagevisibility", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.UpdateMessageVisibility))
|
||||||
|
|
||||||
|
// Stream title
|
||||||
|
http.HandleFunc("/api/integrations/streamtitle", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.SetStreamTitle))
|
||||||
|
|
||||||
|
// Get chat history
|
||||||
|
http.HandleFunc("/api/integrations/chat", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetChatMessages))
|
||||||
|
|
||||||
|
// Connected clients
|
||||||
|
http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients))
|
||||||
|
// Logo path
|
||||||
|
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath))
|
||||||
|
|
||||||
|
// Server tags
|
||||||
|
http.HandleFunc("/api/admin/config/tags", middleware.RequireAdminAuth(admin.SetTags))
|
||||||
|
|
||||||
|
// ffmpeg
|
||||||
|
http.HandleFunc("/api/admin/config/ffmpegpath", middleware.RequireAdminAuth(admin.SetFfmpegPath))
|
||||||
|
|
||||||
|
// Server http port
|
||||||
|
http.HandleFunc("/api/admin/config/webserverport", middleware.RequireAdminAuth(admin.SetWebServerPort))
|
||||||
|
|
||||||
|
// Server rtmp port
|
||||||
|
http.HandleFunc("/api/admin/config/rtmpserverport", middleware.RequireAdminAuth(admin.SetRTMPServerPort))
|
||||||
|
|
||||||
|
// Is server marked as NSFW
|
||||||
|
http.HandleFunc("/api/admin/config/nsfw", middleware.RequireAdminAuth(admin.SetNSFW))
|
||||||
|
|
||||||
|
// directory enabled
|
||||||
|
http.HandleFunc("/api/admin/config/directoryenabled", middleware.RequireAdminAuth(admin.SetDirectoryEnabled))
|
||||||
|
|
||||||
|
// social handles
|
||||||
|
http.HandleFunc("/api/admin/config/socialhandles", middleware.RequireAdminAuth(admin.SetSocialHandles))
|
||||||
|
|
||||||
|
// set the number of video segments and duration per segment in a playlist
|
||||||
|
http.HandleFunc("/api/admin/config/video/streamlatencylevel", middleware.RequireAdminAuth(admin.SetStreamLatencyLevel))
|
||||||
|
|
||||||
|
// set an array of video output configurations
|
||||||
|
http.HandleFunc("/api/admin/config/video/streamoutputvariants", middleware.RequireAdminAuth(admin.SetStreamOutputVariants))
|
||||||
|
|
||||||
|
// set s3 configuration
|
||||||
|
http.HandleFunc("/api/admin/config/s3", middleware.RequireAdminAuth(admin.SetS3Configuration))
|
||||||
|
|
||||||
|
// set server url
|
||||||
|
http.HandleFunc("/api/admin/config/serverurl", middleware.RequireAdminAuth(admin.SetServerURL))
|
||||||
|
|
||||||
|
// reset the YP registration
|
||||||
|
http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration))
|
||||||
|
|
||||||
|
port := config.WebServerPort
|
||||||
|
|
||||||
log.Tracef("Web server running on port: %d", port)
|
log.Tracef("Web server running on port: %d", port)
|
||||||
|
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 46 KiB |
|
@ -5,12 +5,12 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
||||||
<title>{{.Config.Title}}</title>
|
<title>{{.Config.Title}}</title>
|
||||||
<meta name="description" content="{{.Config.Summary}}">
|
<meta name="description" content="{{.Summary}}">
|
||||||
|
|
||||||
<meta property="og:title" content="{{.Config.Title}}">
|
<meta property="og:title" content="{{.Title}}">
|
||||||
<meta property="og:site_name" content="{{.Config.Title}}">
|
<meta property="og:site_name" content="{{.Title}}">
|
||||||
<meta property="og:url" content="{{.RequestedURL}}">
|
<meta property="og:url" content="{{.RequestedURL}}">
|
||||||
<meta property="og:description" content="{{.Config.Summary}}">
|
<meta property="og:description" content="{{.Summary}}">
|
||||||
<meta property="og:type" content="video.other">
|
<meta property="og:type" content="video.other">
|
||||||
<meta property="video:tag" content="{{.TagsString}}">
|
<meta property="video:tag" content="{{.TagsString}}">
|
||||||
|
|
||||||
|
@ -22,11 +22,11 @@
|
||||||
<meta property="og:video:height" content="640" />
|
<meta property="og:video:height" content="640" />
|
||||||
<meta property="og:video:width" content="385" />
|
<meta property="og:video:width" content="385" />
|
||||||
<meta property="og:video:type" content="application/x-mpegURL" />
|
<meta property="og:video:type" content="application/x-mpegURL" />
|
||||||
<meta property="og:video:actor" content="{{.Config.Name}}" />
|
<meta property="og:video:actor" content="{{.Name}}" />
|
||||||
|
|
||||||
<meta property="twitter:title" content="{{.Config.Title}}">
|
<meta property="twitter:title" content="{{.Title}}">
|
||||||
<meta property="twitter:url" content="{{.RequestedURL}}">
|
<meta property="twitter:url" content="{{.RequestedURL}}">
|
||||||
<meta property="twitter:description" content="{{.Config.Summary}}">
|
<meta property="twitter:description" content="{{.Summary}}">
|
||||||
<meta property="twitter:image" content="{{.Image}}">
|
<meta property="twitter:image" content="{{.Image}}">
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||||
|
@ -51,24 +51,24 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>{{.Config.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<h2>{{.Config.Name}}</h2>
|
<h2>{{.Name}}</h2>
|
||||||
|
|
||||||
<center>
|
<center>
|
||||||
<img src="{{.Thumbnail}}" width=10% />
|
<img src="{{.Thumbnail}}" width=10% />
|
||||||
</center>
|
</center>
|
||||||
|
|
||||||
<h3>{{.Config.Summary}}</h3>
|
<h3>{{.Summary}}</h3>
|
||||||
|
|
||||||
{{range .Config.Tags}}
|
{{range .Tags}}
|
||||||
<li>{{.}}</li>
|
<li>{{.}}</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<h3>Connect with {{.Config.Name}} elsewhere by visiting:</h3>
|
<h3>Connect with {{.Name}} elsewhere by visiting:</h3>
|
||||||
|
|
||||||
{{range .Config.SocialHandles}}
|
{{range .SocialHandles}}
|
||||||
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
@ -1,48 +1,12 @@
|
||||||
var request = require('supertest');
|
var request = require('supertest');
|
||||||
request = request('http://127.0.0.1:8080');
|
request = request('http://127.0.0.1:8080');
|
||||||
|
|
||||||
test('stream details are correct', (done) => {
|
|
||||||
request.get('/api/admin/status').auth('admin', 'abc123').expect(200)
|
|
||||||
.then((res) => {
|
|
||||||
expect(res.body.broadcaster.streamDetails.width).toBe(320);
|
|
||||||
expect(res.body.broadcaster.streamDetails.height).toBe(180);
|
|
||||||
expect(res.body.broadcaster.streamDetails.framerate).toBe(24);
|
|
||||||
expect(res.body.broadcaster.streamDetails.videoBitrate).toBe(1269);
|
|
||||||
expect(res.body.broadcaster.streamDetails.videoCodec).toBe('H.264');
|
|
||||||
expect(res.body.broadcaster.streamDetails.audioCodec).toBe('AAC');
|
|
||||||
expect(res.body.online).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin configuration is correct', (done) => {
|
|
||||||
request.get('/api/admin/serverconfig').auth('admin', 'abc123').expect(200)
|
|
||||||
.then((res) => {
|
|
||||||
expect(res.body.instanceDetails.name).toBe('Owncast');
|
|
||||||
expect(res.body.instanceDetails.title).toBe('Owncast');
|
|
||||||
expect(res.body.instanceDetails.summary).toBe('This is brief summary of whom you are or what your stream is. You can edit this description in your config file.');
|
|
||||||
expect(res.body.instanceDetails.logo).toBe('/img/logo.svg');
|
|
||||||
expect(res.body.instanceDetails.tags).toStrictEqual(['music', 'software', 'streaming']);
|
|
||||||
|
|
||||||
expect(res.body.videoSettings.segmentLengthSeconds).toBe(4);
|
|
||||||
expect(res.body.videoSettings.numberOfPlaylistItems).toBe(5);
|
|
||||||
|
|
||||||
expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe(24);
|
|
||||||
expect(res.body.videoSettings.videoQualityVariants[0].encoderPreset).toBe('veryfast');
|
|
||||||
|
|
||||||
expect(res.body.videoSettings.numberOfPlaylistItems).toBe(5);
|
|
||||||
|
|
||||||
expect(res.body.yp.enabled).toBe(false);
|
|
||||||
expect(res.body.streamKey).toBe('abc123');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test('correct number of log entries exist', (done) => {
|
test('correct number of log entries exist', (done) => {
|
||||||
request.get('/api/admin/logs').auth('admin', 'abc123').expect(200)
|
request.get('/api/admin/logs').auth('admin', 'abc123').expect(200)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
// expect(res.body).toHaveLength(4);
|
// expect(res.body).toHaveLength(8);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,7 +5,7 @@ request = request('http://127.0.0.1:8080');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
var ws;
|
var ws;
|
||||||
|
|
||||||
const id = Math.random().toString(36).substring(7);
|
const testMessageId = Math.random().toString(36).substring(7);
|
||||||
const username = 'user' + Math.floor(Math.random() * 100);
|
const username = 'user' + Math.floor(Math.random() * 100);
|
||||||
const message = Math.floor(Math.random() * 100) + ' test 123';
|
const message = Math.floor(Math.random() * 100) + ' test 123';
|
||||||
const messageRaw = message + ' *and some markdown too*';
|
const messageRaw = message + ' *and some markdown too*';
|
||||||
|
@ -15,7 +15,7 @@ const date = new Date().toISOString();
|
||||||
const testMessage = {
|
const testMessage = {
|
||||||
author: username,
|
author: username,
|
||||||
body: messageRaw,
|
body: messageRaw,
|
||||||
id: id,
|
id: testMessageId,
|
||||||
type: 'CHAT',
|
type: 'CHAT',
|
||||||
visible: true,
|
visible: true,
|
||||||
timestamp: date,
|
timestamp: date,
|
||||||
|
@ -37,12 +37,16 @@ test('can send a chat message', (done) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can fetch chat messages', (done) => {
|
test('can fetch chat messages', (done) => {
|
||||||
request.get('/api/chat').expect(200)
|
request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
expect(res.body[0].author).toBe(testMessage.author);
|
const message = res.body.filter(function(msg) {
|
||||||
expect(res.body[0].body).toBe(messageMarkdown);
|
return msg.id = testMessageId;
|
||||||
expect(res.body[0].date).toBe(testMessage.date);
|
})[0];
|
||||||
expect(res.body[0].type).toBe(testMessage.type);
|
|
||||||
|
expect(message.author).toBe(testMessage.author);
|
||||||
|
expect(message.body).toBe(messageMarkdown);
|
||||||
|
expect(message.date).toBe(testMessage.date);
|
||||||
|
expect(message.type).toBe(testMessage.type);
|
||||||
|
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,7 +31,7 @@ test('can send a chat message', (done) => {
|
||||||
var messageId;
|
var messageId;
|
||||||
|
|
||||||
test('verify we can make API call to mark message as hidden', async (done) => {
|
test('verify we can make API call to mark message as hidden', async (done) => {
|
||||||
const res = await request.get('/api/chat').expect(200);
|
const res = await request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
|
||||||
const message = res.body[0];
|
const message = res.body[0];
|
||||||
messageId = message.id;
|
messageId = message.id;
|
||||||
await request.post('/api/admin/chat/updatemessagevisibility')
|
await request.post('/api/admin/chat/updatemessagevisibility')
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue