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