Merge remote-tracking branch 'upstream/master' into save-volume-settings
This commit is contained in:
@@ -0,0 +1 @@
|
||||
webroot/js/web_modules/* linguist-vendored
|
||||
@@ -19,9 +19,13 @@ vendor/
|
||||
/stats.json
|
||||
owncast
|
||||
webroot/thumbnail.jpg
|
||||
webroot/preview.gif
|
||||
webroot/hls
|
||||
webroot/static/content.md
|
||||
hls/
|
||||
dist/
|
||||
transcoder.log
|
||||
chat.db
|
||||
.yp.key
|
||||
|
||||
!webroot/js/web_modules/**/dist
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "doc"]
|
||||
path = doc
|
||||
url = https://github.com/owncast/owncast.github.io/
|
||||
@@ -14,7 +14,7 @@
|
||||
<a href="https://goth.land/">View Demo</a>
|
||||
·
|
||||
<a href="https://owncast.online/docs/faq/">FAQ</a>
|
||||
.
|
||||
·
|
||||
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## Third party web dependencies
|
||||
|
||||
Owncast's web frontend utilizes a few third party Javascript and CSS dependencies that we ship with the application.
|
||||
|
||||
To add, remove, or update one of these components:
|
||||
|
||||
1. Perform your `npm install/uninstall/etc`, or edit the `package.json` file to reflect the change you want to make.
|
||||
2. Edit the `snowpack` `install` block of `package.json` to specify what files you want to add to the Owncast project. This can be an entire library (such as `preact`) or a single file (such as `video.js/dist/video.min.js`). These paths point to files that live in `node_modules`.
|
||||
3. Run `npm run build`. This will download the requested module from NPM, package up the assets you specified, and then copy them to the Owncast web app in the `webroot/js/web_modules` directory.
|
||||
4. Your new web dependency is now available for use in your web code.
|
||||
Generated
+3225
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "owncast-dependencies",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@joeattardi/emoji-button": "^4.2.0",
|
||||
"@justinribeiro/lite-youtube": "^0.9.0",
|
||||
"@videojs/http-streaming": "^2.2.0",
|
||||
"@videojs/themes": "^1.0.0",
|
||||
"htm": "^3.0.4",
|
||||
"preact": "^10.5.3",
|
||||
"showdown": "^1.9.1",
|
||||
"tailwindcss": "^1.8.10",
|
||||
"video.js": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"snowpack": "^2.12.1"
|
||||
},
|
||||
"snowpack": {
|
||||
"install": [
|
||||
"video.js/dist/video.min.js",
|
||||
"@videojs/themes/fantasy/*",
|
||||
"@videojs/http-streaming/dist/videojs-http-streaming.min.js",
|
||||
"video.js/dist/video-js.min.css",
|
||||
"@joeattardi/emoji-button",
|
||||
"@justinribeiro/lite-youtube",
|
||||
"htm",
|
||||
"preact",
|
||||
"showdown",
|
||||
"tailwindcss/dist/tailwind.min.css"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "npm install && npx snowpack install && cp -R web_modules ../../webroot/js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
@@ -25,3 +25,9 @@ instanceDetails:
|
||||
videoSettings:
|
||||
# Change this value and keep it secure. Treat it like a password to your live stream.
|
||||
streamingKey: abc123
|
||||
|
||||
yp:
|
||||
# Enable YP to be listed in the Owncast directory and let people discover your instance.
|
||||
enabled: false
|
||||
# You must specify the public URL of your host that you want the directory to link to.
|
||||
instanceURL: http://yourhost.com
|
||||
|
||||
+49
-42
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
@@ -15,17 +15,15 @@ var _default config
|
||||
|
||||
type config struct {
|
||||
ChatDatabaseFilePath string `yaml:"chatDatabaseFile"`
|
||||
DisableWebFeatures bool `yaml:"disableWebFeatures"`
|
||||
EnableDebugFeatures bool `yaml:"-"`
|
||||
FFMpegPath string `yaml:"ffmpegPath"`
|
||||
Files files `yaml:"files"`
|
||||
InstanceDetails InstanceDetails `yaml:"instanceDetails"`
|
||||
PrivateHLSPath string `yaml:"privateHLSPath"`
|
||||
PublicHLSPath string `yaml:"publicHLSPath"`
|
||||
S3 s3 `yaml:"s3"`
|
||||
VersionInfo string `yaml:"-"`
|
||||
S3 S3 `yaml:"s3"`
|
||||
VersionInfo string `yaml:"-"` // For storing the version/build number
|
||||
VideoSettings videoSettings `yaml:"videoSettings"`
|
||||
WebServerPort int `yaml:"webServerPort"`
|
||||
YP yp `yaml:"yp"`
|
||||
}
|
||||
|
||||
// InstanceDetails defines the user-visible information about this particular instance.
|
||||
@@ -33,11 +31,17 @@ type InstanceDetails struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Summary string `yaml:"summary" json:"summary"`
|
||||
Logo map[string]string `yaml:"logo" json:"logo"`
|
||||
Logo logo `yaml:"logo" json:"logo"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"`
|
||||
ExtraInfoFile string `yaml:"extraUserInfoFileName" json:"extraUserInfoFileName"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `yaml:"nsfw" json:"nsfw"`
|
||||
}
|
||||
|
||||
type logo struct {
|
||||
Large string `yaml:"large" json:"large"`
|
||||
Small string `yaml:"small" json:"small"`
|
||||
}
|
||||
|
||||
type socialHandle struct {
|
||||
@@ -50,7 +54,14 @@ type videoSettings struct {
|
||||
StreamingKey string `yaml:"streamingKey"`
|
||||
StreamQualities []StreamQuality `yaml:"streamQualities"`
|
||||
OfflineContent string `yaml:"offlineContent"`
|
||||
HighestQualityStreamIndex int `yaml"-"`
|
||||
HighestQualityStreamIndex int `yaml:"-"`
|
||||
}
|
||||
|
||||
// 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
|
||||
YPServiceURL string `yaml:"ypServiceURL"` // The base URL to the YP API to register with (optional)
|
||||
}
|
||||
|
||||
// StreamQuality defines the specifics of a single HLS stream variant.
|
||||
@@ -58,35 +69,35 @@ 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"`
|
||||
IsAudioPassthrough bool `yaml:"audioPassthrough"`
|
||||
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"`
|
||||
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"`
|
||||
|
||||
VideoBitrate int `yaml:"videoBitrate"`
|
||||
AudioBitrate int `yaml:"audioBitrate"`
|
||||
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"`
|
||||
ScaledHeight int `yaml:"scaledHeight"`
|
||||
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"`
|
||||
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"`
|
||||
|
||||
Framerate int `yaml:"framerate"`
|
||||
EncoderPreset string `yaml:"encoderPreset"`
|
||||
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"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
ServingEndpoint string `yaml:"servingEndpoint"`
|
||||
AccessKey string `yaml:"accessKey"`
|
||||
Secret string `yaml:"secret"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Region string `yaml:"region"`
|
||||
ACL string `yaml:"acl"`
|
||||
//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 {
|
||||
@@ -129,6 +140,10 @@ func (c *config) verifySettings() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.YP.Enabled && c.YP.InstanceURL == "" {
|
||||
return errors.New("YP is enabled but instance url is not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,22 +155,6 @@ func (c *config) GetVideoSegmentSecondsLength() int {
|
||||
return _default.GetVideoSegmentSecondsLength()
|
||||
}
|
||||
|
||||
func (c *config) GetPublicHLSSavePath() string {
|
||||
if c.PublicHLSPath != "" {
|
||||
return c.PublicHLSPath
|
||||
}
|
||||
|
||||
return _default.PublicHLSPath
|
||||
}
|
||||
|
||||
func (c *config) GetPrivateHLSSavePath() string {
|
||||
if c.PrivateHLSPath != "" {
|
||||
return c.PrivateHLSPath
|
||||
}
|
||||
|
||||
return _default.PrivateHLSPath
|
||||
}
|
||||
|
||||
func (c *config) GetPublicWebServerPort() int {
|
||||
if c.WebServerPort != 0 {
|
||||
return c.WebServerPort
|
||||
@@ -189,6 +188,14 @@ func (c *config) GetFFMpegPath() string {
|
||||
return _default.FFMpegPath
|
||||
}
|
||||
|
||||
func (c *config) GetYPServiceHost() string {
|
||||
if c.YP.YPServiceURL != "" {
|
||||
return c.YP.YPServiceURL
|
||||
}
|
||||
|
||||
return _default.YP.YPServiceURL
|
||||
}
|
||||
|
||||
func (c *config) GetVideoStreamQualities() []StreamQuality {
|
||||
if len(c.VideoSettings.StreamQualities) > 0 {
|
||||
return c.VideoSettings.StreamQualities
|
||||
|
||||
+16
-1
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func findHighestQuality(qualities []StreamQuality) int {
|
||||
type IndexedQuality struct {
|
||||
@@ -32,3 +35,15 @@ func findHighestQuality(qualities []StreamQuality) int {
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package config
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
const (
|
||||
WebRoot = "webroot"
|
||||
PrivateHLSStoragePath = "hls"
|
||||
)
|
||||
|
||||
var (
|
||||
PublicHLSStoragePath = filepath.Join(WebRoot, "hls")
|
||||
)
|
||||
+2
-2
@@ -12,10 +12,10 @@ func getDefaults() config {
|
||||
defaults.FFMpegPath = getDefaultFFMpegPath()
|
||||
defaults.VideoSettings.ChunkLengthInSeconds = 4
|
||||
defaults.Files.MaxNumberInPlaylist = 5
|
||||
defaults.PublicHLSPath = "webroot/hls"
|
||||
defaults.PrivateHLSPath = "hls"
|
||||
defaults.VideoSettings.OfflineContent = "static/offline.m4v"
|
||||
defaults.InstanceDetails.ExtraInfoFile = "/static/content.md"
|
||||
defaults.YP.Enabled = false
|
||||
defaults.YP.YPServiceURL = "https://yp.owncast.online"
|
||||
|
||||
defaultQuality := StreamQuality{
|
||||
IsAudioPassthrough: true,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core"
|
||||
|
||||
"github.com/owncast/owncast/core/rtmp"
|
||||
)
|
||||
|
||||
// DisconnectInboundConnection will force-disconnect an inbound stream
|
||||
func DisconnectInboundConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if !core.GetStatus().Online {
|
||||
controllers.WriteSimpleResponse(w, false, "no inbound stream connected")
|
||||
return
|
||||
}
|
||||
|
||||
rtmp.Disconnect()
|
||||
controllers.WriteSimpleResponse(w, true, "inbound stream disconnected")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/metrics"
|
||||
)
|
||||
|
||||
// GetHardwareStats will return hardware utilization over time
|
||||
func GetHardwareStats(w http.ResponseWriter, r *http.Request) {
|
||||
metrics := metrics.Metrics
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(metrics)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
// GetInboundBroadasterDetails gets the details of the inbound broadcaster
|
||||
func GetInboundBroadasterDetails(w http.ResponseWriter, r *http.Request) {
|
||||
broadcaster := core.GetBroadcaster()
|
||||
if broadcaster == nil {
|
||||
controllers.WriteSimpleResponse(w, false, "no broadcaster connected")
|
||||
return
|
||||
}
|
||||
|
||||
response := inboundBroadasterDetailsResponse{
|
||||
models.BaseAPIResponse{
|
||||
true,
|
||||
"",
|
||||
},
|
||||
broadcaster,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type inboundBroadasterDetailsResponse struct {
|
||||
models.BaseAPIResponse
|
||||
Broadcaster *models.Broadcaster `json:"broadcaster"`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
)
|
||||
|
||||
// GetServerConfig gets the config details of the server
|
||||
func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response := serverConfigAdminResponse{
|
||||
InstanceDetails: config.Config.InstanceDetails,
|
||||
FFmpegPath: config.Config.GetFFMpegPath(),
|
||||
WebServerPort: config.Config.GetPublicWebServerPort(),
|
||||
VideoSettings: videoSettings{
|
||||
VideoQualityVariants: config.Config.GetVideoStreamQualities(),
|
||||
SegmentLengthSeconds: config.Config.GetVideoSegmentSecondsLength(),
|
||||
NumberOfPlaylistItems: config.Config.GetMaxNumberOfReferencedSegmentsInPlaylist(),
|
||||
},
|
||||
S3: config.Config.S3,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type serverConfigAdminResponse struct {
|
||||
InstanceDetails config.InstanceDetails `json:"instanceDetails"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
WebServerPort int `json:"webServerPort"`
|
||||
S3 config.S3 `json:"s3"`
|
||||
VideoSettings videoSettings `json:"videoSettings"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
VideoQualityVariants []config.StreamQuality `json:"videoQualityVariants"`
|
||||
SegmentLengthSeconds int `json:"segmentLengthSeconds"`
|
||||
NumberOfPlaylistItems int `json:"numberOfPlaylistItems"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/metrics"
|
||||
)
|
||||
|
||||
// GetViewersOverTime will return the number of viewers at points in time
|
||||
func GetViewersOverTime(w http.ResponseWriter, r *http.Request) {
|
||||
viewersOverTime := metrics.Metrics.Viewers
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(viewersOverTime)
|
||||
}
|
||||
+3
-3
@@ -4,9 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
)
|
||||
|
||||
//GetChatMessages gets all of the chat messages
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
)
|
||||
|
||||
//GetWebConfig gets the status of the server
|
||||
|
||||
@@ -3,6 +3,8 @@ package controllers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
type j map[string]interface{}
|
||||
@@ -24,3 +26,12 @@ func badRequestHandler(w http.ResponseWriter, err error) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(j{"error": err.Error()})
|
||||
}
|
||||
|
||||
func WriteSimpleResponse(w http.ResponseWriter, success bool, message string) {
|
||||
response := models.BaseAPIResponse{
|
||||
Success: success,
|
||||
Message: message,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ const emojiPath = "/img/emoji" // Relative to webroot
|
||||
func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
|
||||
emojiList := make([]models.CustomEmoji, 0)
|
||||
|
||||
fullPath := filepath.Join("webroot", emojiPath)
|
||||
fullPath := filepath.Join(config.WebRoot, emojiPath)
|
||||
files, err := ioutil.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
|
||||
+8
-14
@@ -5,15 +5,16 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/router/middleware"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
type MetadataPage struct {
|
||||
@@ -30,13 +31,6 @@ func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
isIndexRequest := r.URL.Path == "/" || r.URL.Path == "/index.html"
|
||||
|
||||
// Reject requests for the web UI if it's disabled.
|
||||
if isIndexRequest && config.Config.DisableWebFeatures {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("404 - y u ask 4 this? If this is an error let us know: https://github.com/owncast/owncast/issues"))
|
||||
return
|
||||
}
|
||||
|
||||
// For search engine bots and social scrapers return a special
|
||||
// server-rendered page.
|
||||
if utils.IsUserAgentABot(r.UserAgent()) && isIndexRequest {
|
||||
@@ -60,7 +54,7 @@ func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Set a cache control max-age header
|
||||
middleware.SetCachingHeaders(w, r)
|
||||
|
||||
http.ServeFile(w, r, path.Join("webroot", r.URL.Path))
|
||||
http.ServeFile(w, r, path.Join(config.WebRoot, r.URL.Path))
|
||||
}
|
||||
|
||||
// Return a basic HTML page with server-rendered metadata from the config file
|
||||
@@ -69,13 +63,13 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl := template.Must(template.ParseFiles(path.Join("static", "metadata.html")))
|
||||
|
||||
fullURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, r.URL.Path))
|
||||
imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, config.Config.InstanceDetails.Logo["large"]))
|
||||
imageURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, config.Config.InstanceDetails.Logo.Large))
|
||||
|
||||
status := core.GetStatus()
|
||||
|
||||
// If the thumbnail does not exist or we're offline then just use the logo image
|
||||
var thumbnailURL string
|
||||
if status.Online && utils.DoesFileExists("webroot/thumbnail.jpg") {
|
||||
if status.Online && utils.DoesFileExists(filepath.Join(config.WebRoot, "thumbnail.jpg")) {
|
||||
thumbnail, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, "/thumbnail.jpg"))
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
)
|
||||
|
||||
//GetStatus gets the status of the server
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
//Setup sets up the chat server
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/websocket"
|
||||
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/websocket"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -133,7 +133,7 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
|
||||
time.Sleep(7 * time.Second)
|
||||
|
||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
|
||||
initialMessage := models.ChatMessage{"owncast-server", config.Config.InstanceDetails.Name, initialChatMessageText, config.Config.InstanceDetails.Logo["small"], "initial-message-1", "CHAT", true, time.Now()}
|
||||
initialMessage := models.ChatMessage{"owncast-server", config.Config.InstanceDetails.Name, initialChatMessageText, config.Config.InstanceDetails.Logo.Small, "initial-message-1", "CHAT", true, time.Now()}
|
||||
c.Write(initialMessage)
|
||||
}()
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package core
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gabek/owncast/core/chat"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
//ChatListenerImpl the implementation of the chat client
|
||||
|
||||
+26
-16
@@ -3,22 +3,26 @@ package core
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core/chat"
|
||||
"github.com/gabek/owncast/core/ffmpeg"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/owncast/owncast/yp"
|
||||
)
|
||||
|
||||
var (
|
||||
_stats *models.Stats
|
||||
_storage models.ChunkStorageProvider
|
||||
_cleanupTimer *time.Timer
|
||||
_yp *yp.YP
|
||||
_broadcaster *models.Broadcaster
|
||||
)
|
||||
|
||||
//Start starts up the core processing
|
||||
@@ -40,6 +44,12 @@ func Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Config.YP.Enabled {
|
||||
_yp = yp.NewYP(GetStatus)
|
||||
} else {
|
||||
yp.DisplayInstructions()
|
||||
}
|
||||
|
||||
chat.Setup(ChatListenerImpl{})
|
||||
|
||||
return nil
|
||||
@@ -47,8 +57,8 @@ func Start() error {
|
||||
|
||||
func createInitialOfflineState() error {
|
||||
// Provide default files
|
||||
if !utils.DoesFileExists("webroot/thumbnail.jpg") {
|
||||
if err := utils.Copy("static/logo.png", "webroot/thumbnail.jpg"); err != nil {
|
||||
if !utils.DoesFileExists(filepath.Join(config.WebRoot, "thumbnail.jpg")) {
|
||||
if err := utils.Copy("static/logo.png", filepath.Join(config.WebRoot, "thumbnail.jpg")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -84,22 +94,22 @@ func resetDirectories() {
|
||||
log.Trace("Resetting file directories to a clean slate.")
|
||||
|
||||
// Wipe the public, web-accessible hls data directory
|
||||
os.RemoveAll(config.Config.GetPublicHLSSavePath())
|
||||
os.RemoveAll(config.Config.GetPrivateHLSSavePath())
|
||||
os.MkdirAll(config.Config.GetPublicHLSSavePath(), 0777)
|
||||
os.MkdirAll(config.Config.GetPrivateHLSSavePath(), 0777)
|
||||
os.RemoveAll(config.PublicHLSStoragePath)
|
||||
os.RemoveAll(config.PrivateHLSStoragePath)
|
||||
os.MkdirAll(config.PublicHLSStoragePath, 0777)
|
||||
os.MkdirAll(config.PrivateHLSStoragePath, 0777)
|
||||
|
||||
// Remove the previous thumbnail
|
||||
os.Remove("webroot/thumbnail.jpg")
|
||||
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 {
|
||||
os.MkdirAll(path.Join(config.Config.GetPrivateHLSSavePath(), strconv.Itoa(index)), 0777)
|
||||
os.MkdirAll(path.Join(config.Config.GetPublicHLSSavePath(), strconv.Itoa(index)), 0777)
|
||||
os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||
os.MkdirAll(path.Join(config.PublicHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||
}
|
||||
} else {
|
||||
os.MkdirAll(path.Join(config.Config.GetPrivateHLSSavePath(), strconv.Itoa(0)), 0777)
|
||||
os.MkdirAll(path.Join(config.Config.GetPublicHLSSavePath(), strconv.Itoa(0)), 0777)
|
||||
os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(0)), 0777)
|
||||
os.MkdirAll(path.Join(config.PublicHLSStoragePath, strconv.Itoa(0)), 0777)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/owncast/owncast/config"
|
||||
)
|
||||
|
||||
//ShowStreamOfflineState generates and shows the stream's offline state
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/owncast/owncast/config"
|
||||
)
|
||||
|
||||
//StartThumbnailGenerator starts generating thumbnails
|
||||
@@ -39,7 +39,8 @@ func StartThumbnailGenerator(chunkPath string, variantIndex int) {
|
||||
|
||||
func fireThumbnailGenerator(chunkPath string, variantIndex int) error {
|
||||
// JPG takes less time to encode than PNG
|
||||
outputFile := path.Join("webroot", "thumbnail.jpg")
|
||||
outputFile := path.Join(config.WebRoot, "thumbnail.jpg")
|
||||
previewGifFile := path.Join(config.WebRoot, "preview.gif")
|
||||
|
||||
framePath := path.Join(chunkPath, strconv.Itoa(variantIndex))
|
||||
files, err := ioutil.ReadDir(framePath)
|
||||
@@ -83,12 +84,32 @@ func fireThumbnailGenerator(chunkPath string, variantIndex int) error {
|
||||
}
|
||||
|
||||
ffmpegCmd := strings.Join(thumbnailCmdFlags, " ")
|
||||
|
||||
// fmt.Println(ffmpegCmd)
|
||||
|
||||
if _, err := exec.Command("sh", "-c", ffmpegCmd).Output(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If YP support is enabled also create an animated GIF preview
|
||||
if config.Config.YP.Enabled {
|
||||
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeAnimatedGifPreview(sourceFile string, outputFile string) {
|
||||
// Filter is pulled from https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/
|
||||
animatedGifFlags := []string{
|
||||
config.Config.GetFFMpegPath(),
|
||||
"-y", // Overwrite file
|
||||
"-threads 1", // Low priority processing
|
||||
"-i", sourceFile, // Input
|
||||
"-t 1", // Output is one second in length
|
||||
"-filter_complex", "\"[0:v] fps=8,scale=w=480:h=-1:flags=lanczos,split [a][b];[a] palettegen=stats_mode=full [p];[b][p] paletteuse=new=1\"",
|
||||
outputFile,
|
||||
}
|
||||
|
||||
ffmpegCmd := strings.Join(animatedGifFlags, " ")
|
||||
if _, err := exec.Command("sh", "-c", ffmpegCmd).Output(); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
var _commandExec *exec.Cmd
|
||||
@@ -25,6 +26,7 @@ type Transcoder struct {
|
||||
segmentLengthSeconds int
|
||||
appendToStream bool
|
||||
ffmpegPath string
|
||||
segmentIdentifier string
|
||||
}
|
||||
|
||||
// HLSVariant is a combination of settings that results in a single HLS stream
|
||||
@@ -103,6 +105,10 @@ func (t *Transcoder) getString() string {
|
||||
hlsOptionFlags = append(hlsOptionFlags, "append_list")
|
||||
}
|
||||
|
||||
if t.segmentIdentifier == "" {
|
||||
t.segmentIdentifier = shortid.MustGenerate()
|
||||
}
|
||||
|
||||
ffmpegFlags := []string{
|
||||
"cat", t.input, "|",
|
||||
t.ffmpegPath,
|
||||
@@ -125,7 +131,7 @@ func (t *Transcoder) getString() string {
|
||||
// Filenames
|
||||
"-master_pl_name", "stream.m3u8",
|
||||
"-strftime 1", // Support the use of strftime in filenames
|
||||
"-hls_segment_filename", path.Join(t.segmentOutputPath, "/%v/stream-%s.ts"), // Each segment's filename
|
||||
"-hls_segment_filename", path.Join(t.segmentOutputPath, "/%v/stream-%s-"+t.segmentIdentifier+".ts"), // Each segment's filename
|
||||
"-max_muxing_queue_size", "400", // Workaround for Too many packets error: https://trac.ffmpeg.org/ticket/6375?cversion=0
|
||||
path.Join(t.segmentOutputPath, "/%v/stream.m3u8"), // Each variant's playlist
|
||||
"2> transcoder.log",
|
||||
@@ -182,15 +188,15 @@ func NewTranscoder() Transcoder {
|
||||
var outputPath string
|
||||
if config.Config.S3.Enabled {
|
||||
// Segments are not available via the local HTTP server
|
||||
outputPath = config.Config.GetPrivateHLSSavePath()
|
||||
outputPath = config.PrivateHLSStoragePath
|
||||
} else {
|
||||
// Segments are available via the local HTTP server
|
||||
outputPath = config.Config.GetPublicHLSSavePath()
|
||||
outputPath = config.PublicHLSStoragePath
|
||||
}
|
||||
|
||||
transcoder.segmentOutputPath = outputPath
|
||||
// Playlists are available via the local HTTP server
|
||||
transcoder.playlistOutputPath = config.Config.GetPublicHLSSavePath()
|
||||
transcoder.playlistOutputPath = config.PublicHLSStoragePath
|
||||
|
||||
transcoder.input = utils.GetTemporaryPipePath()
|
||||
transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength()
|
||||
@@ -353,3 +359,8 @@ func (t *Transcoder) SetSegmentLength(seconds int) {
|
||||
func (t *Transcoder) SetAppendToStream(append bool) {
|
||||
t.appendToStream = append
|
||||
}
|
||||
|
||||
// SetIdentifer enables appending a unique identifier to segment file name
|
||||
func (t *Transcoder) SetIdentifier(output string) {
|
||||
t.segmentIdentifier = output
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ func TestFFmpegCommand(t *testing.T) {
|
||||
transcoder.SetInput("fakecontent.flv")
|
||||
transcoder.SetOutputPath("fakeOutput")
|
||||
transcoder.SetHLSPlaylistLength(10)
|
||||
transcoder.SetIdentifier("jdofFGg")
|
||||
|
||||
variant := HLSVariant{}
|
||||
variant.videoBitrate = 1200
|
||||
@@ -22,7 +23,7 @@ func TestFFmpegCommand(t *testing.T) {
|
||||
|
||||
cmd := transcoder.getString()
|
||||
|
||||
expected := `cat fakecontent.flv | /fake/path/ffmpeg -hide_banner -i pipe: -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 119 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=119:keyint=119" -map a:0 -c:a:0 copy -r 30 -preset veryfast -var_stream_map "v:0,a:0 " -f hls -hls_time 4 -hls_list_size 10 -hls_delete_threshold 10 -hls_flags delete_segments+program_date_time+temp_file -tune zerolatency -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename fakeOutput/%v/stream-%s.ts -max_muxing_queue_size 400 fakeOutput/%v/stream.m3u8 2> transcoder.log`
|
||||
expected := `cat fakecontent.flv | /fake/path/ffmpeg -hide_banner -i pipe: -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 119 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=119:keyint=119" -map a:0 -c:a:0 copy -r 30 -preset veryfast -var_stream_map "v:0,a:0 " -f hls -hls_time 4 -hls_list_size 10 -hls_delete_threshold 10 -hls_flags delete_segments+program_date_time+temp_file -tune zerolatency -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename fakeOutput/%v/stream-%s-jdofFGg.ts -max_muxing_queue_size 400 fakeOutput/%v/stream.m3u8 2> transcoder.log`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected. Got %s, want: %s", cmd, expected)
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
|
||||
"github.com/radovskyb/watcher"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,7 +26,7 @@ var (
|
||||
func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
||||
_storage = storage
|
||||
|
||||
pathToMonitor := config.Config.GetPrivateHLSSavePath()
|
||||
pathToMonitor := config.PrivateHLSStoragePath
|
||||
|
||||
// Create at least one structure to store the segments for the different stream variants
|
||||
variants = make([]models.Variant, len(config.Config.VideoSettings.StreamQualities))
|
||||
@@ -63,11 +63,9 @@ func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// fmt.Println(event.Op, relativePath)
|
||||
|
||||
// Handle updates to the master playlist by copying it to webroot
|
||||
if relativePath == path.Join(config.Config.GetPrivateHLSSavePath(), "stream.m3u8") {
|
||||
utils.Copy(event.Path, path.Join(config.Config.GetPublicHLSSavePath(), "stream.m3u8"))
|
||||
if relativePath == path.Join(config.PrivateHLSStoragePath, "stream.m3u8") {
|
||||
utils.Copy(event.Path, path.Join(config.PublicHLSStoragePath, "stream.m3u8"))
|
||||
|
||||
} else if filepath.Ext(event.Path) == ".m3u8" {
|
||||
// Handle updates to playlists, but not the master playlist
|
||||
@@ -82,7 +80,7 @@ func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
||||
|
||||
newObjectPathChannel := make(chan string, 1)
|
||||
go func() {
|
||||
newObjectPath, err := storage.Save(path.Join(config.Config.GetPrivateHLSSavePath(), segment.RelativeUploadPath), 0)
|
||||
newObjectPath, err := storage.Save(path.Join(config.PrivateHLSStoragePath, segment.RelativeUploadPath), 0)
|
||||
if err != nil {
|
||||
log.Errorln("failed to save the file to the chunk storage.", err)
|
||||
}
|
||||
@@ -155,5 +153,5 @@ func updateVariantPlaylist(fullPath string) error {
|
||||
playlistString := string(playlistBytes)
|
||||
playlistString = _storage.GenerateRemotePlaylist(playlistString, variant)
|
||||
|
||||
return WritePlaylist(playlistString, path.Join(config.Config.GetPublicHLSSavePath(), relativePath))
|
||||
return WritePlaylist(playlistString, path.Join(config.PublicHLSStoragePath, relativePath))
|
||||
}
|
||||
|
||||
+44
-4
@@ -14,10 +14,11 @@ import (
|
||||
"github.com/nareix/joy5/format/flv/flvio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/core/ffmpeg"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/nareix/joy5/format/rtmp"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ var (
|
||||
|
||||
var _transcoder ffmpeg.Transcoder
|
||||
var _pipe *os.File
|
||||
var _rtmpConnection net.Conn
|
||||
|
||||
//Start starts the rtmp service, listening on port 1935
|
||||
func Start() {
|
||||
@@ -61,10 +63,36 @@ func Start() {
|
||||
}
|
||||
}
|
||||
|
||||
func setCurrentBroadcasterInfo(t flvio.Tag, remoteAddr string) {
|
||||
data, err := getInboundDetailsFromMetadata(t.DebugFields())
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
broadcaster := models.Broadcaster{
|
||||
RemoteAddr: remoteAddr,
|
||||
Time: time.Now(),
|
||||
StreamDetails: models.InboundStreamDetails{
|
||||
Width: data.Width,
|
||||
Height: data.Height,
|
||||
VideoBitrate: int(data.VideoBitrate),
|
||||
VideoCodec: getVideoCodec(data.VideoCodec),
|
||||
VideoFramerate: data.VideoFramerate,
|
||||
AudioBitrate: int(data.AudioBitrate),
|
||||
AudioCodec: getAudioCodec(data.AudioCodec),
|
||||
Encoder: data.Encoder,
|
||||
},
|
||||
}
|
||||
|
||||
core.SetBroadcaster(broadcaster)
|
||||
}
|
||||
|
||||
func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
||||
c.LogTagEvent = func(isRead bool, t flvio.Tag) {
|
||||
if t.Type == flvio.TAG_AMF0 {
|
||||
log.Tracef("%+v\n", t.DebugFields())
|
||||
setCurrentBroadcasterInfo(t, nc.RemoteAddr().String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +120,7 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
||||
|
||||
_isConnected = true
|
||||
core.SetStreamAsConnected()
|
||||
_rtmpConnection = nc
|
||||
|
||||
f, err := os.OpenFile(pipePath, os.O_RDWR, os.ModeNamedPipe)
|
||||
_pipe = f
|
||||
@@ -121,9 +150,20 @@ func handleDisconnect(conn net.Conn) {
|
||||
_pipe.Close()
|
||||
_isConnected = false
|
||||
_transcoder.Stop()
|
||||
_rtmpConnection = nil
|
||||
core.SetStreamAsDisconnected()
|
||||
}
|
||||
|
||||
// Disconnect will force disconnect the current inbound RTMP connection.
|
||||
func Disconnect() {
|
||||
if _rtmpConnection == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infoln("Inbound stream disconnect requested.")
|
||||
handleDisconnect(_rtmpConnection)
|
||||
}
|
||||
|
||||
//IsConnected gets whether there is an rtmp connection or not
|
||||
//this is only a getter since it is controlled by the rtmp handler
|
||||
func IsConnected() bool {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/nareix/joy5/format/flv/flvio"
|
||||
)
|
||||
|
||||
func getInboundDetailsFromMetadata(metadata []interface{}) (models.RTMPStreamMetadata, error) {
|
||||
metadataComponentsString := fmt.Sprintf("%+v", metadata)
|
||||
re := regexp.MustCompile(`\{(.*?)\}`)
|
||||
submatchall := re.FindAllString(metadataComponentsString, 1)
|
||||
|
||||
if len(submatchall) == 0 {
|
||||
return models.RTMPStreamMetadata{}, errors.New("unable to parse inbound metadata")
|
||||
}
|
||||
|
||||
metadataJSONString := submatchall[0]
|
||||
var details models.RTMPStreamMetadata
|
||||
json.Unmarshal([]byte(metadataJSONString), &details)
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func getAudioCodec(codec interface{}) string {
|
||||
var codecID float64
|
||||
if assertedCodecID, ok := codec.(float64); ok {
|
||||
codecID = assertedCodecID
|
||||
} else {
|
||||
return codec.(string)
|
||||
}
|
||||
|
||||
switch codecID {
|
||||
case flvio.SOUND_MP3:
|
||||
return "MP3"
|
||||
case flvio.SOUND_AAC:
|
||||
return "AAC"
|
||||
case flvio.SOUND_SPEEX:
|
||||
return "Speex"
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func getVideoCodec(codec interface{}) string {
|
||||
var codecID float64
|
||||
if assertedCodecID, ok := codec.(float64); ok {
|
||||
codecID = assertedCodecID
|
||||
} else {
|
||||
return codec.(string)
|
||||
}
|
||||
|
||||
switch codecID {
|
||||
case flvio.VIDEO_H264:
|
||||
return "H.264"
|
||||
case flvio.VIDEO_H265:
|
||||
return "H.265"
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
+3
-3
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+24
-6
@@ -3,10 +3,10 @@ package core
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core/ffmpeg"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/ffmpeg"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
//GetStatus gets the status of the system
|
||||
@@ -33,9 +33,13 @@ func SetStreamAsConnected() {
|
||||
_stats.LastConnectTime = utils.NullTime{time.Now(), true}
|
||||
_stats.LastDisconnectTime = utils.NullTime{time.Now(), false}
|
||||
|
||||
chunkPath := config.Config.GetPublicHLSSavePath()
|
||||
chunkPath := config.PublicHLSStoragePath
|
||||
if usingExternalStorage {
|
||||
chunkPath = config.Config.GetPrivateHLSSavePath()
|
||||
chunkPath = config.PrivateHLSStoragePath
|
||||
}
|
||||
|
||||
if _yp != nil {
|
||||
_yp.Start()
|
||||
}
|
||||
|
||||
ffmpeg.StartThumbnailGenerator(chunkPath, config.Config.VideoSettings.HighestQualityStreamIndex)
|
||||
@@ -45,7 +49,21 @@ func SetStreamAsConnected() {
|
||||
func SetStreamAsDisconnected() {
|
||||
_stats.StreamConnected = false
|
||||
_stats.LastDisconnectTime = utils.NullTime{time.Now(), true}
|
||||
_broadcaster = nil
|
||||
|
||||
if _yp != nil {
|
||||
_yp.Stop()
|
||||
}
|
||||
|
||||
ffmpeg.ShowStreamOfflineState()
|
||||
startCleanupTimer()
|
||||
}
|
||||
|
||||
// SetBroadcaster will store the current inbound broadcasting details
|
||||
func SetBroadcaster(broadcaster models.Broadcaster) {
|
||||
_broadcaster = &broadcaster
|
||||
}
|
||||
|
||||
func GetBroadcaster() *models.Broadcaster {
|
||||
return _broadcaster
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core/playlist"
|
||||
"github.com/gabek/owncast/core/storageproviders"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/playlist"
|
||||
"github.com/owncast/owncast/core/storageproviders"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
)
|
||||
|
||||
//S3Storage is the s3 implementation of the ChunkStorageProvider
|
||||
@@ -94,7 +94,7 @@ func (s *S3Storage) GenerateRemotePlaylist(playlist string, variant models.Varia
|
||||
if fullRemotePath == nil {
|
||||
line = ""
|
||||
} else if s.s3ServingEndpoint != "" {
|
||||
line = fmt.Sprintf("%s/%s/%s", s.s3ServingEndpoint, config.Config.GetPrivateHLSSavePath(), fullRemotePath.RelativeUploadPath)
|
||||
line = fmt.Sprintf("%s/%s/%s", s.s3ServingEndpoint, config.PrivateHLSStoragePath, fullRemotePath.RelativeUploadPath)
|
||||
} else {
|
||||
line = fullRemotePath.RemoteID
|
||||
}
|
||||
|
||||
-1
Submodule doc deleted from 54a0ee1396
@@ -1,4 +1,4 @@
|
||||
module github.com/gabek/owncast
|
||||
module github.com/owncast/owncast
|
||||
|
||||
go 1.14
|
||||
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/mssola/user_agent v0.5.2
|
||||
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1
|
||||
github.com/radovskyb/watcher v1.0.7
|
||||
github.com/shirou/gopsutil v2.20.7+incompatible
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
|
||||
@@ -31,6 +31,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/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1 h1:CskT+S6Ay54OwxBGB0R3Rsx4Muto6UnEYTyKJbyRIAI=
|
||||
github.com/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1/go.mod h1:uIp+gprXxxrWSjjklXD+mN4wed/tMfjMMmN/9+JsA9o=
|
||||
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
|
||||
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
|
||||
github.com/shirou/gopsutil v2.20.7+incompatible h1:Ymv4OD12d6zm+2yONe39VSmp2XooJe8za7ngOLW/o/w=
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/metrics"
|
||||
"github.com/gabek/owncast/router"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core"
|
||||
"github.com/owncast/owncast/metrics"
|
||||
"github.com/owncast/owncast/router"
|
||||
)
|
||||
|
||||
// the following are injected at build-time
|
||||
|
||||
+18
-4
@@ -6,12 +6,14 @@ import (
|
||||
|
||||
const maxCPUAlertingThresholdPCT = 95
|
||||
const maxRAMAlertingThresholdPCT = 95
|
||||
const maxDiskAlertingThresholdPCT = 95
|
||||
|
||||
const alertingError = "The %s utilization of %d%% is higher than the alerting threshold of %d%%. This can cause issues with video generation and delivery. Please visit the documentation at http://owncast.online/docs/troubleshooting/ to help troubleshoot this issue."
|
||||
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."
|
||||
|
||||
func handleAlerting() {
|
||||
handleCPUAlerting()
|
||||
handleRAMAlerting()
|
||||
handleDiskAlerting()
|
||||
}
|
||||
|
||||
func handleCPUAlerting() {
|
||||
@@ -21,7 +23,7 @@ func handleCPUAlerting() {
|
||||
|
||||
avg := recentAverage(Metrics.CPUUtilizations)
|
||||
if avg > maxCPUAlertingThresholdPCT {
|
||||
log.Errorf(alertingError, "CPU", avg, maxCPUAlertingThresholdPCT)
|
||||
log.Errorf(alertingError, "CPU", maxCPUAlertingThresholdPCT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +34,22 @@ func handleRAMAlerting() {
|
||||
|
||||
avg := recentAverage(Metrics.RAMUtilizations)
|
||||
if avg > maxRAMAlertingThresholdPCT {
|
||||
log.Errorf(alertingError, "memory", avg, maxRAMAlertingThresholdPCT)
|
||||
log.Errorf(alertingError, "memory", maxRAMAlertingThresholdPCT)
|
||||
}
|
||||
}
|
||||
|
||||
func recentAverage(values []value) int {
|
||||
func handleDiskAlerting() {
|
||||
if len(Metrics.DiskUtilizations) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
avg := recentAverage(Metrics.DiskUtilizations)
|
||||
|
||||
if avg > maxDiskAlertingThresholdPCT {
|
||||
log.Errorf(alertingError, "disk", maxRAMAlertingThresholdPCT)
|
||||
}
|
||||
}
|
||||
|
||||
func recentAverage(values []timestampedValue) int {
|
||||
return int((values[len(values)-1].Value + values[len(values)-2].Value) / 2)
|
||||
}
|
||||
|
||||
+15
-2
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/cpu"
|
||||
"github.com/shirou/gopsutil/disk"
|
||||
"github.com/shirou/gopsutil/mem"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ func collectCPUUtilization() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
metricValue := value{time.Now(), int(v[0])}
|
||||
metricValue := timestampedValue{time.Now(), int(v[0])}
|
||||
Metrics.CPUUtilizations = append(Metrics.CPUUtilizations, metricValue)
|
||||
}
|
||||
|
||||
@@ -30,6 +31,18 @@ func collectRAMUtilization() {
|
||||
}
|
||||
|
||||
memoryUsage, _ := mem.VirtualMemory()
|
||||
metricValue := value{time.Now(), int(memoryUsage.UsedPercent)}
|
||||
metricValue := timestampedValue{time.Now(), int(memoryUsage.UsedPercent)}
|
||||
Metrics.RAMUtilizations = append(Metrics.RAMUtilizations, metricValue)
|
||||
}
|
||||
|
||||
func collectDiskUtilization() {
|
||||
path := "./"
|
||||
diskUse, _ := disk.Usage(path)
|
||||
|
||||
if len(Metrics.DiskUtilizations) > maxCollectionValues {
|
||||
Metrics.DiskUtilizations = Metrics.DiskUtilizations[1:]
|
||||
}
|
||||
|
||||
metricValue := timestampedValue{time.Now(), int(diskUse.UsedPercent)}
|
||||
Metrics.DiskUtilizations = append(Metrics.DiskUtilizations, metricValue)
|
||||
}
|
||||
|
||||
+12
-10
@@ -5,24 +5,25 @@ import (
|
||||
)
|
||||
|
||||
// How often we poll for updates
|
||||
const metricsPollingInterval = 15 * time.Second
|
||||
const metricsPollingInterval = 1 * time.Minute
|
||||
|
||||
type value struct {
|
||||
Time time.Time
|
||||
Value int
|
||||
}
|
||||
// CollectedMetrics stores different collected + timestamped values
|
||||
type CollectedMetrics struct {
|
||||
CPUUtilizations []timestampedValue `json:"cpu"`
|
||||
RAMUtilizations []timestampedValue `json:"memory"`
|
||||
DiskUtilizations []timestampedValue `json:"disk"`
|
||||
|
||||
type metrics struct {
|
||||
CPUUtilizations []value
|
||||
RAMUtilizations []value
|
||||
Viewers []timestampedValue `json:"-"`
|
||||
}
|
||||
|
||||
// Metrics is the shared Metrics instance
|
||||
var Metrics *metrics
|
||||
var Metrics *CollectedMetrics
|
||||
|
||||
// Start will begin the metrics collection and alerting
|
||||
func Start() {
|
||||
Metrics = new(metrics)
|
||||
Metrics = new(CollectedMetrics)
|
||||
go startViewerCollectionMetrics()
|
||||
handlePolling()
|
||||
|
||||
for range time.Tick(metricsPollingInterval) {
|
||||
handlePolling()
|
||||
@@ -33,6 +34,7 @@ func handlePolling() {
|
||||
// Collect hardware stats
|
||||
collectCPUUtilization()
|
||||
collectRAMUtilization()
|
||||
collectDiskUtilization()
|
||||
|
||||
// Alerting
|
||||
handleAlerting()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package metrics
|
||||
|
||||
import "time"
|
||||
|
||||
type timestampedValue struct {
|
||||
Time time.Time `json:"time"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/core"
|
||||
)
|
||||
|
||||
// How often we poll for updates
|
||||
const viewerMetricsPollingInterval = 5 * time.Minute
|
||||
|
||||
func startViewerCollectionMetrics() {
|
||||
collectViewerCount()
|
||||
|
||||
for range time.Tick(viewerMetricsPollingInterval) {
|
||||
collectViewerCount()
|
||||
}
|
||||
}
|
||||
|
||||
func collectViewerCount() {
|
||||
if len(Metrics.Viewers) > maxCollectionValues {
|
||||
Metrics.Viewers = Metrics.Viewers[1:]
|
||||
}
|
||||
|
||||
count := core.GetStatus().ViewerCount
|
||||
value := timestampedValue{
|
||||
Value: count,
|
||||
Time: time.Now(),
|
||||
}
|
||||
Metrics.Viewers = append(Metrics.Viewers, value)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
// BaseAPIResponse is a simple response to API requests.
|
||||
type BaseAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Broadcaster represents the details around the inbound broadcasting connection.
|
||||
type Broadcaster struct {
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
StreamDetails InboundStreamDetails `json:"streamDetails"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
type InboundStreamDetails struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
VideoFramerate int `json:"framerate"`
|
||||
VideoBitrate int `json:"videoBitrate"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
AudioBitrate int `json:"audioBitrate"`
|
||||
AudioCodec string `json:"audioCodec"`
|
||||
Encoder string `json:"encoder"`
|
||||
}
|
||||
|
||||
// RTMPStreamMetadata is the raw metadata that comes in with a RTMP connection
|
||||
type RTMPStreamMetadata struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
VideoBitrate float32 `json:"videodatarate"`
|
||||
VideoCodec interface{} `json:"videocodecid"`
|
||||
VideoFramerate int `json:"framerate"`
|
||||
AudioBitrate float32 `json:"audiodatarate"`
|
||||
AudioCodec interface{} `json:"audiocodecid"`
|
||||
Encoder string `json:"encoder"`
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gabek/owncast/utils"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
//Stats holds the stats for the system
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package models
|
||||
|
||||
import "github.com/gabek/owncast/utils"
|
||||
import "github.com/owncast/owncast/utils"
|
||||
|
||||
//Status represents the status of the system
|
||||
type Status struct {
|
||||
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: Owncast
|
||||
description: Owncast is a self-hosted live video and web chat server for use with existing popular broadcasting software.
|
||||
version: '0.0.2'
|
||||
servers: []
|
||||
|
||||
tags:
|
||||
- name: Admin
|
||||
description: Admin operations requiring authentication.
|
||||
- name: Chat
|
||||
description: Endpoints related to the chat interface.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
BasicResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
InstanceDetails:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
description: This is brief summary of whom you are or what the stream is.
|
||||
logo:
|
||||
type: object
|
||||
properties:
|
||||
large:
|
||||
type: string
|
||||
small:
|
||||
type: string
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
socialHandles:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
platform:
|
||||
type: string
|
||||
example: github
|
||||
url:
|
||||
type: string
|
||||
example: http://github.com/owncast/owncast
|
||||
extraUserInfoFileName:
|
||||
type: string
|
||||
description: Path to additional content about the server.
|
||||
version:
|
||||
type: string
|
||||
example: Owncast v0.0.2-macOS (ef3796a033b32a312ebf5b334851cbf9959e7ecb)
|
||||
S3:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
endpoint:
|
||||
type: string
|
||||
servingEndpoint:
|
||||
type: string
|
||||
accessKey:
|
||||
type: string
|
||||
secret:
|
||||
type: string
|
||||
bucket:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
acl:
|
||||
type: string
|
||||
required:
|
||||
- enabled
|
||||
StreamQuality:
|
||||
type: object
|
||||
properties:
|
||||
videoPassthrough:
|
||||
type: boolean
|
||||
audioPassthrough:
|
||||
type: boolean
|
||||
videoBitrate:
|
||||
type: integer
|
||||
audioBitrate:
|
||||
type: integer
|
||||
scaledWidth:
|
||||
type: integer
|
||||
scaledHeight:
|
||||
type: integer
|
||||
framerate:
|
||||
type: integer
|
||||
encoderPreset:
|
||||
type: string
|
||||
TimestampedValue:
|
||||
type: object
|
||||
properties:
|
||||
time:
|
||||
type: string
|
||||
format: date-time
|
||||
value:
|
||||
type: integer
|
||||
|
||||
|
||||
securitySchemes:
|
||||
AdminBasicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
description: The username for admin basic auth is `admin` and the password is the stream key.
|
||||
|
||||
responses:
|
||||
BasicResponse:
|
||||
description: Operation Success/Failure Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResponse"
|
||||
examples:
|
||||
success:
|
||||
summary: Operation succeeded.
|
||||
value: {"success": true, "message": "inbound stream disconnected"}
|
||||
failure:
|
||||
summary: Operation failed.
|
||||
value: {"success": false, "message": "no inbound stream connected"}
|
||||
|
||||
paths:
|
||||
|
||||
/api/config:
|
||||
get:
|
||||
summary: Information
|
||||
description: Get the public information about the server. Adds context to the server, as well as information useful for the user interface.
|
||||
tags: ["Server"]
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDetails"
|
||||
|
||||
/api/status:
|
||||
get:
|
||||
summary: Current Status
|
||||
description: This endpoint is used to discover when a server is broadcasting, the number of active viewers as well as other useful information for updating the user interface.
|
||||
tags: ["Server"]
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lastConnectTime:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
overallMaxViewerCount:
|
||||
type: integer
|
||||
sessionMaxViewerCount:
|
||||
type: integer
|
||||
online:
|
||||
type: boolean
|
||||
viewerCount:
|
||||
type: integer
|
||||
lastDisconnectTime:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
examples:
|
||||
online:
|
||||
value:
|
||||
lastConnectTime: "2020-10-03T21:36:22-05:00"
|
||||
lastDisconnectTime: null
|
||||
online: true
|
||||
overallMaxViewerCount: 420
|
||||
sessionMaxViewerCount: 12
|
||||
viewerCount: 7
|
||||
|
||||
/api/chat:
|
||||
get:
|
||||
summary: Historical Chat Messages
|
||||
description: Used to get all chat messages prior to connecting to the websocket.
|
||||
tags: ["Chat"]
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
author:
|
||||
type: string
|
||||
description: Username of the chat message poster.
|
||||
body:
|
||||
type: string
|
||||
description: Escaped HTML of the chat message content.
|
||||
image:
|
||||
type: string
|
||||
description: URL of the chat user avatar.
|
||||
id:
|
||||
type: string
|
||||
description: Unique ID of the chat message.
|
||||
visible:
|
||||
type: boolean
|
||||
description: "TODO"
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
/api/yp:
|
||||
get:
|
||||
summary: Yellow Pages Information
|
||||
description: Information to be used in the Yellow Pages service, a global directory of Owncast servers.
|
||||
tags: ["Server"]
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
logo:
|
||||
type: string
|
||||
nsfw:
|
||||
type: boolean
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
online:
|
||||
type: boolean
|
||||
viewerCount:
|
||||
type: integer
|
||||
overallMaxViewerCount:
|
||||
type: integer
|
||||
sessionMaxViewerCount:
|
||||
type: integer
|
||||
lastConnectTime:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
|
||||
/api/emoji:
|
||||
get:
|
||||
summary: Get Custom Emoji
|
||||
description: Get a list of custom emoji that are supported in chat.
|
||||
tags: ["Chat"]
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the Emoji
|
||||
emoji:
|
||||
type: string
|
||||
description: The relative path to the Emoji image file
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
items:
|
||||
- name: nicolas_cage_party
|
||||
emoji: /img/emoji/nicolas_cage_party.gif
|
||||
- name: parrot
|
||||
emoji: /img/emoji/parrot.gif
|
||||
|
||||
/api/admin/broadcaster:
|
||||
get:
|
||||
summary: "Broadcaster Details"
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Connected Broadcaster Details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
broadcaster:
|
||||
type: object
|
||||
properties:
|
||||
remoteAddr:
|
||||
type: string
|
||||
time:
|
||||
type: string
|
||||
format: date-time
|
||||
streamDetails:
|
||||
type: object
|
||||
properties:
|
||||
width:
|
||||
type: integer
|
||||
height:
|
||||
type: integer
|
||||
frameRate:
|
||||
type: integer
|
||||
videoBitrate:
|
||||
type: integer
|
||||
videoCodec:
|
||||
type: string
|
||||
audioBitrate:
|
||||
type: integer
|
||||
audioCodec:
|
||||
type: string
|
||||
encoder:
|
||||
type: string
|
||||
examples:
|
||||
connected:
|
||||
summary: "Broadcaster Connected"
|
||||
value:
|
||||
success: true
|
||||
message: ""
|
||||
broadcaster:
|
||||
remoteAddr: 127.0.0.1
|
||||
time: "TODO"
|
||||
streamDetails:
|
||||
width: 640
|
||||
height: 480
|
||||
frameRate: 24
|
||||
videoBitrate: 1500
|
||||
videoCodec: "todo"
|
||||
audioBitrate: 256
|
||||
audioCodec: "aac"
|
||||
encoder: "todo"
|
||||
not-connected:
|
||||
summary: "Broadcaster Not Connected"
|
||||
value:
|
||||
success: false
|
||||
message: "no broadcaster connected"
|
||||
|
||||
/api/admin/disconnect:
|
||||
post:
|
||||
summary: Disconnect Broadcaster
|
||||
description: Disconnect the active inbound stream, if one exists, and terminate the broadcast.
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
$ref: "#/components/responses/BasicResponse"
|
||||
|
||||
|
||||
/api/admin/changekey:
|
||||
post:
|
||||
summary: Update Stream Key
|
||||
description: Change the stream key in memory, but not in the config file. This will require all broadcasters to be reconfigured to connect again.
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
requestBody:
|
||||
description: ""
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Stream was disconnected.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: changed
|
||||
|
||||
/api/admin/serverconfig:
|
||||
get:
|
||||
summary: Server Configuration
|
||||
description: Get the current configuration of the Owncast server.
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instanceDetails:
|
||||
$ref: "#/components/schemas/InstanceDetails"
|
||||
ffmpegPath:
|
||||
type: string
|
||||
webServerPort:
|
||||
type: integer
|
||||
s3:
|
||||
$ref: "#/components/schemas/S3"
|
||||
videoSettings:
|
||||
type: object
|
||||
properties:
|
||||
videoQualityVariants:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/StreamQuality"
|
||||
segmentLengthSeconds:
|
||||
type: integer
|
||||
numberOfPlaylistItems:
|
||||
type: integer
|
||||
|
||||
/api/admin/viewersOverTime:
|
||||
get:
|
||||
summary: Viewers Over Time
|
||||
description: Get the tracked viewer count over the collected period.
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimestampedValue"
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
- time: "2020-10-03T21:41:00.381996-05:00"
|
||||
value: 50
|
||||
- time: "2020-10-03T21:42:00.381996-05:00"
|
||||
value: 52
|
||||
|
||||
|
||||
|
||||
|
||||
/api/admin/hardwarestats:
|
||||
get:
|
||||
summary: Hardware Stats
|
||||
description: "Get the CPU, Memory and Disk utilization levels over the collected period."
|
||||
tags: ["Admin"]
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cpu:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimestampedValue"
|
||||
memory:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimestampedValue"
|
||||
disk:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimestampedValue"
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
cpu:
|
||||
- time: "2020-10-03T21:41:00.381996-05:00"
|
||||
value: 23
|
||||
- time: "2020-10-03T21:42:00.381996-05:00"
|
||||
value: 27
|
||||
- time: "2020-10-03T21:43:00.381996-05:00"
|
||||
value: 22
|
||||
memory:
|
||||
- time: "2020-10-03T21:41:00.381996-05:00"
|
||||
value: 65
|
||||
- time: "2020-10-03T21:42:00.381996-05:00"
|
||||
value: 66
|
||||
- time: "2020-10-03T21:43:00.381996-05:00"
|
||||
value: 72
|
||||
disk:
|
||||
- time: "2020-10-03T21:41:00.381996-05:00"
|
||||
value: 11
|
||||
- time: "2020-10-03T21:42:00.381996-05:00"
|
||||
value: 11
|
||||
- time: "2020-10-03T21:43:00.381996-05:00"
|
||||
value: 11
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given
|
||||
// the stream key as the password and and a hardcoded "admin" for username.
|
||||
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||
username := "admin"
|
||||
password := config.Config.VideoSettings.StreamingKey
|
||||
realm := "Owncast Authenticated Request"
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The following line is kind of a work around.
|
||||
// If you want HTTP Basic Auth + Cors it requires _explicit_ origins to be provided in the
|
||||
// Access-Control-Allow-Origin header. So we just pull out the origin header and specify it.
|
||||
// If we want to lock down admin APIs to not be CORS accessible for anywhere, this is where we would do that.
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
|
||||
|
||||
// For request needing CORS, send a 200.
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
// Failed
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
log.Warnln("Failed authentication for", r.URL.Path, "from", r.RemoteAddr, r.UserAgent())
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
log.Traceln("Authenticated request OK for", r.URL.Path, "from", r.RemoteAddr, r.UserAgent())
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
@@ -60,5 +60,5 @@ func getCacheDurationSecondsForPath(filePath string) int {
|
||||
}
|
||||
|
||||
// Default cache length in seconds
|
||||
return 30 * 60
|
||||
return 30
|
||||
}
|
||||
|
||||
+30
-6
@@ -6,10 +6,14 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/controllers"
|
||||
"github.com/gabek/owncast/core/chat"
|
||||
"github.com/gabek/owncast/core/rtmp"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/controllers/admin"
|
||||
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/rtmp"
|
||||
"github.com/owncast/owncast/router/middleware"
|
||||
"github.com/owncast/owncast/yp"
|
||||
)
|
||||
|
||||
//Start starts the router for the http, ws, and rtmp
|
||||
@@ -26,7 +30,6 @@ func Start() error {
|
||||
// custom emoji supported in the chat
|
||||
http.HandleFunc("/api/emoji", controllers.GetCustomEmoji)
|
||||
|
||||
if !config.Config.DisableWebFeatures {
|
||||
// websocket chat server
|
||||
go chat.Start()
|
||||
|
||||
@@ -41,7 +44,28 @@ func Start() error {
|
||||
|
||||
// video embed
|
||||
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
||||
}
|
||||
|
||||
http.HandleFunc("/api/yp", yp.GetYPResponse)
|
||||
|
||||
// Authenticated admin requests
|
||||
|
||||
// Current inbound broadcaster
|
||||
http.HandleFunc("/api/admin/broadcaster", middleware.RequireAdminAuth(admin.GetInboundBroadasterDetails))
|
||||
|
||||
// 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))
|
||||
|
||||
// Server config
|
||||
http.HandleFunc("/api/admin/serverconfig", middleware.RequireAdminAuth(admin.GetServerConfig))
|
||||
|
||||
// Get viewer count over time
|
||||
http.HandleFunc("/api/admin/viewersOverTime", middleware.RequireAdminAuth(admin.GetViewersOverTime))
|
||||
|
||||
// Get hardware stats
|
||||
http.HandleFunc("/api/admin/hardwarestats", middleware.RequireAdminAuth(admin.GetHardwareStats))
|
||||
|
||||
port := config.Config.GetPublicWebServerPort()
|
||||
|
||||
|
||||
+4
-4
@@ -52,7 +52,7 @@ build() {
|
||||
|
||||
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/gabek/owncast
|
||||
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
|
||||
mv owncast-*-${ARCH} owncast
|
||||
|
||||
zip -r -q -8 ../owncast-$NAME-$VERSION.zip .
|
||||
@@ -76,7 +76,7 @@ git tag -a "v${VERSION}" -m "Release build v${VERSION}"
|
||||
|
||||
# On macOS open the Github page for new releases so they can be uploaded
|
||||
if test -f "/usr/bin/open"; then
|
||||
open "https://github.com/gabek/owncast/releases/new"
|
||||
open "https://github.com/owncast/owncast/releases/new"
|
||||
open dist
|
||||
fi
|
||||
|
||||
@@ -90,8 +90,8 @@ cd $(git rev-parse --show-toplevel)
|
||||
|
||||
# Github Packages
|
||||
docker build --build-arg NAME=docker --build-arg VERSION=${VERSION} --build-arg GIT_COMMIT=$GIT_COMMIT -t owncast . -f scripts/Dockerfile-build
|
||||
docker tag $DOCKER_IMAGE docker.pkg.github.com/gabek/owncast/$DOCKER_IMAGE:$VERSION
|
||||
docker push docker.pkg.github.com/gabek/owncast/$DOCKER_IMAGE:$VERSION
|
||||
docker tag $DOCKER_IMAGE docker.pkg.github.com/owncast/owncast/$DOCKER_IMAGE:$VERSION
|
||||
docker push docker.pkg.github.com/owncast/owncast/$DOCKER_IMAGE:$VERSION
|
||||
#
|
||||
# Dockerhub
|
||||
# You must be authenticated via `docker login` with your Dockerhub credentials first.
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
PROJECT_SOURCE_DIR=$(pwd)
|
||||
mkdir $TMPDIR/admin 2> /dev/null
|
||||
cd $TMPDIR/admin
|
||||
git clone --depth 1 https://github.com/owncast/owncast-admin 2> /dev/null
|
||||
cd owncast-admin
|
||||
npm --silent install 2> /dev/null
|
||||
(node_modules/.bin/next build && node_modules/.bin/next export) | grep info
|
||||
ADMIN_BUILD_DIR=$(pwd)
|
||||
cd $PROJECT_SOURCE_DIR
|
||||
mkdir webroot/admin 2> /dev/null
|
||||
cd webroot/admin
|
||||
cp -R ${ADMIN_BUILD_DIR}/out/* .
|
||||
rm -rf $TMPDIR/admin
|
||||
@@ -3,20 +3,19 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/standalone-chat.css" rel="stylesheet" />
|
||||
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@0.6.2/lite-youtube.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="messages-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import StandaloneChat from './js/app-standalone-chat.js';
|
||||
render(
|
||||
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
|
||||
<link href="//unpkg.com/video.js@7.9.3/dist/video-js.css" rel="stylesheet"/>
|
||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||
<script src="//unpkg.com/video.js@7.9.3/dist/alt/video.core.min.js"></script>
|
||||
<script src="//unpkg.com/@videojs/http-streaming@2.1.0/dist/videojs-http-streaming.min.js"></script>
|
||||
<link href="/js/web_modules/videojs/dist/video-js.min.css" rel="stylesheet"/>
|
||||
<link href="/js/web_modules/@videojs/themes/fantasy/index.css" rel="stylesheet" />
|
||||
|
||||
<link href="./styles/video.css" rel="stylesheet" />
|
||||
<link href="./styles/video-only.css" rel="stylesheet" />
|
||||
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -21,7 +17,10 @@
|
||||
<div id="video-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import VideoOnly from './js/app-video-only.js';
|
||||
render(html`<${VideoOnly} />`, document.getElementById("video-only"));
|
||||
</script>
|
||||
|
||||
+11
-20
@@ -1,6 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Owncast</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
|
||||
@@ -23,35 +24,25 @@
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet"/>
|
||||
|
||||
<link href="//unpkg.com/video.js@7.9.3/dist/video-js.css" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||
|
||||
<script src="//unpkg.com/video.js@7.9.3/dist/alt/video.core.min.js" defer></script>
|
||||
<script src="//unpkg.com/@videojs/http-streaming@2.1.0/dist/videojs-http-streaming.min.js" defer></script>
|
||||
|
||||
<!-- markdown renderer -->
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js" defer></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@0.6.2/lite-youtube.js" defer></script>
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
|
||||
<link href="/js/web_modules/videojs/dist/video-js.min.css" rel="stylesheet"/>
|
||||
<link href="/js/web_modules/@videojs/themes/fantasy/index.css" rel="stylesheet" />
|
||||
|
||||
<link href="./styles/video.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/user-content.css" rel="stylesheet" />
|
||||
<link href="./styles/app.css" rel="stylesheet" />
|
||||
|
||||
<!-- Preloads -->
|
||||
<link rel="preconnect" href="https://unpkg.com/@joeattardi/emoji-button@4.2.0/dist/index.js" />
|
||||
<link rel="preconnect" href="https://unpkg.com/preact?module" />
|
||||
<link rel="preconnect" href="https://unpkg.com/htm?module" />
|
||||
|
||||
</head>
|
||||
|
||||
<body class="scrollbar-hidden bg-gray-300 text-gray-800">
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import App from './js/app.js';
|
||||
render(html`<${App} />`, document.getElementById("app"));
|
||||
</script>
|
||||
@@ -77,7 +68,7 @@
|
||||
<img src="https://owncast.online/images/logo.png" />
|
||||
<br/>
|
||||
<p>
|
||||
This <a href="https://owncast.online" target="_blank">Owncast</a> stream requires Javascript to play.
|
||||
This <a href="https://owncast.online" rel="noopener noreferrer" target="_blank">Owncast</a> stream requires Javascript to play.
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import Chat from './components/chat/chat.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
import showdown from '/js/web_modules/showdown.js';
|
||||
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
import SocialIcon from './components/social.js';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { EmojiButton } from 'https://unpkg.com/@joeattardi/emoji-button@4.2.0/dist/index.js';
|
||||
import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js';
|
||||
|
||||
import ContentEditable, { replaceCaret } from './content-editable.js';
|
||||
import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste } from '../../utils/chat.js';
|
||||
import { getLocalStorage, setLocalStorage, classNames } from '../../utils/helpers.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import Message from './message.js';
|
||||
|
||||
@@ -6,7 +6,7 @@ and here:
|
||||
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
|
||||
|
||||
*/
|
||||
import { Component, createRef, h } from 'https://unpkg.com/preact?module';
|
||||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
|
||||
export function replaceCaret(el) {
|
||||
// Place the caret at the end of the element
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
||||
import { formatMessageText } from '../../utils/chat.js';
|
||||
import { formatMessageText, formatTimestamp } from '../../utils/chat.js';
|
||||
import { generateAvatar } from '../../utils/helpers.js';
|
||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
|
||||
@@ -13,9 +13,10 @@ export default class Message extends Component {
|
||||
const { type } = message;
|
||||
|
||||
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||
const { image, author, body } = message;
|
||||
const { image, author, body, timestamp } = message;
|
||||
const formattedMessage = formatMessageText(body, username);
|
||||
const avatar = image || generateAvatar(author);
|
||||
const formattedTimestamp = formatTimestamp(timestamp);
|
||||
|
||||
const authorColor = messageBubbleColorForString(author);
|
||||
const avatarBgColor = { backgroundColor: authorColor };
|
||||
@@ -35,6 +36,7 @@ export default class Message extends Component {
|
||||
</div>
|
||||
<div
|
||||
class="message-text text-gray-300 font-normal overflow-y-hidden"
|
||||
title=${formattedTimestamp}
|
||||
dangerouslySetInnerHTML=${
|
||||
{ __html: formattedMessage }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { generateAvatar, setLocalStorage } from '../../utils/helpers.js';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// https://docs.videojs.com/player
|
||||
|
||||
import videojs from '/js/web_modules/videojs/dist/video.min.js';
|
||||
|
||||
const VIDEO_ID = 'video';
|
||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||
const URL_STREAM = `/hls/stream.m3u8`;
|
||||
@@ -53,13 +55,14 @@ class OwncastPlayer {
|
||||
}
|
||||
|
||||
init() {
|
||||
videojs.Vhs.xhr.beforeRequest = function (options) {
|
||||
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS);
|
||||
|
||||
this.vjsPlayer.beforeRequest = function (options) {
|
||||
const cachebuster = Math.round(new Date().getTime() / 1000);
|
||||
options.uri = `${options.uri}?cachebust=${cachebuster}`;
|
||||
return options;
|
||||
};
|
||||
|
||||
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS);
|
||||
this.addAirplay();
|
||||
this.vjsPlayer.ready(this.handleReady);
|
||||
}
|
||||
@@ -76,11 +79,11 @@ class OwncastPlayer {
|
||||
// play
|
||||
startPlayer() {
|
||||
this.log('Start playing');
|
||||
const source = { ...VIDEO_SRC }
|
||||
const source = { ...VIDEO_SRC };
|
||||
this.vjsPlayer.volume(localStorage.getItem('owncastVolume'));
|
||||
this.vjsPlayer.src(source);
|
||||
// this.vjsPlayer.play();
|
||||
};
|
||||
}
|
||||
|
||||
handleReady() {
|
||||
this.log('on Ready');
|
||||
@@ -124,7 +127,7 @@ class OwncastPlayer {
|
||||
|
||||
setPoster() {
|
||||
const cachebuster = Math.round(new Date().getTime() / 1000);
|
||||
const poster = POSTER_THUMB + "?okhi=" + cachebuster;
|
||||
const poster = POSTER_THUMB + '?okhi=' + cachebuster;
|
||||
|
||||
this.vjsPlayer.poster(poster);
|
||||
}
|
||||
@@ -138,7 +141,6 @@ class OwncastPlayer {
|
||||
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||
var videoJsButtonClass = videojs.getComponent('Button');
|
||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
||||
|
||||
// The `init()` method will also work for constructor logic here, but it is
|
||||
// deprecated. If you provide an `init()` method, it will override the
|
||||
// `constructor()` method!
|
||||
@@ -152,8 +154,10 @@ class OwncastPlayer {
|
||||
},
|
||||
});
|
||||
|
||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass());
|
||||
concreteButtonInstance.addClass("vjs-airplay");
|
||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(
|
||||
new concreteButtonClass()
|
||||
);
|
||||
concreteButtonInstance.addClass('vjs-airplay');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
import { h } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
import { SOCIAL_PLATFORMS } from '../utils/social.js';
|
||||
import { classNames } from '../utils/helpers.js';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CHAT_PLACEHOLDER_OFFLINE,
|
||||
} from './constants.js';
|
||||
|
||||
import showdown from '/js/web_modules/showdown.js';
|
||||
export function formatMessageText(message, username) {
|
||||
showdown.setFlavor('github');
|
||||
let formattedText = new showdown.Converter({
|
||||
@@ -278,3 +279,18 @@ export function convertOnPaste( event = { preventDefault() {} }) {
|
||||
document.execCommand('insertText', false, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimestamp(sentAt) {
|
||||
sentAt = new Date(sentAt);
|
||||
if (isNaN(sentAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let diffInDays = ((new Date()) - sentAt) / (24 * 3600 * 1000);
|
||||
if (diffInDays >= 1) {
|
||||
return `Sent at ${sentAt.toLocaleDateString('en-US', {dateStyle: 'medium'})} at ` +
|
||||
sentAt.toLocaleTimeString();
|
||||
}
|
||||
|
||||
return `Sent at ${sentAt.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
*
|
||||
* The shadowDom / Intersection Observer version of Paul's concept:
|
||||
* https://github.com/paulirish/lite-youtube-embed
|
||||
*
|
||||
* A lightweight YouTube embed. Still should feel the same to the user, just
|
||||
* MUCH faster to initialize and paint.
|
||||
*
|
||||
* Thx to these as the inspiration
|
||||
* https://storage.googleapis.com/amp-vs-non-amp/youtube-lazy.html
|
||||
* https://autoplay-youtube-player.glitch.me/
|
||||
*
|
||||
* Once built it, I also found these (👍👍):
|
||||
* https://github.com/ampproject/amphtml/blob/master/extensions/amp-youtube
|
||||
* https://github.com/Daugilas/lazyYT https://github.com/vb/lazyframe
|
||||
*/
|
||||
class LiteYTEmbed extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.iframeLoaded = false;
|
||||
this.setupDom();
|
||||
}
|
||||
static get observedAttributes() {
|
||||
return ['videoid'];
|
||||
}
|
||||
connectedCallback() {
|
||||
this.addEventListener('pointerover', LiteYTEmbed.warmConnections, {
|
||||
once: true,
|
||||
});
|
||||
this.addEventListener('click', () => this.addIframe());
|
||||
}
|
||||
get videoId() {
|
||||
return encodeURIComponent(this.getAttribute('videoid') || '');
|
||||
}
|
||||
set videoId(id) {
|
||||
this.setAttribute('videoid', id);
|
||||
}
|
||||
get videoTitle() {
|
||||
return this.getAttribute('videotitle') || 'Video';
|
||||
}
|
||||
set videoTitle(title) {
|
||||
this.setAttribute('videotitle', title);
|
||||
}
|
||||
get videoPlay() {
|
||||
return this.getAttribute('videoPlay') || 'Play';
|
||||
}
|
||||
set videoPlay(name) {
|
||||
this.setAttribute('videoPlay', name);
|
||||
}
|
||||
get videoStartAt() {
|
||||
return Number(this.getAttribute('videoStartAt') || '0');
|
||||
}
|
||||
set videoStartAt(time) {
|
||||
this.setAttribute('videoStartAt', String(time));
|
||||
}
|
||||
get autoLoad() {
|
||||
return this.hasAttribute('autoload');
|
||||
}
|
||||
set autoLoad(value) {
|
||||
if (value) {
|
||||
this.setAttribute('autoload', '');
|
||||
}
|
||||
else {
|
||||
this.removeAttribute('autoload');
|
||||
}
|
||||
}
|
||||
get params() {
|
||||
return `start=${this.videoStartAt}&${this.getAttribute('params')}`;
|
||||
}
|
||||
/**
|
||||
* Define our shadowDOM for the component
|
||||
*/
|
||||
setupDom() {
|
||||
const shadowDom = this.attachShadow({ mode: 'open' });
|
||||
shadowDom.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
contain: content;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: calc(100% / (16 / 9));
|
||||
}
|
||||
|
||||
#frame, #fallbackPlaceholder, iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#frame {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#fallbackPlaceholder {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#frame::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==);
|
||||
background-position: top;
|
||||
background-repeat: repeat-x;
|
||||
height: 60px;
|
||||
padding-bottom: 50px;
|
||||
width: 100%;
|
||||
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
/* play button */
|
||||
.lty-playbtn {
|
||||
width: 70px;
|
||||
height: 46px;
|
||||
background-color: #212121;
|
||||
z-index: 1;
|
||||
opacity: 0.8;
|
||||
border-radius: 14%; /* TODO: Consider replacing this with YT's actual svg. Eh. */
|
||||
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
|
||||
border: 0;
|
||||
}
|
||||
#frame:hover .lty-playbtn {
|
||||
background-color: #f00;
|
||||
opacity: 1;
|
||||
}
|
||||
/* play button triangle */
|
||||
.lty-playbtn:before {
|
||||
content: '';
|
||||
border-style: solid;
|
||||
border-width: 11px 0 11px 19px;
|
||||
border-color: transparent transparent transparent #fff;
|
||||
}
|
||||
.lty-playbtn,
|
||||
.lty-playbtn:before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
}
|
||||
|
||||
/* Post-click styles */
|
||||
.lyt-activated {
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#frame.lyt-activated::before,
|
||||
.lyt-activated .lty-playbtn {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<div id="frame">
|
||||
<picture>
|
||||
<source id="webpPlaceholder" type="image/webp">
|
||||
<source id="jpegPlaceholder" type="image/jpeg">
|
||||
<img id="fallbackPlaceholder" referrerpolicy="origin">
|
||||
</picture>
|
||||
<button class="lty-playbtn"></button>
|
||||
</div>
|
||||
`;
|
||||
this.domRefFrame = this.shadowRoot.querySelector('#frame');
|
||||
this.domRefImg = {
|
||||
fallback: this.shadowRoot.querySelector('#fallbackPlaceholder'),
|
||||
webp: this.shadowRoot.querySelector('#webpPlaceholder'),
|
||||
jpeg: this.shadowRoot.querySelector('#jpegPlaceholder'),
|
||||
};
|
||||
this.domRefPlayButton = this.shadowRoot.querySelector('.lty-playbtn');
|
||||
}
|
||||
/**
|
||||
* Parse our attributes and fire up some placeholders
|
||||
*/
|
||||
setupComponent() {
|
||||
this.initImagePlaceholder();
|
||||
this.domRefPlayButton.setAttribute('aria-label', `${this.videoPlay}: ${this.videoTitle}`);
|
||||
this.setAttribute('title', `${this.videoPlay}: ${this.videoTitle}`);
|
||||
if (this.autoLoad) {
|
||||
this.initIntersectionObserver();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Lifecycle method that we use to listen for attribute changes to period
|
||||
* @param {*} name
|
||||
* @param {*} oldVal
|
||||
* @param {*} newVal
|
||||
*/
|
||||
attributeChangedCallback(name, oldVal, newVal) {
|
||||
switch (name) {
|
||||
case 'videoid': {
|
||||
if (oldVal !== newVal) {
|
||||
this.setupComponent();
|
||||
// if we have a previous iframe, remove it and the activated class
|
||||
if (this.domRefFrame.classList.contains('lyt-activated')) {
|
||||
this.domRefFrame.classList.remove('lyt-activated');
|
||||
this.shadowRoot.querySelector('iframe').remove();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Inject the iframe into the component body
|
||||
*/
|
||||
addIframe() {
|
||||
if (!this.iframeLoaded) {
|
||||
const iframeHTML = `
|
||||
<iframe frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen
|
||||
src="https://www.youtube.com/embed/${this.videoId}?autoplay=1&${this.params}"
|
||||
></iframe>`;
|
||||
this.domRefFrame.insertAdjacentHTML('beforeend', iframeHTML);
|
||||
this.domRefFrame.classList.add('lyt-activated');
|
||||
this.iframeLoaded = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Setup the placeholder image for the component
|
||||
*/
|
||||
initImagePlaceholder() {
|
||||
// we don't know which image type to preload, so warm the connection
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://i.ytimg.com/');
|
||||
const posterUrlWebp = `https://i.ytimg.com/vi_webp/${this.videoId}/hqdefault.webp`;
|
||||
const posterUrlJpeg = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`;
|
||||
this.domRefImg.webp.srcset = posterUrlWebp;
|
||||
this.domRefImg.jpeg.srcset = posterUrlJpeg;
|
||||
this.domRefImg.fallback.src = posterUrlJpeg;
|
||||
this.domRefImg.fallback.setAttribute('aria-label', `${this.videoPlay}: ${this.videoTitle}`);
|
||||
this.domRefImg.fallback.setAttribute('alt', `${this.videoPlay}: ${this.videoTitle}`);
|
||||
}
|
||||
/**
|
||||
* Setup the Intersection Observer to load the iframe when scrolled into view
|
||||
*/
|
||||
initIntersectionObserver() {
|
||||
if ('IntersectionObserver' in window &&
|
||||
'IntersectionObserverEntry' in window) {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0,
|
||||
};
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !this.iframeLoaded) {
|
||||
LiteYTEmbed.warmConnections();
|
||||
this.addIframe();
|
||||
observer.unobserve(this);
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Add a <link rel={preload | preconnect} ...> to the head
|
||||
* @param {*} kind
|
||||
* @param {*} url
|
||||
* @param {*} as
|
||||
*/
|
||||
static addPrefetch(kind, url, as) {
|
||||
const linkElem = document.createElement('link');
|
||||
linkElem.rel = kind;
|
||||
linkElem.href = url;
|
||||
if (as) {
|
||||
linkElem.as = as;
|
||||
}
|
||||
linkElem.crossOrigin = 'true';
|
||||
document.head.append(linkElem);
|
||||
}
|
||||
/**
|
||||
* Begin preconnecting to warm up the iframe load Since the embed's netwok
|
||||
* requests load within its iframe, preload/prefetch'ing them outside the
|
||||
* iframe will only cause double-downloads. So, the best we can do is warm up
|
||||
* a few connections to origins that are in the critical path.
|
||||
*
|
||||
* Maybe `<link rel=preload as=document>` would work, but it's unsupported:
|
||||
* http://crbug.com/593267 But TBH, I don't think it'll happen soon with Site
|
||||
* Isolation and split caches adding serious complexity.
|
||||
*/
|
||||
static warmConnections() {
|
||||
if (LiteYTEmbed.preconnected)
|
||||
return;
|
||||
// Host that YT uses to serve JS needed by player, per amp-youtube
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://s.ytimg.com');
|
||||
// The iframe document and most of its subresources come right off
|
||||
// youtube.com
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube.com');
|
||||
// The botguard script is fetched off from google.com
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com');
|
||||
// TODO: Not certain if these ad related domains are in the critical path.
|
||||
// Could verify with domain-specific throttling.
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net');
|
||||
LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net');
|
||||
LiteYTEmbed.preconnected = true;
|
||||
}
|
||||
}
|
||||
LiteYTEmbed.preconnected = false;
|
||||
// Register custom element
|
||||
customElements.define('lite-youtube', LiteYTEmbed);
|
||||
|
||||
export { LiteYTEmbed };
|
||||
+58119
File diff suppressed because one or more lines are too long
@@ -0,0 +1,116 @@
|
||||
.vjs-theme-fantasy {
|
||||
--vjs-theme-fantasy--primary: #9f44b4;
|
||||
--vjs-theme-fantasy--secondary: #fff;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-big-play-button {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: none;
|
||||
line-height: 70px;
|
||||
font-size: 80px;
|
||||
border: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -35px;
|
||||
margin-left: -35px;
|
||||
color: var(--vjs-theme-fantasy--primary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy:hover .vjs-big-play-button,
|
||||
.vjs-theme-fantasy.vjs-big-play-button:focus {
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-control-bar {
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-button > .vjs-icon-placeholder::before {
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-time-control {
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
/* Play Button */
|
||||
.vjs-theme-fantasy .vjs-play-control {
|
||||
font-size: 1.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-volume-panel {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-volume-bar {
|
||||
margin-top: 2.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-city .vjs-volume-panel:hover .vjs-volume-control.vjs-volume-horizontal {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control .vjs-progress-holder {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control:hover .vjs-progress-holder {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-control .vjs-icon-placeholder::before {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
margin-top: 0.2em;
|
||||
border-radius: 1em;
|
||||
border: 3px solid var(--vjs-theme-fantasy--secondary);
|
||||
top: 2px;
|
||||
left: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-control:hover .vjs-icon-placeholder::before {
|
||||
border: 3px solid var(--vjs-theme-fantasy--secondary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-progress {
|
||||
background-color: var(--vjs-theme-fantasy--primary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-progress::before {
|
||||
height: 0.8em;
|
||||
width: 0.8em;
|
||||
content: '';
|
||||
background-color: var(--vjs-theme-fantasy--primary);
|
||||
border: 4px solid var(--vjs-theme-fantasy--secondary);
|
||||
border-radius: 0.8em;
|
||||
top: -0.25em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-fullscreen-control {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-remaining-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nyan version */
|
||||
.vjs-theme-fantasy.nyan .vjs-play-progress {
|
||||
background: linear-gradient(to bottom, #fe0000 0%, #fe9a01 16.666666667%, #fe9a01 16.666666667%, #ffff00 33.332666667%, #ffff00 33.332666667%, #32ff00 49.999326667%, #32ff00 49.999326667%, #0099fe 66.6659926%, #0099fe 66.6659926%, #6633ff 83.33266%, #6633ff 83.33266%);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy.nyan .vjs-play-progress::before {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
background: svg-load('icons/nyan-cat.svg', fill=#fff) no-repeat;
|
||||
border: none;
|
||||
top: -0.35em;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
|
||||
|
||||
function getDefaultExportFromCjs (x) {
|
||||
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
||||
}
|
||||
|
||||
function createCommonjsModule(fn, basedir, module) {
|
||||
return module = {
|
||||
path: basedir,
|
||||
exports: {},
|
||||
require: function (path, base) {
|
||||
return commonjsRequire(path, (base === undefined || base === null) ? module.path : base);
|
||||
}
|
||||
}, fn(module, module.exports), module.exports;
|
||||
}
|
||||
|
||||
function getDefaultExportFromNamespaceIfNotNamed (n) {
|
||||
return n && Object.prototype.hasOwnProperty.call(n, 'default') && Object.keys(n).length === 1 ? n['default'] : n;
|
||||
}
|
||||
|
||||
function commonjsRequire () {
|
||||
throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs');
|
||||
}
|
||||
|
||||
export { commonjsGlobal as a, getDefaultExportFromNamespaceIfNotNamed as b, createCommonjsModule as c, getDefaultExportFromCjs as g };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { b as getDefaultExportFromNamespaceIfNotNamed, a as commonjsGlobal } from './_commonjsHelpers-37fa8da4.js';
|
||||
|
||||
var _nodeResolve_empty = {};
|
||||
|
||||
var _nodeResolve_empty$1 = /*#__PURE__*/Object.freeze({
|
||||
__proto__: null,
|
||||
'default': _nodeResolve_empty
|
||||
});
|
||||
|
||||
var minDoc = /*@__PURE__*/getDefaultExportFromNamespaceIfNotNamed(_nodeResolve_empty$1);
|
||||
|
||||
var topLevel = typeof commonjsGlobal !== 'undefined' ? commonjsGlobal :
|
||||
typeof window !== 'undefined' ? window : {};
|
||||
|
||||
|
||||
var doccy;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
doccy = document;
|
||||
} else {
|
||||
doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4'];
|
||||
|
||||
if (!doccy) {
|
||||
doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4'] = minDoc;
|
||||
}
|
||||
}
|
||||
|
||||
var document_1 = doccy;
|
||||
|
||||
var win;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
win = window;
|
||||
} else if (typeof commonjsGlobal !== "undefined") {
|
||||
win = commonjsGlobal;
|
||||
} else if (typeof self !== "undefined"){
|
||||
win = self;
|
||||
} else {
|
||||
win = {};
|
||||
}
|
||||
|
||||
var window_1 = win;
|
||||
|
||||
export { document_1 as d, window_1 as w };
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a);}return e},t=new Map;function htm_module(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e="";},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0]);}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
|
||||
|
||||
export default htm_module;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"imports": {
|
||||
"@joeattardi/emoji-button": "./@joeattardi/emoji-button.js",
|
||||
"@justinribeiro/lite-youtube": "./@justinribeiro/lite-youtube.js",
|
||||
"@videojs/http-streaming/dist/videojs-http-streaming.min.js": "./@videojs/http-streaming/dist/videojs-http-streaming.min.js",
|
||||
"@videojs/themes/fantasy/index.css": "./@videojs/themes/fantasy/index.css",
|
||||
"htm": "./htm.js",
|
||||
"preact": "./preact.js",
|
||||
"showdown": "./showdown.js",
|
||||
"tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css",
|
||||
"video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css",
|
||||
"video.js/dist/video.min.js": "./videojs/dist/video.min.js"
|
||||
}
|
||||
}
|
||||
Vendored
+3
File diff suppressed because one or more lines are too long
Vendored
+5044
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@ May have overrides for other components with own stylesheets.
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@@ -24,6 +25,9 @@ a:hover {
|
||||
width: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-hidden {
|
||||
scrollbar-width: none; /* moz only */
|
||||
}
|
||||
|
||||
|
||||
#app-container * {
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
.emoji-picker.owncast {
|
||||
--secondary-text-color: rgba(255,255,255,.5);
|
||||
--category-button-color: rgba(255,255,255,.5);
|
||||
--hover-color: rgba(255,255,255,.25);
|
||||
|
||||
background: rgba(26,32,44,1); /* tailwind bg-gray-900 */
|
||||
color: rgba(226,232,240,1); /* tailwind text-gray-300 */
|
||||
@@ -101,15 +102,19 @@
|
||||
}
|
||||
.emoji-picker__emojis::-webkit-scrollbar-track {
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0,0,0,.2);
|
||||
background-color: black;
|
||||
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.emoji-picker__emojis::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0,0,0,.45);
|
||||
background-color: var(--category-button-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.emoji-picker__emojis {
|
||||
scrollbar-color: var(--category-button-color) black;
|
||||
}
|
||||
|
||||
|
||||
/******************************/
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package yp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/utils"
|
||||
)
|
||||
|
||||
type ypDetailsResponse struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Logo string `json:"logo"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
Tags []string `json:"tags"`
|
||||
Online bool `json:"online"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
OverallMaxViewerCount int `json:"overallMaxViewerCount"`
|
||||
SessionMaxViewerCount int `json:"sessionMaxViewerCount"`
|
||||
|
||||
LastConnectTime utils.NullTime `json:"lastConnectTime"`
|
||||
}
|
||||
|
||||
//GetYPResponse gets the status of the server for YP purposes
|
||||
func GetYPResponse(w http.ResponseWriter, r *http.Request) {
|
||||
status := getStatus()
|
||||
|
||||
response := ypDetailsResponse{
|
||||
Name: config.Config.InstanceDetails.Name,
|
||||
Description: config.Config.InstanceDetails.Summary,
|
||||
Logo: config.Config.InstanceDetails.Logo.Large,
|
||||
NSFW: config.Config.InstanceDetails.NSFW,
|
||||
Tags: config.Config.InstanceDetails.Tags,
|
||||
Online: status.Online,
|
||||
ViewerCount: status.ViewerCount,
|
||||
OverallMaxViewerCount: status.OverallMaxViewerCount,
|
||||
SessionMaxViewerCount: status.SessionMaxViewerCount,
|
||||
LastConnectTime: status.LastConnectTime,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package yp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/models"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const pingInterval = 4 * time.Minute
|
||||
|
||||
var getStatus func() models.Status
|
||||
|
||||
//YP is a service for handling listing in the Owncast directory.
|
||||
type YP struct {
|
||||
timer *time.Ticker
|
||||
}
|
||||
|
||||
type ypPingResponse struct {
|
||||
Key string `json:"key"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"errorCode"`
|
||||
}
|
||||
|
||||
type ypPingRequest struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// NewYP creates a new instance of the YP service handler
|
||||
func NewYP(getStatusFunc func() models.Status) *YP {
|
||||
getStatus = getStatusFunc
|
||||
return &YP{}
|
||||
}
|
||||
|
||||
// Start is run when a live stream begins to start pinging YP
|
||||
func (yp *YP) Start() {
|
||||
yp.timer = time.NewTicker(pingInterval)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-yp.timer.C:
|
||||
yp.ping()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
yp.ping()
|
||||
}
|
||||
|
||||
// Stop stops the pinging of YP
|
||||
func (yp *YP) Stop() {
|
||||
yp.timer.Stop()
|
||||
}
|
||||
|
||||
func (yp *YP) ping() {
|
||||
myInstanceURL := config.Config.YP.InstanceURL
|
||||
key := yp.getSavedKey()
|
||||
|
||||
log.Traceln("Pinging YP as: ", config.Config.InstanceDetails.Name)
|
||||
|
||||
request := ypPingRequest{
|
||||
Key: key,
|
||||
URL: myInstanceURL,
|
||||
}
|
||||
|
||||
req, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
pingURL := config.Config.GetYPServiceHost() + "/ping"
|
||||
resp, err := http.Post(pingURL, "application/json", bytes.NewBuffer(req))
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
|
||||
pingResponse := ypPingResponse{}
|
||||
json.Unmarshal(body, &pingResponse)
|
||||
|
||||
if !pingResponse.Success {
|
||||
log.Debugln("YP Ping error returned from service:", pingResponse.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if pingResponse.Key != key {
|
||||
yp.writeSavedKey(pingResponse.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func (yp *YP) writeSavedKey(key string) {
|
||||
f, err := os.Create(".yp.key")
|
||||
defer f.Close()
|
||||
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = f.WriteString(key)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (yp *YP) getSavedKey() string {
|
||||
fileBytes, err := ioutil.ReadFile(".yp.key")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(fileBytes)
|
||||
}
|
||||
|
||||
// DisplayInstructions will let the user know they are not in the directory by default and
|
||||
// how they can enable the feature.
|
||||
func DisplayInstructions() {
|
||||
text := "Your instance can be listed on the Owncast directory at http://something.something by enabling YP in your config. Learn more at http://something.something."
|
||||
log.Debugln(text)
|
||||
}
|
||||
Reference in New Issue
Block a user