mirror of
https://github.com/owncast/owncast.git
synced 2024-11-21 12:18:02 +03:00
0.0.6 -> Master (#731)
* Implement webhook events for external integrations (#574) * Implement webhook events for external integrations Reference #556 * move message type to models and remove duplicate * add json header so content type can be determined * Pass at migrating webhooks to datastore + management apis (#589) * Pass at migrating webhooks to datastore + management apis * Support nil lastUsed timestamps and return back the new webhook on create * Cleanup from review feedback * Simplify a bit Co-authored-by: Aaron Ogle <aaron@geekgonecrazy.com> Co-authored-by: Gabe Kangas <gabek@real-ity.com> * Webhook query cleanup * Access tokens + Send system message external API (#585) * New add, get and delete access token APIs * Create auth token middleware * Update last_used timestamp when using an access token * Add auth'ed endpoint for sending system messages * Cleanup * Update api spec for new apis * Commit updated API documentation * Add auth'ed endpoint for sending user chat messages * Return access token string * Commit updated API documentation * Fix route * Support nil lastUsed time * Commit updated Javascript packages * Remove duplicate function post rebase * Fix msg id generation * Update controllers/admin/chat.go Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> * Webhook query cleanup * Add SystemMessageSent to EventType Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> * Set webhook as used on completion. Closes #610 * Display webhook errors as errors * Commit updated API documentation * Add user joined chat event * Change integration API paths. Update API spec * Update development version of admin that supports integration apis * Commit updated API documentation * Add automated tests for external integration APIs * check error * quiet this test for now * Route up some additional 3rd party apis. #638 * Commit updated API documentation * Save username on user joined event * Add missing scope to valid scopes list * Add generic chat action event API for 3rd parties. Closes #666 * Commit updated API documentation * First pass at moving WIP config framework into project for #234 * Only support exported fields in custom types * Using YP get/set key as a first pass at using the data layer. Fixes + integration. * Ignore test db * Start adding getters and setters for config values * More get/set config work. Starting to populate api with data * Wire up some config edit endpoints * More endpoints * Disable cors middleware * Add more endpoints and add test to test them * Remove the in-memory change APIs * Add endpoint for changing tags * Add more config endpoints * Starting to point more things away from config file and to the datastore * Populate YP with db data * Create new util method for parsing page body markdown and return it in api * Verify proposed path to ffmpeg * For development purposes show the config key in logs * Move stats values to datastore * Moving over more values to the datastore * Move S3 config to datastore * First pass the config -> db migrator * Add the start of the video config apis * It builds pointing everything away from the config * Tweak ffmpeg path error message * Backup database every hour. Closes #549 * Config + defaults + migration work for db * Cleanup logging * Remove all the old config structs * Add descriptive info about migration * Tweak ffmpeg validation logic * Fix db backup path. backup on db version migration * Set video and s3 configurations * Update api spec with new config endpoints * Add migrator for stats file * Commit updated API documentation * Use a dynamic system port for internal HLS writes. Closes #577 (#626) * Use a dynamic system port for internal HLS writes. Closes #577 * Cleanup * YP key migration to datastore * Create a backup directory if needed before migrations * Remove config test that no longer makes sense. Cleanup. * Change number types from float32 to float64 * Update automated test suite * Allow restoring a database backup via command line flags. Closes #549 * Add new hls segment config api * Commit updated API documentation * Update apis to require a value container property * add socialHandles api * Commit updated API documentation * Add new latancy level setting to replace segment settings * Commit updated API documentation * Fix spelling * Commit updated API documentation * hardcode a json api of available social platforms * Add additional icons * Return social handles in server config api * Add socialhandles validation to test * Move list of hard coded social platforms to an api * Remove audio only code from transcoder since we do not use it * Add latency levels api + snapshot of video settings as current broadcast * Add config/serverurl endpoint * Return 404 on YP api if disabled * Surface stream title in YP response * Add stream title to web ui * Cleanup log message. Closes #520 * Rename ffmpeg package to transcoder * Add ws package for testing * Reduce chat backlog to past 5hrs, max 50. Closes #548 * Fix error formatting * Add endpoint for resetting yp registration * Add yp/reset to api spec. return status in response * Return zero viewer count if stream is offline. Closes #422 * Post-rebase fixes * Fix merge conflict in openapi file * Commit updated API documentation * Standardize controller names * Support setting the stream key via the command line. Closes #665 * Return social handles with YP data. First half of https://github.com/owncast/owncast-yp/issues/28 * Give the YP package access to server status regardless if enabled or not * Change delay in automated tests * Add stream title integration API. For #638 * Commit updated API documentation * Add storage to the migrator * Missing returning NSFW value in server config * Add flag to ignore websocket client. Closes #537 * Add error for parsing broadcaster metadata * Add support for a cli specified http server port. Closes #674 * Add cpu usage levels and a temporary mapping between it and libx264 presets * Test for valid url endpoint when saving s3 config * Re-configure storage on every stream to allow changing storage providers * After 5 minutes of a stream being stopped clear the stream title * Hide viewer count once stream goes offline instead of when player stops * Pull steamTitle from the status that gets updated instead of the config * Commit updated API documentation * Optionally show stream title in the header * Reset stream title when server starts * Show chat action when stream title is updated * Allow system messages to come back in persistence * Split out getting chat history for moderation + fix tests * Remove server title and standardize on name only * Commit updated API documentation * Bump github.com/aws/aws-sdk-go from 1.37.1 to 1.37.2 (#680) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.1 to 1.37.2. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.1...v1.37.2) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add video variant and stream latency config file migrator * Remove mostly unused disable upgrade check bool * Commit updated API documentation * Allow bundling the admin from the 0.0.6 branch * Fix saving port numbers * Use name instead of old title on window focus * Work on latency levels. Fix test to use levels. Clean up transcoder to only reference levels * Another place where title -> name * Fix test * Bump github.com/aws/aws-sdk-go from 1.37.2 to 1.37.3 (#690) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.2 to 1.37.3. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.2...v1.37.3) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update dependabot config * Bump github.com/aws/aws-sdk-go from 1.37.3 to 1.37.5 (#693) Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.37.3 to 1.37.5. - [Release notes](https://github.com/aws/aws-sdk-go/releases) - [Changelog](https://github.com/aws/aws-sdk-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-go/compare/v1.37.3...v1.37.5) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript (#694) * Bump video.js from 7.10.2 to 7.11.4 in /build/javascript Bumps [video.js](https://github.com/videojs/video.js) from 7.10.2 to 7.11.4. - [Release notes](https://github.com/videojs/video.js/releases) - [Changelog](https://github.com/videojs/video.js/blob/main/CHANGELOG.md) - [Commits](https://github.com/videojs/video.js/compare/v7.10.2...v7.11.4) Signed-off-by: dependabot[bot] <support@github.com> * Commit updated Javascript packages Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> * Make the latency migrator dynamic so I can tweak values easier * Split out fetching ffmpeg path from validating the path so it can be changed in the admin * Some commenting and linter cleanup * Validate the path for a logo change and throw an error if it does not exist * Logo change requests have to be a real file now * Cleanup, making linter happy * Format javascript on push * Only format js in master * Tweak latency level values * Remove unused config file examples * Fix thumbnail generation after messing with the ffmpeg path getter * Reduce how often we report high hardware utilization warnings * Bundle the 0.0.6 branch version of the admin * Return validated ffmpeg path in admin server config * Change the logo to be stored in the data directory instead of webroot * Bump postcss from 8.2.4 to 8.2.5 in /build/javascript (#702) Bumps [postcss](https://github.com/postcss/postcss) from 8.2.4 to 8.2.5. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.2.4...8.2.5) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Default config file no longer used * don't show stream title when offline addresses https://github.com/owncast/owncast/issues/677 * Remove auto-clearing stream title. #677 * webroot -> data when using logo as thumbnail * Do not list websocket/access token create/delete as integration APIs * Commit updated API documentation * Bundle updated admin * Remove pointing to the 0.0.6 admin branch * Linter cleanup * Linter cleanup * Add donations and follow links to show up under social handles * Prettified Code! * More linter cleanup * Update admin bundle * Remove use of platforms.js and return icons with social handles. Closes #732 * Update admin bundle * Support custom config path for use in migration * Remove unused platform-logos.gif * Reduce log level of message * Remove unused logo files in static dir * Handle dev vs. release build info * Restore logo.png for initial thumbnail * Cleanup some files from the build process that are not needed * Fix incorrect build-time injection var * Fix missing file getting copied to the build * Remove console directory message. * Update admin bundle * Fix comment * Report storage setup error * add some value set error checking * Use validated dynamic ffmpeg path for animated gif preview * Make chat message links be white so they don't hide in the bg. Closes #599 * Restore conditional that was accidentally removed Co-authored-by: Aaron Ogle <geekgonecrazy@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Ginger Wong <omqmail@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: nebunez <uoj2y7wak869@opayq.net> Co-authored-by: gabek <gabek@users.noreply.github.com>
This commit is contained in:
parent
05ec74a1e3
commit
bc2caadb74
125 changed files with 5544 additions and 1510 deletions
4
.github/workflows/javascript-formatting.yml
vendored
4
.github/workflows/javascript-formatting.yml
vendored
|
@ -4,8 +4,8 @@ name: Format Javascript
|
|||
on:
|
||||
pull_request:
|
||||
push:
|
||||
# branches:
|
||||
# - master
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -32,6 +32,7 @@ data/
|
|||
transcoder.log
|
||||
chat.db
|
||||
.yp.key
|
||||
|
||||
backup/
|
||||
!webroot/js/web_modules/**/dist
|
||||
!core/data
|
||||
test/test.db
|
||||
|
|
22
.vscode/settings.json
vendored
Normal file
22
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"Debugln",
|
||||
"Errorln",
|
||||
"Ffmpeg",
|
||||
"Mbps",
|
||||
"Owncast",
|
||||
"RTMP",
|
||||
"Tracef",
|
||||
"Traceln",
|
||||
"Warnf",
|
||||
"Warnln",
|
||||
"ffmpegpath",
|
||||
"ffmpg",
|
||||
"mattn",
|
||||
"nolint",
|
||||
"preact",
|
||||
"rtmpserverport",
|
||||
"sqlite",
|
||||
"videojs"
|
||||
]
|
||||
}
|
|
@ -16,9 +16,11 @@ shutdown () {
|
|||
trap shutdown INT TERM ABRT EXIT
|
||||
|
||||
echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..."
|
||||
git clone --depth 1 https://github.com/owncast/owncast-admin 2> /dev/null
|
||||
git clone https://github.com/owncast/owncast-admin 2> /dev/null
|
||||
cd owncast-admin
|
||||
|
||||
git checkout 0.0.6
|
||||
|
||||
echo "Installing npm modules for the owncast admin..."
|
||||
npm --silent install 2> /dev/null
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ BUILD_TEMP_DIRECTORY="$(mktemp -d)"
|
|||
cd $BUILD_TEMP_DIRECTORY
|
||||
|
||||
echo "Cloning owncast into $BUILD_TEMP_DIRECTORY..."
|
||||
git clone --depth 1 https://github.com/owncast/owncast 2> /dev/null
|
||||
git clone https://github.com/owncast/owncast 2> /dev/null
|
||||
cd owncast
|
||||
|
||||
echo "Changing to branch: $GIT_BRANCH"
|
||||
|
@ -58,26 +58,22 @@ build() {
|
|||
VERSION=$4
|
||||
GIT_COMMIT=$5
|
||||
|
||||
echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH}..."
|
||||
echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH} ${GIT_COMMIT}..."
|
||||
|
||||
mkdir -p dist/${NAME}
|
||||
mkdir -p dist/${NAME}/webroot/static
|
||||
mkdir -p dist/${NAME}/data
|
||||
|
||||
# Default files
|
||||
cp config-default.yaml dist/${NAME}/config.yaml
|
||||
cp data/content-example.md dist/${NAME}/data/content.md
|
||||
|
||||
cp -R webroot/ dist/${NAME}/webroot/
|
||||
|
||||
# Copy the production pruned+minified css to the build's directory.
|
||||
cp "${TMPDIR}tailwind.min.css" ./dist/${NAME}/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
|
||||
cp -R static/ dist/${NAME}/static
|
||||
cp README.md dist/${NAME}
|
||||
cp webroot/img/logo.svg dist/${NAME}/data/logo.svg
|
||||
|
||||
pushd dist/${NAME} >> /dev/null
|
||||
|
||||
CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildType=${NAME}" -targets "${OS}/${ARCH}" github.com/owncast/owncast
|
||||
CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildPlatform=${NAME}" -targets "${OS}/${ARCH}" github.com/owncast/owncast
|
||||
mv owncast-*-${ARCH} owncast
|
||||
|
||||
zip -r -q -8 ../owncast-$VERSION-$NAME.zip .
|
||||
|
|
325
config/config.go
325
config/config.go
|
@ -1,299 +1,40 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Config contains a reference to the configuration.
|
||||
var Config *config
|
||||
var _default config
|
||||
// These are runtime-set values used for configuration.
|
||||
|
||||
type config struct {
|
||||
DatabaseFilePath string `yaml:"databaseFile"`
|
||||
EnableDebugFeatures bool `yaml:"-"`
|
||||
FFMpegPath string `yaml:"ffmpegPath"`
|
||||
Files files `yaml:"files"`
|
||||
InstanceDetails InstanceDetails `yaml:"instanceDetails"`
|
||||
S3 S3 `yaml:"s3"`
|
||||
VersionInfo string `yaml:"-"` // For storing the version/build number
|
||||
VersionNumber string `yaml:"-"`
|
||||
VideoSettings videoSettings `yaml:"videoSettings"`
|
||||
WebServerPort int `yaml:"webServerPort"`
|
||||
RTMPServerPort int `yaml:"rtmpServerPort"`
|
||||
DisableUpgradeChecks bool `yaml:"disableUpgradeChecks"`
|
||||
YP YP `yaml:"yp"`
|
||||
}
|
||||
|
||||
// InstanceDetails defines the user-visible information about this particular instance.
|
||||
type InstanceDetails struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Summary string `yaml:"summary" json:"summary"`
|
||||
// Logo logo `yaml:"logo" json:"logo"`
|
||||
Logo string `yaml:"logo" json:"logo"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `yaml:"nsfw" json:"nsfw"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
}
|
||||
|
||||
// type logo struct {
|
||||
// Large string `yaml:"large" json:"large"`
|
||||
// Small string `yaml:"small" json:"small"`
|
||||
// }
|
||||
|
||||
type socialHandle struct {
|
||||
Platform string `yaml:"platform" json:"platform"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Icon string `yaml:"icon" json:"icon"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"`
|
||||
StreamingKey string `yaml:"streamingKey"`
|
||||
StreamQualities []StreamQuality `yaml:"streamQualities"`
|
||||
HighestQualityStreamIndex int `yaml:"-"`
|
||||
}
|
||||
|
||||
// YP allows registration to the central Owncast YP (Yellow pages) service operating as a directory.
|
||||
type YP struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
InstanceURL string `yaml:"instanceURL" json:"instanceUrl"` // The public URL the directory should link to
|
||||
YPServiceURL string `yaml:"ypServiceURL" json:"-"` // The base URL to the YP API to register with (optional)
|
||||
}
|
||||
|
||||
// StreamQuality defines the specifics of a single HLS stream variant.
|
||||
type StreamQuality struct {
|
||||
// Enable passthrough to copy the video and/or audio directly from the
|
||||
// incoming stream and disable any transcoding. It will ignore any of
|
||||
// the below settings.
|
||||
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"`
|
||||
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"`
|
||||
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"`
|
||||
AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"`
|
||||
|
||||
// Set only one of these in order to keep your current aspect ratio.
|
||||
// Or set neither to not scale the video.
|
||||
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"`
|
||||
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"`
|
||||
|
||||
Framerate int `yaml:"framerate" json:"framerate"`
|
||||
EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"`
|
||||
}
|
||||
|
||||
type files struct {
|
||||
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"`
|
||||
}
|
||||
|
||||
// S3 is for configuring the S3 integration.
|
||||
type S3 struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"`
|
||||
ServingEndpoint string `yaml:"servingEndpoint" json:"servingEndpoint,omitempty"`
|
||||
AccessKey string `yaml:"accessKey" json:"accessKey,omitempty"`
|
||||
Secret string `yaml:"secret" json:"secret,omitempty"`
|
||||
Bucket string `yaml:"bucket" json:"bucket,omitempty"`
|
||||
Region string `yaml:"region" json:"region,omitempty"`
|
||||
ACL string `yaml:"acl" json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
func (c *config) load(filePath string) error {
|
||||
if !utils.DoesFileExists(filePath) {
|
||||
log.Fatal("ERROR: valid config.yaml is required. Copy config-default.yaml to config.yaml and edit")
|
||||
}
|
||||
|
||||
yamlFile, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("yamlFile.Get err #%v ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(yamlFile, c); err != nil {
|
||||
log.Fatalf("Error reading the config file.\nHave you recently updated your version of Owncast?\nIf so there may be changes to the config.\nPlease read the change log for your version at https://owncast.online/posts/\n%v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.VideoSettings.HighestQualityStreamIndex = findHighestQuality(c.VideoSettings.StreamQualities)
|
||||
|
||||
// Add custom page content to the instance details.
|
||||
customContentMarkdownData, err := ioutil.ReadFile(ExtraInfoFile)
|
||||
if err == nil {
|
||||
customContentMarkdownString := string(customContentMarkdownData)
|
||||
c.InstanceDetails.ExtraPageContent = utils.RenderSimpleMarkdown(customContentMarkdownString)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *config) verifySettings() error {
|
||||
if c.VideoSettings.StreamingKey == "" {
|
||||
return errors.New("No stream key set. Please set one in your config file.")
|
||||
}
|
||||
|
||||
if c.S3.Enabled {
|
||||
if c.S3.AccessKey == "" || c.S3.Secret == "" {
|
||||
return errors.New("s3 support requires an access key and secret")
|
||||
}
|
||||
|
||||
if c.S3.Region == "" || c.S3.Endpoint == "" {
|
||||
return errors.New("s3 support requires a region and endpoint")
|
||||
}
|
||||
|
||||
if c.S3.Bucket == "" {
|
||||
return errors.New("s3 support requires a bucket created for storing public video segments")
|
||||
}
|
||||
}
|
||||
|
||||
if c.YP.Enabled && c.YP.InstanceURL == "" {
|
||||
return errors.New("YP is enabled but instance url is not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *config) GetVideoSegmentSecondsLength() int {
|
||||
if c.VideoSettings.ChunkLengthInSeconds != 0 {
|
||||
return c.VideoSettings.ChunkLengthInSeconds
|
||||
}
|
||||
|
||||
return _default.GetVideoSegmentSecondsLength()
|
||||
}
|
||||
|
||||
func (c *config) GetPublicWebServerPort() int {
|
||||
if c.WebServerPort != 0 {
|
||||
return c.WebServerPort
|
||||
}
|
||||
|
||||
return _default.WebServerPort
|
||||
}
|
||||
|
||||
func (c *config) GetRTMPServerPort() int {
|
||||
if c.RTMPServerPort != 0 {
|
||||
return c.RTMPServerPort
|
||||
}
|
||||
|
||||
return _default.RTMPServerPort
|
||||
}
|
||||
|
||||
func (c *config) GetMaxNumberOfReferencedSegmentsInPlaylist() int {
|
||||
if c.Files.MaxNumberInPlaylist > 0 {
|
||||
return c.Files.MaxNumberInPlaylist
|
||||
}
|
||||
|
||||
return _default.GetMaxNumberOfReferencedSegmentsInPlaylist()
|
||||
}
|
||||
|
||||
func (c *config) GetFFMpegPath() string {
|
||||
if c.FFMpegPath != "" {
|
||||
if err := verifyFFMpegPath(c.FFMpegPath); err == nil {
|
||||
return c.FFMpegPath
|
||||
} else {
|
||||
log.Errorln(c.FFMpegPath, "is an invalid path to ffmpeg. Will try to use a copy in your path, if possible.")
|
||||
}
|
||||
}
|
||||
|
||||
// First look to see if ffmpeg is in the current working directory
|
||||
localCopy := "./ffmpeg"
|
||||
hasLocalCopyError := verifyFFMpegPath(localCopy)
|
||||
if hasLocalCopyError == nil {
|
||||
// No error, so all is good. Use the local copy.
|
||||
return localCopy
|
||||
}
|
||||
|
||||
cmd := exec.Command("which", "ffmpeg")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalln("Unable to determine path to ffmpeg. Please specify it in the config file.")
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(string(out))
|
||||
if err := verifyFFMpegPath(path); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (c *config) GetYPServiceHost() string {
|
||||
if c.YP.YPServiceURL != "" {
|
||||
return c.YP.YPServiceURL
|
||||
}
|
||||
|
||||
return _default.YP.YPServiceURL
|
||||
}
|
||||
|
||||
func (c *config) GetDataFilePath() string {
|
||||
if c.DatabaseFilePath != "" {
|
||||
return c.DatabaseFilePath
|
||||
}
|
||||
|
||||
return _default.DatabaseFilePath
|
||||
}
|
||||
|
||||
func (c *config) GetVideoStreamQualities() []StreamQuality {
|
||||
if len(c.VideoSettings.StreamQualities) > 0 {
|
||||
return c.VideoSettings.StreamQualities
|
||||
}
|
||||
|
||||
return _default.VideoSettings.StreamQualities
|
||||
}
|
||||
|
||||
// GetFramerate returns the framerate or default.
|
||||
func (q *StreamQuality) GetFramerate() int {
|
||||
if q.IsVideoPassthrough {
|
||||
return 0
|
||||
}
|
||||
|
||||
if q.Framerate > 0 {
|
||||
return q.Framerate
|
||||
}
|
||||
|
||||
return _default.VideoSettings.StreamQualities[0].Framerate
|
||||
}
|
||||
|
||||
// GetEncoderPreset returns the preset or default.
|
||||
func (q *StreamQuality) GetEncoderPreset() string {
|
||||
if q.IsVideoPassthrough {
|
||||
return ""
|
||||
}
|
||||
|
||||
if q.EncoderPreset != "" {
|
||||
return q.EncoderPreset
|
||||
}
|
||||
|
||||
return _default.VideoSettings.StreamQualities[0].EncoderPreset
|
||||
}
|
||||
|
||||
func (q *StreamQuality) GetIsAudioPassthrough() bool {
|
||||
if q.IsAudioPassthrough {
|
||||
return true
|
||||
}
|
||||
|
||||
if q.AudioBitrate == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Load tries to load the configuration file.
|
||||
func Load(filePath string, versionInfo string, versionNumber string) error {
|
||||
Config = new(config)
|
||||
_default = getDefaults()
|
||||
|
||||
if err := Config.load(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Config.VersionInfo = versionInfo
|
||||
Config.VersionNumber = versionNumber
|
||||
return Config.verifySettings()
|
||||
// DatabaseFilePath is the path to the file ot be used as the global database for this run of the application.
|
||||
var DatabaseFilePath = "data/owncast.db"
|
||||
|
||||
// EnableDebugFeatures will print additional data to help in debugging.
|
||||
var EnableDebugFeatures = false
|
||||
|
||||
// VersionNumber is the current version string.
|
||||
var VersionNumber = StaticVersionNumber
|
||||
|
||||
// WebServerPort is the port for Owncast's webserver that is used for this execution of the service.
|
||||
var WebServerPort = 8080
|
||||
|
||||
// InternalHLSListenerPort is the port for HLS writes that is used for this execution of the service.
|
||||
var InternalHLSListenerPort = "8927"
|
||||
|
||||
// ConfigFilePath is the path to the config file for migration.
|
||||
var ConfigFilePath = "config.yaml"
|
||||
|
||||
// GitCommit is an optional commit this build was made from.
|
||||
var GitCommit = ""
|
||||
|
||||
// BuildPlatform is the optional platform this release was built for.
|
||||
var BuildPlatform = "local"
|
||||
|
||||
// GetReleaseString gets the version string.
|
||||
func GetReleaseString() string {
|
||||
var versionNumber = VersionNumber
|
||||
var buildPlatform = BuildPlatform
|
||||
var gitCommit = GitCommit
|
||||
|
||||
return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit)
|
||||
}
|
||||
|
|
|
@ -1,49 +1 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func findHighestQuality(qualities []StreamQuality) int {
|
||||
type IndexedQuality struct {
|
||||
index int
|
||||
quality StreamQuality
|
||||
}
|
||||
|
||||
if len(qualities) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
indexedQualities := make([]IndexedQuality, 0)
|
||||
for index, quality := range qualities {
|
||||
indexedQuality := IndexedQuality{index, quality}
|
||||
indexedQualities = append(indexedQualities, indexedQuality)
|
||||
}
|
||||
|
||||
sort.Slice(indexedQualities, func(a, b int) bool {
|
||||
if indexedQualities[a].quality.IsVideoPassthrough && !indexedQualities[b].quality.IsVideoPassthrough {
|
||||
return true
|
||||
}
|
||||
|
||||
if !indexedQualities[a].quality.IsVideoPassthrough && indexedQualities[b].quality.IsVideoPassthrough {
|
||||
return false
|
||||
}
|
||||
|
||||
return indexedQualities[a].quality.VideoBitrate > indexedQualities[b].quality.VideoBitrate
|
||||
})
|
||||
|
||||
return indexedQualities[0].index
|
||||
}
|
||||
|
||||
// MarshalJSON is a custom JSON marshal function for video stream qualities.
|
||||
func (q *StreamQuality) MarshalJSON() ([]byte, error) {
|
||||
type Alias StreamQuality
|
||||
return json.Marshal(&struct {
|
||||
Framerate int `json:"framerate"`
|
||||
*Alias
|
||||
}{
|
||||
Framerate: q.GetFramerate(),
|
||||
Alias: (*Alias)(q),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
_default = getDefaults()
|
||||
|
||||
encoderPreset := "veryfast"
|
||||
framerate := 24
|
||||
|
||||
quality := StreamQuality{}
|
||||
if quality.GetEncoderPreset() != encoderPreset {
|
||||
t.Errorf("default encoder preset does not match expected. Got %s, want: %s", quality.GetEncoderPreset(), encoderPreset)
|
||||
}
|
||||
|
||||
if quality.GetFramerate() != framerate {
|
||||
t.Errorf("default framerate does not match expected. Got %d, want: %d", quality.GetFramerate(), framerate)
|
||||
}
|
||||
}
|
|
@ -3,14 +3,23 @@ package config
|
|||
import "path/filepath"
|
||||
|
||||
const (
|
||||
WebRoot = "webroot"
|
||||
PrivateHLSStoragePath = "hls"
|
||||
GeoIPDatabasePath = "data/GeoLite2-City.mmdb"
|
||||
ExtraInfoFile = "data/content.md"
|
||||
StatsFile = "data/stats.json"
|
||||
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
|
||||
StaticVersionNumber = "0.0.6" // Shown when you build from master
|
||||
// WebRoot is the web server root directory.
|
||||
WebRoot = "webroot"
|
||||
// PrivateHLSStoragePath is the HLS write directory.
|
||||
PrivateHLSStoragePath = "hls"
|
||||
// ExtraInfoFile is the markdown file for page content. Remove this after the migrator is removed.
|
||||
ExtraInfoFile = "data/content.md"
|
||||
// StatsFile is the json file we used to save stats in. Remove this after the migrator is removed.
|
||||
StatsFile = "data/stats.json"
|
||||
// FfmpegSuggestedVersion is the version of ffmpeg we suggest.
|
||||
FfmpegSuggestedVersion = "v4.1.5" // Requires the v
|
||||
// BackupDirectory is the directory we write backup files to.
|
||||
BackupDirectory = "backup"
|
||||
)
|
||||
|
||||
var (
|
||||
// PublicHLSStoragePath is the directory we write public HLS files to for distribution.
|
||||
PublicHLSStoragePath = filepath.Join(WebRoot, "hls")
|
||||
)
|
||||
|
|
|
@ -1,22 +1,59 @@
|
|||
package config
|
||||
|
||||
func getDefaults() config {
|
||||
defaults := config{}
|
||||
defaults.WebServerPort = 8080
|
||||
defaults.RTMPServerPort = 1935
|
||||
defaults.VideoSettings.ChunkLengthInSeconds = 4
|
||||
defaults.Files.MaxNumberInPlaylist = 5
|
||||
defaults.YP.Enabled = false
|
||||
defaults.YP.YPServiceURL = "https://yp.owncast.online"
|
||||
defaults.DatabaseFilePath = "data/owncast.db"
|
||||
import "github.com/owncast/owncast/models"
|
||||
|
||||
defaultQuality := StreamQuality{
|
||||
IsAudioPassthrough: true,
|
||||
VideoBitrate: 1200,
|
||||
EncoderPreset: "veryfast",
|
||||
Framerate: 24,
|
||||
}
|
||||
defaults.VideoSettings.StreamQualities = []StreamQuality{defaultQuality}
|
||||
// Defaults will hold default configuration values.
|
||||
type Defaults struct {
|
||||
Name string
|
||||
Title string
|
||||
Summary string
|
||||
Logo string
|
||||
Tags []string
|
||||
PageBodyContent string
|
||||
|
||||
return defaults
|
||||
DatabaseFilePath string
|
||||
WebServerPort int
|
||||
RTMPServerPort int
|
||||
StreamKey string
|
||||
|
||||
YPEnabled bool
|
||||
YPServer string
|
||||
|
||||
SegmentLengthSeconds int
|
||||
SegmentsInPlaylist int
|
||||
StreamVariants []models.StreamOutputVariant
|
||||
}
|
||||
|
||||
// GetDefaults will return default configuration values.
|
||||
func GetDefaults() Defaults {
|
||||
return Defaults{
|
||||
Name: "Owncast",
|
||||
Title: "My Owncast Server",
|
||||
Summary: "This is brief summary of whom you are or what your stream is. You can edit this description in the admin.",
|
||||
Logo: "logo.svg",
|
||||
Tags: []string{
|
||||
"owncast",
|
||||
"streaming",
|
||||
},
|
||||
|
||||
PageBodyContent: "# This is your page content that can be edited from the admin.",
|
||||
|
||||
DatabaseFilePath: "data/owncast.db",
|
||||
|
||||
YPEnabled: false,
|
||||
YPServer: "https://yp.owncast.online",
|
||||
|
||||
WebServerPort: 8080,
|
||||
RTMPServerPort: 1935,
|
||||
StreamKey: "abc123",
|
||||
|
||||
StreamVariants: []models.StreamOutputVariant{
|
||||
{
|
||||
IsAudioPassthrough: true,
|
||||
VideoBitrate: 1200,
|
||||
EncoderPreset: "veryfast",
|
||||
Framerate: 24,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@ import (
|
|||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// verifyFFMpegPath verifies that the path exists, is a file, and is executable.
|
||||
func verifyFFMpegPath(path string) error {
|
||||
// VerifyFFMpegPath verifies that the path exists, is a file, and is executable.
|
||||
func VerifyFFMpegPath(path string) error {
|
||||
stat, err := os.Stat(path)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
|
@ -39,12 +39,12 @@ func verifyFFMpegPath(path string) error {
|
|||
|
||||
response := string(out)
|
||||
if response == "" {
|
||||
return fmt.Errorf("unable to determine the version of your ffmpeg installation at %s. you may experience issues with video.", path)
|
||||
return fmt.Errorf("unable to determine the version of your ffmpeg installation at %s you may experience issues with video", path)
|
||||
}
|
||||
|
||||
responseComponents := strings.Split(response, " ")
|
||||
if len(responseComponents) < 3 {
|
||||
log.Debugf("unable to determine the version of your ffmpeg installation at %s. you may experience issues with video.", path)
|
||||
log.Debugf("unable to determine the version of your ffmpeg installation at %s you may experience issues with video", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -59,7 +59,7 @@ func verifyFFMpegPath(path string) error {
|
|||
}
|
||||
|
||||
if semver.Compare(versionString, FfmpegSuggestedVersion) == -1 {
|
||||
return fmt.Errorf("your %s version of ffmpeg at %s may be older than the suggested version of %s. you may experience issues with video.", versionString, path, FfmpegSuggestedVersion)
|
||||
return fmt.Errorf("your %s version of ffmpeg at %s may be older than the suggested version of %s you may experience issues with video", versionString, path, FfmpegSuggestedVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
100
controllers/admin/accessToken.go
Normal file
100
controllers/admin/accessToken.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
type deleteTokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// CreateAccessToken will generate a 3rd party access token.
|
||||
func CreateAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request createTokenRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all the scopes provided are valid
|
||||
if !models.HasValidScopes(request.Scopes) {
|
||||
controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided"))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateAccessToken()
|
||||
if err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.InsertToken(token, request.Name, request.Scopes); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
controllers.WriteResponse(w, models.AccessToken{
|
||||
Token: token,
|
||||
Name: request.Name,
|
||||
Scopes: request.Scopes,
|
||||
Timestamp: time.Now(),
|
||||
LastUsed: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccessTokens will return all 3rd party access tokens.
|
||||
func GetAccessTokens(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
tokens, err := data.GetAccessTokens()
|
||||
if err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteResponse(w, tokens)
|
||||
}
|
||||
|
||||
// DeleteAccessToken will return a single 3rd party access token.
|
||||
func DeleteAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method != controllers.POST {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request deleteTokenRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Token == "" {
|
||||
controllers.BadRequestHandler(w, errors.New("must provide a token"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.DeleteToken(request.Token); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "deleted token")
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChangeExtraPageContent will change the optional page content.
|
||||
func ChangeExtraPageContent(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request changeExtraPageContentRequest
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
config.Config.InstanceDetails.ExtraPageContent = utils.RenderSimpleMarkdown(request.Key)
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
type changeExtraPageContentRequest struct {
|
||||
Key string `json:"content"`
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChangeStreamKey will change the stream key (in memory).
|
||||
func ChangeStreamKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request changeStreamKeyRequest
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
config.Config.VideoSettings.StreamingKey = request.Key
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
type changeStreamKeyRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChangeStreamName will change the stream key (in memory).
|
||||
func ChangeStreamName(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request changeStreamNameRequest
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
config.Config.InstanceDetails.Name = request.Name
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
type changeStreamNameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChangeStreamTags will change the stream key (in memory).
|
||||
func ChangeStreamTags(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request changeStreamTagsRequest
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
config.Config.InstanceDetails.Tags = request.Tags
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
type changeStreamTagsRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChangeStreamTitle will change the stream key (in memory).
|
||||
func ChangeStreamTitle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request changeStreamTitleRequest
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
config.Config.InstanceDetails.Title = request.Title
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
type changeStreamTitleRequest struct {
|
||||
Title string `json:"title"`
|
||||
}
|
|
@ -4,36 +4,36 @@ package admin
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
// UpdateMessageVisibility updates an array of message IDs to have the same visiblity.
|
||||
func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
if r.Method != controllers.POST {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request messageVisibilityUpdateRequest // creates an empty struc
|
||||
var request messageVisibilityUpdateRequest
|
||||
|
||||
err := decoder.Decode(&request) // decode the json into `request`
|
||||
err := decoder.Decode(&request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
// // make sql update call here.
|
||||
// // := means create a new var
|
||||
// _db := data.GetDatabase()
|
||||
// updateMessageVisibility(_db, request)
|
||||
|
||||
if err := chat.SetMessagesVisibility(request.IDArray, request.Visible); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
|
@ -49,12 +49,99 @@ type messageVisibilityUpdateRequest struct {
|
|||
|
||||
// GetChatMessages returns all of the chat messages, unfiltered.
|
||||
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// middleware.EnableCors(&w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
messages := core.GetAllChatMessages(false)
|
||||
messages := core.GetModerationChatMessages()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(messages); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendSystemMessage will send an official "SYSTEM" message
|
||||
// to chat on behalf of your server.
|
||||
func SendSystemMessage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var message models.ChatEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
message.MessageType = models.SystemMessageSent
|
||||
message.Author = data.GetServerName()
|
||||
message.ClientID = "owncast-server"
|
||||
message.ID = shortid.MustGenerate()
|
||||
message.Visible = true
|
||||
|
||||
message.SetDefaults()
|
||||
message.RenderAndSanitizeMessageBody()
|
||||
|
||||
if err := core.SendMessageToChat(message); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "sent")
|
||||
}
|
||||
|
||||
// SendUserMessage will send a message to chat on behalf of a user.
|
||||
func SendUserMessage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var message models.ChatEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !message.Valid() {
|
||||
controllers.BadRequestHandler(w, errors.New("invalid chat message; id, author, and body are required"))
|
||||
return
|
||||
}
|
||||
|
||||
message.MessageType = models.MessageSent
|
||||
message.ClientID = "external-request"
|
||||
message.ID = shortid.MustGenerate()
|
||||
message.Visible = true
|
||||
|
||||
message.SetDefaults()
|
||||
message.RenderAndSanitizeMessageBody()
|
||||
|
||||
if err := core.SendMessageToChat(message); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "sent")
|
||||
}
|
||||
|
||||
// SendChatAction will send a generic chat action.
|
||||
func SendChatAction(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var message models.ChatEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
message.MessageType = models.ChatActionSent
|
||||
message.ClientID = "external-request"
|
||||
message.ID = shortid.MustGenerate()
|
||||
message.Visible = true
|
||||
|
||||
if message.Author != "" {
|
||||
message.Body = fmt.Sprintf("%s %s", message.Author, message.Body)
|
||||
}
|
||||
|
||||
message.SetDefaults()
|
||||
|
||||
if err := core.SendMessageToChat(message); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "sent")
|
||||
}
|
||||
|
|
475
controllers/admin/config.go
Normal file
475
controllers/admin/config.go
Normal file
|
@ -0,0 +1,475 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ConfigValue is a container object that holds a value, is encoded, and saved to the database.
|
||||
type ConfigValue struct {
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// SetTags will handle the web config request to set tags.
|
||||
func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValues, success := getValuesFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
var tagStrings []string
|
||||
for _, tag := range configValues {
|
||||
tagStrings = append(tagStrings, tag.Value.(string))
|
||||
}
|
||||
|
||||
if err := data.SetServerMetadataTags(tagStrings); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetStreamTitle will handle the web config request to set the current stream title.
|
||||
func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
value := configValue.Value.(string)
|
||||
|
||||
if err := data.SetStreamTitle(value); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
if value != "" {
|
||||
sendSystemChatAction(fmt.Sprintf("Stream title changed to **%s**", value))
|
||||
}
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
func sendSystemChatAction(messageText string) {
|
||||
message := models.ChatEvent{}
|
||||
message.Body = messageText
|
||||
message.MessageType = models.ChatActionSent
|
||||
message.ClientID = "internal-server"
|
||||
message.SetDefaults()
|
||||
|
||||
if err := core.SendMessageToChat(message); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetServerName will handle the web config request to set the server's name.
|
||||
func SetServerName(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetServerName(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetServerSummary will handle the web config request to set the about/summary text.
|
||||
func SetServerSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetServerSummary(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetExtraPageContent will handle the web config request to set the page markdown content.
|
||||
func SetExtraPageContent(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetExtraPageBodyContent(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetStreamKey will handle the web config request to set the server stream key.
|
||||
func SetStreamKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetStreamKey(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetLogoPath will handle the web config request to validate and set the logo path.
|
||||
func SetLogoPath(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
imgPath := configValue.Value.(string)
|
||||
fullPath := filepath.Join("data", imgPath)
|
||||
if !utils.DoesFileExists(fullPath) {
|
||||
controllers.WriteSimpleResponse(w, false, fmt.Sprintf("%s does not exist", fullPath))
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetLogoPath(imgPath); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetNSFW will handle the web config request to set the NSFW flag.
|
||||
func SetNSFW(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetNSFW(configValue.Value.(bool)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetFfmpegPath will handle the web config request to validate and set an updated copy of ffmpg.
|
||||
func SetFfmpegPath(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
path := configValue.Value.(string)
|
||||
if err := utils.VerifyFFMpegPath(path); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetFfmpegPath(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "changed")
|
||||
}
|
||||
|
||||
// SetWebServerPort will handle the web config request to set the server's HTTP port.
|
||||
func SetWebServerPort(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetHTTPPortNumber(configValue.Value.(float64)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "http port set")
|
||||
}
|
||||
|
||||
// SetRTMPServerPort will handle the web config request to set the inbound RTMP port.
|
||||
func SetRTMPServerPort(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetRTMPPortNumber(configValue.Value.(float64)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "rtmp port set")
|
||||
}
|
||||
|
||||
// SetServerURL will handle the web config request to set the full server URL.
|
||||
func SetServerURL(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetServerURL(configValue.Value.(string)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "server url set")
|
||||
}
|
||||
|
||||
// SetDirectoryEnabled will handle the web config request to enable or disable directory registration.
|
||||
func SetDirectoryEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetDirectoryEnabled(configValue.Value.(bool)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
controllers.WriteSimpleResponse(w, true, "directory state changed")
|
||||
}
|
||||
|
||||
// SetStreamLatencyLevel will handle the web config request to set the stream latency level.
|
||||
func SetStreamLatencyLevel(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
configValue, success := getValueFromRequest(w, r)
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetStreamLatencyLevel(configValue.Value.(float64)); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "error setting stream latency "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "set stream latency")
|
||||
}
|
||||
|
||||
// SetS3Configuration will handle the web config request to set the storage configuration.
|
||||
func SetS3Configuration(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
type s3ConfigurationRequest struct {
|
||||
Value models.S3 `json:"value"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var newS3Config s3ConfigurationRequest
|
||||
if err := decoder.Decode(&newS3Config); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update s3 config with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
if newS3Config.Value.Enabled {
|
||||
if newS3Config.Value.Endpoint == "" || !utils.IsValidUrl((newS3Config.Value.Endpoint)) {
|
||||
controllers.WriteSimpleResponse(w, false, "s3 support requires an endpoint")
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if newS3Config.Value.AccessKey == "" || newS3Config.Value.Secret == "" {
|
||||
controllers.WriteSimpleResponse(w, false, "s3 support requires an access key and secret")
|
||||
return
|
||||
}
|
||||
|
||||
if newS3Config.Value.Region == "" {
|
||||
controllers.WriteSimpleResponse(w, false, "s3 support requires a region and endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
if newS3Config.Value.Bucket == "" {
|
||||
controllers.WriteSimpleResponse(w, false, "s3 support requires a bucket created for storing public video segments")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data.SetS3Config(newS3Config.Value)
|
||||
controllers.WriteSimpleResponse(w, true, "storage configuration changed")
|
||||
|
||||
}
|
||||
|
||||
// SetStreamOutputVariants will handle the web config request to set the video output stream variants.
|
||||
func SetStreamOutputVariants(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
type streamOutputVariantRequest struct {
|
||||
Value []models.StreamOutputVariant `json:"value"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var videoVariants streamOutputVariantRequest
|
||||
if err := decoder.Decode(&videoVariants); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update video config with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary: Convert the cpuUsageLevel to a preset. In the future we will have
|
||||
// different codec models that will handle this for us and we won't
|
||||
// be keeping track of presets at all. But for now...
|
||||
presetMapping := []string{
|
||||
"ultrafast",
|
||||
"superfast",
|
||||
"veryfast",
|
||||
"faster",
|
||||
"fast",
|
||||
}
|
||||
|
||||
for i, variant := range videoVariants.Value {
|
||||
preset := "superfast"
|
||||
if variant.CPUUsageLevel > 0 && variant.CPUUsageLevel <= len(presetMapping) {
|
||||
preset = presetMapping[variant.CPUUsageLevel-1]
|
||||
}
|
||||
variant.EncoderPreset = preset
|
||||
videoVariants.Value[i] = variant
|
||||
}
|
||||
|
||||
if err := data.SetStreamOutputVariants(videoVariants.Value); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update video config with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "stream output variants updated")
|
||||
}
|
||||
|
||||
// SetSocialHandles will handle the web config request to set the external social profile links.
|
||||
func SetSocialHandles(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
type socialHandlesRequest struct {
|
||||
Value []models.SocialHandle `json:"value"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var socialHandles socialHandlesRequest
|
||||
if err := decoder.Decode(&socialHandles); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update social handles with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetSocialHandles(socialHandles.Value); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update social handles with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "social handles updated")
|
||||
}
|
||||
|
||||
func requirePOST(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method != controllers.POST {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func getValueFromRequest(w http.ResponseWriter, r *http.Request) (ConfigValue, bool) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var configValue ConfigValue
|
||||
if err := decoder.Decode(&configValue); err != nil {
|
||||
log.Warnln(err)
|
||||
controllers.WriteSimpleResponse(w, false, "unable to parse new value")
|
||||
return configValue, false
|
||||
}
|
||||
|
||||
return configValue, true
|
||||
}
|
||||
|
||||
func getValuesFromRequest(w http.ResponseWriter, r *http.Request) ([]ConfigValue, bool) {
|
||||
var values []ConfigValue
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var configValue ConfigValue
|
||||
if err := decoder.Decode(&configValue); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to parse array of values")
|
||||
return values, false
|
||||
}
|
||||
|
||||
object := reflect.ValueOf(configValue.Value)
|
||||
|
||||
for i := 0; i < object.Len(); i++ {
|
||||
values = append(values, ConfigValue{Value: object.Index(i).Interface()})
|
||||
}
|
||||
|
||||
return values, true
|
||||
}
|
|
@ -5,35 +5,50 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetServerConfig gets the config details of the server.
|
||||
func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var videoQualityVariants = make([]config.StreamQuality, 0)
|
||||
for _, variant := range config.Config.GetVideoStreamQualities() {
|
||||
videoQualityVariants = append(videoQualityVariants, config.StreamQuality{
|
||||
var videoQualityVariants = make([]models.StreamOutputVariant, 0)
|
||||
for _, variant := range data.GetStreamOutputVariants() {
|
||||
videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{
|
||||
IsAudioPassthrough: variant.GetIsAudioPassthrough(),
|
||||
IsVideoPassthrough: variant.IsVideoPassthrough,
|
||||
Framerate: variant.GetFramerate(),
|
||||
EncoderPreset: variant.GetEncoderPreset(),
|
||||
VideoBitrate: variant.VideoBitrate,
|
||||
AudioBitrate: variant.AudioBitrate,
|
||||
CPUUsageLevel: variant.GetCPUUsageLevel(),
|
||||
})
|
||||
}
|
||||
response := serverConfigAdminResponse{
|
||||
InstanceDetails: config.Config.InstanceDetails,
|
||||
FFmpegPath: config.Config.GetFFMpegPath(),
|
||||
StreamKey: config.Config.VideoSettings.StreamingKey,
|
||||
WebServerPort: config.Config.GetPublicWebServerPort(),
|
||||
RTMPServerPort: config.Config.GetRTMPServerPort(),
|
||||
VideoSettings: videoSettings{
|
||||
VideoQualityVariants: videoQualityVariants,
|
||||
SegmentLengthSeconds: config.Config.GetVideoSegmentSecondsLength(),
|
||||
NumberOfPlaylistItems: config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist(),
|
||||
InstanceDetails: webConfigResponse{
|
||||
Name: data.GetServerName(),
|
||||
Summary: data.GetServerSummary(),
|
||||
Tags: data.GetServerMetadataTags(),
|
||||
ExtraPageContent: data.GetExtraPageBodyContent(),
|
||||
StreamTitle: data.GetStreamTitle(),
|
||||
Logo: data.GetLogoPath(),
|
||||
SocialHandles: data.GetSocialHandles(),
|
||||
NSFW: data.GetNSFW(),
|
||||
},
|
||||
YP: config.Config.YP,
|
||||
S3: config.Config.S3,
|
||||
FFmpegPath: utils.ValidatedFfmpegPath(data.GetFfMpegPath()),
|
||||
StreamKey: data.GetStreamKey(),
|
||||
WebServerPort: config.WebServerPort,
|
||||
RTMPServerPort: data.GetRTMPPortNumber(),
|
||||
VideoSettings: videoSettings{
|
||||
VideoQualityVariants: videoQualityVariants,
|
||||
LatencyLevel: data.GetStreamLatencyLevel().Level,
|
||||
},
|
||||
YP: yp{
|
||||
Enabled: data.GetDirectoryEnabled(),
|
||||
InstanceURL: data.GetServerURL(),
|
||||
},
|
||||
S3: data.GetS3Config(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
@ -44,18 +59,36 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type serverConfigAdminResponse struct {
|
||||
InstanceDetails config.InstanceDetails `json:"instanceDetails"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
WebServerPort int `json:"webServerPort"`
|
||||
RTMPServerPort int `json:"rtmpServerPort"`
|
||||
S3 config.S3 `json:"s3"`
|
||||
VideoSettings videoSettings `json:"videoSettings"`
|
||||
YP config.YP `json:"yp"`
|
||||
InstanceDetails webConfigResponse `json:"instanceDetails"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
WebServerPort int `json:"webServerPort"`
|
||||
RTMPServerPort int `json:"rtmpServerPort"`
|
||||
S3 models.S3 `json:"s3"`
|
||||
VideoSettings videoSettings `json:"videoSettings"`
|
||||
LatencyLevel int `json:"latencyLevel"`
|
||||
YP yp `json:"yp"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
VideoQualityVariants []config.StreamQuality `json:"videoQualityVariants"`
|
||||
SegmentLengthSeconds int `json:"segmentLengthSeconds"`
|
||||
NumberOfPlaylistItems int `json:"numberOfPlaylistItems"`
|
||||
VideoQualityVariants []models.StreamOutputVariant `json:"videoQualityVariants"`
|
||||
LatencyLevel int `json:"latencyLevel"`
|
||||
}
|
||||
|
||||
type webConfigResponse struct {
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
}
|
||||
|
||||
type yp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
InstanceURL string `json:"instanceUrl"` // The public URL the directory should link to
|
||||
YPServiceURL string `json:"-"` // The base URL to the YP API to register with (optional)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
@ -14,15 +14,17 @@ import (
|
|||
func Status(w http.ResponseWriter, r *http.Request) {
|
||||
broadcaster := core.GetBroadcaster()
|
||||
status := core.GetStatus()
|
||||
currentBroadcast := core.GetCurrentBroadcast()
|
||||
|
||||
response := adminStatusResponse{
|
||||
Broadcaster: broadcaster,
|
||||
CurrentBroadcast: currentBroadcast,
|
||||
Online: status.Online,
|
||||
ViewerCount: status.ViewerCount,
|
||||
OverallPeakViewerCount: status.OverallMaxViewerCount,
|
||||
SessionPeakViewerCount: status.SessionMaxViewerCount,
|
||||
VersionNumber: status.VersionNumber,
|
||||
DisableUpgradeChecks: config.Config.DisableUpgradeChecks,
|
||||
StreamTitle: data.GetStreamTitle(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
@ -33,12 +35,12 @@ func Status(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type adminStatusResponse struct {
|
||||
Broadcaster *models.Broadcaster `json:"broadcaster"`
|
||||
Online bool `json:"online"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
OverallPeakViewerCount int `json:"overallPeakViewerCount"`
|
||||
SessionPeakViewerCount int `json:"sessionPeakViewerCount"`
|
||||
|
||||
VersionNumber string `json:"versionNumber"`
|
||||
DisableUpgradeChecks bool `json:"disableUpgradeChecks"`
|
||||
Broadcaster *models.Broadcaster `json:"broadcaster"`
|
||||
CurrentBroadcast *models.CurrentBroadcast `json:"currentBroadcast"`
|
||||
Online bool `json:"online"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
OverallPeakViewerCount int `json:"overallPeakViewerCount"`
|
||||
SessionPeakViewerCount int `json:"sessionPeakViewerCount"`
|
||||
StreamTitle string `json:"streamTitle"`
|
||||
VersionNumber string `json:"versionNumber"`
|
||||
}
|
||||
|
|
84
controllers/admin/webhooks.go
Normal file
84
controllers/admin/webhooks.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
type deleteWebhookRequest struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type createWebhookRequest struct {
|
||||
URL string `json:"url"`
|
||||
Events []models.EventType `json:"events"`
|
||||
}
|
||||
|
||||
// CreateWebhook will add a single webhook.
|
||||
func CreateWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request createWebhookRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all the scopes provided are valid
|
||||
if !models.HasValidEvents(request.Events) {
|
||||
controllers.BadRequestHandler(w, errors.New("one or more invalid event provided"))
|
||||
return
|
||||
}
|
||||
|
||||
newWebhookID, err := data.InsertWebhook(request.URL, request.Events)
|
||||
if err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteResponse(w, models.Webhook{
|
||||
ID: newWebhookID,
|
||||
URL: request.URL,
|
||||
Events: request.Events,
|
||||
Timestamp: time.Now(),
|
||||
LastUsed: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWebhooks will return all webhooks.
|
||||
func GetWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
webhooks, err := data.GetWebhooks()
|
||||
if err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteResponse(w, webhooks)
|
||||
}
|
||||
|
||||
// DeleteWebhook will delete a single webhook.
|
||||
func DeleteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != controllers.POST {
|
||||
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var request deleteWebhookRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
controllers.BadRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.DeleteWebhook(request.ID); err != nil {
|
||||
controllers.InternalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "deleted webhook")
|
||||
}
|
20
controllers/admin/yp.go
Normal file
20
controllers/admin/yp.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ResetYPRegistration will clear the YP protocol registration key.
|
||||
func ResetYPRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
log.Traceln("Resetting YP registration key")
|
||||
if err := data.SetDirectoryRegistrationKey(""); err != nil {
|
||||
log.Errorln(err)
|
||||
controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
controllers.WriteSimpleResponse(w, true, "reset")
|
||||
}
|
|
@ -5,7 +5,6 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
@ -17,32 +16,16 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
messages := core.GetAllChatMessages(true)
|
||||
messages := core.GetAllChatMessages()
|
||||
|
||||
err := json.NewEncoder(w).Encode(messages)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
case http.MethodPost:
|
||||
var message models.ChatEvent
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := core.SendMessageToChat(message); err != nil {
|
||||
badRequestHandler(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(j{"success": true}); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
if err := json.NewEncoder(w).Encode(j{"error": "method not implemented (PRs are accepted)"}); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,17 +5,63 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
type webConfigResponse struct {
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
}
|
||||
|
||||
// GetWebConfig gets the status of the server.
|
||||
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.EnableCors(&w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
configuration := config.Config.InstanceDetails
|
||||
configuration.Version = config.Config.VersionInfo
|
||||
pageContent := utils.RenderPageContentMarkdown(data.GetExtraPageBodyContent())
|
||||
socialHandles := data.GetSocialHandles()
|
||||
for i, handle := range socialHandles {
|
||||
platform := models.GetSocialHandle(handle.Platform)
|
||||
if platform != nil {
|
||||
handle.Icon = platform.Icon
|
||||
socialHandles[i] = handle
|
||||
}
|
||||
}
|
||||
|
||||
configuration := webConfigResponse{
|
||||
Name: data.GetServerName(),
|
||||
Summary: data.GetServerSummary(),
|
||||
Logo: "/logo",
|
||||
Tags: data.GetServerMetadataTags(),
|
||||
Version: config.GetReleaseString(),
|
||||
NSFW: data.GetNSFW(),
|
||||
ExtraPageContent: pageContent,
|
||||
StreamTitle: data.GetStreamTitle(),
|
||||
SocialHandles: socialHandles,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(configuration); err != nil {
|
||||
badRequestHandler(w, err)
|
||||
BadRequestHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllSocialPlatforms will return a list of all social platform types.
|
||||
func GetAllSocialPlatforms(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.EnableCors(&w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
platforms := models.GetAllSocialHandles()
|
||||
if err := json.NewEncoder(w).Encode(platforms); err != nil {
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,6 @@ func GetConnectedClients(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(clients); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
|
7
controllers/constants.go
Normal file
7
controllers/constants.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package controllers
|
||||
|
||||
// POST is the HTTP POST method.
|
||||
const POST = "POST"
|
||||
|
||||
// GET is the HTTP GET method.
|
||||
const GET = "GET"
|
|
@ -5,39 +5,65 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type j map[string]interface{}
|
||||
|
||||
func internalErrorHandler(w http.ResponseWriter, err error) {
|
||||
// InternalErrorHandler will return an error message as an HTTP response.
|
||||
func InternalErrorHandler(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Errorln(err)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
func badRequestHandler(w http.ResponseWriter, err error) {
|
||||
// BadRequestHandler will return an HTTP 500 as an HTTP response.
|
||||
func BadRequestHandler(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugln(err)
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if err := json.NewEncoder(w).Encode(j{"error": err.Error()}); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteSimpleResponse will return a message as a response.
|
||||
func WriteSimpleResponse(w http.ResponseWriter, success bool, message string) {
|
||||
response := models.BaseAPIResponse{
|
||||
Success: success,
|
||||
Message: message,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if success {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteResponse will return an object as a JSON encoded response.
|
||||
func WriteResponse(w http.ResponseWriter, response interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,6 @@ func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(emojiList); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,16 +14,23 @@ import (
|
|||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
// MetadataPage represents a server-rendered web page for bots and web scrapers.
|
||||
type MetadataPage struct {
|
||||
Config config.InstanceDetails
|
||||
RequestedURL string
|
||||
Image string
|
||||
Thumbnail string
|
||||
TagsString string
|
||||
RequestedURL string
|
||||
Image string
|
||||
Thumbnail string
|
||||
TagsString string
|
||||
Title string
|
||||
Summary string
|
||||
Name string
|
||||
Tags []string
|
||||
SocialHandles []models.SocialHandle
|
||||
}
|
||||
|
||||
// IndexHandler handles the default index route.
|
||||
|
@ -70,7 +77,7 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
log.Panicln(err)
|
||||
}
|
||||
imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, config.Config.InstanceDetails.Logo))
|
||||
imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, data.GetLogoPath()))
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
}
|
||||
|
@ -91,8 +98,15 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
|
|||
thumbnailURL = imageURL.String()
|
||||
}
|
||||
|
||||
tagsString := strings.Join(config.Config.InstanceDetails.Tags, ",")
|
||||
metadata := MetadataPage{config.Config.InstanceDetails, fullURL.String(), imageURL.String(), thumbnailURL, tagsString}
|
||||
tagsString := strings.Join(data.GetServerMetadataTags(), ",")
|
||||
metadata := MetadataPage{
|
||||
RequestedURL: fullURL.String(),
|
||||
Image: imageURL.String(),
|
||||
Thumbnail: thumbnailURL,
|
||||
TagsString: tagsString,
|
||||
Tags: data.GetServerMetadataTags(),
|
||||
SocialHandles: data.GetSocialHandles(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
err = tmpl.Execute(w, metadata)
|
||||
|
|
65
controllers/logo.go
Normal file
65
controllers/logo.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetLogo will return the logo image as a response.
|
||||
func GetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
imageFilename := data.GetLogoPath()
|
||||
if imageFilename == "" {
|
||||
returnDefault(w)
|
||||
return
|
||||
}
|
||||
imagePath := filepath.Join("data", imageFilename)
|
||||
imageBytes, err := getImage(imagePath)
|
||||
if err != nil {
|
||||
returnDefault(w)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := "image/jpeg"
|
||||
if filepath.Ext(imageFilename) == ".svg" {
|
||||
contentType = "image/svg+xml"
|
||||
} else if filepath.Ext(imageFilename) == ".gif" {
|
||||
contentType = "image/gif"
|
||||
} else if filepath.Ext(imageFilename) == ".png" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)
|
||||
writeBytesAsImage(imageBytes, contentType, w, cacheTime)
|
||||
}
|
||||
|
||||
func returnDefault(w http.ResponseWriter) {
|
||||
imagePath := filepath.Join(config.WebRoot, "img", "logo.svg")
|
||||
imageBytes, err := getImage(imagePath)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)
|
||||
writeBytesAsImage(imageBytes, "image/svg+xml", w, cacheTime)
|
||||
}
|
||||
|
||||
func writeBytesAsImage(data []byte, contentType string, w http.ResponseWriter, cacheSeconds int) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(cacheSeconds))
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Println("unable to write image.")
|
||||
}
|
||||
}
|
||||
|
||||
func getImage(path string) ([]byte, error) {
|
||||
return ioutil.ReadFile(path)
|
||||
}
|
|
@ -16,6 +16,6 @@ func GetStatus(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||
internalErrorHandler(w, err)
|
||||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,12 +60,16 @@ func SendMessage(message models.ChatEvent) {
|
|||
}
|
||||
|
||||
// GetMessages gets all of the messages.
|
||||
func GetMessages(filtered bool) []models.ChatEvent {
|
||||
func GetMessages() []models.ChatEvent {
|
||||
if _server == nil {
|
||||
return []models.ChatEvent{}
|
||||
}
|
||||
|
||||
return getChatHistory(filtered)
|
||||
return getChatHistory()
|
||||
}
|
||||
|
||||
func GetModerationChatMessages() []models.ChatEvent {
|
||||
return getChatModerationHistory()
|
||||
}
|
||||
|
||||
func GetClient(clientID string) *Client {
|
||||
|
|
|
@ -28,36 +28,38 @@ type Client struct {
|
|||
Username *string
|
||||
ClientID string // How we identify unique viewers when counting viewer counts.
|
||||
Geo *geoip.GeoDetails `json:"geo"`
|
||||
Ignore bool // If set to true this will not be treated as a viewer
|
||||
|
||||
socketID string // How we identify a single websocket client.
|
||||
ws *websocket.Conn
|
||||
ch chan models.ChatEvent
|
||||
pingch chan models.PingMessage
|
||||
usernameChangeChannel chan models.NameChangeEvent
|
||||
userJoinedChannel chan models.UserJoinedEvent
|
||||
|
||||
doneCh chan bool
|
||||
|
||||
rateLimiter *rate.Limiter
|
||||
}
|
||||
|
||||
const (
|
||||
CHAT = "CHAT"
|
||||
NAMECHANGE = "NAME_CHANGE"
|
||||
PING = "PING"
|
||||
PONG = "PONG"
|
||||
VISIBILITYUPDATE = "VISIBILITY-UPDATE"
|
||||
)
|
||||
|
||||
// NewClient creates a new chat client.
|
||||
func NewClient(ws *websocket.Conn) *Client {
|
||||
if ws == nil {
|
||||
log.Panicln("ws cannot be nil")
|
||||
}
|
||||
|
||||
var ignoreClient = false
|
||||
for _, extraData := range ws.Config().Protocol {
|
||||
if extraData == "IGNORE_CLIENT" {
|
||||
ignoreClient = true
|
||||
}
|
||||
}
|
||||
|
||||
ch := make(chan models.ChatEvent, channelBufSize)
|
||||
doneCh := make(chan bool)
|
||||
pingch := make(chan models.PingMessage)
|
||||
usernameChangeChannel := make(chan models.NameChangeEvent)
|
||||
userJoinedChannel := make(chan models.UserJoinedEvent)
|
||||
|
||||
ipAddress := utils.GetIPAddressFromRequest(ws.Request())
|
||||
userAgent := ws.Request().UserAgent()
|
||||
|
@ -66,7 +68,7 @@ func NewClient(ws *websocket.Conn) *Client {
|
|||
|
||||
rateLimiter := rate.NewLimiter(0.6, 5)
|
||||
|
||||
return &Client{time.Now(), 0, userAgent, ipAddress, nil, clientID, nil, socketID, ws, ch, pingch, usernameChangeChannel, doneCh, rateLimiter}
|
||||
return &Client{time.Now(), 0, userAgent, ipAddress, nil, clientID, nil, ignoreClient, socketID, ws, ch, pingch, usernameChangeChannel, userJoinedChannel, doneCh, rateLimiter}
|
||||
}
|
||||
|
||||
func (c *Client) write(msg models.ChatEvent) {
|
||||
|
@ -105,6 +107,12 @@ func (c *Client) listenWrite() {
|
|||
if err != nil {
|
||||
c.handleClientSocketError(err)
|
||||
}
|
||||
case msg := <-c.userJoinedChannel:
|
||||
err := websocket.JSON.Send(c.ws, msg)
|
||||
if err != nil {
|
||||
c.handleClientSocketError(err)
|
||||
}
|
||||
|
||||
// receive done request
|
||||
case <-c.doneCh:
|
||||
_server.removeClient(c)
|
||||
|
@ -157,28 +165,46 @@ func (c *Client) listenRead() {
|
|||
log.Errorln(err)
|
||||
}
|
||||
|
||||
messageType := messageTypeCheck["type"]
|
||||
messageType := messageTypeCheck["type"].(string)
|
||||
|
||||
if !c.passesRateLimit() {
|
||||
continue
|
||||
}
|
||||
|
||||
if messageType == CHAT {
|
||||
if messageType == models.MessageSent {
|
||||
c.chatMessageReceived(data)
|
||||
} else if messageType == NAMECHANGE {
|
||||
} else if messageType == models.UserNameChanged {
|
||||
c.userChangedName(data)
|
||||
} else if messageType == models.UserJoined {
|
||||
c.userJoined(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) userJoined(data []byte) {
|
||||
var msg models.UserJoinedEvent
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
msg.ID = shortid.MustGenerate()
|
||||
msg.Type = models.UserJoined
|
||||
msg.Timestamp = time.Now()
|
||||
|
||||
c.Username = &msg.Username
|
||||
|
||||
_server.userJoined(msg)
|
||||
}
|
||||
|
||||
func (c *Client) userChangedName(data []byte) {
|
||||
var msg models.NameChangeEvent
|
||||
err := json.Unmarshal(data, &msg)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
msg.Type = NAMECHANGE
|
||||
msg.Type = models.UserNameChanged
|
||||
msg.ID = shortid.MustGenerate()
|
||||
_server.usernameChanged(msg)
|
||||
c.Username = &msg.NewName
|
||||
|
@ -191,10 +217,7 @@ func (c *Client) chatMessageReceived(data []byte) {
|
|||
log.Errorln(err)
|
||||
}
|
||||
|
||||
id, _ := shortid.Generate()
|
||||
msg.ID = id
|
||||
msg.Timestamp = time.Now()
|
||||
msg.Visible = true
|
||||
msg.SetDefaults()
|
||||
|
||||
c.MessageCount++
|
||||
c.Username = &msg.Author
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/core/webhooks"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
@ -20,8 +22,10 @@ func SetMessagesVisibility(messageIDs []string, visibility bool) error {
|
|||
log.Errorln(err)
|
||||
continue
|
||||
}
|
||||
message.MessageType = VISIBILITYUPDATE
|
||||
message.MessageType = models.VisibiltyToggled
|
||||
_server.sendAll(message)
|
||||
|
||||
go webhooks.SendChatEvent(message)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
@ -61,15 +61,8 @@ func addMessage(message models.ChatEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
func getChatHistory(filtered bool) []models.ChatEvent {
|
||||
func getChat(query string) []models.ChatEvent {
|
||||
history := make([]models.ChatEvent, 0)
|
||||
|
||||
// Get all messages sent within the past day
|
||||
var query = "SELECT * FROM messages WHERE messageType != 'SYSTEM' AND datetime(timestamp) >=datetime('now', '-1 Day')"
|
||||
if filtered {
|
||||
query = query + " AND visible = 1"
|
||||
}
|
||||
|
||||
rows, err := _db.Query(query)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -80,7 +73,7 @@ func getChatHistory(filtered bool) []models.ChatEvent {
|
|||
var id string
|
||||
var author string
|
||||
var body string
|
||||
var messageType string
|
||||
var messageType models.EventType
|
||||
var visible int
|
||||
var timestamp time.Time
|
||||
|
||||
|
@ -109,6 +102,17 @@ func getChatHistory(filtered bool) []models.ChatEvent {
|
|||
return history
|
||||
}
|
||||
|
||||
func getChatModerationHistory() []models.ChatEvent {
|
||||
var query = "SELECT * FROM messages WHERE messageType == 'CHAT' AND datetime(timestamp) >=datetime('now', '-5 Hour')"
|
||||
return getChat(query)
|
||||
}
|
||||
|
||||
func getChatHistory() []models.ChatEvent {
|
||||
// Get all messages sent within the past 5hrs, max 50
|
||||
var query = "SELECT * FROM messages WHERE datetime(timestamp) >=datetime('now', '-5 Hour') AND visible = 1 LIMIT 50"
|
||||
return getChat(query)
|
||||
}
|
||||
|
||||
func saveMessageVisibility(messageIDs []string, visible bool) error {
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
|
@ -150,7 +154,7 @@ func getMessageById(messageID string) (models.ChatEvent, error) {
|
|||
var id string
|
||||
var author string
|
||||
var body string
|
||||
var messageType string
|
||||
var messageType models.EventType
|
||||
var visible int
|
||||
var timestamp time.Time
|
||||
|
||||
|
|
|
@ -9,7 +9,8 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/websocket"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/webhooks"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
|
@ -61,7 +62,7 @@ func (s *server) sendAll(msg models.ChatEvent) {
|
|||
}
|
||||
|
||||
func (s *server) ping() {
|
||||
ping := models.PingMessage{MessageType: PING}
|
||||
ping := models.PingMessage{MessageType: models.PING}
|
||||
for _, c := range s.Clients {
|
||||
c.pingch <- ping
|
||||
}
|
||||
|
@ -71,6 +72,16 @@ func (s *server) usernameChanged(msg models.NameChangeEvent) {
|
|||
for _, c := range s.Clients {
|
||||
c.usernameChangeChannel <- msg
|
||||
}
|
||||
|
||||
go webhooks.SendChatEventUsernameChanged(msg)
|
||||
}
|
||||
|
||||
func (s *server) userJoined(msg models.UserJoinedEvent) {
|
||||
for _, c := range s.Clients {
|
||||
c.userJoinedChannel <- msg
|
||||
}
|
||||
|
||||
go webhooks.SendChatEventUserJoined(msg)
|
||||
}
|
||||
|
||||
func (s *server) onConnection(ws *websocket.Conn) {
|
||||
|
@ -103,8 +114,10 @@ func (s *server) Listen() {
|
|||
s.Clients[c.socketID] = c
|
||||
l.Unlock()
|
||||
|
||||
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
|
||||
s.sendWelcomeMessageToClient(c)
|
||||
if !c.Ignore {
|
||||
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
|
||||
s.sendWelcomeMessageToClient(c)
|
||||
}
|
||||
|
||||
// remove a client
|
||||
case c := <-s.delCh:
|
||||
|
@ -122,7 +135,11 @@ func (s *server) Listen() {
|
|||
s.sendAll(msg)
|
||||
|
||||
// Store in the message history
|
||||
msg.SetDefaults()
|
||||
addMessage(msg)
|
||||
|
||||
// Send webhooks
|
||||
go webhooks.SendChatEvent(msg)
|
||||
}
|
||||
case ping := <-s.pingCh:
|
||||
fmt.Println("PING?", ping)
|
||||
|
@ -154,8 +171,8 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
|
|||
// Add an artificial delay so people notice this message come in.
|
||||
time.Sleep(7 * time.Second)
|
||||
|
||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
|
||||
initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: config.Config.InstanceDetails.Name, Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()}
|
||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", data.GetServerName(), data.GetServerSummary())
|
||||
initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: data.GetServerName(), Body: initialChatMessageText, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()}
|
||||
c.write(initialMessage)
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
@ -26,16 +24,16 @@ func (cl ChatListenerImpl) MessageSent(message models.ChatEvent) {
|
|||
|
||||
// SendMessageToChat sends a message to the chat server.
|
||||
func SendMessageToChat(message models.ChatEvent) error {
|
||||
if !message.Valid() {
|
||||
return errors.New("invalid chat message; id, author, and body are required")
|
||||
}
|
||||
|
||||
chat.SendMessage(message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllChatMessages gets all of the chat messages.
|
||||
func GetAllChatMessages(filtered bool) []models.ChatEvent {
|
||||
return chat.GetMessages(filtered)
|
||||
func GetAllChatMessages() []models.ChatEvent {
|
||||
return chat.GetMessages()
|
||||
}
|
||||
|
||||
func GetModerationChatMessages() []models.ChatEvent {
|
||||
return chat.GetModerationChatMessages()
|
||||
}
|
||||
|
|
52
core/core.go
52
core/core.go
|
@ -10,8 +10,9 @@ import (
|
|||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/rtmp"
|
||||
"github.com/owncast/owncast/core/transcoder"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/owncast/owncast/yp"
|
||||
|
@ -20,33 +21,41 @@ import (
|
|||
var (
|
||||
_stats *models.Stats
|
||||
_storage models.StorageProvider
|
||||
_transcoder *ffmpeg.Transcoder
|
||||
_transcoder *transcoder.Transcoder
|
||||
_yp *yp.YP
|
||||
_broadcaster *models.Broadcaster
|
||||
)
|
||||
|
||||
var handler ffmpeg.HLSHandler
|
||||
var fileWriter = ffmpeg.FileWriterReceiverService{}
|
||||
var handler transcoder.HLSHandler
|
||||
var fileWriter = transcoder.FileWriterReceiverService{}
|
||||
|
||||
// Start starts up the core processing.
|
||||
func Start() error {
|
||||
resetDirectories()
|
||||
|
||||
data.PopulateDefaults()
|
||||
// Once a couple versions pass we can remove the old data migrators.
|
||||
data.RunMigrations()
|
||||
|
||||
if err := data.VerifySettings(); err != nil {
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setupStats(); err != nil {
|
||||
log.Error("failed to setup the stats")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setupStorage(); err != nil {
|
||||
log.Error("failed to setup the storage")
|
||||
return err
|
||||
}
|
||||
|
||||
// The HLS handler takes the written HLS playlists and segments
|
||||
// and makes storage decisions. It's rather simple right now
|
||||
// but will play more useful when recordings come into play.
|
||||
handler = ffmpeg.HLSHandler{}
|
||||
handler.Storage = _storage
|
||||
handler = transcoder.HLSHandler{}
|
||||
|
||||
if err := setupStorage(); err != nil {
|
||||
log.Errorln("storage error", err)
|
||||
}
|
||||
|
||||
fileWriter.SetupFileWriterReceiverService(&handler)
|
||||
|
||||
if err := createInitialOfflineState(); err != nil {
|
||||
|
@ -54,10 +63,8 @@ func Start() error {
|
|||
return err
|
||||
}
|
||||
|
||||
if config.Config.YP.Enabled {
|
||||
if data.GetDirectoryEnabled() {
|
||||
_yp = yp.NewYP(GetStatus)
|
||||
} else {
|
||||
yp.DisplayInstructions()
|
||||
}
|
||||
|
||||
chat.Setup(ChatListenerImpl{})
|
||||
|
@ -65,8 +72,8 @@ func Start() error {
|
|||
// start the rtmp server
|
||||
go rtmp.Start(setStreamAsConnected, setBroadcaster)
|
||||
|
||||
port := config.Config.GetPublicWebServerPort()
|
||||
rtmpPort := config.Config.GetRTMPServerPort()
|
||||
port := config.WebServerPort
|
||||
rtmpPort := data.GetRTMPPortNumber()
|
||||
log.Infof("Web server is listening on port %d, RTMP is accepting inbound streams on port %d.", port, rtmpPort)
|
||||
log.Infoln("The web admin interface is available at /admin.")
|
||||
|
||||
|
@ -94,13 +101,13 @@ func transitionToOfflineVideoStreamContent() {
|
|||
|
||||
offlineFilename := "offline.ts"
|
||||
offlineFilePath := "static/" + offlineFilename
|
||||
_transcoder := ffmpeg.NewTranscoder()
|
||||
_transcoder.SetSegmentLength(10)
|
||||
_transcoder := transcoder.NewTranscoder()
|
||||
_transcoder.SetInput(offlineFilePath)
|
||||
_transcoder.Start()
|
||||
|
||||
// Copy the logo to be the thumbnail
|
||||
err := utils.Copy(filepath.Join("webroot", config.Config.InstanceDetails.Logo), "webroot/thumbnail.jpg")
|
||||
logo := data.GetLogoPath()
|
||||
err := utils.Copy(filepath.Join("data", logo), "webroot/thumbnail.jpg")
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
@ -129,8 +136,8 @@ func resetDirectories() {
|
|||
os.Remove(filepath.Join(config.WebRoot, "thumbnail.jpg"))
|
||||
|
||||
// Create private hls data dirs
|
||||
if len(config.Config.VideoSettings.StreamQualities) != 0 {
|
||||
for index := range config.Config.VideoSettings.StreamQualities {
|
||||
if len(data.GetStreamOutputVariants()) != 0 {
|
||||
for index := range data.GetStreamOutputVariants() {
|
||||
err = os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
|
@ -154,7 +161,8 @@ func resetDirectories() {
|
|||
}
|
||||
|
||||
// Remove the previous thumbnail
|
||||
err = utils.Copy(path.Join(config.WebRoot, config.Config.InstanceDetails.Logo), "webroot/thumbnail.jpg")
|
||||
logo := data.GetLogoPath()
|
||||
err = utils.Copy(path.Join("data", logo), "webroot/thumbnail.jpg")
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
|
199
core/data/accessTokens.go
Normal file
199
core/data/accessTokens.go
Normal file
|
@ -0,0 +1,199 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func createAccessTokensTable() {
|
||||
log.Traceln("Creating access_tokens table...")
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
"token" string NOT NULL PRIMARY KEY,
|
||||
"name" string,
|
||||
"scopes" TEXT,
|
||||
"timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used" DATETIME
|
||||
);`
|
||||
|
||||
stmt, err := _db.Prepare(createTableSQL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// InsertToken will add a new token to the database.
|
||||
func InsertToken(token string, name string, scopes []string) error {
|
||||
log.Println("Adding new access token:", name)
|
||||
|
||||
scopesString := strings.Join(scopes, ",")
|
||||
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("INSERT INTO access_tokens(token, name, scopes) values(?, ?, ?)")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err = stmt.Exec(token, name, scopesString); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteToken will delete a token from the database.
|
||||
func DeleteToken(token string) error {
|
||||
log.Println("Deleting access token:", token)
|
||||
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("DELETE FROM access_tokens WHERE token = ?")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
result, err := stmt.Exec(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
|
||||
tx.Rollback() //nolint
|
||||
return errors.New(token + " not found")
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DoesTokenSupportScope will determine if a specific token has access to perform a scoped action.
|
||||
func DoesTokenSupportScope(token string, scope string) (bool, error) {
|
||||
// This will split the scopes from comma separated to individual rows
|
||||
// so we can efficiently find if a token supports a single scope.
|
||||
// This is SQLite specific, so if we ever support other database
|
||||
// backends we need to support other methods.
|
||||
var query = `SELECT count(*) FROM (
|
||||
WITH RECURSIVE split(token, scope, rest) AS (
|
||||
SELECT token, '', scopes || ',' FROM access_tokens
|
||||
UNION ALL
|
||||
SELECT token,
|
||||
substr(rest, 0, instr(rest, ',')),
|
||||
substr(rest, instr(rest, ',')+1)
|
||||
FROM split
|
||||
WHERE rest <> '')
|
||||
SELECT token, scope
|
||||
FROM split
|
||||
WHERE scope <> ''
|
||||
ORDER BY token, scope
|
||||
) AS token WHERE token.token = ? AND token.scope = ?;`
|
||||
|
||||
row := _db.QueryRow(query, token, scope)
|
||||
|
||||
var count = 0
|
||||
err := row.Scan(&count)
|
||||
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetAccessTokens will return all access tokens.
|
||||
func GetAccessTokens() ([]models.AccessToken, error) { //nolint
|
||||
tokens := make([]models.AccessToken, 0)
|
||||
|
||||
// Get all messages sent within the past day
|
||||
var query = "SELECT * FROM access_tokens"
|
||||
|
||||
rows, err := _db.Query(query)
|
||||
if err != nil {
|
||||
return tokens, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var token string
|
||||
var name string
|
||||
var scopes string
|
||||
var timestampString string
|
||||
var lastUsedString *string
|
||||
|
||||
if err := rows.Scan(&token, &name, &scopes, ×tampString, &lastUsedString); err != nil {
|
||||
log.Error("There is a problem reading the database.", err)
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
timestamp, err := time.Parse(time.RFC3339, timestampString)
|
||||
if err != nil {
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
var lastUsed *time.Time = nil
|
||||
if lastUsedString != nil {
|
||||
lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString)
|
||||
lastUsed = &lastUsedTime
|
||||
}
|
||||
|
||||
singleToken := models.AccessToken{
|
||||
Name: name,
|
||||
Token: token,
|
||||
Scopes: strings.Split(scopes, ","),
|
||||
Timestamp: timestamp,
|
||||
LastUsed: lastUsed,
|
||||
}
|
||||
|
||||
tokens = append(tokens, singleToken)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// SetAccessTokenAsUsed will update the last used timestamp for a token.
|
||||
func SetAccessTokenAsUsed(token string) error {
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("UPDATE access_tokens SET last_used = CURRENT_TIMESTAMP WHERE token = ?")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err := stmt.Exec(token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
18
core/data/cache.go
Normal file
18
core/data/cache.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package data
|
||||
|
||||
import "errors"
|
||||
|
||||
// GetCachedValue will return a value for key from the cache.
|
||||
func (ds *Datastore) GetCachedValue(key string) ([]byte, error) {
|
||||
// Check for a cached value
|
||||
if val, ok := ds.cache[key]; ok {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
return nil, errors.New(key + " not found in cache")
|
||||
}
|
||||
|
||||
// SetCachedValue will set a value for key in the cache.
|
||||
func (ds *Datastore) SetCachedValue(key string, b []byte) {
|
||||
ds.cache[key] = b
|
||||
}
|
450
core/data/config.go
Normal file
450
core/data/config.go
Normal file
|
@ -0,0 +1,450 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const extraContentKey = "extra_page_content"
|
||||
const streamTitleKey = "stream_title"
|
||||
const streamKeyKey = "stream_key"
|
||||
const logoPathKey = "logo_path"
|
||||
const serverSummaryKey = "server_summary"
|
||||
const serverNameKey = "server_name"
|
||||
const serverURLKey = "server_url"
|
||||
const httpPortNumberKey = "http_port_number"
|
||||
const rtmpPortNumberKey = "rtmp_port_number"
|
||||
const serverMetadataTagsKey = "server_metadata_tags"
|
||||
const directoryEnabledKey = "directory_enabled"
|
||||
const directoryRegistrationKeyKey = "directory_registration_key"
|
||||
const socialHandlesKey = "social_handles"
|
||||
const peakViewersSessionKey = "peak_viewers_session"
|
||||
const peakViewersOverallKey = "peak_viewers_overall"
|
||||
const lastDisconnectTimeKey = "last_disconnect_time"
|
||||
const ffmpegPathKey = "ffmpeg_path"
|
||||
const nsfwKey = "nsfw"
|
||||
const s3StorageEnabledKey = "s3_storage_enabled"
|
||||
const s3StorageConfigKey = "s3_storage_config"
|
||||
const videoLatencyLevel = "video_latency_level"
|
||||
const videoStreamOutputVariantsKey = "video_stream_output_variants"
|
||||
|
||||
// GetExtraPageBodyContent will return the user-supplied body content.
|
||||
func GetExtraPageBodyContent() string {
|
||||
content, err := _datastore.GetString(extraContentKey)
|
||||
if err != nil {
|
||||
log.Errorln(extraContentKey, err)
|
||||
return config.GetDefaults().PageBodyContent
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// SetExtraPageBodyContent will set the user-supplied body content.
|
||||
func SetExtraPageBodyContent(content string) error {
|
||||
return _datastore.SetString(extraContentKey, content)
|
||||
}
|
||||
|
||||
// GetStreamTitle will return the name of the current stream.
|
||||
func GetStreamTitle() string {
|
||||
title, err := _datastore.GetString(streamTitleKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
// SetStreamTitle will set the name of the current stream.
|
||||
func SetStreamTitle(title string) error {
|
||||
return _datastore.SetString(streamTitleKey, title)
|
||||
}
|
||||
|
||||
// GetStreamKey will return the inbound streaming password.
|
||||
func GetStreamKey() string {
|
||||
key, err := _datastore.GetString(streamKeyKey)
|
||||
if err != nil {
|
||||
log.Errorln(streamKeyKey, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// SetStreamKey will set the inbound streaming password.
|
||||
func SetStreamKey(key string) error {
|
||||
return _datastore.SetString(streamKeyKey, key)
|
||||
}
|
||||
|
||||
// GetLogoPath will return the path for the logo, relative to webroot.
|
||||
func GetLogoPath() string {
|
||||
logo, err := _datastore.GetString(logoPathKey)
|
||||
if err != nil {
|
||||
log.Errorln(logoPathKey, err)
|
||||
return config.GetDefaults().Logo
|
||||
}
|
||||
|
||||
if logo == "" {
|
||||
return config.GetDefaults().Logo
|
||||
}
|
||||
|
||||
return logo
|
||||
}
|
||||
|
||||
// SetLogoPath will set the path for the logo, relative to webroot.
|
||||
func SetLogoPath(logo string) error {
|
||||
return _datastore.SetString(logoPathKey, logo)
|
||||
}
|
||||
|
||||
// GetServerSummary will return the server summary text.
|
||||
func GetServerSummary() string {
|
||||
summary, err := _datastore.GetString(serverSummaryKey)
|
||||
if err != nil {
|
||||
log.Errorln(serverSummaryKey, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// SetServerSummary will set the server summary text.
|
||||
func SetServerSummary(summary string) error {
|
||||
return _datastore.SetString(serverSummaryKey, summary)
|
||||
}
|
||||
|
||||
// GetServerName will return the server name text.
|
||||
func GetServerName() string {
|
||||
name, err := _datastore.GetString(serverNameKey)
|
||||
if err != nil {
|
||||
log.Errorln(serverNameKey, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// SetServerName will set the server name text.
|
||||
func SetServerName(name string) error {
|
||||
return _datastore.SetString(serverNameKey, name)
|
||||
}
|
||||
|
||||
// GetServerURL will return the server URL.
|
||||
func GetServerURL() string {
|
||||
url, err := _datastore.GetString(serverURLKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// SetServerURL will set the server URL.
|
||||
func SetServerURL(url string) error {
|
||||
return _datastore.SetString(serverURLKey, url)
|
||||
}
|
||||
|
||||
// GetHTTPPortNumber will return the server HTTP port.
|
||||
func GetHTTPPortNumber() int {
|
||||
port, err := _datastore.GetNumber(httpPortNumberKey)
|
||||
if err != nil {
|
||||
log.Errorln(httpPortNumberKey, err)
|
||||
return config.GetDefaults().WebServerPort
|
||||
}
|
||||
|
||||
if port == 0 {
|
||||
return config.GetDefaults().WebServerPort
|
||||
}
|
||||
return int(port)
|
||||
}
|
||||
|
||||
// SetHTTPPortNumber will set the server HTTP port.
|
||||
func SetHTTPPortNumber(port float64) error {
|
||||
return _datastore.SetNumber(httpPortNumberKey, port)
|
||||
}
|
||||
|
||||
// GetRTMPPortNumber will return the server RTMP port.
|
||||
func GetRTMPPortNumber() int {
|
||||
port, err := _datastore.GetNumber(rtmpPortNumberKey)
|
||||
if err != nil {
|
||||
log.Errorln(rtmpPortNumberKey, err)
|
||||
return config.GetDefaults().RTMPServerPort
|
||||
}
|
||||
|
||||
if port == 0 {
|
||||
return config.GetDefaults().RTMPServerPort
|
||||
}
|
||||
|
||||
return int(port)
|
||||
}
|
||||
|
||||
// SetRTMPPortNumber will set the server RTMP port.
|
||||
func SetRTMPPortNumber(port float64) error {
|
||||
return _datastore.SetNumber(rtmpPortNumberKey, port)
|
||||
}
|
||||
|
||||
// GetServerMetadataTags will return the metadata tags.
|
||||
func GetServerMetadataTags() []string {
|
||||
tagsString, err := _datastore.GetString(serverMetadataTagsKey)
|
||||
if err != nil {
|
||||
log.Errorln(serverMetadataTagsKey, err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return strings.Split(tagsString, ",")
|
||||
}
|
||||
|
||||
// SetServerMetadataTags will return the metadata tags.
|
||||
func SetServerMetadataTags(tags []string) error {
|
||||
tagString := strings.Join(tags, ",")
|
||||
return _datastore.SetString(serverMetadataTagsKey, tagString)
|
||||
}
|
||||
|
||||
// GetDirectoryEnabled will return if this server should register to YP.
|
||||
func GetDirectoryEnabled() bool {
|
||||
enabled, err := _datastore.GetBool(directoryEnabledKey)
|
||||
if err != nil {
|
||||
return config.GetDefaults().YPEnabled
|
||||
}
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
// SetDirectoryEnabled will set if this server should register to YP.
|
||||
func SetDirectoryEnabled(enabled bool) error {
|
||||
return _datastore.SetBool(directoryEnabledKey, enabled)
|
||||
}
|
||||
|
||||
// SetDirectoryRegistrationKey will set the YP protocol registration key.
|
||||
func SetDirectoryRegistrationKey(key string) error {
|
||||
return _datastore.SetString(directoryRegistrationKeyKey, key)
|
||||
}
|
||||
|
||||
// GetDirectoryRegistrationKey will return the YP protocol registration key.
|
||||
func GetDirectoryRegistrationKey() string {
|
||||
key, _ := _datastore.GetString(directoryRegistrationKeyKey)
|
||||
return key
|
||||
}
|
||||
|
||||
// GetSocialHandles will return the external social links.
|
||||
func GetSocialHandles() []models.SocialHandle {
|
||||
var socialHandles []models.SocialHandle
|
||||
|
||||
configEntry, err := _datastore.Get(socialHandlesKey)
|
||||
if err != nil {
|
||||
log.Errorln(socialHandlesKey, err)
|
||||
return socialHandles
|
||||
}
|
||||
|
||||
if err := configEntry.getObject(&socialHandles); err != nil {
|
||||
log.Errorln(err)
|
||||
return socialHandles
|
||||
}
|
||||
|
||||
return socialHandles
|
||||
}
|
||||
|
||||
// SetSocialHandles will set the external social links.
|
||||
func SetSocialHandles(socialHandles []models.SocialHandle) error {
|
||||
var configEntry = ConfigEntry{Key: socialHandlesKey, Value: socialHandles}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// GetPeakSessionViewerCount will return the max number of viewers for this stream.
|
||||
func GetPeakSessionViewerCount() int {
|
||||
count, err := _datastore.GetNumber(peakViewersSessionKey)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(count)
|
||||
}
|
||||
|
||||
// SetPeakSessionViewerCount will set the max number of viewers for this stream.
|
||||
func SetPeakSessionViewerCount(count int) error {
|
||||
return _datastore.SetNumber(peakViewersSessionKey, float64(count))
|
||||
}
|
||||
|
||||
// GetPeakOverallViewerCount will return the overall max number of viewers.
|
||||
func GetPeakOverallViewerCount() int {
|
||||
count, err := _datastore.GetNumber(peakViewersOverallKey)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(count)
|
||||
}
|
||||
|
||||
// SetPeakOverallViewerCount will set the overall max number of viewers.
|
||||
func SetPeakOverallViewerCount(count int) error {
|
||||
return _datastore.SetNumber(peakViewersOverallKey, float64(count))
|
||||
}
|
||||
|
||||
// GetLastDisconnectTime will return the time the last stream ended.
|
||||
func GetLastDisconnectTime() (time.Time, error) {
|
||||
var disconnectTime time.Time
|
||||
configEntry, err := _datastore.Get(lastDisconnectTimeKey)
|
||||
if err != nil {
|
||||
return disconnectTime, err
|
||||
}
|
||||
|
||||
if err := configEntry.getObject(disconnectTime); err != nil {
|
||||
return disconnectTime, err
|
||||
}
|
||||
|
||||
return disconnectTime, nil
|
||||
}
|
||||
|
||||
// SetLastDisconnectTime will set the time the last stream ended.
|
||||
func SetLastDisconnectTime(disconnectTime time.Time) error {
|
||||
var configEntry = ConfigEntry{Key: lastDisconnectTimeKey, Value: disconnectTime}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// SetNSFW will set if this stream has NSFW content.
|
||||
func SetNSFW(isNSFW bool) error {
|
||||
return _datastore.SetBool(nsfwKey, isNSFW)
|
||||
}
|
||||
|
||||
// GetNSFW will return if this stream has NSFW content.
|
||||
func GetNSFW() bool {
|
||||
nsfw, err := _datastore.GetBool(nsfwKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return nsfw
|
||||
}
|
||||
|
||||
// SetFfmpegPath will set the custom ffmpeg path.
|
||||
func SetFfmpegPath(path string) error {
|
||||
return _datastore.SetString(ffmpegPathKey, path)
|
||||
}
|
||||
|
||||
// GetFfMpegPath will return the ffmpeg path.
|
||||
func GetFfMpegPath() string {
|
||||
path, err := _datastore.GetString(ffmpegPathKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GetS3Config will return the external storage configuration.
|
||||
func GetS3Config() models.S3 {
|
||||
configEntry, err := _datastore.Get(s3StorageConfigKey)
|
||||
if err != nil {
|
||||
return models.S3{Enabled: false}
|
||||
}
|
||||
|
||||
var s3Config models.S3
|
||||
if err := configEntry.getObject(&s3Config); err != nil {
|
||||
return models.S3{Enabled: false}
|
||||
}
|
||||
|
||||
return s3Config
|
||||
}
|
||||
|
||||
// SetS3Config will set the external storage configuration.
|
||||
func SetS3Config(config models.S3) error {
|
||||
var configEntry = ConfigEntry{Key: s3StorageConfigKey, Value: config}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// GetS3StorageEnabled will return if external storage is enabled.
|
||||
func GetS3StorageEnabled() bool {
|
||||
enabled, err := _datastore.GetBool(s3StorageEnabledKey)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return false
|
||||
}
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
// SetS3StorageEnabled will enable or disable external storage.
|
||||
func SetS3StorageEnabled(enabled bool) error {
|
||||
return _datastore.SetBool(s3StorageEnabledKey, enabled)
|
||||
}
|
||||
|
||||
// GetStreamLatencyLevel will return the stream latency level.
|
||||
func GetStreamLatencyLevel() models.LatencyLevel {
|
||||
level, err := _datastore.GetNumber(videoLatencyLevel)
|
||||
if err != nil || level == 0 {
|
||||
level = 4
|
||||
}
|
||||
|
||||
return models.GetLatencyLevel(int(level))
|
||||
}
|
||||
|
||||
// SetStreamLatencyLevel will set the stream latency level.
|
||||
func SetStreamLatencyLevel(level float64) error {
|
||||
return _datastore.SetNumber(videoLatencyLevel, level)
|
||||
}
|
||||
|
||||
// GetStreamOutputVariants will return all of the stream output variants.
|
||||
func GetStreamOutputVariants() []models.StreamOutputVariant {
|
||||
configEntry, err := _datastore.Get(videoStreamOutputVariantsKey)
|
||||
if err != nil {
|
||||
return config.GetDefaults().StreamVariants
|
||||
}
|
||||
|
||||
var streamOutputVariants []models.StreamOutputVariant
|
||||
if err := configEntry.getObject(&streamOutputVariants); err != nil {
|
||||
return config.GetDefaults().StreamVariants
|
||||
}
|
||||
|
||||
if len(streamOutputVariants) == 0 {
|
||||
return config.GetDefaults().StreamVariants
|
||||
}
|
||||
|
||||
return streamOutputVariants
|
||||
}
|
||||
|
||||
// SetStreamOutputVariants will set the stream output variants.
|
||||
func SetStreamOutputVariants(variants []models.StreamOutputVariant) error {
|
||||
var configEntry = ConfigEntry{Key: videoStreamOutputVariantsKey, Value: variants}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// VerifySettings will perform a sanity check for specific settings values.
|
||||
func VerifySettings() error {
|
||||
if GetStreamKey() == "" {
|
||||
return errors.New("no stream key set. Please set one in your config file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindHighestVideoQualityIndex will return the highest quality from a slice of variants.
|
||||
func FindHighestVideoQualityIndex(qualities []models.StreamOutputVariant) int {
|
||||
type IndexedQuality struct {
|
||||
index int
|
||||
quality models.StreamOutputVariant
|
||||
}
|
||||
|
||||
if len(qualities) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
indexedQualities := make([]IndexedQuality, 0)
|
||||
for index, quality := range qualities {
|
||||
indexedQuality := IndexedQuality{index, quality}
|
||||
indexedQualities = append(indexedQualities, indexedQuality)
|
||||
}
|
||||
|
||||
sort.Slice(indexedQualities, func(a, b int) bool {
|
||||
if indexedQualities[a].quality.IsVideoPassthrough && !indexedQualities[b].quality.IsVideoPassthrough {
|
||||
return true
|
||||
}
|
||||
|
||||
if !indexedQualities[a].quality.IsVideoPassthrough && indexedQualities[b].quality.IsVideoPassthrough {
|
||||
return false
|
||||
}
|
||||
|
||||
return indexedQualities[a].quality.VideoBitrate > indexedQualities[b].quality.VideoBitrate
|
||||
})
|
||||
|
||||
return indexedQualities[0].index
|
||||
}
|
46
core/data/configEntry.go
Normal file
46
core/data/configEntry.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
)
|
||||
|
||||
// ConfigEntry is the actual object saved to the database.
|
||||
// The Value is encoded using encoding/gob.
|
||||
type ConfigEntry struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
func (c *ConfigEntry) getString() (string, error) {
|
||||
decoder := c.getDecoder()
|
||||
var result string
|
||||
err := decoder.Decode(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *ConfigEntry) getNumber() (float64, error) {
|
||||
decoder := c.getDecoder()
|
||||
var result float64
|
||||
err := decoder.Decode(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *ConfigEntry) getBool() (bool, error) {
|
||||
decoder := c.getDecoder()
|
||||
var result bool
|
||||
err := decoder.Decode(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *ConfigEntry) getObject(result interface{}) error {
|
||||
decoder := c.getDecoder()
|
||||
err := decoder.Decode(result)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ConfigEntry) getDecoder() *gob.Decoder {
|
||||
valueBytes := c.Value.([]byte)
|
||||
decoder := gob.NewDecoder(bytes.NewBuffer(valueBytes))
|
||||
return decoder
|
||||
}
|
|
@ -8,25 +8,32 @@ import (
|
|||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
schemaVersion = 0
|
||||
backupFile = "backup/owncastdb.bak"
|
||||
)
|
||||
|
||||
var _db *sql.DB
|
||||
var _datastore *Datastore
|
||||
|
||||
// GetDatabase will return the shared instance of the actual database.
|
||||
func GetDatabase() *sql.DB {
|
||||
return _db
|
||||
}
|
||||
|
||||
func SetupPersistence() error {
|
||||
file := config.Config.DatabaseFilePath
|
||||
// GetStore will return the shared instance of the read/write datastore.
|
||||
func GetStore() *Datastore {
|
||||
return _datastore
|
||||
}
|
||||
|
||||
// SetupPersistence will open the datastore and make it available.
|
||||
func SetupPersistence(file string) error {
|
||||
// Create empty DB file if it doesn't exist.
|
||||
if !utils.DoesFileExists(file) {
|
||||
log.Traceln("Creating new database at", file)
|
||||
|
@ -79,11 +86,26 @@ func SetupPersistence() error {
|
|||
}
|
||||
|
||||
_db = db
|
||||
|
||||
createWebhooksTable()
|
||||
createAccessTokensTable()
|
||||
|
||||
_datastore = &Datastore{}
|
||||
_datastore.Setup()
|
||||
|
||||
dbBackupTicker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
for range dbBackupTicker.C {
|
||||
utils.Backup(_db, backupFile)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDatabase(db *sql.DB, from, to int) error {
|
||||
log.Printf("Migrating database from version %d to %d\n", from, to)
|
||||
utils.Backup(db, fmt.Sprintf("backup/owncast-v%d.bak", from))
|
||||
for v := from; v < to; v++ {
|
||||
switch v {
|
||||
case 0:
|
||||
|
|
115
core/data/data_test.go
Normal file
115
core/data/data_test.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbFile := "../../test/test.db"
|
||||
|
||||
SetupPersistence(dbFile)
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
const testKey = "test string key"
|
||||
const testValue = "test string value"
|
||||
|
||||
err := _datastore.SetString(testKey, testValue)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get the config entry from the database
|
||||
stringTestResult, err := _datastore.GetString(testKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if stringTestResult != testValue {
|
||||
t.Error("expected", testValue, "but test returned", stringTestResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumber(t *testing.T) {
|
||||
const testKey = "test number key"
|
||||
const testValue = 42
|
||||
|
||||
err := _datastore.SetNumber(testKey, testValue)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get the config entry from the database
|
||||
numberTestResult, err := _datastore.GetNumber(testKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(numberTestResult)
|
||||
|
||||
if numberTestResult != testValue {
|
||||
t.Error("expected", testValue, "but test returned", numberTestResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBool(t *testing.T) {
|
||||
const testKey = "test bool key"
|
||||
const testValue = true
|
||||
|
||||
err := _datastore.SetBool(testKey, testValue)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get the config entry from the database
|
||||
numberTestResult, err := _datastore.GetBool(testKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(numberTestResult)
|
||||
|
||||
if numberTestResult != testValue {
|
||||
t.Error("expected", testValue, "but test returned", numberTestResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomType(t *testing.T) {
|
||||
const testKey = "test custom type key"
|
||||
|
||||
// Test an example struct with a slice
|
||||
testStruct := TestStruct{
|
||||
Test: "Test string 123 in test struct",
|
||||
TestSlice: []string{"test string 1", "test string 2"},
|
||||
}
|
||||
|
||||
// Save config entry to the database
|
||||
if err := _datastore.Save(ConfigEntry{testKey, &testStruct}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Get the config entry from the database
|
||||
entryResult, err := _datastore.Get(testKey)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Get a typed struct out of it
|
||||
var testResult TestStruct
|
||||
if err := entryResult.getObject(&testResult); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
fmt.Printf("%+v", testResult)
|
||||
|
||||
if testResult.TestSlice[0] != testStruct.TestSlice[0] {
|
||||
t.Error("expected", testStruct.TestSlice[0], "but test returned", testResult.TestSlice[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Custom type for testing
|
||||
type TestStruct struct {
|
||||
Test string
|
||||
TestSlice []string
|
||||
privateProperty string
|
||||
}
|
43
core/data/defaults.go
Normal file
43
core/data/defaults.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
// HasPopulatedDefaults will determine if the defaults have been inserted into the database.
|
||||
func HasPopulatedDefaults() bool {
|
||||
hasPopulated, err := _datastore.GetBool("HAS_POPULATED_DEFAULTS")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hasPopulated
|
||||
}
|
||||
|
||||
// PopulateDefaults will set default values in the database.
|
||||
func PopulateDefaults() {
|
||||
defaults := config.GetDefaults()
|
||||
|
||||
if HasPopulatedDefaults() {
|
||||
return
|
||||
}
|
||||
|
||||
_ = SetStreamKey(defaults.StreamKey)
|
||||
_ = SetHTTPPortNumber(float64(defaults.WebServerPort))
|
||||
_ = SetRTMPPortNumber(float64(defaults.RTMPServerPort))
|
||||
_ = SetLogoPath(defaults.Logo)
|
||||
_ = SetServerMetadataTags([]string{"owncast", "streaming"})
|
||||
_ = SetServerSummary("Welcome to your new Owncast server! This description can be changed in the admin")
|
||||
_ = SetServerName("Owncast")
|
||||
_ = SetStreamKey(defaults.StreamKey)
|
||||
_ = SetExtraPageBodyContent("This is your page's content that can be edited in the admin.")
|
||||
_ = SetSocialHandles([]models.SocialHandle{
|
||||
{
|
||||
Platform: "github",
|
||||
URL: "https://github.com/owncast/owncast",
|
||||
},
|
||||
})
|
||||
|
||||
_datastore.warmCache()
|
||||
_ = _datastore.SetBool("HAS_POPULATED_DEFAULTS", true)
|
||||
}
|
266
core/data/migrator.go
Normal file
266
core/data/migrator.go
Normal file
|
@ -0,0 +1,266 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// RunMigrations will start the migration process from the config file.
|
||||
func RunMigrations() {
|
||||
if !utils.DoesFileExists(config.BackupDirectory) {
|
||||
if err := os.Mkdir(config.BackupDirectory, 0700); err != nil {
|
||||
log.Errorln("Unable to create backup directory", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
migrateConfigFile()
|
||||
migrateStatsFile()
|
||||
migrateYPKey()
|
||||
}
|
||||
|
||||
func migrateStatsFile() {
|
||||
oldStats := models.Stats{
|
||||
Clients: make(map[string]models.Client),
|
||||
}
|
||||
|
||||
if !utils.DoesFileExists(config.StatsFile) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infoln("Migrating", config.StatsFile, "to new datastore")
|
||||
|
||||
jsonFile, err := ioutil.ReadFile(config.StatsFile)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonFile, &oldStats); err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = SetPeakSessionViewerCount(oldStats.SessionMaxViewerCount)
|
||||
_ = SetPeakOverallViewerCount(oldStats.OverallMaxViewerCount)
|
||||
|
||||
if err := utils.Move(config.StatsFile, "backup/stats.old"); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateYPKey() {
|
||||
filePath := ".yp.key"
|
||||
|
||||
if !utils.DoesFileExists(filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infoln("Migrating", filePath, "to new datastore")
|
||||
|
||||
keyFile, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Errorln("Unable to migrate", keyFile, "there may be issues registering with the directory")
|
||||
}
|
||||
|
||||
if err := SetDirectoryRegistrationKey(string(keyFile)); err != nil {
|
||||
log.Errorln("Unable to migrate", keyFile, "there may be issues registering with the directory")
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.Move(filePath, "backup/yp.key.old"); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateConfigFile() {
|
||||
filePath := config.ConfigFilePath
|
||||
|
||||
if !utils.DoesFileExists(filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infoln("Migrating", filePath, "to new datastore")
|
||||
|
||||
var oldConfig configFile
|
||||
|
||||
yamlFile, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Errorln("config file err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(yamlFile, &oldConfig); err != nil {
|
||||
log.Errorln("Error reading the config file.", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = SetServerName(oldConfig.InstanceDetails.Name)
|
||||
_ = SetServerSummary(oldConfig.InstanceDetails.Summary)
|
||||
_ = SetServerMetadataTags(oldConfig.InstanceDetails.Tags)
|
||||
_ = SetStreamKey(oldConfig.VideoSettings.StreamingKey)
|
||||
_ = SetNSFW(oldConfig.InstanceDetails.NSFW)
|
||||
_ = SetServerURL(oldConfig.YP.InstanceURL)
|
||||
_ = SetDirectoryEnabled(oldConfig.YP.Enabled)
|
||||
_ = SetSocialHandles(oldConfig.InstanceDetails.SocialHandles)
|
||||
_ = SetFfmpegPath(oldConfig.FFMpegPath)
|
||||
_ = SetHTTPPortNumber(float64(oldConfig.WebServerPort))
|
||||
_ = SetRTMPPortNumber(float64(oldConfig.RTMPServerPort))
|
||||
|
||||
// Migrate logo
|
||||
if logo := oldConfig.InstanceDetails.Logo; logo != "" {
|
||||
filename := filepath.Base(logo)
|
||||
newPath := filepath.Join("data", filename)
|
||||
err := utils.Copy(filepath.Join("webroot", logo), newPath)
|
||||
log.Traceln("Copying logo from", logo, "to", newPath)
|
||||
if err != nil {
|
||||
log.Errorln("Error moving logo", logo, err)
|
||||
} else {
|
||||
_ = SetLogoPath(filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate video variants
|
||||
variants := []models.StreamOutputVariant{}
|
||||
for _, variant := range oldConfig.VideoSettings.StreamQualities {
|
||||
migratedVariant := models.StreamOutputVariant{}
|
||||
migratedVariant.IsAudioPassthrough = true
|
||||
migratedVariant.IsVideoPassthrough = variant.IsVideoPassthrough
|
||||
migratedVariant.Framerate = variant.Framerate
|
||||
migratedVariant.VideoBitrate = variant.VideoBitrate
|
||||
migratedVariant.ScaledHeight = variant.ScaledHeight
|
||||
migratedVariant.ScaledWidth = variant.ScaledWidth
|
||||
|
||||
presetMapping := map[string]int{
|
||||
"ultrafast": 1,
|
||||
"superfast": 2,
|
||||
"veryfast": 3,
|
||||
"faster": 4,
|
||||
"fast": 5,
|
||||
}
|
||||
migratedVariant.CPUUsageLevel = presetMapping[variant.EncoderPreset]
|
||||
variants = append(variants, migratedVariant)
|
||||
}
|
||||
_ = SetStreamOutputVariants(variants)
|
||||
|
||||
// Migrate latency level
|
||||
level := 4
|
||||
oldSegmentLength := oldConfig.VideoSettings.ChunkLengthInSeconds
|
||||
oldNumberOfSegments := oldConfig.Files.MaxNumberInPlaylist
|
||||
latencyLevels := models.GetLatencyConfigs()
|
||||
|
||||
if oldSegmentLength == latencyLevels[1].SecondsPerSegment && oldNumberOfSegments == latencyLevels[1].SegmentCount {
|
||||
level = 1
|
||||
} else if oldSegmentLength == latencyLevels[2].SecondsPerSegment && oldNumberOfSegments == latencyLevels[2].SegmentCount {
|
||||
level = 2
|
||||
} else if oldSegmentLength == latencyLevels[3].SecondsPerSegment && oldNumberOfSegments == latencyLevels[3].SegmentCount {
|
||||
level = 3
|
||||
} else if oldSegmentLength == latencyLevels[5].SecondsPerSegment && oldNumberOfSegments == latencyLevels[5].SegmentCount {
|
||||
level = 5
|
||||
} else if oldSegmentLength >= latencyLevels[6].SecondsPerSegment && oldNumberOfSegments >= latencyLevels[6].SegmentCount {
|
||||
level = 6
|
||||
}
|
||||
|
||||
_ = SetStreamLatencyLevel(float64(level))
|
||||
|
||||
// Migrate storage config
|
||||
_ = SetS3Config(models.S3(oldConfig.Storage))
|
||||
|
||||
// Migrate the old content.md file
|
||||
content, err := ioutil.ReadFile(config.ExtraInfoFile)
|
||||
if err == nil && len(content) > 0 {
|
||||
_ = SetExtraPageBodyContent(string(content))
|
||||
}
|
||||
|
||||
if err := utils.Move(filePath, "backup/config.old"); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
||||
log.Infoln("Your old config file can be found in the backup directory for reference. For all future configuration use the web admin.")
|
||||
}
|
||||
|
||||
type configFile struct {
|
||||
DatabaseFilePath string `yaml:"databaseFile"`
|
||||
EnableDebugFeatures bool `yaml:"-"`
|
||||
FFMpegPath string
|
||||
Files files `yaml:"files"`
|
||||
InstanceDetails instanceDetails `yaml:"instanceDetails"`
|
||||
VersionInfo string `yaml:"-"` // For storing the version/build number
|
||||
VersionNumber string `yaml:"-"`
|
||||
VideoSettings videoSettings `yaml:"videoSettings"`
|
||||
WebServerPort int
|
||||
RTMPServerPort int
|
||||
YP yp `yaml:"yp"`
|
||||
Storage s3 `yaml:"s3"`
|
||||
}
|
||||
|
||||
// instanceDetails defines the user-visible information about this particular instance.
|
||||
type instanceDetails struct {
|
||||
Name string `yaml:"name"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Logo string `yaml:"logo"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Version string `yaml:"version"`
|
||||
NSFW bool `yaml:"nsfw"`
|
||||
ExtraPageContent string `yaml:"extraPageContent"`
|
||||
StreamTitle string `yaml:"streamTitle"`
|
||||
SocialHandles []models.SocialHandle `yaml:"socialHandles"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"`
|
||||
StreamingKey string `yaml:"streamingKey"`
|
||||
StreamQualities []streamQuality `yaml:"streamQualities"`
|
||||
HighestQualityStreamIndex int `yaml:"-"`
|
||||
}
|
||||
|
||||
// yp allows registration to the central Owncast yp (Yellow pages) service operating as a directory.
|
||||
type yp struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
InstanceURL string `yaml:"instanceUrl"` // The public URL the directory should link to
|
||||
}
|
||||
|
||||
// streamQuality defines the specifics of a single HLS stream variant.
|
||||
type streamQuality struct {
|
||||
// Enable passthrough to copy the video and/or audio directly from the
|
||||
// incoming stream and disable any transcoding. It will ignore any of
|
||||
// the below settings.
|
||||
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"`
|
||||
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"`
|
||||
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"`
|
||||
AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"`
|
||||
|
||||
// Set only one of these in order to keep your current aspect ratio.
|
||||
// Or set neither to not scale the video.
|
||||
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"`
|
||||
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"`
|
||||
|
||||
Framerate int `yaml:"framerate" json:"framerate"`
|
||||
EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"`
|
||||
}
|
||||
|
||||
type files struct {
|
||||
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"`
|
||||
}
|
||||
|
||||
// s3 is for configuring the s3 integration.
|
||||
type s3 struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"`
|
||||
ServingEndpoint string `yaml:"servingEndpoint" json:"servingEndpoint,omitempty"`
|
||||
AccessKey string `yaml:"accessKey" json:"accessKey,omitempty"`
|
||||
Secret string `yaml:"secret" json:"secret,omitempty"`
|
||||
Bucket string `yaml:"bucket" json:"bucket,omitempty"`
|
||||
Region string `yaml:"region" json:"region,omitempty"`
|
||||
ACL string `yaml:"acl" json:"acl,omitempty"`
|
||||
}
|
152
core/data/persistence.go
Normal file
152
core/data/persistence.go
Normal file
|
@ -0,0 +1,152 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
|
||||
// sqlite requires a blank import.
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Datastore is the global key/value store for configuration values.
|
||||
type Datastore struct {
|
||||
db *sql.DB
|
||||
cache map[string][]byte
|
||||
}
|
||||
|
||||
func (ds *Datastore) warmCache() {
|
||||
log.Traceln("Warming config value cache")
|
||||
|
||||
res, err := ds.db.Query("SELECT key, value FROM datastore")
|
||||
if err != nil || res.Err() != nil {
|
||||
log.Errorln("error warming config cache", err, res.Err())
|
||||
}
|
||||
defer res.Close()
|
||||
|
||||
for res.Next() {
|
||||
var rowKey string
|
||||
var rowValue []byte
|
||||
if err := res.Scan(&rowKey, &rowValue); err != nil {
|
||||
log.Errorln("error pre-caching config row", err)
|
||||
}
|
||||
ds.cache[rowKey] = rowValue
|
||||
}
|
||||
}
|
||||
|
||||
// Get will query the database for the key and return the entry.
|
||||
func (ds *Datastore) Get(key string) (ConfigEntry, error) {
|
||||
cachedValue, err := ds.GetCachedValue(key)
|
||||
if err == nil {
|
||||
return ConfigEntry{
|
||||
Key: key,
|
||||
Value: cachedValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var resultKey string
|
||||
var resultValue []byte
|
||||
|
||||
row := ds.db.QueryRow("SELECT key, value FROM datastore WHERE key = ? LIMIT 1", key)
|
||||
if err := row.Scan(&resultKey, &resultValue); err != nil {
|
||||
return ConfigEntry{}, err
|
||||
}
|
||||
|
||||
result := ConfigEntry{
|
||||
Key: resultKey,
|
||||
Value: resultValue,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Save will save the ConfigEntry to the database.
|
||||
func (ds *Datastore) Save(e ConfigEntry) error {
|
||||
var dataGob bytes.Buffer
|
||||
enc := gob.NewEncoder(&dataGob)
|
||||
if err := enc.Encode(e.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := ds.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stmt *sql.Stmt
|
||||
var count int
|
||||
row := ds.db.QueryRow("SELECT COUNT(*) FROM datastore WHERE key = ? LIMIT 1", e.Key)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
stmt, err = tx.Prepare("INSERT INTO datastore(key, value) values(?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(e.Key, dataGob.Bytes())
|
||||
} else {
|
||||
stmt, err = tx.Prepare("UPDATE datastore SET value=? WHERE key=?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(dataGob.Bytes(), e.Key)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
ds.SetCachedValue(e.Key, dataGob.Bytes())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setup will create the datastore table and perform initial initialization.
|
||||
func (ds *Datastore) Setup() {
|
||||
ds.cache = make(map[string][]byte)
|
||||
ds.db = GetDatabase()
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS datastore (
|
||||
"key" string NOT NULL PRIMARY KEY,
|
||||
"value" BLOB,
|
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);`
|
||||
|
||||
stmt, err := ds.db.Prepare(createTableSQL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if !HasPopulatedDefaults() {
|
||||
PopulateDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset will delete all config entries in the datastore and start over.
|
||||
func (ds *Datastore) Reset() {
|
||||
sql := "DELETE FROM datastore"
|
||||
stmt, err := ds.db.Prepare(sql)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err = stmt.Exec(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
PopulateDefaults()
|
||||
}
|
46
core/data/types.go
Normal file
46
core/data/types.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package data
|
||||
|
||||
// GetString will return the string value for a key.
|
||||
func (ds *Datastore) GetString(key string) (string, error) {
|
||||
configEntry, err := ds.Get(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return configEntry.getString()
|
||||
}
|
||||
|
||||
// SetString will set the string value for a key.
|
||||
func (ds *Datastore) SetString(key string, value string) error {
|
||||
configEntry := ConfigEntry{key, value}
|
||||
return ds.Save(configEntry)
|
||||
}
|
||||
|
||||
// GetNumber will return the numeric value for a key.
|
||||
func (ds *Datastore) GetNumber(key string) (float64, error) {
|
||||
configEntry, err := ds.Get(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return configEntry.getNumber()
|
||||
}
|
||||
|
||||
// SetNumber will set the numeric value for a key.
|
||||
func (ds *Datastore) SetNumber(key string, value float64) error {
|
||||
configEntry := ConfigEntry{key, value}
|
||||
return ds.Save(configEntry)
|
||||
}
|
||||
|
||||
// GetBool will return the boolean value for a key.
|
||||
func (ds *Datastore) GetBool(key string) (bool, error) {
|
||||
configEntry, err := ds.Get(key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return configEntry.getBool()
|
||||
}
|
||||
|
||||
// SetBool will set the boolean value for a key.
|
||||
func (ds *Datastore) SetBool(key string, value bool) error {
|
||||
configEntry := ConfigEntry{key, value}
|
||||
return ds.Save(configEntry)
|
||||
}
|
220
core/data/webhooks.go
Normal file
220
core/data/webhooks.go
Normal file
|
@ -0,0 +1,220 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func createWebhooksTable() {
|
||||
log.Traceln("Creating webhooks table...")
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS webhooks (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"url" string NOT NULL,
|
||||
"events" TEXT NOT NULL,
|
||||
"timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used" DATETIME
|
||||
);`
|
||||
|
||||
stmt, err := _db.Prepare(createTableSQL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// InsertWebhook will add a new webhook to the database.
|
||||
func InsertWebhook(url string, events []models.EventType) (int, error) {
|
||||
log.Println("Adding new webhook:", url)
|
||||
|
||||
eventsString := strings.Join(events, ",")
|
||||
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stmt, err := tx.Prepare("INSERT INTO webhooks(url, events) values(?, ?)")
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
insertResult, err := stmt.Exec(url, eventsString)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
newID, err := insertResult.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(newID), err
|
||||
}
|
||||
|
||||
// DeleteWebhook will delete a webhook from the database.
|
||||
func DeleteWebhook(id int) error {
|
||||
log.Println("Deleting webhook:", id)
|
||||
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("DELETE FROM webhooks WHERE id = ?")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
result, err := stmt.Exec(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
|
||||
tx.Rollback() //nolint
|
||||
return errors.New(fmt.Sprint(id) + " not found")
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebhooksForEvent will return all of the webhooks that want to be notified about an event type.
|
||||
func GetWebhooksForEvent(event models.EventType) []models.Webhook {
|
||||
webhooks := make([]models.Webhook, 0)
|
||||
|
||||
var query = `SELECT * FROM (
|
||||
WITH RECURSIVE split(url, event, rest) AS (
|
||||
SELECT url, '', events || ',' FROM webhooks
|
||||
UNION ALL
|
||||
SELECT url,
|
||||
substr(rest, 0, instr(rest, ',')),
|
||||
substr(rest, instr(rest, ',')+1)
|
||||
FROM split
|
||||
WHERE rest <> '')
|
||||
SELECT url, event
|
||||
FROM split
|
||||
WHERE event <> ''
|
||||
) AS webhook WHERE event IS "` + event + `"`
|
||||
|
||||
rows, err := _db.Query(query)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var url string
|
||||
|
||||
err = rows.Scan(&url, &event)
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
log.Error("There is a problem with the database.")
|
||||
break
|
||||
}
|
||||
|
||||
singleWebhook := models.Webhook{
|
||||
URL: url,
|
||||
}
|
||||
|
||||
webhooks = append(webhooks, singleWebhook)
|
||||
}
|
||||
return webhooks
|
||||
}
|
||||
|
||||
// GetWebhooks will return all the webhooks.
|
||||
func GetWebhooks() ([]models.Webhook, error) { //nolint
|
||||
webhooks := make([]models.Webhook, 0)
|
||||
|
||||
var query = "SELECT * FROM webhooks"
|
||||
|
||||
rows, err := _db.Query(query)
|
||||
if err != nil {
|
||||
return webhooks, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var url string
|
||||
var events string
|
||||
var timestampString string
|
||||
var lastUsedString *string
|
||||
|
||||
if err := rows.Scan(&id, &url, &events, ×tampString, &lastUsedString); err != nil {
|
||||
log.Error("There is a problem reading the database.", err)
|
||||
return webhooks, err
|
||||
}
|
||||
|
||||
timestamp, err := time.Parse(time.RFC3339, timestampString)
|
||||
if err != nil {
|
||||
return webhooks, err
|
||||
}
|
||||
|
||||
var lastUsed *time.Time = nil
|
||||
if lastUsedString != nil {
|
||||
lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString)
|
||||
lastUsed = &lastUsedTime
|
||||
}
|
||||
|
||||
singleWebhook := models.Webhook{
|
||||
ID: id,
|
||||
URL: url,
|
||||
Events: strings.Split(events, ","),
|
||||
Timestamp: timestamp,
|
||||
LastUsed: lastUsed,
|
||||
}
|
||||
|
||||
webhooks = append(webhooks, singleWebhook)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return webhooks, err
|
||||
}
|
||||
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
// SetWebhookAsUsed will update the last used time for a webhook.
|
||||
func SetWebhookAsUsed(id string) error {
|
||||
tx, err := _db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("UPDATE webhooks SET last_used = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err := stmt.Exec(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -11,7 +11,7 @@ import (
|
|||
func setCurrentBroadcasterInfo(t flvio.Tag, remoteAddr string) {
|
||||
data, err := getInboundDetailsFromMetadata(t.DebugFields())
|
||||
if err != nil {
|
||||
log.Traceln("RTMP meadata:", err)
|
||||
log.Warnln("Unable to parse inbound broadcaster details:", err)
|
||||
}
|
||||
|
||||
broadcaster := models.Broadcaster{
|
||||
|
|
|
@ -8,14 +8,13 @@ import (
|
|||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/nareix/joy5/format/flv"
|
||||
"github.com/nareix/joy5/format/flv/flvio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/nareix/joy5/format/rtmp"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
@ -35,7 +34,7 @@ func Start(setStreamAsConnected func(), setBroadcaster func(models.Broadcaster))
|
|||
_setStreamAsConnected = setStreamAsConnected
|
||||
_setBroadcaster = setBroadcaster
|
||||
|
||||
port := config.Config.GetRTMPServerPort()
|
||||
port := data.GetRTMPPortNumber()
|
||||
s := rtmp.NewServer()
|
||||
var lis net.Listener
|
||||
var err error
|
||||
|
@ -45,7 +44,7 @@ func Start(setStreamAsConnected func(), setBroadcaster func(models.Broadcaster))
|
|||
|
||||
s.LogEvent = func(c *rtmp.Conn, nc net.Conn, e int) {
|
||||
es := rtmp.EventString[e]
|
||||
log.Traceln(unsafe.Pointer(c), nc.LocalAddr(), nc.RemoteAddr(), es)
|
||||
log.Traceln("RTMP", nc.LocalAddr(), nc.RemoteAddr(), es)
|
||||
}
|
||||
|
||||
s.HandleConn = HandleConn
|
||||
|
@ -81,7 +80,7 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
|||
|
||||
streamingKeyComponents := strings.Split(c.URL.Path, "/")
|
||||
streamingKey := streamingKeyComponents[len(streamingKeyComponents)-1]
|
||||
if streamingKey != config.Config.VideoSettings.StreamingKey {
|
||||
if streamingKey != data.GetStreamKey() {
|
||||
log.Errorln("invalid streaming key; rejecting incoming stream")
|
||||
nc.Close()
|
||||
return
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/geoip"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
@ -20,17 +17,13 @@ import (
|
|||
var l = sync.Mutex{}
|
||||
|
||||
func setupStats() error {
|
||||
s, err := getSavedStats()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := getSavedStats()
|
||||
_stats = &s
|
||||
|
||||
statsSaveTimer := time.NewTicker(1 * time.Minute)
|
||||
go func() {
|
||||
for range statsSaveTimer.C {
|
||||
if err := saveStatsToFile(); err != nil {
|
||||
if err := saveStats(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
@ -48,7 +41,8 @@ func IsStreamConnected() bool {
|
|||
// Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available.
|
||||
// So account for that with an artificial buffer of four segments.
|
||||
timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds()
|
||||
if timeSinceLastConnected < float64(config.Config.GetVideoSegmentSecondsLength())*2.3 {
|
||||
waitTime := math.Max(float64(data.GetStreamLatencyLevel().SecondsPerSegment)*3.0, 7)
|
||||
if timeSinceLastConnected < waitTime {
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -103,42 +97,27 @@ func GetClients() []models.Client {
|
|||
return clients
|
||||
}
|
||||
|
||||
func saveStatsToFile() error {
|
||||
jsonData, err := json.Marshal(_stats)
|
||||
if err != nil {
|
||||
return err
|
||||
func saveStats() error {
|
||||
if err := data.SetPeakOverallViewerCount(_stats.OverallMaxViewerCount); err != nil {
|
||||
log.Errorln("error saving viewer count", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(config.StatsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
if err := data.SetPeakSessionViewerCount(_stats.SessionMaxViewerCount); err != nil {
|
||||
log.Errorln("error saving viewer count", err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(jsonData); err != nil {
|
||||
return err
|
||||
if err := data.SetLastDisconnectTime(_stats.LastConnectTime.Time); err != nil {
|
||||
log.Errorln("error saving disconnect time", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSavedStats() (models.Stats, error) {
|
||||
func getSavedStats() models.Stats {
|
||||
savedLastDisconnectTime, savedLastDisconnectTimeErr := data.GetLastDisconnectTime()
|
||||
result := models.Stats{
|
||||
Clients: make(map[string]models.Client),
|
||||
}
|
||||
|
||||
if !utils.DoesFileExists(config.StatsFile) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
jsonFile, err := ioutil.ReadFile(config.StatsFile)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonFile, &result); err != nil {
|
||||
return result, err
|
||||
Clients: make(map[string]models.Client),
|
||||
SessionMaxViewerCount: data.GetPeakSessionViewerCount(),
|
||||
OverallMaxViewerCount: data.GetPeakOverallViewerCount(),
|
||||
LastDisconnectTime: utils.NullTime{Time: savedLastDisconnectTime, Valid: savedLastDisconnectTimeErr == nil},
|
||||
}
|
||||
|
||||
// If the stats were saved > 5min ago then ignore the
|
||||
|
@ -147,5 +126,5 @@ func getSavedStats() (models.Stats, error) {
|
|||
result.SessionMaxViewerCount = 0
|
||||
}
|
||||
|
||||
return result, err
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package core
|
|||
|
||||
import (
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
|
@ -11,17 +12,27 @@ func GetStatus() models.Status {
|
|||
return models.Status{}
|
||||
}
|
||||
|
||||
viewerCount := 0
|
||||
if IsStreamConnected() {
|
||||
viewerCount = len(_stats.Clients)
|
||||
}
|
||||
|
||||
return models.Status{
|
||||
Online: IsStreamConnected(),
|
||||
ViewerCount: len(_stats.Clients),
|
||||
ViewerCount: viewerCount,
|
||||
OverallMaxViewerCount: _stats.OverallMaxViewerCount,
|
||||
SessionMaxViewerCount: _stats.SessionMaxViewerCount,
|
||||
LastDisconnectTime: _stats.LastDisconnectTime,
|
||||
LastConnectTime: _stats.LastConnectTime,
|
||||
VersionNumber: config.Config.VersionNumber,
|
||||
VersionNumber: config.VersionNumber,
|
||||
StreamTitle: data.GetStreamTitle(),
|
||||
}
|
||||
}
|
||||
|
||||
func GetCurrentBroadcast() *models.CurrentBroadcast {
|
||||
return _currentBroadcast
|
||||
}
|
||||
|
||||
// setBroadcaster will store the current inbound broadcasting details.
|
||||
func setBroadcaster(broadcaster models.Broadcaster) {
|
||||
_broadcaster = &broadcaster
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/storageproviders"
|
||||
)
|
||||
|
||||
func setupStorage() error {
|
||||
handler.Storage = _storage
|
||||
s3Config := data.GetS3Config()
|
||||
|
||||
if config.Config.S3.Enabled {
|
||||
if s3Config.Enabled {
|
||||
_storage = &storageproviders.S3Storage{}
|
||||
} else {
|
||||
_storage = &storageproviders.LocalStorage{}
|
||||
|
@ -18,5 +18,7 @@ func setupStorage() error {
|
|||
return err
|
||||
}
|
||||
|
||||
handler.Storage = _storage
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/core/transcoder"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
|
@ -24,7 +24,7 @@ func (s *LocalStorage) Setup() error {
|
|||
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
||||
go func() {
|
||||
for range _onlineCleanupTicker.C {
|
||||
ffmpeg.CleanupOldContent(config.PublicHLSStoragePath)
|
||||
transcoder.CleanupOldContent(config.PublicHLSStoragePath)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/playlist"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
@ -44,19 +45,20 @@ var _uploader *s3manager.Uploader
|
|||
func (s *S3Storage) Setup() error {
|
||||
log.Trace("Setting up S3 for external storage of video...")
|
||||
|
||||
if config.Config.S3.ServingEndpoint != "" {
|
||||
s.host = config.Config.S3.ServingEndpoint
|
||||
s3Config := data.GetS3Config()
|
||||
if s3Config.ServingEndpoint != "" {
|
||||
s.host = s3Config.ServingEndpoint
|
||||
} else {
|
||||
s.host = fmt.Sprintf("%s/%s", config.Config.S3.Endpoint, config.Config.S3.Bucket)
|
||||
s.host = fmt.Sprintf("%s/%s", s3Config.Endpoint, s3Config.Bucket)
|
||||
}
|
||||
|
||||
s.s3Endpoint = config.Config.S3.Endpoint
|
||||
s.s3ServingEndpoint = config.Config.S3.ServingEndpoint
|
||||
s.s3Region = config.Config.S3.Region
|
||||
s.s3Bucket = config.Config.S3.Bucket
|
||||
s.s3AccessKey = config.Config.S3.AccessKey
|
||||
s.s3Secret = config.Config.S3.Secret
|
||||
s.s3ACL = config.Config.S3.ACL
|
||||
s.s3Endpoint = s3Config.Endpoint
|
||||
s.s3ServingEndpoint = s3Config.ServingEndpoint
|
||||
s.s3Region = s3Config.Region
|
||||
s.s3Bucket = s3Config.Bucket
|
||||
s.s3AccessKey = s3Config.AccessKey
|
||||
s.s3Secret = s3Config.Secret
|
||||
s.s3ACL = s3Config.ACL
|
||||
|
||||
s.sess = s.connectAWS()
|
||||
|
||||
|
@ -81,7 +83,7 @@ func (s *S3Storage) SegmentWritten(localFilePath string) {
|
|||
|
||||
// Warn the user about long-running save operations
|
||||
if averagePerformance != 0 {
|
||||
if averagePerformance > float64(config.Config.GetVideoSegmentSecondsLength())*0.9 {
|
||||
if averagePerformance > float64(data.GetStreamLatencyLevel().SecondsPerSegment)*0.9 {
|
||||
log.Warnln("Possible slow uploads: average upload S3 save duration", averagePerformance, "s. troubleshoot this issue by visiting https://owncast.online/docs/troubleshooting/")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,11 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/rtmp"
|
||||
"github.com/owncast/owncast/core/transcoder"
|
||||
"github.com/owncast/owncast/core/webhooks"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
|
@ -23,12 +26,20 @@ var _offlineCleanupTimer *time.Timer
|
|||
// While a stream takes place cleanup old HLS content every N min.
|
||||
var _onlineCleanupTicker *time.Ticker
|
||||
|
||||
var _currentBroadcast *models.CurrentBroadcast
|
||||
|
||||
// setStreamAsConnected sets the stream as connected.
|
||||
func setStreamAsConnected() {
|
||||
|
||||
_stats.StreamConnected = true
|
||||
_stats.LastConnectTime = utils.NullTime{Time: time.Now(), Valid: true}
|
||||
_stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: false}
|
||||
|
||||
_currentBroadcast = &models.CurrentBroadcast{
|
||||
LatencyLevel: data.GetStreamLatencyLevel(),
|
||||
OutputSettings: data.GetStreamOutputVariants(),
|
||||
}
|
||||
|
||||
StopOfflineCleanupTimer()
|
||||
startOnlineCleanupTimer()
|
||||
|
||||
|
@ -37,23 +48,28 @@ func setStreamAsConnected() {
|
|||
}
|
||||
|
||||
segmentPath := config.PublicHLSStoragePath
|
||||
if config.Config.S3.Enabled {
|
||||
s3Config := data.GetS3Config()
|
||||
|
||||
if err := setupStorage(); err != nil {
|
||||
log.Fatalln("failed to setup the storage", err)
|
||||
}
|
||||
|
||||
if s3Config.Enabled {
|
||||
segmentPath = config.PrivateHLSStoragePath
|
||||
}
|
||||
|
||||
go func() {
|
||||
_transcoder = ffmpeg.NewTranscoder()
|
||||
if _broadcaster != nil {
|
||||
_transcoder.SetVideoOnly(_broadcaster.StreamDetails.VideoOnly)
|
||||
}
|
||||
|
||||
_transcoder = transcoder.NewTranscoder()
|
||||
_transcoder.TranscoderCompleted = func(error) {
|
||||
SetStreamAsDisconnected()
|
||||
_transcoder = nil
|
||||
_currentBroadcast = nil
|
||||
}
|
||||
_transcoder.Start()
|
||||
}()
|
||||
|
||||
ffmpeg.StartThumbnailGenerator(segmentPath, config.Config.VideoSettings.HighestQualityStreamIndex)
|
||||
go webhooks.SendStreamStatusEvent(models.StreamStarted)
|
||||
transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings))
|
||||
}
|
||||
|
||||
// SetStreamAsDisconnected sets the stream as disconnected.
|
||||
|
@ -65,14 +81,14 @@ func SetStreamAsDisconnected() {
|
|||
offlineFilename := "offline.ts"
|
||||
offlineFilePath := "static/" + offlineFilename
|
||||
|
||||
ffmpeg.StopThumbnailGenerator()
|
||||
transcoder.StopThumbnailGenerator()
|
||||
rtmp.Disconnect()
|
||||
|
||||
if _yp != nil {
|
||||
_yp.Stop()
|
||||
}
|
||||
|
||||
for index := range config.Config.GetVideoStreamQualities() {
|
||||
for index := range data.GetStreamOutputVariants() {
|
||||
playlistFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/stream.m3u8"), index)
|
||||
segmentFilePath := fmt.Sprintf(filepath.Join(config.PrivateHLSStoragePath, "%d/%s"), index, offlineFilename)
|
||||
|
||||
|
@ -97,7 +113,7 @@ func SetStreamAsDisconnected() {
|
|||
}
|
||||
|
||||
variantPlaylist := playlist.(*m3u8.MediaPlaylist)
|
||||
if len(variantPlaylist.Segments) > config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist() {
|
||||
if len(variantPlaylist.Segments) > int(data.GetStreamLatencyLevel().SegmentCount) {
|
||||
variantPlaylist.Segments = variantPlaylist.Segments[:len(variantPlaylist.Segments)]
|
||||
}
|
||||
|
||||
|
@ -144,6 +160,8 @@ func SetStreamAsDisconnected() {
|
|||
|
||||
StartOfflineCleanupTimer()
|
||||
stopOnlineCleanupTimer()
|
||||
|
||||
go webhooks.SendStreamStatusEvent(models.StreamStopped)
|
||||
}
|
||||
|
||||
// StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected.
|
||||
|
@ -153,6 +171,7 @@ func StartOfflineCleanupTimer() {
|
|||
for range _offlineCleanupTimer.C {
|
||||
// Reset the session count since the session is over
|
||||
_stats.SessionMaxViewerCount = 0
|
||||
// Set video to offline state
|
||||
resetDirectories()
|
||||
transitionToOfflineVideoStreamContent()
|
||||
}
|
||||
|
@ -170,7 +189,7 @@ func startOnlineCleanupTimer() {
|
|||
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
|
||||
go func() {
|
||||
for range _onlineCleanupTicker.C {
|
||||
ffmpeg.CleanupOldContent(config.PrivateHLSStoragePath)
|
||||
transcoder.CleanupOldContent(config.PrivateHLSStoragePath)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"net/http"
|
||||
|
@ -34,15 +34,22 @@ func (s *FileWriterReceiverService) SetupFileWriterReceiverService(callbacks Fil
|
|||
httpServer := http.NewServeMux()
|
||||
httpServer.HandleFunc("/", s.uploadHandler)
|
||||
|
||||
localListenerAddress := "127.0.0.1:" + strconv.Itoa(config.Config.GetPublicWebServerPort()+1)
|
||||
localListenerAddress := "127.0.0.1:0"
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(localListenerAddress, httpServer); err != nil {
|
||||
log.Fatal(err)
|
||||
listener, err := net.Listen("tcp", localListenerAddress)
|
||||
if err != nil {
|
||||
log.Fatalln("Unable to start internal video writing service", err)
|
||||
}
|
||||
|
||||
listenerPort := strings.Split(listener.Addr().String(), ":")[1]
|
||||
config.InternalHLSListenerPort = listenerPort
|
||||
log.Traceln("Transcoder response service listening on: " + listenerPort)
|
||||
|
||||
if err := http.Serve(listener, httpServer); err != nil {
|
||||
log.Fatalln("Unable to start internal video writing service", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Traceln("Transcoder response listening on: " + localListenerAddress)
|
||||
}
|
||||
|
||||
func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -86,6 +93,6 @@ func (s *FileWriterReceiverService) fileWritten(path string) {
|
|||
}
|
||||
|
||||
func returnError(err error, w http.ResponseWriter) {
|
||||
log.Errorln(err)
|
||||
log.Debugln(err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError)+": "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
@ -7,14 +7,14 @@ import (
|
|||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
)
|
||||
|
||||
// CleanupOldContent will delete old files from the private dir that are no longer being referenced
|
||||
// in the stream.
|
||||
func CleanupOldContent(baseDirectory string) {
|
||||
// Determine how many files we should keep on disk
|
||||
maxNumber := config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist()
|
||||
maxNumber := int(data.GetStreamLatencyLevel().SegmentCount)
|
||||
buffer := 10
|
||||
|
||||
files, err := getAllFilesRecursive(baseDirectory)
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/models"
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
|
@ -11,6 +11,8 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
var _timer *time.Ticker
|
||||
|
@ -78,9 +80,10 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
|||
}
|
||||
|
||||
mostRecentFile := path.Join(framePath, names[0])
|
||||
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
|
||||
|
||||
thumbnailCmdFlags := []string{
|
||||
config.Config.GetFFMpegPath(),
|
||||
ffmpegPath,
|
||||
"-y", // Overwrite file
|
||||
"-threads 1", // Low priority processing
|
||||
"-t 1", // Pull from frame 1
|
||||
|
@ -96,7 +99,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
|||
}
|
||||
|
||||
// If YP support is enabled also create an animated GIF preview
|
||||
if config.Config.YP.Enabled {
|
||||
if data.GetDirectoryEnabled() {
|
||||
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||
}
|
||||
|
||||
|
@ -104,9 +107,11 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
|||
}
|
||||
|
||||
func makeAnimatedGifPreview(sourceFile string, outputFile string) {
|
||||
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
|
||||
|
||||
// Filter is pulled from https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/
|
||||
animatedGifFlags := []string{
|
||||
config.Config.GetFFMpegPath(),
|
||||
ffmpegPath,
|
||||
"-y", // Overwrite file
|
||||
"-threads 1", // Low priority processing
|
||||
"-i", sourceFile, // Input
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
@ -10,6 +10,8 @@ import (
|
|||
"github.com/teris-io/shortid"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
|
@ -21,14 +23,15 @@ type Transcoder struct {
|
|||
segmentOutputPath string
|
||||
playlistOutputPath string
|
||||
variants []HLSVariant
|
||||
hlsPlaylistLength int
|
||||
segmentLengthSeconds int
|
||||
appendToStream bool
|
||||
ffmpegPath string
|
||||
segmentIdentifier string
|
||||
internalListenerPort int
|
||||
videoOnly bool // If true ignore any audio, if any
|
||||
TranscoderCompleted func(error)
|
||||
internalListenerPort string
|
||||
|
||||
currentStreamOutputSettings []models.StreamOutputVariant
|
||||
currentLatencyLevel models.LatencyLevel
|
||||
|
||||
TranscoderCompleted func(error)
|
||||
}
|
||||
|
||||
// HLSVariant is a combination of settings that results in a single HLS stream.
|
||||
|
@ -81,11 +84,8 @@ func (t *Transcoder) Start() {
|
|||
command := t.getString()
|
||||
|
||||
log.Tracef("Video transcoder started with %d stream variants.", len(t.variants))
|
||||
if t.videoOnly {
|
||||
log.Tracef("Transcoder requested to operate on video only, ignoring audio.")
|
||||
}
|
||||
|
||||
if config.Config.EnableDebugFeatures {
|
||||
if config.EnableDebugFeatures {
|
||||
log.Println(command)
|
||||
}
|
||||
|
||||
|
@ -103,16 +103,8 @@ func (t *Transcoder) Start() {
|
|||
}
|
||||
|
||||
func (t *Transcoder) getString() string {
|
||||
var port int
|
||||
if config.Config != nil {
|
||||
port = config.Config.GetPublicWebServerPort() + 1
|
||||
} else if t.internalListenerPort != 0 {
|
||||
port = t.internalListenerPort
|
||||
} else {
|
||||
log.Panicln("A internal port must be set for transcoder callback")
|
||||
}
|
||||
|
||||
localListenerAddress := "http://127.0.0.1:" + strconv.Itoa(port)
|
||||
var port = t.internalListenerPort
|
||||
localListenerAddress := "http://127.0.0.1:" + port
|
||||
|
||||
hlsOptionFlags := []string{}
|
||||
|
||||
|
@ -139,8 +131,8 @@ func (t *Transcoder) getString() string {
|
|||
// HLS Output
|
||||
"-f", "hls",
|
||||
|
||||
"-hls_time", strconv.Itoa(t.segmentLengthSeconds), // Length of each segment
|
||||
"-hls_list_size", strconv.Itoa(t.hlsPlaylistLength), // Max # in variant playlist
|
||||
"-hls_time", strconv.Itoa(t.currentLatencyLevel.SecondsPerSegment), // Length of each segment
|
||||
"-hls_list_size", strconv.Itoa(t.currentLatencyLevel.SegmentCount), // Max # in variant playlist
|
||||
"-hls_delete_threshold", "10", // Start deleting files after hls_list_size + 10
|
||||
hlsOptionsString,
|
||||
|
||||
|
@ -165,7 +157,7 @@ func (t *Transcoder) getString() string {
|
|||
return strings.Join(ffmpegFlags, " ")
|
||||
}
|
||||
|
||||
func getVariantFromConfigQuality(quality config.StreamQuality, index int) HLSVariant {
|
||||
func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int) HLSVariant {
|
||||
variant := HLSVariant{}
|
||||
variant.index = index
|
||||
variant.isAudioPassthrough = quality.IsAudioPassthrough
|
||||
|
@ -202,12 +194,17 @@ func getVariantFromConfigQuality(quality config.StreamQuality, index int) HLSVar
|
|||
|
||||
// NewTranscoder will return a new Transcoder, populated by the config.
|
||||
func NewTranscoder() *Transcoder {
|
||||
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
|
||||
|
||||
transcoder := new(Transcoder)
|
||||
transcoder.ffmpegPath = config.Config.GetFFMpegPath()
|
||||
transcoder.hlsPlaylistLength = config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist()
|
||||
transcoder.ffmpegPath = ffmpegPath
|
||||
transcoder.internalListenerPort = config.InternalHLSListenerPort
|
||||
|
||||
transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants()
|
||||
transcoder.currentLatencyLevel = data.GetStreamLatencyLevel()
|
||||
|
||||
var outputPath string
|
||||
if config.Config.S3.Enabled {
|
||||
if data.GetS3Config().Enabled {
|
||||
// Segments are not available via the local HTTP server
|
||||
outputPath = config.PrivateHLSStoragePath
|
||||
} else {
|
||||
|
@ -220,10 +217,8 @@ func NewTranscoder() *Transcoder {
|
|||
transcoder.playlistOutputPath = config.PublicHLSStoragePath
|
||||
|
||||
transcoder.input = utils.GetTemporaryPipePath()
|
||||
transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength()
|
||||
|
||||
qualities := config.Config.GetVideoStreamQualities()
|
||||
for index, quality := range qualities {
|
||||
for index, quality := range transcoder.currentStreamOutputSettings {
|
||||
variant := getVariantFromConfigQuality(quality, index)
|
||||
transcoder.AddVariant(variant)
|
||||
}
|
||||
|
@ -257,12 +252,7 @@ func (t *Transcoder) getVariantsString() string {
|
|||
for _, variant := range t.variants {
|
||||
variantsCommandFlags = variantsCommandFlags + " " + variant.getVariantString(t)
|
||||
singleVariantMap := ""
|
||||
if t.videoOnly {
|
||||
singleVariantMap = fmt.Sprintf("v:%d ", variant.index)
|
||||
} else {
|
||||
singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index)
|
||||
}
|
||||
|
||||
singleVariantMap = fmt.Sprintf("v:%d,a:%d ", variant.index, variant.index)
|
||||
variantsStreamMaps = variantsStreamMaps + singleVariantMap
|
||||
}
|
||||
variantsCommandFlags = variantsCommandFlags + " " + variantsStreamMaps + "\""
|
||||
|
@ -306,7 +296,7 @@ func (v *HLSVariant) getVideoQualityString(t *Transcoder) string {
|
|||
|
||||
// -1 to work around segments being generated slightly larger than expected.
|
||||
// https://trac.ffmpeg.org/ticket/6915?replyto=58#comment:57
|
||||
gop := (t.segmentLengthSeconds * v.framerate) - 1
|
||||
gop := (t.currentLatencyLevel.SecondsPerSegment * v.framerate) - 1
|
||||
|
||||
// For limiting the output bitrate
|
||||
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||
|
@ -374,16 +364,6 @@ func (t *Transcoder) SetOutputPath(output string) {
|
|||
t.segmentOutputPath = output
|
||||
}
|
||||
|
||||
// SetHLSPlaylistLength will set the max number of items in a HLS variant's playlist.
|
||||
func (t *Transcoder) SetHLSPlaylistLength(length int) {
|
||||
t.hlsPlaylistLength = length
|
||||
}
|
||||
|
||||
// SetSegmentLength Specifies the number of seconds each segment should be.
|
||||
func (t *Transcoder) SetSegmentLength(seconds int) {
|
||||
t.segmentLengthSeconds = seconds
|
||||
}
|
||||
|
||||
// SetAppendToStream enables appending to the HLS stream instead of overwriting.
|
||||
func (t *Transcoder) SetAppendToStream(append bool) {
|
||||
t.appendToStream = append
|
||||
|
@ -394,11 +374,6 @@ func (t *Transcoder) SetIdentifier(output string) {
|
|||
t.segmentIdentifier = output
|
||||
}
|
||||
|
||||
func (t *Transcoder) SetInternalHTTPPort(port int) {
|
||||
func (t *Transcoder) SetInternalHTTPPort(port string) {
|
||||
t.internalListenerPort = port
|
||||
}
|
||||
|
||||
// SetVideoOnly will ignore any audio streams, if any.
|
||||
func (t *Transcoder) SetVideoOnly(videoOnly bool) {
|
||||
t.videoOnly = videoOnly
|
||||
}
|
|
@ -1,18 +1,21 @@
|
|||
package ffmpeg
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
func TestFFmpegCommand(t *testing.T) {
|
||||
latencyLevel := models.GetLatencyLevel(3)
|
||||
|
||||
transcoder := new(Transcoder)
|
||||
transcoder.ffmpegPath = "/fake/path/ffmpeg"
|
||||
transcoder.SetSegmentLength(4)
|
||||
transcoder.SetInput("fakecontent.flv")
|
||||
transcoder.SetOutputPath("fakeOutput")
|
||||
transcoder.SetHLSPlaylistLength(10)
|
||||
transcoder.SetIdentifier("jdofFGg")
|
||||
transcoder.SetInternalHTTPPort(8123)
|
||||
transcoder.SetInternalHTTPPort("8123")
|
||||
transcoder.currentLatencyLevel = latencyLevel
|
||||
|
||||
variant := HLSVariant{}
|
||||
variant.videoBitrate = 1200
|
||||
|
@ -35,7 +38,7 @@ func TestFFmpegCommand(t *testing.T) {
|
|||
|
||||
cmd := transcoder.getString()
|
||||
|
||||
expected := `/fake/path/ffmpeg -hide_banner -loglevel warning -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 119 -profile:v:0 high -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=119:keyint=119" -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -bufsize:v:1 4200k -g:v:1 95 -profile:v:1 high -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0:min-keyint=95:keyint=95" -map a:0? -c:a:1 copy -preset faster -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 4 -hls_list_size 10 -hls_delete_threshold 10 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 -fflags +genpts http://127.0.0.1:8123/%v/stream.m3u8 2> transcoder.log`
|
||||
expected := `/fake/path/ffmpeg -hide_banner -loglevel warning -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 89 -profile:v:0 high -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=89:keyint=89" -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -bufsize:v:1 4200k -g:v:1 71 -profile:v:1 high -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0:min-keyint=71:keyint=71" -map a:0? -c:a:1 copy -preset faster -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 3 -hls_delete_threshold 10 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 -fflags +genpts http://127.0.0.1:8123/%v/stream.m3u8 2> transcoder.log`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
39
core/webhooks/chat.go
Normal file
39
core/webhooks/chat.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
func SendChatEvent(chatEvent models.ChatEvent) {
|
||||
webhookEvent := WebhookEvent{
|
||||
Type: chatEvent.MessageType,
|
||||
EventData: &WebhookChatMessage{
|
||||
Author: chatEvent.Author,
|
||||
Body: chatEvent.Body,
|
||||
RawBody: chatEvent.RawBody,
|
||||
ID: chatEvent.ID,
|
||||
Visible: chatEvent.Visible,
|
||||
Timestamp: &chatEvent.Timestamp,
|
||||
},
|
||||
}
|
||||
|
||||
SendEventToWebhooks(webhookEvent)
|
||||
}
|
||||
|
||||
func SendChatEventUsernameChanged(event models.NameChangeEvent) {
|
||||
webhookEvent := WebhookEvent{
|
||||
Type: models.UserNameChanged,
|
||||
EventData: event,
|
||||
}
|
||||
|
||||
SendEventToWebhooks(webhookEvent)
|
||||
}
|
||||
|
||||
func SendChatEventUserJoined(event models.UserJoinedEvent) {
|
||||
webhookEvent := WebhookEvent{
|
||||
Type: models.UserNameChanged,
|
||||
EventData: event,
|
||||
}
|
||||
|
||||
SendEventToWebhooks(webhookEvent)
|
||||
}
|
7
core/webhooks/stream.go
Normal file
7
core/webhooks/stream.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package webhooks
|
||||
|
||||
import "github.com/owncast/owncast/models"
|
||||
|
||||
func SendStreamStatusEvent(eventType models.EventType) {
|
||||
SendEventToWebhooks(WebhookEvent{Type: eventType})
|
||||
}
|
67
core/webhooks/webhooks.go
Normal file
67
core/webhooks/webhooks.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
type WebhookEvent struct {
|
||||
Type models.EventType `json:"type"` // messageSent | userJoined | userNameChange
|
||||
EventData interface{} `json:"eventData,omitempty"`
|
||||
}
|
||||
|
||||
type WebhookChatMessage struct {
|
||||
Author string `json:"author,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
RawBody string `json:"rawBody,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Visible bool `json:"visible"`
|
||||
Timestamp *time.Time `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
func SendEventToWebhooks(payload WebhookEvent) {
|
||||
webhooks := data.GetWebhooksForEvent(payload.Type)
|
||||
|
||||
for _, webhook := range webhooks {
|
||||
log.Debugf("Event %s sent to Webhook %s", payload.Type, webhook.URL)
|
||||
if err := sendWebhook(webhook.URL, payload); err != nil {
|
||||
log.Errorf("Event: %s failed to send to webhook: %s Error: %s", payload.Type, webhook.URL, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendWebhook(url string, payload WebhookEvent) error {
|
||||
jsonText, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonText))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := data.SetWebhookAsUsed(url); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
# Stream description and content can go here.
|
||||
|
||||
1. Edit `content.md` in markdown.
|
||||
1. And it'll go here.
|
File diff suppressed because one or more lines are too long
|
@ -1,50 +0,0 @@
|
|||
# See https://owncast.online/docs/configuration/ for more details
|
||||
|
||||
instanceDetails:
|
||||
name: Owncast
|
||||
title: Owncast
|
||||
summary: "This is brief summary of whom you are or what your stream is. You can read more about it at owncast.online. You can edit this description in your config file."
|
||||
|
||||
logo: /img/logo.svg
|
||||
|
||||
tags:
|
||||
- music
|
||||
- software
|
||||
- streaming
|
||||
|
||||
nsfw: false
|
||||
|
||||
# https://owncast.online/docs/configuration/#external-links
|
||||
# for full list of supported social links. All optional.
|
||||
socialHandles:
|
||||
- platform: github
|
||||
url: http://github.com/owncast/owncast
|
||||
- platform: mastodon
|
||||
url: http://mastodon.something/owncast
|
||||
|
||||
videoSettings:
|
||||
# Change this value and keep it secure. Treat it like a password to your live stream.
|
||||
streamingKey: abc123
|
||||
|
||||
streamQualities:
|
||||
- low:
|
||||
videoBitrate: 800
|
||||
scaledWidth: 640
|
||||
encoderPreset: veryfast
|
||||
|
||||
- medium:
|
||||
videoBitrate: 1200
|
||||
encoderPreset: veryfast
|
||||
|
||||
- high:
|
||||
videoBitrate: 1600
|
||||
encoderPreset: veryfast
|
||||
|
||||
# Set to true if you don't want the service checking for future releases.
|
||||
disableUpgradeChecks: false
|
||||
|
||||
# Off by default. You can optionally list yourself in the Owncast directory.
|
||||
# Make sure your instanceURL is the public URL to your Owncast instance.
|
||||
yp:
|
||||
enabled: true
|
||||
instanceURL: https://stream.myserver.org
|
|
@ -8,12 +8,12 @@ import (
|
|||
"net"
|
||||
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
"github.com/owncast/owncast/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var _geoIPCache = map[string]GeoDetails{}
|
||||
var _enabled = true // Try to use GeoIP support it by default.
|
||||
var geoIPDatabasePath = "data/GeoLite2-City.mmdb"
|
||||
|
||||
// GeoDetails stores details about a location.
|
||||
type GeoDetails struct {
|
||||
|
@ -53,7 +53,7 @@ func FetchGeoForIP(ip string) {
|
|||
}
|
||||
|
||||
go func() {
|
||||
db, err := geoip2.Open(config.GeoIPDatabasePath)
|
||||
db, err := geoip2.Open(geoIPDatabasePath)
|
||||
if err != nil {
|
||||
log.Traceln("GeoIP support is disabled. visit http://owncast.online/docs/geoip to learn how to enable.", err)
|
||||
_enabled = false
|
||||
|
|
1
go.mod
1
go.mod
|
@ -16,6 +16,7 @@ require (
|
|||
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/oschwald/geoip2-golang v1.4.0
|
||||
github.com/schollz/sqlite3dump v1.2.4
|
||||
github.com/shirou/gopsutil v2.20.9+incompatible
|
||||
github.com/sirupsen/logrus v1.8.0
|
||||
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
|
||||
|
|
3
go.sum
3
go.sum
|
@ -31,6 +31,7 @@ github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g=
|
|||
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
|
||||
github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
||||
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||
|
@ -51,6 +52,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/schollz/sqlite3dump v1.2.4 h1:b3dgcKLsHZhF6OsB2EK+e/oA77vh4P/45TAh2R35OFI=
|
||||
github.com/schollz/sqlite3dump v1.2.4/go.mod h1:SEajZA5udi52Taht5xQYlFfHwr7AIrqPrLDrAoFv17o=
|
||||
github.com/shirou/gopsutil v2.20.9+incompatible h1:msXs2frUV+O/JLva9EDLpuJ84PrFsdCTCQex8PUdtkQ=
|
||||
github.com/shirou/gopsutil v2.20.9+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/sirupsen/logrus v1.8.0 h1:nfhvjKcUMhBMVqbKHJlk5RPrrfYr/NMo3692g0dwfWU=
|
||||
|
|
86
main.go
86
main.go
|
@ -2,7 +2,8 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/owncast/owncast/logging"
|
||||
|
@ -14,32 +15,61 @@ import (
|
|||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/metrics"
|
||||
"github.com/owncast/owncast/router"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
// the following are injected at build-time.
|
||||
var (
|
||||
// GitCommit is the commit which this version of owncast is running.
|
||||
GitCommit = "unknown"
|
||||
GitCommit = ""
|
||||
// BuildVersion is the version.
|
||||
BuildVersion = "0.0.0"
|
||||
// BuildType is the type of build.
|
||||
BuildType = "localdev"
|
||||
BuildVersion = config.StaticVersionNumber
|
||||
// BuildPlatform is the type of build.
|
||||
BuildPlatform = "dev"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configureLogging()
|
||||
|
||||
log.Infoln(getReleaseString())
|
||||
// Enable bundling of admin assets
|
||||
_ = pkger.Include("/admin")
|
||||
|
||||
configFile := flag.String("configFile", "config.yaml", "Config File full path. Defaults to current folder")
|
||||
configFile := flag.String("configFile", "config.yaml", "Config file path to migrate to the new database")
|
||||
dbFile := flag.String("database", "", "Path to the database file.")
|
||||
enableDebugOptions := flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
|
||||
enableVerboseLogging := flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
|
||||
restoreDatabaseFile := flag.String("restoreDatabase", "", "Restore an Owncast database backup")
|
||||
newStreamKey := flag.String("streamkey", "", "Set your stream key/admin password")
|
||||
webServerPortOverride := flag.String("webserverport", "", "Force the web server to listen on a specific port")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
config.ConfigFilePath = *configFile
|
||||
config.VersionNumber = BuildVersion
|
||||
if GitCommit != "" {
|
||||
config.GitCommit = GitCommit
|
||||
} else {
|
||||
config.GitCommit = time.Now().Format("20060102")
|
||||
}
|
||||
config.BuildPlatform = BuildPlatform
|
||||
|
||||
log.Infoln(config.GetReleaseString())
|
||||
|
||||
// Allows a user to restore a specific database backup
|
||||
if *restoreDatabaseFile != "" {
|
||||
databaseFile := config.DatabaseFilePath
|
||||
if *dbFile != "" {
|
||||
databaseFile = *dbFile
|
||||
}
|
||||
|
||||
if err := utils.Restore(*restoreDatabaseFile, databaseFile); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
log.Println("Database has been restored. Restart Owncast.")
|
||||
log.Exit(0)
|
||||
}
|
||||
|
||||
if *enableDebugOptions {
|
||||
logrus.SetReportCaller(true)
|
||||
}
|
||||
|
@ -50,41 +80,51 @@ func main() {
|
|||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
|
||||
if err := config.Load(*configFile, getReleaseString(), getVersionNumber()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config.Config.EnableDebugFeatures = *enableDebugOptions
|
||||
config.EnableDebugFeatures = *enableDebugOptions
|
||||
|
||||
if *dbFile != "" {
|
||||
config.Config.DatabaseFilePath = *dbFile
|
||||
} else if config.Config.DatabaseFilePath == "" {
|
||||
config.Config.DatabaseFilePath = config.Config.GetDataFilePath()
|
||||
config.DatabaseFilePath = *dbFile
|
||||
}
|
||||
|
||||
go metrics.Start()
|
||||
|
||||
err := data.SetupPersistence()
|
||||
err := data.SetupPersistence(config.DatabaseFilePath)
|
||||
if err != nil {
|
||||
log.Fatalln("failed to open database", err)
|
||||
}
|
||||
|
||||
if *newStreamKey != "" {
|
||||
if err := data.SetStreamKey(*newStreamKey); err != nil {
|
||||
log.Errorln("Error setting your stream key.", err)
|
||||
} else {
|
||||
log.Infoln("Stream key changed to", *newStreamKey)
|
||||
}
|
||||
|
||||
log.Exit(0)
|
||||
}
|
||||
|
||||
// starts the core
|
||||
if err := core.Start(); err != nil {
|
||||
log.Fatalln("failed to start the core package", err)
|
||||
}
|
||||
|
||||
// Set the web server port
|
||||
if *webServerPortOverride != "" {
|
||||
portNumber, err := strconv.Atoi(*webServerPortOverride)
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
return
|
||||
}
|
||||
|
||||
config.WebServerPort = portNumber
|
||||
} else {
|
||||
config.WebServerPort = data.GetHTTPPortNumber()
|
||||
}
|
||||
|
||||
if err := router.Start(); err != nil {
|
||||
log.Fatalln("failed to start/run the router", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getReleaseString gets the version string.
|
||||
func getReleaseString() string {
|
||||
return fmt.Sprintf("Owncast v%s-%s (%s)", BuildVersion, BuildType, GitCommit)
|
||||
}
|
||||
|
||||
func getVersionNumber() string {
|
||||
return BuildVersion
|
||||
}
|
||||
|
||||
func configureLogging() {
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const maxCPUAlertingThresholdPCT = 80
|
||||
const maxRAMAlertingThresholdPCT = 80
|
||||
const maxCPUAlertingThresholdPCT = 85
|
||||
const maxRAMAlertingThresholdPCT = 85
|
||||
const maxDiskAlertingThresholdPCT = 90
|
||||
|
||||
const alertingError = "The %s utilization of %d%% can cause issues with video generation and delivery. Please visit the documentation at http://owncast.online/docs/troubleshooting/ to help troubleshoot this issue."
|
||||
var inCpuAlertingState = false
|
||||
var inRamAlertingState = false
|
||||
var inDiskAlertingState = false
|
||||
|
||||
var errorResetDuration = time.Minute * 5
|
||||
|
||||
const alertingError = "The %s utilization of %d%% could cause problems with video generation and delivery. Visit the documentation at http://owncast.online/docs/troubleshooting/ if you are experiencing issues."
|
||||
|
||||
func handleAlerting() {
|
||||
handleCPUAlerting()
|
||||
|
@ -22,8 +30,15 @@ func handleCPUAlerting() {
|
|||
}
|
||||
|
||||
avg := recentAverage(Metrics.CPUUtilizations)
|
||||
if avg > maxCPUAlertingThresholdPCT {
|
||||
if avg > maxCPUAlertingThresholdPCT && !inCpuAlertingState {
|
||||
log.Warnf(alertingError, "CPU", maxCPUAlertingThresholdPCT)
|
||||
inCpuAlertingState = true
|
||||
|
||||
resetTimer := time.NewTimer(errorResetDuration)
|
||||
go func() {
|
||||
<-resetTimer.C
|
||||
inCpuAlertingState = false
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,8 +48,15 @@ func handleRAMAlerting() {
|
|||
}
|
||||
|
||||
avg := recentAverage(Metrics.RAMUtilizations)
|
||||
if avg > maxRAMAlertingThresholdPCT {
|
||||
if avg > maxRAMAlertingThresholdPCT && !inRamAlertingState {
|
||||
log.Warnf(alertingError, "memory", maxRAMAlertingThresholdPCT)
|
||||
inRamAlertingState = true
|
||||
|
||||
resetTimer := time.NewTimer(errorResetDuration)
|
||||
go func() {
|
||||
<-resetTimer.C
|
||||
inRamAlertingState = false
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,8 +67,15 @@ func handleDiskAlerting() {
|
|||
|
||||
avg := recentAverage(Metrics.DiskUtilizations)
|
||||
|
||||
if avg > maxDiskAlertingThresholdPCT {
|
||||
if avg > maxDiskAlertingThresholdPCT && !inDiskAlertingState {
|
||||
log.Warnf(alertingError, "disk", maxRAMAlertingThresholdPCT)
|
||||
inDiskAlertingState = true
|
||||
|
||||
resetTimer := time.NewTimer(errorResetDuration)
|
||||
go func() {
|
||||
<-resetTimer.C
|
||||
inDiskAlertingState = false
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
53
models/accessToken.go
Normal file
53
models/accessToken.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// ScopeCanSendUserMessages will allow sending chat messages as users.
|
||||
ScopeCanSendUserMessages = "CAN_SEND_MESSAGES"
|
||||
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
|
||||
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
|
||||
// ScopeHasAdminAccess will allow performing administrative actions on the server.
|
||||
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
|
||||
)
|
||||
|
||||
// For a scope to be seen as "valid" it must live in this slice.
|
||||
var validAccessTokenScopes = []string{
|
||||
ScopeCanSendUserMessages,
|
||||
ScopeCanSendSystemMessages,
|
||||
ScopeHasAdminAccess,
|
||||
}
|
||||
|
||||
// AccessToken gives access to 3rd party code to access specific Owncast APIs.
|
||||
type AccessToken struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
LastUsed *time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
// HasValidScopes will verify that all the scopes provided are valid.
|
||||
// This is not a efficient method.
|
||||
func HasValidScopes(scopes []string) bool {
|
||||
for _, scope := range scopes {
|
||||
if !findItemInSlice(validAccessTokenScopes, scope) {
|
||||
log.Errorln("Invalid scope", scope)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findItemInSlice(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -9,6 +9,7 @@ type Broadcaster struct {
|
|||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// InboundStreamDetails represents an inbound broadcast stream.
|
||||
type InboundStreamDetails struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
|
|
12
models/chatActionEvent.go
Normal file
12
models/chatActionEvent.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ChatActionEvent represents a generic action that took place by a chat user.
|
||||
type ChatActionEvent struct {
|
||||
Username string `json:"username"`
|
||||
Type EventType `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/teris-io/shortid"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
|
@ -18,8 +19,9 @@ type ChatEvent struct {
|
|||
|
||||
Author string `json:"author,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
RawBody string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
MessageType string `json:"type"`
|
||||
MessageType EventType `json:"type"`
|
||||
Visible bool `json:"visible"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
@ -29,15 +31,24 @@ func (m ChatEvent) Valid() bool {
|
|||
return m.Author != "" && m.Body != "" && m.ID != ""
|
||||
}
|
||||
|
||||
// SetDefaults will set default values on a chat event object.
|
||||
func (m *ChatEvent) SetDefaults() {
|
||||
id, _ := shortid.Generate()
|
||||
m.ID = id
|
||||
m.Timestamp = time.Now()
|
||||
m.Visible = true
|
||||
}
|
||||
|
||||
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
|
||||
// the message into something safe and renderable for clients.
|
||||
func (m *ChatEvent) RenderAndSanitizeMessageBody() {
|
||||
raw := m.Body
|
||||
m.RawBody = m.Body
|
||||
|
||||
// Set the new, sanitized and rendered message body
|
||||
m.Body = RenderAndSanitize(raw)
|
||||
m.Body = RenderAndSanitize(m.RawBody)
|
||||
}
|
||||
|
||||
// Empty will return if this message's contents is empty.
|
||||
func (m *ChatEvent) Empty() bool {
|
||||
return m.Body == ""
|
||||
}
|
||||
|
|
|
@ -8,10 +8,12 @@ import (
|
|||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
// ConnectedClientsResponse is the response of the currently connected chat clients.
|
||||
type ConnectedClientsResponse struct {
|
||||
Clients []Client `json:"clients"`
|
||||
}
|
||||
|
||||
// Client represents a single chat client.
|
||||
type Client struct {
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
LastSeen time.Time `json:"-"`
|
||||
|
@ -23,6 +25,7 @@ type Client struct {
|
|||
Geo *geoip.GeoDetails `json:"geo"`
|
||||
}
|
||||
|
||||
// GenerateClientFromRequest will return a chat client from a http request.
|
||||
func GenerateClientFromRequest(req *http.Request) Client {
|
||||
return Client{
|
||||
ConnectedAt: time.Now(),
|
||||
|
|
7
models/currentBroadcast.go
Normal file
7
models/currentBroadcast.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
// CurrentBroadcast represents the configuration associated with the currently active stream.
|
||||
type CurrentBroadcast struct {
|
||||
OutputSettings []StreamOutputVariant `json:"outputSettings"`
|
||||
LatencyLevel LatencyLevel `json:"latencyLevel"`
|
||||
}
|
27
models/eventType.go
Normal file
27
models/eventType.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package models
|
||||
|
||||
// EventType is the type of a websocket event.
|
||||
type EventType = string
|
||||
|
||||
const (
|
||||
// MessageSent is the event sent when a chat event takes place.
|
||||
MessageSent EventType = "CHAT"
|
||||
// UserJoined is the event sent when a chat user join action takes place.
|
||||
UserJoined EventType = "USER_JOINED"
|
||||
// UserNameChanged is the event sent when a chat username change takes place.
|
||||
UserNameChanged EventType = "NAME_CHANGE"
|
||||
// VisibiltyToggled is the event sent when a chat message's visibility changes.
|
||||
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
|
||||
// PING is a ping message.
|
||||
PING EventType = "PING"
|
||||
// PONG is a pong message.
|
||||
PONG EventType = "PONG"
|
||||
// StreamStarted represents a stream started event.
|
||||
StreamStarted EventType = "STREAM_STARTED"
|
||||
// StreamStopped represents a stream stopped event.
|
||||
StreamStopped EventType = "STREAM_STOPPED"
|
||||
// SystemMessageSent is the event sent when a system message is sent.
|
||||
SystemMessageSent EventType = "SYSTEM"
|
||||
// ChatActionSent is a generic chat action that can be used for anything that doesn't need specific handling or formatting.
|
||||
ChatActionSent EventType = "CHAT_ACTION"
|
||||
)
|
25
models/latencyLevels.go
Normal file
25
models/latencyLevels.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package models
|
||||
|
||||
// LatencyLevel is a representation of HLS configuration values.
|
||||
type LatencyLevel struct {
|
||||
Level int `json:"level"`
|
||||
SecondsPerSegment int `json:"-"`
|
||||
SegmentCount int `json:"-"`
|
||||
}
|
||||
|
||||
// GetLatencyConfigs will return the available latency level options.
|
||||
func GetLatencyConfigs() map[int]LatencyLevel {
|
||||
return map[int]LatencyLevel{
|
||||
1: {Level: 1, SecondsPerSegment: 1, SegmentCount: 2},
|
||||
2: {Level: 2, SecondsPerSegment: 2, SegmentCount: 2},
|
||||
3: {Level: 3, SecondsPerSegment: 3, SegmentCount: 3},
|
||||
4: {Level: 4, SecondsPerSegment: 3, SegmentCount: 4}, // Default
|
||||
5: {Level: 5, SecondsPerSegment: 4, SegmentCount: 5},
|
||||
6: {Level: 6, SecondsPerSegment: 6, SegmentCount: 10},
|
||||
}
|
||||
}
|
||||
|
||||
// GetLatencyLevel will return the latency level at index.
|
||||
func GetLatencyLevel(index int) LatencyLevel {
|
||||
return GetLatencyConfigs()[index]
|
||||
}
|
|
@ -2,9 +2,9 @@ package models
|
|||
|
||||
// NameChangeEvent represents a user changing their name in chat.
|
||||
type NameChangeEvent struct {
|
||||
OldName string `json:"oldName"`
|
||||
NewName string `json:"newName"`
|
||||
Image string `json:"image"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
OldName string `json:"oldName"`
|
||||
NewName string `json:"newName"`
|
||||
Image string `json:"image"`
|
||||
Type EventType `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
|
|
@ -2,5 +2,5 @@ package models
|
|||
|
||||
// PingMessage represents a ping message between the client and server.
|
||||
type PingMessage struct {
|
||||
MessageType string `json:"type"`
|
||||
MessageType EventType `json:"type"`
|
||||
}
|
||||
|
|
13
models/s3Storage.go
Normal file
13
models/s3Storage.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package models
|
||||
|
||||
// S3 is the storage configuration.
|
||||
type S3 struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
ServingEndpoint string `json:"servingEndpoint,omitempty"`
|
||||
AccessKey string `json:"accessKey,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
}
|
110
models/socialHandle.go
Normal file
110
models/socialHandle.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
package models
|
||||
|
||||
// SocialHandle represents an external link.
|
||||
type SocialHandle struct {
|
||||
Platform string `yaml:"platform" json:"platform,omitempty"`
|
||||
URL string `yaml:"url" json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// GetSocialHandle will return the details for a supported platform.
|
||||
func GetSocialHandle(platform string) *SocialHandle {
|
||||
allPlatforms := GetAllSocialHandles()
|
||||
if platform, ok := allPlatforms[platform]; ok {
|
||||
return &platform
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllSocialHandles will return a list of all the social platforms we support.
|
||||
func GetAllSocialHandles() map[string]SocialHandle {
|
||||
socialHandlePlatforms := map[string]SocialHandle{
|
||||
"bandcamp": {
|
||||
Platform: "Bandcamp",
|
||||
Icon: "/img/platformlogos/bandcamp.svg",
|
||||
},
|
||||
"discord": {
|
||||
Platform: "Discord",
|
||||
Icon: "/img/platformlogos/discord.svg",
|
||||
},
|
||||
"facebook": {
|
||||
Platform: "Facebook",
|
||||
Icon: "/img/platformlogos/facebook.svg",
|
||||
},
|
||||
"github": {
|
||||
Platform: "GitHub",
|
||||
Icon: "/img/platformlogos/github.svg",
|
||||
},
|
||||
"gitlab": {
|
||||
Platform: "GitLab",
|
||||
Icon: "/img/platformlogos/gitlab.svg",
|
||||
},
|
||||
"instagram": {
|
||||
Platform: "Instagram",
|
||||
Icon: "/img/platformlogos/instagram.svg",
|
||||
},
|
||||
"keyoxide": {
|
||||
Platform: "Keyoxide",
|
||||
Icon: "/img/platformlogos/keyoxide.png",
|
||||
},
|
||||
"kofi": {
|
||||
Platform: "Ko-Fi",
|
||||
Icon: "/img/platformlogos/ko-fi.svg",
|
||||
},
|
||||
"linkedin": {
|
||||
Platform: "LinkedIn",
|
||||
Icon: "/img/platformlogos/linkedin.svg",
|
||||
},
|
||||
"mastodon": {
|
||||
Platform: "Mastodon",
|
||||
Icon: "/img/platformlogos/mastodon.svg",
|
||||
},
|
||||
"patreon": {
|
||||
Platform: "Patreon",
|
||||
Icon: "/img/platformlogos/patreon.svg",
|
||||
},
|
||||
"paypal": {
|
||||
Platform: "Paypal",
|
||||
Icon: "/img/platformlogos/paypal.svg",
|
||||
},
|
||||
"snapchat": {
|
||||
Platform: "Snapchat",
|
||||
Icon: "/img/platformlogos/snapchat.svg",
|
||||
},
|
||||
"soundcloud": {
|
||||
Platform: "Soundcloud",
|
||||
Icon: "/img/platformlogos/soundcloud.svg",
|
||||
},
|
||||
"spotify": {
|
||||
Platform: "Spotify",
|
||||
Icon: "/img/platformlogos/spotify.svg",
|
||||
},
|
||||
"tiktok": {
|
||||
Platform: "TikTok",
|
||||
Icon: "/img/platformlogos/tiktok.svg",
|
||||
},
|
||||
"twitch": {
|
||||
Platform: "Twitch",
|
||||
Icon: "/img/platformlogos/twitch.svg",
|
||||
},
|
||||
"twitter": {
|
||||
Platform: "Twitter",
|
||||
Icon: "/img/platformlogos/twitter.svg",
|
||||
},
|
||||
"youtube": {
|
||||
Platform: "YouTube",
|
||||
Icon: "/img/platformlogos/youtube.svg",
|
||||
},
|
||||
"donate": {
|
||||
Platform: "Donations",
|
||||
Icon: "/img/platformlogos/donate.svg",
|
||||
},
|
||||
"follow": {
|
||||
Platform: "Follow",
|
||||
Icon: "/img/platformlogos/follow.svg",
|
||||
},
|
||||
}
|
||||
|
||||
return socialHandlePlatforms
|
||||
}
|
|
@ -13,4 +13,5 @@ type Status struct {
|
|||
LastDisconnectTime utils.NullTime `json:"lastDisconnectTime"`
|
||||
|
||||
VersionNumber string `json:"versionNumber"`
|
||||
StreamTitle string `json:"streamTitle"`
|
||||
}
|
||||
|
|
89
models/streamOutputVariant.go
Normal file
89
models/streamOutputVariant.go
Normal file
|
@ -0,0 +1,89 @@
|
|||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// StreamOutputVariant defines the output specifics of a single HLS stream variant.
|
||||
type StreamOutputVariant struct {
|
||||
// Enable passthrough to copy the video and/or audio directly from the
|
||||
// incoming stream and disable any transcoding. It will ignore any of
|
||||
// the below settings.
|
||||
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"`
|
||||
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"`
|
||||
|
||||
VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"`
|
||||
AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"`
|
||||
|
||||
// Set only one of these in order to keep your current aspect ratio.
|
||||
// Or set neither to not scale the video.
|
||||
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"`
|
||||
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"`
|
||||
|
||||
Framerate int `yaml:"framerate" json:"framerate"`
|
||||
EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"` // Remove after migration is no longer used
|
||||
// CPUUsageLevel represents a codec preset to configure CPU usage.
|
||||
CPUUsageLevel int `json:"cpuUsageLevel"`
|
||||
}
|
||||
|
||||
// GetFramerate returns the framerate or default.
|
||||
func (q *StreamOutputVariant) GetFramerate() int {
|
||||
if q.IsVideoPassthrough {
|
||||
return 0
|
||||
}
|
||||
|
||||
if q.Framerate > 0 {
|
||||
return q.Framerate
|
||||
}
|
||||
|
||||
return 24
|
||||
}
|
||||
|
||||
// GetEncoderPreset returns the preset or default.
|
||||
func (q *StreamOutputVariant) GetEncoderPreset() string {
|
||||
if q.IsVideoPassthrough {
|
||||
return ""
|
||||
}
|
||||
|
||||
if q.EncoderPreset != "" {
|
||||
return q.EncoderPreset
|
||||
}
|
||||
|
||||
return "veryfast"
|
||||
}
|
||||
|
||||
// GetCPUUsageLevel will return the libx264 codec encoder preset that maps to a level.
|
||||
func (q *StreamOutputVariant) GetCPUUsageLevel() int {
|
||||
presetMapping := map[string]int{
|
||||
"ultrafast": 1,
|
||||
"superfast": 2,
|
||||
"veryfast": 3,
|
||||
"faster": 4,
|
||||
"fast": 5,
|
||||
}
|
||||
|
||||
return presetMapping[q.GetEncoderPreset()]
|
||||
}
|
||||
|
||||
// GetIsAudioPassthrough will return if this variant audio is passthrough.
|
||||
func (q *StreamOutputVariant) GetIsAudioPassthrough() bool {
|
||||
if q.IsAudioPassthrough {
|
||||
return true
|
||||
}
|
||||
|
||||
if q.AudioBitrate == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalJSON is a custom JSON marshal function for video stream qualities.
|
||||
func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) {
|
||||
type Alias StreamOutputVariant
|
||||
return json.Marshal(&struct {
|
||||
Framerate int `json:"framerate"`
|
||||
*Alias
|
||||
}{
|
||||
Framerate: q.GetFramerate(),
|
||||
Alias: (*Alias)(q),
|
||||
})
|
||||
}
|
11
models/userJoinedEvent.go
Normal file
11
models/userJoinedEvent.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UserJoinedEvent represents an event when a user joins the chat.
|
||||
type UserJoinedEvent struct {
|
||||
Username string `json:"username"`
|
||||
Type EventType `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
}
|
33
models/webhook.go
Normal file
33
models/webhook.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Webhook is an event that is sent to 3rd party, external services with details about something that took place within an Owncast server.
|
||||
type Webhook struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Events []EventType `json:"events"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
LastUsed *time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
// For an event to be seen as "valid" it must live in this slice.
|
||||
var validEvents = []EventType{
|
||||
MessageSent,
|
||||
UserJoined,
|
||||
UserNameChanged,
|
||||
VisibiltyToggled,
|
||||
StreamStarted,
|
||||
StreamStopped,
|
||||
}
|
||||
|
||||
// HasValidEvents will verify that all the events provided are valid.
|
||||
// This is not a efficient method.
|
||||
func HasValidEvents(events []EventType) bool {
|
||||
for _, event := range events {
|
||||
if !findItemInSlice(validEvents, event) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
1020
openapi.yaml
1020
openapi.yaml
File diff suppressed because it is too large
Load diff
2
pkged.go
2
pkged.go
File diff suppressed because one or more lines are too long
|
@ -3,8 +3,9 @@ package middleware
|
|||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
@ -13,7 +14,7 @@ import (
|
|||
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := "admin"
|
||||
password := config.Config.VideoSettings.StreamingKey
|
||||
password := data.GetStreamKey()
|
||||
realm := "Owncast Authenticated Request"
|
||||
|
||||
// The following line is kind of a work around.
|
||||
|
@ -43,3 +44,34 @@ func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
|||
handler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAccessToken(scope string, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ")
|
||||
token := strings.Join(authHeader, "")
|
||||
|
||||
if len(authHeader) == 0 || token == "" {
|
||||
log.Warnln("invalid access token")
|
||||
w.WriteHeader(http.StatusUnauthorized) //nolint
|
||||
w.Write([]byte("invalid access token")) //nolint
|
||||
return
|
||||
}
|
||||
|
||||
if accepted, err := data.DoesTokenSupportScope(token, scope); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError) //nolint
|
||||
w.Write([]byte(err.Error())) //nolint
|
||||
return
|
||||
} else if !accepted {
|
||||
log.Warnln("invalid access token")
|
||||
w.WriteHeader(http.StatusUnauthorized) //nolint
|
||||
w.Write([]byte("invalid access token")) //nolint
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
|
||||
if err := data.SetAccessTokenAsUsed(token); err != nil {
|
||||
log.Debugln(token, "not found when updating last_used timestamp")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
120
router/router.go
120
router/router.go
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/controllers/admin"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/yp"
|
||||
)
|
||||
|
@ -48,8 +49,15 @@ func Start() error {
|
|||
// video embed
|
||||
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
||||
|
||||
// return the YP protocol data
|
||||
http.HandleFunc("/api/yp", yp.GetYPResponse)
|
||||
|
||||
// list of all social platforms
|
||||
http.HandleFunc("/api/socialplatforms", controllers.GetAllSocialPlatforms)
|
||||
|
||||
// return the logo
|
||||
http.HandleFunc("/logo", controllers.GetLogo)
|
||||
|
||||
// Authenticated admin requests
|
||||
|
||||
// Current inbound broadcaster
|
||||
|
@ -58,21 +66,6 @@ func Start() error {
|
|||
// Disconnect inbound stream
|
||||
http.HandleFunc("/api/admin/disconnect", middleware.RequireAdminAuth(admin.DisconnectInboundConnection))
|
||||
|
||||
// Change the current streaming key in memory
|
||||
http.HandleFunc("/api/admin/changekey", middleware.RequireAdminAuth(admin.ChangeStreamKey))
|
||||
|
||||
// Change the current streaming name in memory
|
||||
http.HandleFunc("/api/admin/changename", middleware.RequireAdminAuth(admin.ChangeStreamName))
|
||||
|
||||
// Change the current streaming name in memory
|
||||
http.HandleFunc("/api/admin/changetitle", middleware.RequireAdminAuth(admin.ChangeStreamTitle))
|
||||
|
||||
// Change the current streaming name in memory
|
||||
http.HandleFunc("/api/admin/changetags", middleware.RequireAdminAuth(admin.ChangeStreamTags))
|
||||
|
||||
// Change the extra page content in memory
|
||||
http.HandleFunc("/api/admin/changeextrapagecontent", middleware.RequireAdminAuth(admin.ChangeExtraPageContent))
|
||||
|
||||
// Server config
|
||||
http.HandleFunc("/api/admin/serverconfig", middleware.RequireAdminAuth(admin.GetServerConfig))
|
||||
|
||||
|
@ -94,10 +87,103 @@ func Start() error {
|
|||
// Get all chat messages for the admin, unfiltered.
|
||||
http.HandleFunc("/api/admin/chat/messages", middleware.RequireAdminAuth(admin.GetChatMessages))
|
||||
|
||||
// Update chat message visibilty
|
||||
// Update chat message visibility
|
||||
http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility))
|
||||
// Update config values
|
||||
|
||||
port := config.Config.GetPublicWebServerPort()
|
||||
// Change the current streaming key in memory
|
||||
http.HandleFunc("/api/admin/config/key", middleware.RequireAdminAuth(admin.SetStreamKey))
|
||||
|
||||
// Change the extra page content in memory
|
||||
http.HandleFunc("/api/admin/config/pagecontent", middleware.RequireAdminAuth(admin.SetExtraPageContent))
|
||||
|
||||
// Stream title
|
||||
http.HandleFunc("/api/admin/config/streamtitle", middleware.RequireAdminAuth(admin.SetStreamTitle))
|
||||
|
||||
// Server name
|
||||
http.HandleFunc("/api/admin/config/name", middleware.RequireAdminAuth(admin.SetServerName))
|
||||
|
||||
// Server summary
|
||||
http.HandleFunc("/api/admin/config/serversummary", middleware.RequireAdminAuth(admin.SetServerSummary))
|
||||
|
||||
// Return all webhooks
|
||||
http.HandleFunc("/api/admin/webhooks", middleware.RequireAdminAuth(admin.GetWebhooks))
|
||||
|
||||
// Delete a single webhook
|
||||
http.HandleFunc("/api/admin/webhooks/delete", middleware.RequireAdminAuth(admin.DeleteWebhook))
|
||||
|
||||
// Create a single webhook
|
||||
http.HandleFunc("/api/admin/webhooks/create", middleware.RequireAdminAuth(admin.CreateWebhook))
|
||||
|
||||
// Get all access tokens
|
||||
http.HandleFunc("/api/admin/accesstokens", middleware.RequireAdminAuth(admin.GetAccessTokens))
|
||||
|
||||
// Delete a single access token
|
||||
http.HandleFunc("/api/admin/accesstokens/delete", middleware.RequireAdminAuth(admin.DeleteAccessToken))
|
||||
|
||||
// Create a single access token
|
||||
http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateAccessToken))
|
||||
|
||||
// Send a system message to chat
|
||||
http.HandleFunc("/api/integrations/chat/system", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessage))
|
||||
|
||||
// Send a user message to chat
|
||||
http.HandleFunc("/api/integrations/chat/user", middleware.RequireAccessToken(models.ScopeCanSendUserMessages, admin.SendUserMessage))
|
||||
|
||||
// Send a user action to chat
|
||||
http.HandleFunc("/api/integrations/chat/action", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendChatAction))
|
||||
|
||||
// Hide chat message
|
||||
http.HandleFunc("/api/integrations/chat/messagevisibility", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.UpdateMessageVisibility))
|
||||
|
||||
// Stream title
|
||||
http.HandleFunc("/api/integrations/streamtitle", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.SetStreamTitle))
|
||||
|
||||
// Get chat history
|
||||
http.HandleFunc("/api/integrations/chat", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetChatMessages))
|
||||
|
||||
// Connected clients
|
||||
http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients))
|
||||
// Logo path
|
||||
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath))
|
||||
|
||||
// Server tags
|
||||
http.HandleFunc("/api/admin/config/tags", middleware.RequireAdminAuth(admin.SetTags))
|
||||
|
||||
// ffmpeg
|
||||
http.HandleFunc("/api/admin/config/ffmpegpath", middleware.RequireAdminAuth(admin.SetFfmpegPath))
|
||||
|
||||
// Server http port
|
||||
http.HandleFunc("/api/admin/config/webserverport", middleware.RequireAdminAuth(admin.SetWebServerPort))
|
||||
|
||||
// Server rtmp port
|
||||
http.HandleFunc("/api/admin/config/rtmpserverport", middleware.RequireAdminAuth(admin.SetRTMPServerPort))
|
||||
|
||||
// Is server marked as NSFW
|
||||
http.HandleFunc("/api/admin/config/nsfw", middleware.RequireAdminAuth(admin.SetNSFW))
|
||||
|
||||
// directory enabled
|
||||
http.HandleFunc("/api/admin/config/directoryenabled", middleware.RequireAdminAuth(admin.SetDirectoryEnabled))
|
||||
|
||||
// social handles
|
||||
http.HandleFunc("/api/admin/config/socialhandles", middleware.RequireAdminAuth(admin.SetSocialHandles))
|
||||
|
||||
// set the number of video segments and duration per segment in a playlist
|
||||
http.HandleFunc("/api/admin/config/video/streamlatencylevel", middleware.RequireAdminAuth(admin.SetStreamLatencyLevel))
|
||||
|
||||
// set an array of video output configurations
|
||||
http.HandleFunc("/api/admin/config/video/streamoutputvariants", middleware.RequireAdminAuth(admin.SetStreamOutputVariants))
|
||||
|
||||
// set s3 configuration
|
||||
http.HandleFunc("/api/admin/config/s3", middleware.RequireAdminAuth(admin.SetS3Configuration))
|
||||
|
||||
// set server url
|
||||
http.HandleFunc("/api/admin/config/serverurl", middleware.RequireAdminAuth(admin.SetServerURL))
|
||||
|
||||
// reset the YP registration
|
||||
http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration))
|
||||
|
||||
port := config.WebServerPort
|
||||
|
||||
log.Tracef("Web server running on port: %d", port)
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 46 KiB |
|
@ -5,12 +5,12 @@
|
|||
<meta charset="utf-8">
|
||||
|
||||
<title>{{.Config.Title}}</title>
|
||||
<meta name="description" content="{{.Config.Summary}}">
|
||||
<meta name="description" content="{{.Summary}}">
|
||||
|
||||
<meta property="og:title" content="{{.Config.Title}}">
|
||||
<meta property="og:site_name" content="{{.Config.Title}}">
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
<meta property="og:site_name" content="{{.Title}}">
|
||||
<meta property="og:url" content="{{.RequestedURL}}">
|
||||
<meta property="og:description" content="{{.Config.Summary}}">
|
||||
<meta property="og:description" content="{{.Summary}}">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="video:tag" content="{{.TagsString}}">
|
||||
|
||||
|
@ -22,11 +22,11 @@
|
|||
<meta property="og:video:height" content="640" />
|
||||
<meta property="og:video:width" content="385" />
|
||||
<meta property="og:video:type" content="application/x-mpegURL" />
|
||||
<meta property="og:video:actor" content="{{.Config.Name}}" />
|
||||
<meta property="og:video:actor" content="{{.Name}}" />
|
||||
|
||||
<meta property="twitter:title" content="{{.Config.Title}}">
|
||||
<meta property="twitter:title" content="{{.Title}}">
|
||||
<meta property="twitter:url" content="{{.RequestedURL}}">
|
||||
<meta property="twitter:description" content="{{.Config.Summary}}">
|
||||
<meta property="twitter:description" content="{{.Summary}}">
|
||||
<meta property="twitter:image" content="{{.Image}}">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||
|
@ -51,24 +51,24 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<h1>{{.Config.Title}}</h1>
|
||||
<h2>{{.Config.Name}}</h2>
|
||||
<h1>{{.Title}}</h1>
|
||||
<h2>{{.Name}}</h2>
|
||||
|
||||
<center>
|
||||
<img src="{{.Thumbnail}}" width=10% />
|
||||
</center>
|
||||
|
||||
<h3>{{.Config.Summary}}</h3>
|
||||
<h3>{{.Summary}}</h3>
|
||||
|
||||
{{range .Config.Tags}}
|
||||
{{range .Tags}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
|
||||
<br/>
|
||||
|
||||
<h3>Connect with {{.Config.Name}} elsewhere by visiting:</h3>
|
||||
<h3>Connect with {{.Name}} elsewhere by visiting:</h3>
|
||||
|
||||
{{range .Config.SocialHandles}}
|
||||
{{range .SocialHandles}}
|
||||
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -1,48 +1,12 @@
|
|||
var request = require('supertest');
|
||||
request = request('http://127.0.0.1:8080');
|
||||
|
||||
test('stream details are correct', (done) => {
|
||||
request.get('/api/admin/status').auth('admin', 'abc123').expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.broadcaster.streamDetails.width).toBe(320);
|
||||
expect(res.body.broadcaster.streamDetails.height).toBe(180);
|
||||
expect(res.body.broadcaster.streamDetails.framerate).toBe(24);
|
||||
expect(res.body.broadcaster.streamDetails.videoBitrate).toBe(1269);
|
||||
expect(res.body.broadcaster.streamDetails.videoCodec).toBe('H.264');
|
||||
expect(res.body.broadcaster.streamDetails.audioCodec).toBe('AAC');
|
||||
expect(res.body.online).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('admin configuration is correct', (done) => {
|
||||
request.get('/api/admin/serverconfig').auth('admin', 'abc123').expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.instanceDetails.name).toBe('Owncast');
|
||||
expect(res.body.instanceDetails.title).toBe('Owncast');
|
||||
expect(res.body.instanceDetails.summary).toBe('This is brief summary of whom you are or what your stream is. You can edit this description in your config file.');
|
||||
expect(res.body.instanceDetails.logo).toBe('/img/logo.svg');
|
||||
expect(res.body.instanceDetails.tags).toStrictEqual(['music', 'software', 'streaming']);
|
||||
|
||||
expect(res.body.videoSettings.segmentLengthSeconds).toBe(4);
|
||||
expect(res.body.videoSettings.numberOfPlaylistItems).toBe(5);
|
||||
|
||||
expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe(24);
|
||||
expect(res.body.videoSettings.videoQualityVariants[0].encoderPreset).toBe('veryfast');
|
||||
|
||||
expect(res.body.videoSettings.numberOfPlaylistItems).toBe(5);
|
||||
|
||||
expect(res.body.yp.enabled).toBe(false);
|
||||
expect(res.body.streamKey).toBe('abc123');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('correct number of log entries exist', (done) => {
|
||||
request.get('/api/admin/logs').auth('admin', 'abc123').expect(200)
|
||||
.then((res) => {
|
||||
// expect(res.body).toHaveLength(4);
|
||||
// expect(res.body).toHaveLength(8);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ request = request('http://127.0.0.1:8080');
|
|||
const WebSocket = require('ws');
|
||||
var ws;
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
const testMessageId = Math.random().toString(36).substring(7);
|
||||
const username = 'user' + Math.floor(Math.random() * 100);
|
||||
const message = Math.floor(Math.random() * 100) + ' test 123';
|
||||
const messageRaw = message + ' *and some markdown too*';
|
||||
|
@ -15,7 +15,7 @@ const date = new Date().toISOString();
|
|||
const testMessage = {
|
||||
author: username,
|
||||
body: messageRaw,
|
||||
id: id,
|
||||
id: testMessageId,
|
||||
type: 'CHAT',
|
||||
visible: true,
|
||||
timestamp: date,
|
||||
|
@ -37,12 +37,16 @@ test('can send a chat message', (done) => {
|
|||
});
|
||||
|
||||
test('can fetch chat messages', (done) => {
|
||||
request.get('/api/chat').expect(200)
|
||||
request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body[0].author).toBe(testMessage.author);
|
||||
expect(res.body[0].body).toBe(messageMarkdown);
|
||||
expect(res.body[0].date).toBe(testMessage.date);
|
||||
expect(res.body[0].type).toBe(testMessage.type);
|
||||
const message = res.body.filter(function(msg) {
|
||||
return msg.id = testMessageId;
|
||||
})[0];
|
||||
|
||||
expect(message.author).toBe(testMessage.author);
|
||||
expect(message.body).toBe(messageMarkdown);
|
||||
expect(message.date).toBe(testMessage.date);
|
||||
expect(message.type).toBe(testMessage.type);
|
||||
|
||||
done();
|
||||
});
|
||||
|
|
|
@ -31,7 +31,7 @@ test('can send a chat message', (done) => {
|
|||
var messageId;
|
||||
|
||||
test('verify we can make API call to mark message as hidden', async (done) => {
|
||||
const res = await request.get('/api/chat').expect(200);
|
||||
const res = await request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
|
||||
const message = res.body[0];
|
||||
messageId = message.id;
|
||||
await request.post('/api/admin/chat/updatemessagevisibility')
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue