92 changed files with 68526 additions and 258 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
webroot/js/web_modules/* linguist-vendored |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
[submodule "doc"] |
||||
path = doc |
||||
url = https://github.com/owncast/owncast.github.io/ |
@ -0,0 +1,10 @@
@@ -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. |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
@@ -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" |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
package config |
||||
|
||||
import "path/filepath" |
||||
|
||||
const ( |
||||
WebRoot = "webroot" |
||||
PrivateHLSStoragePath = "hls" |
||||
) |
||||
|
||||
var ( |
||||
PublicHLSStoragePath = filepath.Join(WebRoot, "hls") |
||||
) |
@ -0,0 +1,35 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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) |
||||
} |
@ -0,0 +1,64 @@
@@ -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" |
||||
} |
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
package metrics |
||||
|
||||
import "time" |
||||
|
||||
type timestampedValue struct { |
||||
Time time.Time `json:"time"` |
||||
Value int `json:"value"` |
||||
} |
@ -0,0 +1,31 @@
@@ -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 @@
@@ -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 @@
@@ -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"` |
||||
} |
@ -0,0 +1,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 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,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 |
File diff suppressed because one or more lines are too long
@ -0,0 +1,301 @@
@@ -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 }; |
File diff suppressed because one or more lines are too long
@ -0,0 +1,116 @@
@@ -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 @@
@@ -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 @@
@@ -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 }; |
@ -0,0 +1,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; |
@ -0,0 +1,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" |
||||
} |
||||
} |
File diff suppressed because one or more lines are too long
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
@ -0,0 +1,46 @@
@@ -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 @@
@@ -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) |
||||
} |
Loading…
Reference in new issue