Browse Source
* Implement webhook events for external integrations (#574) * Implement webhook events for external integrations Reference #556 * move message type to models and remove duplicate * add json header so content type can be determined * Pass at migrating webhooks to datastore + management apis (#589) * Pass at migrating webhooks to datastore + management apis * Support nil lastUsed timestamps and return back the new webhook on create * Cleanup from review feedback * Simplify a bit Co-authored-by: Aaron Ogle <aaron@geekgonecrazy.com> Co-authored-by: Gabe Kangas <gabek@real-ity.com> * Webhook query cleanup * Access tokens + Send system message external API (#585) * New add, get and delete access token APIs * Create auth token middleware * Update last_used timestamp when using an access token * Add auth'ed endpoint for sending system messages * Cleanup * Update api spec for new apis * Commit updated API documentation * Add auth'ed endpoint for sending user chat messages * Return access token string * Commit updated API documentation * Fix route * Support nil lastUsed time * Commit updated Javascript packages * Remove duplicate function post rebase * Fix msg id generation * Update controllers/admin/chat.go Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> * Webhook query cleanup * Add SystemMessageSent to EventType Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> * Set webhook as used on completion. Closes #610 * Display webhook errors as errors * Commit updated API documentation * Add user joined chat event * Change integration API paths. Update API spec * Update development version of admin that supports integration apis * Commit updated API documentation * Add automated tests for external integration APIs * check error * quiet this test for now * Route up some additional 3rd party apis. #638 * Commit updated API documentation * Save username on user joined event * Add missing scope to valid scopes list * Add generic chat action event API for 3rd parties. Closes #666 * Commit updated API documentation * First pass at moving WIP config framework into project for #234 * Only support exported fields in custom types * Using YP get/set key as a first pass at using the data layer. Fixes + integration. * Ignore test db * Start adding getters and setters for config values * More get/set config work. Starting to populate api with data * Wire up some config edit endpoints * More endpoints * Disable cors middleware * Add more endpoints and add test to test them * Remove the in-memory change APIs * Add endpoint for changing tags * Add more config endpoints * Starting to point more things away from config file and to the datastore * Populate YP with db data * Create new util method for parsing page body markdown and return it in api * Verify proposed path to ffmpeg * For development purposes show the config key in logs * Move stats values to datastore * Moving over more values to the datastore * Move S3 config to datastore * First pass the config -> db migrator * Add the start of the video config apis * It builds pointing everything away from the config * Tweak ffmpeg path error message * Backup database every hour. Closes #549 * Config + defaults + migration work for db * Cleanup logging * Remove all the old config structs * Add descriptive info about migration * Tweak ffmpeg validation logic * Fix db backup path. backup on db version migration * Set video and s3 configurations * Update api spec with new config endpoints * Add migrator for stats file * Commit updated API documentation * Use a dynamic system port for internal HLS writes. Closes #577 (#626) * Use a dynamic system port for internal HLS writes. Closes #577 * Cleanup * YP key migration to datastore * Create a backup directory if needed before migrations * Remove config test that no longer makes sense. Cleanup. * Change number types from float32 to float64 * Update automated test suite * Allow restoring a database backup via command line flags. Closes #549 * Add new hls segment config api * Commit updated API documentation * Update apis to require a value container property * add socialHandles api * Commit updated API documentation * Add new latancy level setting to replace segment settings * Commit updated API documentation * Fix spelling * Commit updated API documentation * hardcode a json api of available social platforms * Add additional icons * Return social handles in server config api * Add socialhandles validation to test * Move list of hard coded social platforms to an api * Remove audio only code from transcoder since we do not use it * Add latency levels api + snapshot of video settings as current broadcast * Add config/serverurl endpoint * Return 404 on YP api if disabled * Surface stream title in YP response * Add stream title to web ui * Cleanup log message. Closes #520 * Rename ffmpeg package to transcoder * Add ws package for testing * Reduce chat backlog to past 5hrs, max 50. Closes #548 * Fix error formatting * Add endpoint for resetting yp registration * Add yp/reset to api spec. return status in response * Return zero viewer count if stream is offline. Closes #422 * Post-rebase fixes * Fix merge conflict in openapi file * Commit updated API documentation * Standardize controller names * Support setting the stream key via the command line. Closes #665 * Return social handles with YP data. First half of https://github.com/owncast/owncast-yp/issues/28 * Give the YP package access to server status regardless if enabled or not * Change delay in automated tests * Add stream title integration API. For #638 * Commit updated API documentation * Add storage to the migrator * Missing returning NSFW value in server config * Add flag to ignore websocket client. Closes #537 * Add error for parsing broadcaster metadata * Add support for a cli specified http server port. Closes #674 * Add cpu usage levels and a temporary mapping between it and libx264 presets * Test for valid url endpoint when saving s3 config * Re-configure storage on every stream to allow changing storage providers * After 5 minutes of a stream being stopped clear the stream title * Hide viewer count once stream goes offline instead of when player stops * Pull steamTitle from the status that gets updated instead of the config * Commit updated API documentation * Optionally show stream title in the header * Reset stream title when server starts * Show chat action when stream title is updated * Allow system messages to come back in persistence * Split out getting chat history for moderation + fix tests * Remove server title and standardize on name only * Commit updated API documentation * Bump github.com/aws/aws-sdk-go from 1.37.1 to 1.37.2 (#680) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.1 to 1.37.2. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.1...v1.37.2) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add video variant and stream latency config file migrator * Remove mostly unused disable upgrade check bool * Commit updated API documentation * Allow bundling the admin from the 0.0.6 branch * Fix saving port numbers * Use name instead of old title on window focus * Work on latency levels. Fix test to use levels. Clean up transcoder to only reference levels * Another place where title -> name * Fix test * Bump github.com/aws/aws-sdk-go from 1.37.2 to 1.37.3 (#690) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.2 to 1.37.3. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.2...v1.37.3) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update dependabot config * Bump github.com/aws/aws-sdk-go from 1.37.3 to 1.37.5 (#693) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.3 to 1.37.5. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.3...v1.37.5) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript (#694) * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript Bumps [video.js](https://github.com/videojs/video.js) from 7.10.2 to 7.11.4. - [Release notes](https://github.com/videojs/video.js/releases) - [Changelog](https://github.com/videojs/video.js/blob/main/CHANGELOG.md) - [Commits](https://github.com/videojs/video.js/compare/v7.10.2...v7.11.4) Signed-off-by: dependabot[bot] <support@github.com> * Commit updated Javascript packages Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> * Make the latency migrator dynamic so I can tweak values easier * Split out fetching ffmpeg path from validating the path so it can be changed in the admin * Some commenting and linter cleanup * Validate the path for a logo change and throw an error if it does not exist * Logo change requests have to be a real file now * Cleanup, making linter happy * Format javascript on push * Only format js in master * Tweak latency level values * Remove unused config file examples * Fix thumbnail generation after messing with the ffmpeg path getter * Reduce how often we report high hardware utilization warnings * Bundle the 0.0.6 branch version of the admin * Return validated ffmpeg path in admin server config * Change the logo to be stored in the data directory instead of webroot * Bump postcss from 8.2.4 to 8.2.5 in /build/javascript (#702) Bumps [postcss](https://github.com/postcss/postcss) from 8.2.4 to 8.2.5. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.2.4...8.2.5) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Default config file no longer used * don't show stream title when offline addresses https://github.com/owncast/owncast/issues/677 * Remove auto-clearing stream title. #677 * webroot -> data when using logo as thumbnail * Do not list websocket/access token create/delete as integration APIs * Commit updated API documentation * Bundle updated admin * Remove pointing to the 0.0.6 admin branch * Linter cleanup * Linter cleanup * Add donations and follow links to show up under social handles * Prettified Code! * More linter cleanup * Update admin bundle * Remove use of platforms.js and return icons with social handles. Closes #732 * Update admin bundle * Support custom config path for use in migration * Remove unused platform-logos.gif * Reduce log level of message * Remove unused logo files in static dir * Handle dev vs. release build info * Restore logo.png for initial thumbnail * Cleanup some files from the build process that are not needed * Fix incorrect build-time injection var * Fix missing file getting copied to the build * Remove console directory message. * Update admin bundle * Fix comment * Report storage setup error * add some value set error checking * Use validated dynamic ffmpeg path for animated gif preview * Make chat message links be white so they don't hide in the bg. Closes #599 * Restore conditional that was accidentally removed Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Ginger Wong <omqmail@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: nebunez <uoj2y7wak869@opayq.net> Co-authored-by: gabek <gabek@users.noreply.github.com>pull/745/head
125 changed files with 5578 additions and 1544 deletions
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
{ |
||||
"cSpell.words": [ |
||||
"Debugln", |
||||
"Errorln", |
||||
"Ffmpeg", |
||||
"Mbps", |
||||
"Owncast", |
||||
"RTMP", |
||||
"Tracef", |
||||
"Traceln", |
||||
"Warnf", |
||||
"Warnln", |
||||
"ffmpegpath", |
||||
"ffmpg", |
||||
"mattn", |
||||
"nolint", |
||||
"preact", |
||||
"rtmpserverport", |
||||
"sqlite", |
||||
"videojs" |
||||
] |
||||
} |
@ -1,299 +1,40 @@
@@ -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 |
||||
|
||||
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") |
||||
} |
||||
// These are runtime-set values used for configuration.
|
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (c *config) GetVideoSegmentSecondsLength() int { |
||||
if c.VideoSettings.ChunkLengthInSeconds != 0 { |
||||
return c.VideoSettings.ChunkLengthInSeconds |
||||
} |
||||
// 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" |
||||
|
||||
return _default.GetVideoSegmentSecondsLength() |
||||
} |
||||
// EnableDebugFeatures will print additional data to help in debugging.
|
||||
var EnableDebugFeatures = false |
||||
|
||||
func (c *config) GetPublicWebServerPort() int { |
||||
if c.WebServerPort != 0 { |
||||
return c.WebServerPort |
||||
} |
||||
|
||||
return _default.WebServerPort |
||||
} |
||||
// VersionNumber is the current version string.
|
||||
var VersionNumber = StaticVersionNumber |
||||
|
||||
func (c *config) GetRTMPServerPort() int { |
||||
if c.RTMPServerPort != 0 { |
||||
return c.RTMPServerPort |
||||
} |
||||
// WebServerPort is the port for Owncast's webserver that is used for this execution of the service.
|
||||
var WebServerPort = 8080 |
||||
|
||||
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 |
||||
} |
||||
// InternalHLSListenerPort is the port for HLS writes that is used for this execution of the service.
|
||||
var InternalHLSListenerPort = "8927" |
||||
|
||||
func (c *config) GetDataFilePath() string { |
||||
if c.DatabaseFilePath != "" { |
||||
return c.DatabaseFilePath |
||||
} |
||||
// ConfigFilePath is the path to the config file for migration.
|
||||
var ConfigFilePath = "config.yaml" |
||||
|
||||
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 |
||||
} |
||||
// GitCommit is an optional commit this build was made from.
|
||||
var GitCommit = "" |
||||
|
||||
// Load tries to load the configuration file.
|
||||
func Load(filePath string, versionInfo string, versionNumber string) error { |
||||
Config = new(config) |
||||
_default = getDefaults() |
||||
// BuildPlatform is the optional platform this release was built for.
|
||||
var BuildPlatform = "local" |
||||
|
||||
if err := Config.load(filePath); err != nil { |
||||
return err |
||||
} |
||||
// GetReleaseString gets the version string.
|
||||
func GetReleaseString() string { |
||||
var versionNumber = VersionNumber |
||||
var buildPlatform = BuildPlatform |
||||
var gitCommit = GitCommit |
||||
|
||||
Config.VersionInfo = versionInfo |
||||
Config.VersionNumber = versionNumber |
||||
return Config.verifySettings() |
||||
return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit) |
||||
} |
||||
|
@ -1,19 +0,0 @@
@@ -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) |
||||
} |
||||
} |
@ -1,22 +1,59 @@
@@ -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" |
||||
|
||||
defaultQuality := StreamQuality{ |
||||
IsAudioPassthrough: true, |
||||
VideoBitrate: 1200, |
||||
EncoderPreset: "veryfast", |
||||
Framerate: 24, |
||||
} |
||||
defaults.VideoSettings.StreamQualities = []StreamQuality{defaultQuality} |
||||
import "github.com/owncast/owncast/models" |
||||
|
||||
// Defaults will hold default configuration values.
|
||||
type Defaults struct { |
||||
Name string |
||||
Title string |
||||
Summary string |
||||
Logo string |
||||
Tags []string |
||||
PageBodyContent string |
||||
|
||||
DatabaseFilePath string |
||||
WebServerPort int |
||||
RTMPServerPort int |
||||
StreamKey string |
||||
|
||||
YPEnabled bool |
||||
YPServer string |
||||
|
||||
SegmentLengthSeconds int |
||||
SegmentsInPlaylist int |
||||
StreamVariants []models.StreamOutputVariant |
||||
} |
||||
|
||||
return defaults |
||||
// 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, |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
|
@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
|
||||
package admin |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/controllers" |
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/models" |
||||
"github.com/owncast/owncast/utils" |
||||
) |
||||
|
||||
type deleteTokenRequest struct { |
||||
Token string `json:"token"` |
||||
} |
||||
|
||||
type createTokenRequest struct { |
||||
Name string `json:"name"` |
||||
Scopes []string `json:"scopes"` |
||||
} |
||||
|
||||
// CreateAccessToken will generate a 3rd party access token.
|
||||
func CreateAccessToken(w http.ResponseWriter, r *http.Request) { |
||||
decoder := json.NewDecoder(r.Body) |
||||
var request createTokenRequest |
||||
if err := decoder.Decode(&request); err != nil { |
||||
controllers.BadRequestHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
// Verify all the scopes provided are valid
|
||||
if !models.HasValidScopes(request.Scopes) { |
||||
controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided")) |
||||
return |
||||
} |
||||
|
||||
token, err := utils.GenerateAccessToken() |
||||
if err != nil { |
||||
controllers.InternalErrorHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
if err := data.InsertToken(token, request.Name, request.Scopes); err != nil { |
||||
controllers.InternalErrorHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
controllers.WriteResponse(w, models.AccessToken{ |
||||
Token: token, |
||||
Name: request.Name, |
||||
Scopes: request.Scopes, |
||||
Timestamp: time.Now(), |
||||
LastUsed: nil, |
||||
}) |
||||
} |
||||
|
||||
// GetAccessTokens will return all 3rd party access tokens.
|
||||
func GetAccessTokens(w http.ResponseWriter, r *http.Request) { |
||||
w.Header().Set("Content-Type", "application/json") |
||||
|
||||
tokens, err := data.GetAccessTokens() |
||||
if err != nil { |
||||
controllers.InternalErrorHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
controllers.WriteResponse(w, tokens) |
||||
} |
||||
|
||||
// DeleteAccessToken will return a single 3rd party access token.
|
||||
func DeleteAccessToken(w http.ResponseWriter, r *http.Request) { |
||||
w.Header().Set("Content-Type", "application/json") |
||||
|
||||
if r.Method != controllers.POST { |
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request deleteTokenRequest |
||||
if err := decoder.Decode(&request); err != nil { |
||||
controllers.BadRequestHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
if request.Token == "" { |
||||
controllers.BadRequestHandler(w, errors.New("must provide a token")) |
||||
return |
||||
} |
||||
|
||||
if err := data.DeleteToken(request.Token); err != nil { |
||||
controllers.InternalErrorHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
controllers.WriteSimpleResponse(w, true, "deleted token") |
||||
} |
@ -1,36 +0,0 @@
@@ -1,36 +0,0 @@
|
||||
package admin |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/controllers" |
||||
"github.com/owncast/owncast/utils" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// ChangeExtraPageContent will change the optional page content.
|
||||
func ChangeExtraPageContent(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != "POST" { |
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request changeExtraPageContentRequest |
||||
err := decoder.Decode(&request) |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
controllers.WriteSimpleResponse(w, false, "") |
||||
return |
||||
} |
||||
|
||||
config.Config.InstanceDetails.ExtraPageContent = utils.RenderSimpleMarkdown(request.Key) |
||||
controllers.WriteSimpleResponse(w, true, "changed") |
||||
} |
||||
|
||||
type changeExtraPageContentRequest struct { |
||||
Key string `json:"content"` |
||||
} |
@ -1,35 +0,0 @@
@@ -1,35 +0,0 @@
|
||||
package admin |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/controllers" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// ChangeStreamKey will change the stream key (in memory).
|
||||
func ChangeStreamKey(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != "POST" { |
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request changeStreamKeyRequest |
||||
err := decoder.Decode(&request) |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
controllers.WriteSimpleResponse(w, false, "") |
||||
return |
||||
} |
||||
|
||||
config.Config.VideoSettings.StreamingKey = request.Key |
||||
controllers.WriteSimpleResponse(w, true, "changed") |
||||
} |
||||
|
||||
type changeStreamKeyRequest struct { |
||||
Key string `json:"key"` |
||||
} |
@ -1,35 +0,0 @@
@@ -1,35 +0,0 @@
|
||||
package admin |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/controllers" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// ChangeStreamName will change the stream key (in memory).
|
||||
func ChangeStreamName(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != "POST" { |
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request changeStreamNameRequest |
||||
err := decoder.Decode(&request) |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
controllers.WriteSimpleResponse(w, false, "") |
||||
return |
||||
} |
||||
|
||||
config.Config.InstanceDetails.Name = request.Name |
||||
controllers.WriteSimpleResponse(w, true, "changed") |
||||
} |
||||
|
||||
type changeStreamNameRequest struct { |
||||
Name string `json:"name"` |
||||
} |
@ -1,35 +0,0 @@
@@ -1,35 +0,0 @@
|
||||
package admin |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/controllers" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// ChangeStreamTags will change the stream key (in memory).
|
||||
func ChangeStreamTags(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != "POST" { |
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request changeStreamTagsRequest |
||||
err := decoder.Decode(&request) |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
controllers.WriteSimpleResponse(w, false, "") |
||||
return |
||||
} |
||||
|
||||
config.Config.InstanceDetails.Tags = request.Tags |
||||
controllers.WriteSimpleResponse(w, true, "changed") |
||||
} |
||||
|
||||
type changeStreamTagsRequest struct { |
||||
Tags []string `json:"tags"` |
||||
} |
@ -1,35 +0,0 @@
@@ -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"` |
||||
} |
@ -0,0 +1,475 @@
@@ -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 |
||||
} |
@ -0,0 +1,84 @@
@@ -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") |
||||
} |
@ -0,0 +1,20 @@
@@ -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") |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package controllers |
||||
|
||||
// POST is the HTTP POST method.
|
||||
const POST = "POST" |
||||
|
||||
// GET is the HTTP GET method.
|
||||
const GET = "GET" |
@ -0,0 +1,65 @@
@@ -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) |
||||
} |
@ -0,0 +1,199 @@
@@ -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 |
||||
} |
@ -0,0 +1,18 @@
@@ -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 |
||||
} |
@ -0,0 +1,450 @@
@@ -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 |
||||
} |
@ -0,0 +1,46 @@
@@ -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 |
||||
} |
@ -0,0 +1,115 @@
@@ -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 |
||||
} |
@ -0,0 +1,43 @@
@@ -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) |
||||
} |
@ -0,0 +1,266 @@
@@ -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"` |
||||
} |
@ -0,0 +1,152 @@
@@ -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() |
||||
} |
@ -0,0 +1,46 @@
@@ -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) |
||||
} |
@ -0,0 +1,220 @@
@@ -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 |
||||
} |
@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
|
||||
package ffmpeg |
||||
package transcoder |
||||
|
||||
import ( |
||||
"github.com/owncast/owncast/models" |
@ -0,0 +1,39 @@
@@ -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) |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package webhooks |
||||
|
||||
import "github.com/owncast/owncast/models" |
||||
|
||||
func SendStreamStatusEvent(eventType models.EventType) { |
||||
SendEventToWebhooks(WebhookEvent{Type: eventType}) |
||||
} |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
package webhooks |
||||
|
||||
import ( |
||||
"bytes" |
||||
"encoding/json" |
||||
"net/http" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/models" |
||||
) |
||||
|
||||
type WebhookEvent struct { |
||||
Type models.EventType `json:"type"` // messageSent | userJoined | userNameChange
|
||||
EventData interface{} `json:"eventData,omitempty"` |
||||
} |
||||
|
||||
type WebhookChatMessage struct { |
||||
Author string `json:"author,omitempty"` |
||||
Body string `json:"body,omitempty"` |
||||
RawBody string `json:"rawBody,omitempty"` |
||||
ID string `json:"id,omitempty"` |
||||
Visible bool `json:"visible"` |
||||
Timestamp *time.Time `json:"timestamp,omitempty"` |
||||
} |
||||
|
||||
func SendEventToWebhooks(payload WebhookEvent) { |
||||
webhooks := data.GetWebhooksForEvent(payload.Type) |
||||
|
||||
for _, webhook := range webhooks { |
||||
log.Debugf("Event %s sent to Webhook %s", payload.Type, webhook.URL) |
||||
if err := sendWebhook(webhook.URL, payload); err != nil { |
||||
log.Errorf("Event: %s failed to send to webhook: %s Error: %s", payload.Type, webhook.URL, err) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func sendWebhook(url string, payload WebhookEvent) error { |
||||
jsonText, err := json.Marshal(payload) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonText)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
req.Header.Set("Content-Type", "application/json") |
||||
|
||||
client := &http.Client{} |
||||
|
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
defer resp.Body.Close() |
||||
|
||||
if err := data.SetWebhookAsUsed(url); err != nil { |
||||
log.Warnln(err) |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -1,4 +0,0 @@
@@ -1,4 +0,0 @@
|
||||
# Stream description and content can go here. |
||||
|
||||
1. Edit `content.md` in markdown. |
||||
1. And it'll go here. |
File diff suppressed because one or more lines are too long
@ -1,50 +0,0 @@
@@ -1,50 +0,0 @@
|
||||
# See https://owncast.online/docs/configuration/ for more details |
||||
|
||||
instanceDetails: |
||||
name: Owncast |
||||
title: Owncast |
||||
summary: "This is brief summary of whom you are or what your stream is. You can read more about it at owncast.online. You can edit this description in your config file." |
||||
|
||||
logo: /img/logo.svg |
||||
|
||||
tags: |
||||
- music |
||||
- software |
||||
- streaming |
||||
|
||||
nsfw: false |
||||
|
||||
# https://owncast.online/docs/configuration/#external-links |
||||
# for full list of supported social links. All optional. |
||||
socialHandles: |
||||
- platform: github |
||||
url: http://github.com/owncast/owncast |
||||
- platform: mastodon |
||||
url: http://mastodon.something/owncast |
||||
|
||||
videoSettings: |
||||
# Change this value and keep it secure. Treat it like a password to your live stream. |
||||
streamingKey: abc123 |
||||
|
||||
streamQualities: |
||||
- low: |
||||
videoBitrate: 800 |
||||
scaledWidth: 640 |
||||
encoderPreset: veryfast |
||||
|
||||
- medium: |
||||
videoBitrate: 1200 |
||||
encoderPreset: veryfast |
||||
|
||||
- high: |
||||
videoBitrate: 1600 |
||||
encoderPreset: veryfast |
||||
|
||||
# Set to true if you don't want the service checking for future releases. |
||||
disableUpgradeChecks: false |
||||
|
||||
# Off by default. You can optionally list yourself in the Owncast directory. |
||||
# Make sure your instanceURL is the public URL to your Owncast instance. |
||||
yp: |
||||
enabled: true |
||||
instanceURL: https://stream.myserver.org |
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
package models |
||||
|
||||
import ( |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
const ( |
||||
// ScopeCanSendUserMessages will allow sending chat messages as users.
|
||||
ScopeCanSendUserMessages = "CAN_SEND_MESSAGES" |
||||
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
|
||||
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES" |
||||
// ScopeHasAdminAccess will allow performing administrative actions on the server.
|
||||
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS" |
||||
) |
||||
|
||||
// For a scope to be seen as "valid" it must live in this slice.
|
||||
var validAccessTokenScopes = []string{ |
||||
ScopeCanSendUserMessages, |
||||
ScopeCanSendSystemMessages, |
||||
ScopeHasAdminAccess, |
||||
} |
||||
|
||||
// AccessToken gives access to 3rd party code to access specific Owncast APIs.
|
||||
type AccessToken struct { |
||||
Token string `json:"token"` |
||||
Name string `json:"name"` |
||||
Scopes []string `json:"scopes"` |
||||
Timestamp time.Time `json:"timestamp"` |
||||
LastUsed *time.Time `json:"lastUsed"` |
||||
} |
||||
|
||||
// HasValidScopes will verify that all the scopes provided are valid.
|
||||
// This is not a efficient method.
|
||||
func HasValidScopes(scopes []string) bool { |
||||
for _, scope := range scopes { |
||||
if !findItemInSlice(validAccessTokenScopes, scope) { |
||||
log.Errorln("Invalid scope", scope) |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
func findItemInSlice(slice []string, value string) bool { |
||||
for _, item := range slice { |
||||
if item == value { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
package models |
||||
|
||||
import "time" |
||||
|
||||
// ChatActionEvent represents a generic action that took place by a chat user.
|
||||
type ChatActionEvent struct { |
||||
Username string `json:"username"` |
||||
Type EventType `json:"type"` |
||||
ID string `json:"id"` |
||||
Timestamp time.Time `json:"timestamp,omitempty"` |
||||
Message string `json:"message,omitempty"` |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package models |
||||
|
||||
// CurrentBroadcast represents the configuration associated with the currently active stream.
|
||||
type CurrentBroadcast struct { |
||||
OutputSettings []StreamOutputVariant `json:"outputSettings"` |
||||
LatencyLevel LatencyLevel `json:"latencyLevel"` |
||||
} |
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
package models |
||||
|
||||
// EventType is the type of a websocket event.
|
||||
type EventType = string |
||||
|
||||
const ( |
||||
// MessageSent is the event sent when a chat event takes place.
|
||||
MessageSent EventType = "CHAT" |
||||
// UserJoined is the event sent when a chat user join action takes place.
|
||||
UserJoined EventType = "USER_JOINED" |
||||
// UserNameChanged is the event sent when a chat username change takes place.
|
||||
UserNameChanged EventType = "NAME_CHANGE" |
||||
// VisibiltyToggled is the event sent when a chat message's visibility changes.
|
||||
VisibiltyToggled EventType = "VISIBILITY-UPDATE" |
||||
// PING is a ping message.
|
||||
PING EventType = "PING" |
||||
// PONG is a pong message.
|
||||
PONG EventType = "PONG" |
||||
// StreamStarted represents a stream started event.
|
||||
StreamStarted EventType = "STREAM_STARTED" |
||||
// StreamStopped represents a stream stopped event.
|
||||
StreamStopped EventType = "STREAM_STOPPED" |
||||
// SystemMessageSent is the event sent when a system message is sent.
|
||||
SystemMessageSent EventType = "SYSTEM" |
||||
// ChatActionSent is a generic chat action that can be used for anything that doesn't need specific handling or formatting.
|
||||
ChatActionSent EventType = "CHAT_ACTION" |
||||
) |
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
package models |
||||
|
||||
// LatencyLevel is a representation of HLS configuration values.
|
||||
type LatencyLevel struct { |
||||
Level int `json:"level"` |
||||
SecondsPerSegment int `json:"-"` |
||||
SegmentCount int `json:"-"` |
||||
} |
||||
|
||||
// GetLatencyConfigs will return the available latency level options.
|
||||
func GetLatencyConfigs() map[int]LatencyLevel { |
||||
return map[int]LatencyLevel{ |
||||
1: {Level: 1, SecondsPerSegment: 1, SegmentCount: 2}, |
||||
2: {Level: 2, SecondsPerSegment: 2, SegmentCount: 2}, |
||||
3: {Level: 3, SecondsPerSegment: 3, SegmentCount: 3}, |
||||
4: {Level: 4, SecondsPerSegment: 3, SegmentCount: 4}, // Default
|
||||
5: {Level: 5, SecondsPerSegment: 4, SegmentCount: 5}, |
||||
6: {Level: 6, SecondsPerSegment: 6, SegmentCount: 10}, |
||||
} |
||||
} |
||||
|
||||
// GetLatencyLevel will return the latency level at index.
|
||||
func GetLatencyLevel(index int) LatencyLevel { |
||||
return GetLatencyConfigs()[index] |
||||
} |
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
package models |
||||
|
||||
// S3 is the storage configuration.
|
||||
type S3 struct { |
||||
Enabled bool `json:"enabled"` |
||||
Endpoint string `json:"endpoint,omitempty"` |
||||
ServingEndpoint string `json:"servingEndpoint,omitempty"` |
||||
AccessKey string `json:"accessKey,omitempty"` |
||||
Secret string `json:"secret,omitempty"` |
||||
Bucket string `json:"bucket,omitempty"` |
||||
Region string `json:"region,omitempty"` |
||||
ACL string `json:"acl,omitempty"` |
||||
} |
@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
|
||||
package models |
||||
|
||||
// SocialHandle represents an external link.
|
||||
type SocialHandle struct { |
||||
Platform string `yaml:"platform" json:"platform,omitempty"` |
||||
URL string `yaml:"url" json:"url,omitempty"` |
||||
Icon string `json:"icon,omitempty"` |
||||
} |
||||
|
||||
// GetSocialHandle will return the details for a supported platform.
|
||||
func GetSocialHandle(platform string) *SocialHandle { |
||||
allPlatforms := GetAllSocialHandles() |
||||
if platform, ok := allPlatforms[platform]; ok { |
||||
return &platform |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// GetAllSocialHandles will return a list of all the social platforms we support.
|
||||
func GetAllSocialHandles() map[string]SocialHandle { |
||||
socialHandlePlatforms := map[string]SocialHandle{ |
||||
"bandcamp": { |
||||
Platform: "Bandcamp", |
||||
Icon: "/img/platformlogos/bandcamp.svg", |
||||
}, |
||||
"discord": { |
||||
Platform: "Discord", |
||||
Icon: "/img/platformlogos/discord.svg", |
||||
}, |
||||
"facebook": { |
||||
Platform: "Facebook", |
||||
Icon: "/img/platformlogos/facebook.svg", |
||||
}, |
||||
"github": { |
||||
Platform: "GitHub", |
||||
Icon: "/img/platformlogos/github.svg", |
||||
}, |
||||
"gitlab": { |
||||
Platform: "GitLab", |
||||
Icon: "/img/platformlogos/gitlab.svg", |
||||
}, |
||||
"instagram": { |
||||
Platform: "Instagram", |
||||
Icon: "/img/platformlogos/instagram.svg", |
||||
}, |
||||
"keyoxide": { |
||||
Platform: "Keyoxide", |
||||
Icon: "/img/platformlogos/keyoxide.png", |
||||
}, |
||||
"kofi": { |
||||
Platform: "Ko-Fi", |
||||
Icon: "/img/platformlogos/ko-fi.svg", |
||||
}, |
||||
"linkedin": { |
||||
Platform: "LinkedIn", |
||||
Icon: "/img/platformlogos/linkedin.svg", |
||||
}, |
||||
"mastodon": { |
||||
Platform: "Mastodon", |
||||
Icon: "/img/platformlogos/mastodon.svg", |
||||
}, |
||||
"patreon": { |
||||
Platform: "Patreon", |
||||
Icon: "/img/platformlogos/patreon.svg", |
||||
}, |
||||
"paypal": { |
||||
Platform: "Paypal", |
||||
Icon: "/img/platformlogos/paypal.svg", |
||||
}, |
||||
"snapchat": { |
||||
Platform: "Snapchat", |
||||
Icon: "/img/platformlogos/snapchat.svg", |
||||
}, |
||||
"soundcloud": { |
||||
Platform: "Soundcloud", |
||||
Icon: "/img/platformlogos/soundcloud.svg", |
||||
}, |
||||
"spotify": { |
||||
Platform: "Spotify", |
||||
Icon: "/img/platformlogos/spotify.svg", |
||||
}, |
||||
"tiktok": { |
||||
Platform: "TikTok", |
||||
Icon: "/img/platformlogos/tiktok.svg", |
||||
}, |
||||
"twitch": { |
||||
Platform: "Twitch", |
||||
Icon: "/img/platformlogos/twitch.svg", |
||||
}, |
||||
"twitter": { |
||||
Platform: "Twitter", |
||||
Icon: "/img/platformlogos/twitter.svg", |
||||
}, |
||||
"youtube": { |
||||
Platform: "YouTube", |
||||
Icon: "/img/platformlogos/youtube.svg", |
||||
}, |
||||
"donate": { |
||||
Platform: "Donations", |
||||
Icon: "/img/platformlogos/donate.svg", |
||||
}, |
||||
"follow": { |
||||
Platform: "Follow", |
||||
Icon: "/img/platformlogos/follow.svg", |
||||
}, |
||||
} |
||||
|
||||
return socialHandlePlatforms |
||||
} |
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
package models |
||||
|
||||
import "encoding/json" |
||||
|
||||
// StreamOutputVariant defines the output specifics of a single HLS stream variant.
|
||||
type StreamOutputVariant struct { |
||||
// Enable passthrough to copy the video and/or audio directly from the
|
||||
// incoming stream and disable any transcoding. It will ignore any of
|
||||
// the below settings.
|
||||
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"` |
||||
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"` |
||||
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"` |
||||
AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"` |
||||
|
||||
// Set only one of these in order to keep your current aspect ratio.
|
||||
// Or set neither to not scale the video.
|
||||
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"` |
||||
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"` |
||||
|
||||
Framerate int `yaml:"framerate" json:"framerate"` |
||||
EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"` // Remove after migration is no longer used
|
||||
// CPUUsageLevel represents a codec preset to configure CPU usage.
|
||||
CPUUsageLevel int `json:"cpuUsageLevel"` |
||||
} |
||||
|
||||
// GetFramerate returns the framerate or default.
|
||||
func (q *StreamOutputVariant) GetFramerate() int { |
||||
if q.IsVideoPassthrough { |
||||
return 0 |
||||
} |
||||
|
||||
if q.Framerate > 0 { |
||||
return q.Framerate |
||||
} |
||||
|
||||
return 24 |
||||
} |
||||
|
||||
// GetEncoderPreset returns the preset or default.
|
||||
func (q *StreamOutputVariant) GetEncoderPreset() string { |
||||
if q.IsVideoPassthrough { |
||||
return "" |
||||
} |
||||
|
||||
if q.EncoderPreset != "" { |
||||
return q.EncoderPreset |
||||
} |
||||
|
||||
return "veryfast" |
||||
} |
||||
|
||||
// GetCPUUsageLevel will return the libx264 codec encoder preset that maps to a level.
|
||||
func (q *StreamOutputVariant) GetCPUUsageLevel() int { |
||||
presetMapping := map[string]int{ |
||||
"ultrafast": 1, |
||||
"superfast": 2, |
||||
"veryfast": 3, |
||||
"faster": 4, |
||||
"fast": 5, |
||||
} |
||||
|
||||
return presetMapping[q.GetEncoderPreset()] |
||||
} |
||||
|
||||
// GetIsAudioPassthrough will return if this variant audio is passthrough.
|
||||
func (q *StreamOutputVariant) GetIsAudioPassthrough() bool { |
||||
if q.IsAudioPassthrough { |
||||
return true |
||||
} |
||||
|
||||
if q.AudioBitrate == 0 { |
||||
return true |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
// MarshalJSON is a custom JSON marshal function for video stream qualities.
|
||||
func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) { |
||||
type Alias StreamOutputVariant |
||||
return json.Marshal(&struct { |
||||
Framerate int `json:"framerate"` |
||||
*Alias |
||||
}{ |
||||
Framerate: q.GetFramerate(), |
||||
Alias: (*Alias)(q), |
||||
}) |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
package models |
||||
|
||||
import "time" |
||||
|
||||
// UserJoinedEvent represents an event when a user joins the chat.
|
||||
type UserJoinedEvent struct { |
||||
Username string `json:"username"` |
||||
Type EventType `json:"type"` |
||||
ID string `json:"id"` |
||||
Timestamp time.Time `json:"timestamp,omitempty"` |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
package models |
||||
|
||||
import "time" |
||||
|
||||
// Webhook is an event that is sent to 3rd party, external services with details about something that took place within an Owncast server.
|
||||
type Webhook struct { |
||||
ID int `json:"id"` |
||||
URL string `json:"url"` |
||||
Events []EventType `json:"events"` |
||||
Timestamp time.Time `json:"timestamp"` |
||||
LastUsed *time.Time `json:"lastUsed"` |
||||
} |
||||
|
||||
// For an event to be seen as "valid" it must live in this slice.
|
||||
var validEvents = []EventType{ |
||||
MessageSent, |
||||
UserJoined, |
||||
UserNameChanged, |
||||
VisibiltyToggled, |
||||
StreamStarted, |
||||
StreamStopped, |
||||
} |
||||
|
||||
// HasValidEvents will verify that all the events provided are valid.
|
||||
// This is not a efficient method.
|
||||
func HasValidEvents(events []EventType) bool { |
||||
for _, event := range events { |
||||
if !findItemInSlice(validEvents, event) { |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
} |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 46 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue