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:
Gabe Kangas 2021-02-18 23:05:52 -08:00 committed by GitHub
parent 05ec74a1e3
commit bc2caadb74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 5544 additions and 1510 deletions

View file

@ -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
View file

@ -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
View 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"
]
}

View file

@ -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

View file

@ -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 .

View file

@ -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()
} }

View file

@ -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),
})
}

View file

@ -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)
}
}

View file

@ -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")
) )

View file

@ -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,
},
},
}
} }

View file

@ -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

View 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")
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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
View 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
}

View file

@ -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)
} }

View file

@ -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"`
} }

View 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
View 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")
}

View file

@ -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)
} }
} }
} }

View file

@ -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)
} }
} }

View file

@ -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
View 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"

View file

@ -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)
} }
} }

View file

@ -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)
} }
} }

View file

@ -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
View 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)
}

View file

@ -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)
} }
} }

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}() }()
} }

View file

@ -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()
} }

View file

@ -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
View 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, &timestampString, &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
View 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
View 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
View 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
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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, &timestampString, &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
}

View file

@ -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{

View file

@ -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

View file

@ -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
} }

View file

@ -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

View file

@ -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
} }

View file

@ -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

View file

@ -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/")
} }
} }

View file

@ -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)
} }
}() }()
} }

View file

@ -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)
} }

View file

@ -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)

View file

@ -1,4 +1,4 @@
package ffmpeg package transcoder
import ( import (
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"

View file

@ -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

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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
}

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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
View file

@ -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() {

View file

@ -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
View 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
}

View file

@ -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
View 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"`
}

View file

@ -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 == ""
} }

View file

@ -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(),

View 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
View 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
View 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]
}

View file

@ -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"`
} }

View file

@ -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
View 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
View 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
}

View file

@ -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"`
} }

View 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
View 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
View 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
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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")
}
})
}

View file

@ -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

View file

@ -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}}

View file

@ -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();
}); });
}); });

View file

@ -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();
}); });

View file

@ -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