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
|
/stats.json
|
||||||
owncast
|
owncast
|
||||||
webroot/thumbnail.jpg
|
webroot/thumbnail.jpg
|
||||||
|
webroot/preview.gif
|
||||||
webroot/hls
|
webroot/hls
|
||||||
webroot/static/content.md
|
webroot/static/content.md
|
||||||
hls/
|
hls/
|
||||||
dist/
|
dist/
|
||||||
transcoder.log
|
transcoder.log
|
||||||
chat.db
|
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://goth.land/">View Demo</a>
|
||||||
·
|
·
|
||||||
<a href="https://owncast.online/docs/faq/">FAQ</a>
|
<a href="https://owncast.online/docs/faq/">FAQ</a>
|
||||||
.
|
·
|
||||||
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
||||||
</p>
|
</p>
|
||||||
</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:
|
videoSettings:
|
||||||
# Change this value and keep it secure. Treat it like a password to your live stream.
|
# Change this value and keep it secure. Treat it like a password to your live stream.
|
||||||
streamingKey: abc123
|
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"
|
"errors"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
@@ -15,17 +15,15 @@ var _default config
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
ChatDatabaseFilePath string `yaml:"chatDatabaseFile"`
|
ChatDatabaseFilePath string `yaml:"chatDatabaseFile"`
|
||||||
DisableWebFeatures bool `yaml:"disableWebFeatures"`
|
|
||||||
EnableDebugFeatures bool `yaml:"-"`
|
EnableDebugFeatures bool `yaml:"-"`
|
||||||
FFMpegPath string `yaml:"ffmpegPath"`
|
FFMpegPath string `yaml:"ffmpegPath"`
|
||||||
Files files `yaml:"files"`
|
Files files `yaml:"files"`
|
||||||
InstanceDetails InstanceDetails `yaml:"instanceDetails"`
|
InstanceDetails InstanceDetails `yaml:"instanceDetails"`
|
||||||
PrivateHLSPath string `yaml:"privateHLSPath"`
|
S3 S3 `yaml:"s3"`
|
||||||
PublicHLSPath string `yaml:"publicHLSPath"`
|
VersionInfo string `yaml:"-"` // For storing the version/build number
|
||||||
S3 s3 `yaml:"s3"`
|
|
||||||
VersionInfo string `yaml:"-"`
|
|
||||||
VideoSettings videoSettings `yaml:"videoSettings"`
|
VideoSettings videoSettings `yaml:"videoSettings"`
|
||||||
WebServerPort int `yaml:"webServerPort"`
|
WebServerPort int `yaml:"webServerPort"`
|
||||||
|
YP yp `yaml:"yp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceDetails defines the user-visible information about this particular instance.
|
// InstanceDetails defines the user-visible information about this particular instance.
|
||||||
@@ -33,11 +31,17 @@ type InstanceDetails struct {
|
|||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Title string `yaml:"title" json:"title"`
|
Title string `yaml:"title" json:"title"`
|
||||||
Summary string `yaml:"summary" json:"summary"`
|
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"`
|
Tags []string `yaml:"tags" json:"tags"`
|
||||||
SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"`
|
SocialHandles []socialHandle `yaml:"socialHandles" json:"socialHandles"`
|
||||||
ExtraInfoFile string `yaml:"extraUserInfoFileName" json:"extraUserInfoFileName"`
|
ExtraInfoFile string `yaml:"extraUserInfoFileName" json:"extraUserInfoFileName"`
|
||||||
Version string `json:"version"`
|
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 {
|
type socialHandle struct {
|
||||||
@@ -50,7 +54,14 @@ type videoSettings struct {
|
|||||||
StreamingKey string `yaml:"streamingKey"`
|
StreamingKey string `yaml:"streamingKey"`
|
||||||
StreamQualities []StreamQuality `yaml:"streamQualities"`
|
StreamQualities []StreamQuality `yaml:"streamQualities"`
|
||||||
OfflineContent string `yaml:"offlineContent"`
|
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.
|
// 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
|
// Enable passthrough to copy the video and/or audio directly from the
|
||||||
// incoming stream and disable any transcoding. It will ignore any of
|
// incoming stream and disable any transcoding. It will ignore any of
|
||||||
// the below settings.
|
// the below settings.
|
||||||
IsVideoPassthrough bool `yaml:"videoPassthrough"`
|
IsVideoPassthrough bool `yaml:"videoPassthrough" json:"videoPassthrough"`
|
||||||
IsAudioPassthrough bool `yaml:"audioPassthrough"`
|
IsAudioPassthrough bool `yaml:"audioPassthrough" json:"audioPassthrough"`
|
||||||
|
|
||||||
VideoBitrate int `yaml:"videoBitrate"`
|
VideoBitrate int `yaml:"videoBitrate" json:"videoBitrate"`
|
||||||
AudioBitrate int `yaml:"audioBitrate"`
|
AudioBitrate int `yaml:"audioBitrate" json:"audioBitrate"`
|
||||||
|
|
||||||
// Set only one of these in order to keep your current aspect ratio.
|
// Set only one of these in order to keep your current aspect ratio.
|
||||||
// Or set neither to not scale the video.
|
// Or set neither to not scale the video.
|
||||||
ScaledWidth int `yaml:"scaledWidth"`
|
ScaledWidth int `yaml:"scaledWidth" json:"scaledWidth,omitempty"`
|
||||||
ScaledHeight int `yaml:"scaledHeight"`
|
ScaledHeight int `yaml:"scaledHeight" json:"scaledHeight,omitempty"`
|
||||||
|
|
||||||
Framerate int `yaml:"framerate"`
|
Framerate int `yaml:"framerate" json:"framerate"`
|
||||||
EncoderPreset string `yaml:"encoderPreset"`
|
EncoderPreset string `yaml:"encoderPreset" json:"encoderPreset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type files struct {
|
type files struct {
|
||||||
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"`
|
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//s3 is for configuring the s3 integration
|
//S3 is for configuring the S3 integration
|
||||||
type s3 struct {
|
type S3 struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"`
|
||||||
ServingEndpoint string `yaml:"servingEndpoint"`
|
ServingEndpoint string `yaml:"servingEndpoint" json:"servingEndpoint,omitempty"`
|
||||||
AccessKey string `yaml:"accessKey"`
|
AccessKey string `yaml:"accessKey" json:"accessKey,omitempty"`
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret" json:"secret,omitempty"`
|
||||||
Bucket string `yaml:"bucket"`
|
Bucket string `yaml:"bucket" json:"bucket,omitempty"`
|
||||||
Region string `yaml:"region"`
|
Region string `yaml:"region" json:"region,omitempty"`
|
||||||
ACL string `yaml:"acl"`
|
ACL string `yaml:"acl" json:"acl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *config) load(filePath string) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,22 +155,6 @@ func (c *config) GetVideoSegmentSecondsLength() int {
|
|||||||
return _default.GetVideoSegmentSecondsLength()
|
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 {
|
func (c *config) GetPublicWebServerPort() int {
|
||||||
if c.WebServerPort != 0 {
|
if c.WebServerPort != 0 {
|
||||||
return c.WebServerPort
|
return c.WebServerPort
|
||||||
@@ -189,6 +188,14 @@ func (c *config) GetFFMpegPath() string {
|
|||||||
return _default.FFMpegPath
|
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 {
|
func (c *config) GetVideoStreamQualities() []StreamQuality {
|
||||||
if len(c.VideoSettings.StreamQualities) > 0 {
|
if len(c.VideoSettings.StreamQualities) > 0 {
|
||||||
return c.VideoSettings.StreamQualities
|
return c.VideoSettings.StreamQualities
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "sort"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
func findHighestQuality(qualities []StreamQuality) int {
|
func findHighestQuality(qualities []StreamQuality) int {
|
||||||
type IndexedQuality struct {
|
type IndexedQuality struct {
|
||||||
@@ -32,3 +35,15 @@ func findHighestQuality(qualities []StreamQuality) int {
|
|||||||
|
|
||||||
return indexedQualities[0].index
|
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.FFMpegPath = getDefaultFFMpegPath()
|
||||||
defaults.VideoSettings.ChunkLengthInSeconds = 4
|
defaults.VideoSettings.ChunkLengthInSeconds = 4
|
||||||
defaults.Files.MaxNumberInPlaylist = 5
|
defaults.Files.MaxNumberInPlaylist = 5
|
||||||
defaults.PublicHLSPath = "webroot/hls"
|
|
||||||
defaults.PrivateHLSPath = "hls"
|
|
||||||
defaults.VideoSettings.OfflineContent = "static/offline.m4v"
|
defaults.VideoSettings.OfflineContent = "static/offline.m4v"
|
||||||
defaults.InstanceDetails.ExtraInfoFile = "/static/content.md"
|
defaults.InstanceDetails.ExtraInfoFile = "/static/content.md"
|
||||||
|
defaults.YP.Enabled = false
|
||||||
|
defaults.YP.YPServiceURL = "https://yp.owncast.online"
|
||||||
|
|
||||||
defaultQuality := StreamQuality{
|
defaultQuality := StreamQuality{
|
||||||
IsAudioPassthrough: true,
|
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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gabek/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
//GetChatMessages gets all of the chat messages
|
//GetChatMessages gets all of the chat messages
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
//GetWebConfig gets the status of the server
|
//GetWebConfig gets the status of the server
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type j map[string]interface{}
|
type j map[string]interface{}
|
||||||
@@ -24,3 +26,12 @@ func badRequestHandler(w http.ResponseWriter, err error) {
|
|||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
json.NewEncoder(w).Encode(j{"error": err.Error()})
|
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"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/config"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const emojiPath = "/img/emoji" // Relative to webroot
|
|||||||
func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
|
func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
|
||||||
emojiList := make([]models.CustomEmoji, 0)
|
emojiList := make([]models.CustomEmoji, 0)
|
||||||
|
|
||||||
fullPath := filepath.Join("webroot", emojiPath)
|
fullPath := filepath.Join(config.WebRoot, emojiPath)
|
||||||
files, err := ioutil.ReadDir(fullPath)
|
files, err := ioutil.ReadDir(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
|
|||||||
+8
-14
@@ -5,15 +5,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/gabek/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MetadataPage struct {
|
type MetadataPage struct {
|
||||||
@@ -30,13 +31,6 @@ func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
isIndexRequest := r.URL.Path == "/" || r.URL.Path == "/index.html"
|
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
|
// For search engine bots and social scrapers return a special
|
||||||
// server-rendered page.
|
// server-rendered page.
|
||||||
if utils.IsUserAgentABot(r.UserAgent()) && isIndexRequest {
|
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
|
// Set a cache control max-age header
|
||||||
middleware.SetCachingHeaders(w, r)
|
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
|
// 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")))
|
tmpl := template.Must(template.ParseFiles(path.Join("static", "metadata.html")))
|
||||||
|
|
||||||
fullURL, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, r.URL.Path))
|
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()
|
status := core.GetStatus()
|
||||||
|
|
||||||
// If the thumbnail does not exist or we're offline then just use the logo image
|
// If the thumbnail does not exist or we're offline then just use the logo image
|
||||||
var thumbnailURL string
|
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"))
|
thumbnail, err := url.Parse(fmt.Sprintf("http://%s%s", r.Host, "/thumbnail.jpg"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gabek/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/gabek/owncast/router/middleware"
|
"github.com/owncast/owncast/router/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
//GetStatus gets the status of the server
|
//GetStatus gets the status of the server
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
//Setup sets up the chat server
|
//Setup sets up the chat server
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/net/websocket"
|
"golang.org/x/net/websocket"
|
||||||
|
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
|
|
||||||
"github.com/teris-io/shortid"
|
"github.com/teris-io/shortid"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-3
@@ -8,8 +8,8 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/net/websocket"
|
"golang.org/x/net/websocket"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -133,7 +133,7 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
|
|||||||
time.Sleep(7 * time.Second)
|
time.Sleep(7 * time.Second)
|
||||||
|
|
||||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
|
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", 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)
|
c.Write(initialMessage)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package core
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/gabek/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
//ChatListenerImpl the implementation of the chat client
|
//ChatListenerImpl the implementation of the chat client
|
||||||
|
|||||||
+26
-16
@@ -3,22 +3,26 @@ package core
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
"github.com/gabek/owncast/core/ffmpeg"
|
"github.com/owncast/owncast/core/ffmpeg"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
|
"github.com/owncast/owncast/yp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_stats *models.Stats
|
_stats *models.Stats
|
||||||
_storage models.ChunkStorageProvider
|
_storage models.ChunkStorageProvider
|
||||||
_cleanupTimer *time.Timer
|
_cleanupTimer *time.Timer
|
||||||
|
_yp *yp.YP
|
||||||
|
_broadcaster *models.Broadcaster
|
||||||
)
|
)
|
||||||
|
|
||||||
//Start starts up the core processing
|
//Start starts up the core processing
|
||||||
@@ -40,6 +44,12 @@ func Start() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Config.YP.Enabled {
|
||||||
|
_yp = yp.NewYP(GetStatus)
|
||||||
|
} else {
|
||||||
|
yp.DisplayInstructions()
|
||||||
|
}
|
||||||
|
|
||||||
chat.Setup(ChatListenerImpl{})
|
chat.Setup(ChatListenerImpl{})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -47,8 +57,8 @@ func Start() error {
|
|||||||
|
|
||||||
func createInitialOfflineState() error {
|
func createInitialOfflineState() error {
|
||||||
// Provide default files
|
// Provide default files
|
||||||
if !utils.DoesFileExists("webroot/thumbnail.jpg") {
|
if !utils.DoesFileExists(filepath.Join(config.WebRoot, "thumbnail.jpg")) {
|
||||||
if err := utils.Copy("static/logo.png", "webroot/thumbnail.jpg"); err != nil {
|
if err := utils.Copy("static/logo.png", filepath.Join(config.WebRoot, "thumbnail.jpg")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,22 +94,22 @@ func resetDirectories() {
|
|||||||
log.Trace("Resetting file directories to a clean slate.")
|
log.Trace("Resetting file directories to a clean slate.")
|
||||||
|
|
||||||
// Wipe the public, web-accessible hls data directory
|
// Wipe the public, web-accessible hls data directory
|
||||||
os.RemoveAll(config.Config.GetPublicHLSSavePath())
|
os.RemoveAll(config.PublicHLSStoragePath)
|
||||||
os.RemoveAll(config.Config.GetPrivateHLSSavePath())
|
os.RemoveAll(config.PrivateHLSStoragePath)
|
||||||
os.MkdirAll(config.Config.GetPublicHLSSavePath(), 0777)
|
os.MkdirAll(config.PublicHLSStoragePath, 0777)
|
||||||
os.MkdirAll(config.Config.GetPrivateHLSSavePath(), 0777)
|
os.MkdirAll(config.PrivateHLSStoragePath, 0777)
|
||||||
|
|
||||||
// Remove the previous thumbnail
|
// Remove the previous thumbnail
|
||||||
os.Remove("webroot/thumbnail.jpg")
|
os.Remove(filepath.Join(config.WebRoot, "thumbnail.jpg"))
|
||||||
|
|
||||||
// Create private hls data dirs
|
// Create private hls data dirs
|
||||||
if len(config.Config.VideoSettings.StreamQualities) != 0 {
|
if len(config.Config.VideoSettings.StreamQualities) != 0 {
|
||||||
for index := range config.Config.VideoSettings.StreamQualities {
|
for index := range config.Config.VideoSettings.StreamQualities {
|
||||||
os.MkdirAll(path.Join(config.Config.GetPrivateHLSSavePath(), strconv.Itoa(index)), 0777)
|
os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||||
os.MkdirAll(path.Join(config.Config.GetPublicHLSSavePath(), strconv.Itoa(index)), 0777)
|
os.MkdirAll(path.Join(config.PublicHLSStoragePath, strconv.Itoa(index)), 0777)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
os.MkdirAll(path.Join(config.Config.GetPrivateHLSSavePath(), strconv.Itoa(0)), 0777)
|
os.MkdirAll(path.Join(config.PrivateHLSStoragePath, strconv.Itoa(0)), 0777)
|
||||||
os.MkdirAll(path.Join(config.Config.GetPublicHLSSavePath(), strconv.Itoa(0)), 0777)
|
os.MkdirAll(path.Join(config.PublicHLSStoragePath, strconv.Itoa(0)), 0777)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
//ShowStreamOfflineState generates and shows the stream's offline state
|
//ShowStreamOfflineState generates and shows the stream's offline state
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
//StartThumbnailGenerator starts generating thumbnails
|
//StartThumbnailGenerator starts generating thumbnails
|
||||||
@@ -39,7 +39,8 @@ func StartThumbnailGenerator(chunkPath string, variantIndex int) {
|
|||||||
|
|
||||||
func fireThumbnailGenerator(chunkPath string, variantIndex int) error {
|
func fireThumbnailGenerator(chunkPath string, variantIndex int) error {
|
||||||
// JPG takes less time to encode than PNG
|
// 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))
|
framePath := path.Join(chunkPath, strconv.Itoa(variantIndex))
|
||||||
files, err := ioutil.ReadDir(framePath)
|
files, err := ioutil.ReadDir(framePath)
|
||||||
@@ -83,12 +84,32 @@ func fireThumbnailGenerator(chunkPath string, variantIndex int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ffmpegCmd := strings.Join(thumbnailCmdFlags, " ")
|
ffmpegCmd := strings.Join(thumbnailCmdFlags, " ")
|
||||||
|
|
||||||
// fmt.Println(ffmpegCmd)
|
|
||||||
|
|
||||||
if _, err := exec.Command("sh", "-c", ffmpegCmd).Output(); err != nil {
|
if _, err := exec.Command("sh", "-c", ffmpegCmd).Output(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If YP support is enabled also create an animated GIF preview
|
||||||
|
if config.Config.YP.Enabled {
|
||||||
|
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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"
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/teris-io/shortid"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _commandExec *exec.Cmd
|
var _commandExec *exec.Cmd
|
||||||
@@ -25,6 +26,7 @@ type Transcoder struct {
|
|||||||
segmentLengthSeconds int
|
segmentLengthSeconds int
|
||||||
appendToStream bool
|
appendToStream bool
|
||||||
ffmpegPath string
|
ffmpegPath string
|
||||||
|
segmentIdentifier string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HLSVariant is a combination of settings that results in a single HLS stream
|
// HLSVariant is a combination of settings that results in a single HLS stream
|
||||||
@@ -103,6 +105,10 @@ func (t *Transcoder) getString() string {
|
|||||||
hlsOptionFlags = append(hlsOptionFlags, "append_list")
|
hlsOptionFlags = append(hlsOptionFlags, "append_list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.segmentIdentifier == "" {
|
||||||
|
t.segmentIdentifier = shortid.MustGenerate()
|
||||||
|
}
|
||||||
|
|
||||||
ffmpegFlags := []string{
|
ffmpegFlags := []string{
|
||||||
"cat", t.input, "|",
|
"cat", t.input, "|",
|
||||||
t.ffmpegPath,
|
t.ffmpegPath,
|
||||||
@@ -125,7 +131,7 @@ func (t *Transcoder) getString() string {
|
|||||||
// Filenames
|
// Filenames
|
||||||
"-master_pl_name", "stream.m3u8",
|
"-master_pl_name", "stream.m3u8",
|
||||||
"-strftime 1", // Support the use of strftime in filenames
|
"-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
|
"-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
|
path.Join(t.segmentOutputPath, "/%v/stream.m3u8"), // Each variant's playlist
|
||||||
"2> transcoder.log",
|
"2> transcoder.log",
|
||||||
@@ -182,15 +188,15 @@ func NewTranscoder() Transcoder {
|
|||||||
var outputPath string
|
var outputPath string
|
||||||
if config.Config.S3.Enabled {
|
if config.Config.S3.Enabled {
|
||||||
// Segments are not available via the local HTTP server
|
// Segments are not available via the local HTTP server
|
||||||
outputPath = config.Config.GetPrivateHLSSavePath()
|
outputPath = config.PrivateHLSStoragePath
|
||||||
} else {
|
} else {
|
||||||
// Segments are available via the local HTTP server
|
// Segments are available via the local HTTP server
|
||||||
outputPath = config.Config.GetPublicHLSSavePath()
|
outputPath = config.PublicHLSStoragePath
|
||||||
}
|
}
|
||||||
|
|
||||||
transcoder.segmentOutputPath = outputPath
|
transcoder.segmentOutputPath = outputPath
|
||||||
// Playlists are available via the local HTTP server
|
// Playlists are available via the local HTTP server
|
||||||
transcoder.playlistOutputPath = config.Config.GetPublicHLSSavePath()
|
transcoder.playlistOutputPath = config.PublicHLSStoragePath
|
||||||
|
|
||||||
transcoder.input = utils.GetTemporaryPipePath()
|
transcoder.input = utils.GetTemporaryPipePath()
|
||||||
transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength()
|
transcoder.segmentLengthSeconds = config.Config.GetVideoSegmentSecondsLength()
|
||||||
@@ -353,3 +359,8 @@ func (t *Transcoder) SetSegmentLength(seconds int) {
|
|||||||
func (t *Transcoder) SetAppendToStream(append bool) {
|
func (t *Transcoder) SetAppendToStream(append bool) {
|
||||||
t.appendToStream = append
|
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.SetInput("fakecontent.flv")
|
||||||
transcoder.SetOutputPath("fakeOutput")
|
transcoder.SetOutputPath("fakeOutput")
|
||||||
transcoder.SetHLSPlaylistLength(10)
|
transcoder.SetHLSPlaylistLength(10)
|
||||||
|
transcoder.SetIdentifier("jdofFGg")
|
||||||
|
|
||||||
variant := HLSVariant{}
|
variant := HLSVariant{}
|
||||||
variant.videoBitrate = 1200
|
variant.videoBitrate = 1200
|
||||||
@@ -22,7 +23,7 @@ func TestFFmpegCommand(t *testing.T) {
|
|||||||
|
|
||||||
cmd := transcoder.getString()
|
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 {
|
if cmd != expected {
|
||||||
t.Errorf("ffmpeg command does not match expected. Got %s, want: %s", 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/radovskyb/watcher"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -26,7 +26,7 @@ var (
|
|||||||
func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
||||||
_storage = storage
|
_storage = storage
|
||||||
|
|
||||||
pathToMonitor := config.Config.GetPrivateHLSSavePath()
|
pathToMonitor := config.PrivateHLSStoragePath
|
||||||
|
|
||||||
// Create at least one structure to store the segments for the different stream variants
|
// Create at least one structure to store the segments for the different stream variants
|
||||||
variants = make([]models.Variant, len(config.Config.VideoSettings.StreamQualities))
|
variants = make([]models.Variant, len(config.Config.VideoSettings.StreamQualities))
|
||||||
@@ -63,11 +63,9 @@ func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// fmt.Println(event.Op, relativePath)
|
|
||||||
|
|
||||||
// Handle updates to the master playlist by copying it to webroot
|
// Handle updates to the master playlist by copying it to webroot
|
||||||
if relativePath == path.Join(config.Config.GetPrivateHLSSavePath(), "stream.m3u8") {
|
if relativePath == path.Join(config.PrivateHLSStoragePath, "stream.m3u8") {
|
||||||
utils.Copy(event.Path, path.Join(config.Config.GetPublicHLSSavePath(), "stream.m3u8"))
|
utils.Copy(event.Path, path.Join(config.PublicHLSStoragePath, "stream.m3u8"))
|
||||||
|
|
||||||
} else if filepath.Ext(event.Path) == ".m3u8" {
|
} else if filepath.Ext(event.Path) == ".m3u8" {
|
||||||
// Handle updates to playlists, but not the master playlist
|
// Handle updates to playlists, but not the master playlist
|
||||||
@@ -82,7 +80,7 @@ func StartVideoContentMonitor(storage models.ChunkStorageProvider) error {
|
|||||||
|
|
||||||
newObjectPathChannel := make(chan string, 1)
|
newObjectPathChannel := make(chan string, 1)
|
||||||
go func() {
|
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 {
|
if err != nil {
|
||||||
log.Errorln("failed to save the file to the chunk storage.", err)
|
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 := string(playlistBytes)
|
||||||
playlistString = _storage.GenerateRemotePlaylist(playlistString, variant)
|
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"
|
"github.com/nareix/joy5/format/flv/flvio"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/gabek/owncast/core/ffmpeg"
|
"github.com/owncast/owncast/core/ffmpeg"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/models"
|
||||||
|
"github.com/owncast/owncast/utils"
|
||||||
"github.com/nareix/joy5/format/rtmp"
|
"github.com/nareix/joy5/format/rtmp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ var (
|
|||||||
|
|
||||||
var _transcoder ffmpeg.Transcoder
|
var _transcoder ffmpeg.Transcoder
|
||||||
var _pipe *os.File
|
var _pipe *os.File
|
||||||
|
var _rtmpConnection net.Conn
|
||||||
|
|
||||||
//Start starts the rtmp service, listening on port 1935
|
//Start starts the rtmp service, listening on port 1935
|
||||||
func Start() {
|
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) {
|
func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
||||||
c.LogTagEvent = func(isRead bool, t flvio.Tag) {
|
c.LogTagEvent = func(isRead bool, t flvio.Tag) {
|
||||||
if t.Type == flvio.TAG_AMF0 {
|
if t.Type == flvio.TAG_AMF0 {
|
||||||
log.Tracef("%+v\n", t.DebugFields())
|
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
|
_isConnected = true
|
||||||
core.SetStreamAsConnected()
|
core.SetStreamAsConnected()
|
||||||
|
_rtmpConnection = nc
|
||||||
|
|
||||||
f, err := os.OpenFile(pipePath, os.O_RDWR, os.ModeNamedPipe)
|
f, err := os.OpenFile(pipePath, os.O_RDWR, os.ModeNamedPipe)
|
||||||
_pipe = f
|
_pipe = f
|
||||||
@@ -121,9 +150,20 @@ func handleDisconnect(conn net.Conn) {
|
|||||||
_pipe.Close()
|
_pipe.Close()
|
||||||
_isConnected = false
|
_isConnected = false
|
||||||
_transcoder.Stop()
|
_transcoder.Stop()
|
||||||
|
_rtmpConnection = nil
|
||||||
core.SetStreamAsDisconnected()
|
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
|
//IsConnected gets whether there is an rtmp connection or not
|
||||||
//this is only a getter since it is controlled by the rtmp handler
|
//this is only a getter since it is controlled by the rtmp handler
|
||||||
func IsConnected() bool {
|
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"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
+24
-6
@@ -3,10 +3,10 @@ package core
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core/ffmpeg"
|
"github.com/owncast/owncast/core/ffmpeg"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
//GetStatus gets the status of the system
|
//GetStatus gets the status of the system
|
||||||
@@ -33,9 +33,13 @@ func SetStreamAsConnected() {
|
|||||||
_stats.LastConnectTime = utils.NullTime{time.Now(), true}
|
_stats.LastConnectTime = utils.NullTime{time.Now(), true}
|
||||||
_stats.LastDisconnectTime = utils.NullTime{time.Now(), false}
|
_stats.LastDisconnectTime = utils.NullTime{time.Now(), false}
|
||||||
|
|
||||||
chunkPath := config.Config.GetPublicHLSSavePath()
|
chunkPath := config.PublicHLSStoragePath
|
||||||
if usingExternalStorage {
|
if usingExternalStorage {
|
||||||
chunkPath = config.Config.GetPrivateHLSSavePath()
|
chunkPath = config.PrivateHLSStoragePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if _yp != nil {
|
||||||
|
_yp.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpeg.StartThumbnailGenerator(chunkPath, config.Config.VideoSettings.HighestQualityStreamIndex)
|
ffmpeg.StartThumbnailGenerator(chunkPath, config.Config.VideoSettings.HighestQualityStreamIndex)
|
||||||
@@ -45,7 +49,21 @@ func SetStreamAsConnected() {
|
|||||||
func SetStreamAsDisconnected() {
|
func SetStreamAsDisconnected() {
|
||||||
_stats.StreamConnected = false
|
_stats.StreamConnected = false
|
||||||
_stats.LastDisconnectTime = utils.NullTime{time.Now(), true}
|
_stats.LastDisconnectTime = utils.NullTime{time.Now(), true}
|
||||||
|
_broadcaster = nil
|
||||||
|
|
||||||
|
if _yp != nil {
|
||||||
|
_yp.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
ffmpeg.ShowStreamOfflineState()
|
ffmpeg.ShowStreamOfflineState()
|
||||||
startCleanupTimer()
|
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
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core/playlist"
|
"github.com/owncast/owncast/core/playlist"
|
||||||
"github.com/gabek/owncast/core/storageproviders"
|
"github.com/owncast/owncast/core/storageproviders"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
//S3Storage is the s3 implementation of the ChunkStorageProvider
|
//S3Storage is the s3 implementation of the ChunkStorageProvider
|
||||||
@@ -94,7 +94,7 @@ func (s *S3Storage) GenerateRemotePlaylist(playlist string, variant models.Varia
|
|||||||
if fullRemotePath == nil {
|
if fullRemotePath == nil {
|
||||||
line = ""
|
line = ""
|
||||||
} else if s.s3ServingEndpoint != "" {
|
} 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 {
|
} else {
|
||||||
line = fullRemotePath.RemoteID
|
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
|
go 1.14
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/mssola/user_agent v0.5.2
|
github.com/mssola/user_agent v0.5.2
|
||||||
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||||
|
github.com/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1
|
||||||
github.com/radovskyb/watcher v1.0.7
|
github.com/radovskyb/watcher v1.0.7
|
||||||
github.com/shirou/gopsutil v2.20.7+incompatible
|
github.com/shirou/gopsutil v2.20.7+incompatible
|
||||||
github.com/sirupsen/logrus v1.6.0
|
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/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 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
|
||||||
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
|
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=
|
github.com/shirou/gopsutil v2.20.7+incompatible h1:Ymv4OD12d6zm+2yONe39VSmp2XooJe8za7ngOLW/o/w=
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/core"
|
"github.com/owncast/owncast/core"
|
||||||
"github.com/gabek/owncast/metrics"
|
"github.com/owncast/owncast/metrics"
|
||||||
"github.com/gabek/owncast/router"
|
"github.com/owncast/owncast/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// the following are injected at build-time
|
// the following are injected at build-time
|
||||||
|
|||||||
+18
-4
@@ -6,12 +6,14 @@ import (
|
|||||||
|
|
||||||
const maxCPUAlertingThresholdPCT = 95
|
const maxCPUAlertingThresholdPCT = 95
|
||||||
const maxRAMAlertingThresholdPCT = 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() {
|
func handleAlerting() {
|
||||||
handleCPUAlerting()
|
handleCPUAlerting()
|
||||||
handleRAMAlerting()
|
handleRAMAlerting()
|
||||||
|
handleDiskAlerting()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCPUAlerting() {
|
func handleCPUAlerting() {
|
||||||
@@ -21,7 +23,7 @@ func handleCPUAlerting() {
|
|||||||
|
|
||||||
avg := recentAverage(Metrics.CPUUtilizations)
|
avg := recentAverage(Metrics.CPUUtilizations)
|
||||||
if avg > maxCPUAlertingThresholdPCT {
|
if avg > maxCPUAlertingThresholdPCT {
|
||||||
log.Errorf(alertingError, "CPU", avg, maxCPUAlertingThresholdPCT)
|
log.Errorf(alertingError, "CPU", maxCPUAlertingThresholdPCT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +34,22 @@ func handleRAMAlerting() {
|
|||||||
|
|
||||||
avg := recentAverage(Metrics.RAMUtilizations)
|
avg := recentAverage(Metrics.RAMUtilizations)
|
||||||
if avg > maxRAMAlertingThresholdPCT {
|
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)
|
return int((values[len(values)-1].Value + values[len(values)-2].Value) / 2)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/cpu"
|
"github.com/shirou/gopsutil/cpu"
|
||||||
|
"github.com/shirou/gopsutil/disk"
|
||||||
"github.com/shirou/gopsutil/mem"
|
"github.com/shirou/gopsutil/mem"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ func collectCPUUtilization() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metricValue := value{time.Now(), int(v[0])}
|
metricValue := timestampedValue{time.Now(), int(v[0])}
|
||||||
Metrics.CPUUtilizations = append(Metrics.CPUUtilizations, metricValue)
|
Metrics.CPUUtilizations = append(Metrics.CPUUtilizations, metricValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,18 @@ func collectRAMUtilization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
memoryUsage, _ := mem.VirtualMemory()
|
memoryUsage, _ := mem.VirtualMemory()
|
||||||
metricValue := value{time.Now(), int(memoryUsage.UsedPercent)}
|
metricValue := timestampedValue{time.Now(), int(memoryUsage.UsedPercent)}
|
||||||
Metrics.RAMUtilizations = append(Metrics.RAMUtilizations, metricValue)
|
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
|
// How often we poll for updates
|
||||||
const metricsPollingInterval = 15 * time.Second
|
const metricsPollingInterval = 1 * time.Minute
|
||||||
|
|
||||||
type value struct {
|
// CollectedMetrics stores different collected + timestamped values
|
||||||
Time time.Time
|
type CollectedMetrics struct {
|
||||||
Value int
|
CPUUtilizations []timestampedValue `json:"cpu"`
|
||||||
}
|
RAMUtilizations []timestampedValue `json:"memory"`
|
||||||
|
DiskUtilizations []timestampedValue `json:"disk"`
|
||||||
|
|
||||||
type metrics struct {
|
Viewers []timestampedValue `json:"-"`
|
||||||
CPUUtilizations []value
|
|
||||||
RAMUtilizations []value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics is the shared Metrics instance
|
// Metrics is the shared Metrics instance
|
||||||
var Metrics *metrics
|
var Metrics *CollectedMetrics
|
||||||
|
|
||||||
// Start will begin the metrics collection and alerting
|
// Start will begin the metrics collection and alerting
|
||||||
func Start() {
|
func Start() {
|
||||||
Metrics = new(metrics)
|
Metrics = new(CollectedMetrics)
|
||||||
|
go startViewerCollectionMetrics()
|
||||||
|
handlePolling()
|
||||||
|
|
||||||
for range time.Tick(metricsPollingInterval) {
|
for range time.Tick(metricsPollingInterval) {
|
||||||
handlePolling()
|
handlePolling()
|
||||||
@@ -33,6 +34,7 @@ func handlePolling() {
|
|||||||
// Collect hardware stats
|
// Collect hardware stats
|
||||||
collectCPUUtilization()
|
collectCPUUtilization()
|
||||||
collectRAMUtilization()
|
collectRAMUtilization()
|
||||||
|
collectDiskUtilization()
|
||||||
|
|
||||||
// Alerting
|
// Alerting
|
||||||
handleAlerting()
|
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 (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabek/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
//Stats holds the stats for the system
|
//Stats holds the stats for the system
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "github.com/gabek/owncast/utils"
|
import "github.com/owncast/owncast/utils"
|
||||||
|
|
||||||
//Status represents the status of the system
|
//Status represents the status of the system
|
||||||
type Status struct {
|
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
|
// Default cache length in seconds
|
||||||
return 30 * 60
|
return 30
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -6,10 +6,14 @@ import (
|
|||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/gabek/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/gabek/owncast/controllers"
|
"github.com/owncast/owncast/controllers"
|
||||||
"github.com/gabek/owncast/core/chat"
|
"github.com/owncast/owncast/controllers/admin"
|
||||||
"github.com/gabek/owncast/core/rtmp"
|
|
||||||
|
"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
|
//Start starts the router for the http, ws, and rtmp
|
||||||
@@ -26,7 +30,6 @@ func Start() error {
|
|||||||
// custom emoji supported in the chat
|
// custom emoji supported in the chat
|
||||||
http.HandleFunc("/api/emoji", controllers.GetCustomEmoji)
|
http.HandleFunc("/api/emoji", controllers.GetCustomEmoji)
|
||||||
|
|
||||||
if !config.Config.DisableWebFeatures {
|
|
||||||
// websocket chat server
|
// websocket chat server
|
||||||
go chat.Start()
|
go chat.Start()
|
||||||
|
|
||||||
@@ -41,7 +44,28 @@ func Start() error {
|
|||||||
|
|
||||||
// video embed
|
// video embed
|
||||||
http.HandleFunc("/embed/video", controllers.GetVideoEmbed)
|
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()
|
port := config.Config.GetPublicWebServerPort()
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -52,7 +52,7 @@ build() {
|
|||||||
|
|
||||||
pushd dist/${NAME} >> /dev/null
|
pushd dist/${NAME} >> /dev/null
|
||||||
|
|
||||||
CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildType=${NAME}" -targets "${OS}/${ARCH}" github.com/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
|
mv owncast-*-${ARCH} owncast
|
||||||
|
|
||||||
zip -r -q -8 ../owncast-$NAME-$VERSION.zip .
|
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
|
# On macOS open the Github page for new releases so they can be uploaded
|
||||||
if test -f "/usr/bin/open"; then
|
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
|
open dist
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ cd $(git rev-parse --show-toplevel)
|
|||||||
|
|
||||||
# Github Packages
|
# 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 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 tag $DOCKER_IMAGE docker.pkg.github.com/owncast/owncast/$DOCKER_IMAGE:$VERSION
|
||||||
docker push docker.pkg.github.com/gabek/owncast/$DOCKER_IMAGE:$VERSION
|
docker push docker.pkg.github.com/owncast/owncast/$DOCKER_IMAGE:$VERSION
|
||||||
#
|
#
|
||||||
# Dockerhub
|
# Dockerhub
|
||||||
# You must be authenticated via `docker login` with your Dockerhub credentials first.
|
# 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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
<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/chat.css" rel="stylesheet" />
|
||||||
<link href="./styles/standalone-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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="messages-only"></div>
|
<div id="messages-only"></div>
|
||||||
|
|
||||||
<script type="module">
|
<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';
|
import StandaloneChat from './js/app-standalone-chat.js';
|
||||||
render(
|
render(
|
||||||
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
||||||
|
|||||||
@@ -3,17 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
<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="/js/web_modules/videojs/dist/video-js.min.css" rel="stylesheet"/>
|
||||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
<link href="/js/web_modules/@videojs/themes/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="./styles/video.css" rel="stylesheet" />
|
<link href="./styles/video.css" rel="stylesheet" />
|
||||||
<link href="./styles/video-only.css" rel="stylesheet" />
|
<link href="./styles/video-only.css" rel="stylesheet" />
|
||||||
|
|
||||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -21,7 +17,10 @@
|
|||||||
<div id="video-only"></div>
|
<div id="video-only"></div>
|
||||||
|
|
||||||
<script type="module">
|
<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';
|
import VideoOnly from './js/app-video-only.js';
|
||||||
render(html`<${VideoOnly} />`, document.getElementById("video-only"));
|
render(html`<${VideoOnly} />`, document.getElementById("video-only"));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+11
-20
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<title>Owncast</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
<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="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
<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" 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/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.css" rel="stylesheet" />
|
||||||
<link href="./styles/chat.css" rel="stylesheet" />
|
<link href="./styles/chat.css" rel="stylesheet" />
|
||||||
<link href="./styles/user-content.css" rel="stylesheet" />
|
<link href="./styles/user-content.css" rel="stylesheet" />
|
||||||
<link href="./styles/app.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>
|
</head>
|
||||||
|
|
||||||
<body class="scrollbar-hidden bg-gray-300 text-gray-800">
|
<body class="scrollbar-hidden bg-gray-300 text-gray-800">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
<script type="module">
|
<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';
|
import App from './js/app.js';
|
||||||
render(html`<${App} />`, document.getElementById("app"));
|
render(html`<${App} />`, document.getElementById("app"));
|
||||||
</script>
|
</script>
|
||||||
@@ -77,7 +68,7 @@
|
|||||||
<img src="https://owncast.online/images/logo.png" />
|
<img src="https://owncast.online/images/logo.png" />
|
||||||
<br/>
|
<br/>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
import { h, Component } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
import Chat from './components/chat/chat.js';
|
import Chat from './components/chat/chat.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
import { h, Component } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
import { OwncastPlayer } from './components/player.js';
|
import { OwncastPlayer } from './components/player.js';
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
import { h, Component } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
import showdown from '/js/web_modules/showdown.js';
|
||||||
|
|
||||||
import { OwncastPlayer } from './components/player.js';
|
import { OwncastPlayer } from './components/player.js';
|
||||||
import SocialIcon from './components/social.js';
|
import SocialIcon from './components/social.js';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
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 ContentEditable, { replaceCaret } from './content-editable.js';
|
||||||
import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste } from '../../utils/chat.js';
|
import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste } from '../../utils/chat.js';
|
||||||
import { getLocalStorage, setLocalStorage, classNames } from '../../utils/helpers.js';
|
import { getLocalStorage, setLocalStorage, classNames } from '../../utils/helpers.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
import Message from './message.js';
|
import Message from './message.js';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ and here:
|
|||||||
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
|
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) {
|
export function replaceCaret(el) {
|
||||||
// Place the caret at the end of the element
|
// Place the caret at the end of the element
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
import { h, Component } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
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 { generateAvatar } from '../../utils/helpers.js';
|
||||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||||
|
|
||||||
@@ -13,9 +13,10 @@ export default class Message extends Component {
|
|||||||
const { type } = message;
|
const { type } = message;
|
||||||
|
|
||||||
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||||
const { image, author, body } = message;
|
const { image, author, body, timestamp } = message;
|
||||||
const formattedMessage = formatMessageText(body, username);
|
const formattedMessage = formatMessageText(body, username);
|
||||||
const avatar = image || generateAvatar(author);
|
const avatar = image || generateAvatar(author);
|
||||||
|
const formattedTimestamp = formatTimestamp(timestamp);
|
||||||
|
|
||||||
const authorColor = messageBubbleColorForString(author);
|
const authorColor = messageBubbleColorForString(author);
|
||||||
const avatarBgColor = { backgroundColor: authorColor };
|
const avatarBgColor = { backgroundColor: authorColor };
|
||||||
@@ -35,6 +36,7 @@ export default class Message extends Component {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="message-text text-gray-300 font-normal overflow-y-hidden"
|
class="message-text text-gray-300 font-normal overflow-y-hidden"
|
||||||
|
title=${formattedTimestamp}
|
||||||
dangerouslySetInnerHTML=${
|
dangerouslySetInnerHTML=${
|
||||||
{ __html: formattedMessage }
|
{ __html: formattedMessage }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
import { generateAvatar, setLocalStorage } from '../../utils/helpers.js';
|
import { generateAvatar, setLocalStorage } from '../../utils/helpers.js';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// https://docs.videojs.com/player
|
// https://docs.videojs.com/player
|
||||||
|
|
||||||
|
import videojs from '/js/web_modules/videojs/dist/video.min.js';
|
||||||
|
|
||||||
const VIDEO_ID = 'video';
|
const VIDEO_ID = 'video';
|
||||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||||
const URL_STREAM = `/hls/stream.m3u8`;
|
const URL_STREAM = `/hls/stream.m3u8`;
|
||||||
@@ -53,13 +55,14 @@ class OwncastPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
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);
|
const cachebuster = Math.round(new Date().getTime() / 1000);
|
||||||
options.uri = `${options.uri}?cachebust=${cachebuster}`;
|
options.uri = `${options.uri}?cachebust=${cachebuster}`;
|
||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS);
|
|
||||||
this.addAirplay();
|
this.addAirplay();
|
||||||
this.vjsPlayer.ready(this.handleReady);
|
this.vjsPlayer.ready(this.handleReady);
|
||||||
}
|
}
|
||||||
@@ -76,11 +79,11 @@ class OwncastPlayer {
|
|||||||
// play
|
// play
|
||||||
startPlayer() {
|
startPlayer() {
|
||||||
this.log('Start playing');
|
this.log('Start playing');
|
||||||
const source = { ...VIDEO_SRC }
|
const source = { ...VIDEO_SRC };
|
||||||
this.vjsPlayer.volume(localStorage.getItem('owncastVolume'));
|
this.vjsPlayer.volume(localStorage.getItem('owncastVolume'));
|
||||||
this.vjsPlayer.src(source);
|
this.vjsPlayer.src(source);
|
||||||
// this.vjsPlayer.play();
|
// this.vjsPlayer.play();
|
||||||
};
|
}
|
||||||
|
|
||||||
handleReady() {
|
handleReady() {
|
||||||
this.log('on Ready');
|
this.log('on Ready');
|
||||||
@@ -124,7 +127,7 @@ class OwncastPlayer {
|
|||||||
|
|
||||||
setPoster() {
|
setPoster() {
|
||||||
const cachebuster = Math.round(new Date().getTime() / 1000);
|
const cachebuster = Math.round(new Date().getTime() / 1000);
|
||||||
const poster = POSTER_THUMB + "?okhi=" + cachebuster;
|
const poster = POSTER_THUMB + '?okhi=' + cachebuster;
|
||||||
|
|
||||||
this.vjsPlayer.poster(poster);
|
this.vjsPlayer.poster(poster);
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,6 @@ class OwncastPlayer {
|
|||||||
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||||
var videoJsButtonClass = videojs.getComponent('Button');
|
var videoJsButtonClass = videojs.getComponent('Button');
|
||||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
||||||
|
|
||||||
// The `init()` method will also work for constructor logic here, but it is
|
// The `init()` method will also work for constructor logic here, but it is
|
||||||
// deprecated. If you provide an `init()` method, it will override the
|
// deprecated. If you provide an `init()` method, it will override the
|
||||||
// `constructor()` method!
|
// `constructor()` method!
|
||||||
@@ -152,8 +154,10 @@ class OwncastPlayer {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass());
|
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(
|
||||||
concreteButtonInstance.addClass("vjs-airplay");
|
new concreteButtonClass()
|
||||||
|
);
|
||||||
|
concreteButtonInstance.addClass('vjs-airplay');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h } from 'https://unpkg.com/preact?module';
|
import { h } from '/js/web_modules/preact.js';
|
||||||
import htm from 'https://unpkg.com/htm?module';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
import { SOCIAL_PLATFORMS } from '../utils/social.js';
|
import { SOCIAL_PLATFORMS } from '../utils/social.js';
|
||||||
import { classNames } from '../utils/helpers.js';
|
import { classNames } from '../utils/helpers.js';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CHAT_PLACEHOLDER_OFFLINE,
|
CHAT_PLACEHOLDER_OFFLINE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
|
||||||
|
import showdown from '/js/web_modules/showdown.js';
|
||||||
export function formatMessageText(message, username) {
|
export function formatMessageText(message, username) {
|
||||||
showdown.setFlavor('github');
|
showdown.setFlavor('github');
|
||||||
let formattedText = new showdown.Converter({
|
let formattedText = new showdown.Converter({
|
||||||
@@ -278,3 +279,18 @@ export function convertOnPaste( event = { preventDefault() {} }) {
|
|||||||
document.execCommand('insertText', false, value);
|
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 {
|
html {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -24,6 +25,9 @@ a:hover {
|
|||||||
width: 0px;
|
width: 0px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
.scrollbar-hidden {
|
||||||
|
scrollbar-width: none; /* moz only */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#app-container * {
|
#app-container * {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
.emoji-picker.owncast {
|
.emoji-picker.owncast {
|
||||||
--secondary-text-color: rgba(255,255,255,.5);
|
--secondary-text-color: rgba(255,255,255,.5);
|
||||||
--category-button-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 */
|
background: rgba(26,32,44,1); /* tailwind bg-gray-900 */
|
||||||
color: rgba(226,232,240,1); /* tailwind text-gray-300 */
|
color: rgba(226,232,240,1); /* tailwind text-gray-300 */
|
||||||
@@ -101,15 +102,19 @@
|
|||||||
}
|
}
|
||||||
.emoji-picker__emojis::-webkit-scrollbar-track {
|
.emoji-picker__emojis::-webkit-scrollbar-track {
|
||||||
border-radius: 8px;
|
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);
|
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-picker__emojis::-webkit-scrollbar-thumb {
|
.emoji-picker__emojis::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(0,0,0,.45);
|
background-color: var(--category-button-color);
|
||||||
border-radius: 8px;
|
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