Browse Source
* First pass at restructuring the project; untested but it does compile
* Restructure builds and runs 🎉
* Add the dist folder to the gitignore
* Update core/playlist/monitor.go
* golint and reorganize the monitor.go file
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
pull/23/head
42 changed files with 1309 additions and 1000 deletions
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
package main |
||||
|
||||
type ChunkStorage interface { |
||||
Setup(config Config) |
||||
Save(filePath string, retryCount int) string |
||||
GenerateRemotePlaylist(playlist string, variant Variant) string |
||||
} |
@ -1,112 +0,0 @@
@@ -1,112 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/ioutil" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"gopkg.in/yaml.v2" |
||||
) |
||||
|
||||
// Config struct
|
||||
type Config struct { |
||||
IPFS IPFS `yaml:"ipfs"` |
||||
PublicHLSPath string `yaml:"publicHLSPath"` |
||||
PrivateHLSPath string `yaml:"privateHLSPath"` |
||||
VideoSettings VideoSettings `yaml:"videoSettings"` |
||||
Files Files `yaml:"files"` |
||||
FFMpegPath string `yaml:"ffmpegPath"` |
||||
WebServerPort int `yaml:"webServerPort"` |
||||
S3 S3 `yaml:"s3"` |
||||
EnableOfflineImage bool `yaml:"enableOfflineImage"` |
||||
} |
||||
|
||||
type VideoSettings struct { |
||||
ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"` |
||||
StreamingKey string `yaml:"streamingKey"` |
||||
EncoderPreset string `yaml:"encoderPreset"` |
||||
StreamQualities []StreamQuality `yaml:"streamQualities"` |
||||
EnablePassthrough bool `yaml:"passthrough"` |
||||
OfflineImage string `yaml:"offlineImage"` |
||||
} |
||||
|
||||
type StreamQuality struct { |
||||
Bitrate int `yaml:"bitrate"` |
||||
} |
||||
|
||||
// MaxNumberOnDisk must be at least as large as MaxNumberInPlaylist
|
||||
type Files struct { |
||||
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"` |
||||
} |
||||
|
||||
type IPFS struct { |
||||
Enabled bool `yaml:"enabled"` |
||||
Gateway string `yaml:"gateway"` |
||||
} |
||||
|
||||
type S3 struct { |
||||
Enabled bool `yaml:"enabled"` |
||||
Endpoint string `yaml:"endpoint"` |
||||
AccessKey string `yaml:"accessKey"` |
||||
Secret string `yaml:"secret"` |
||||
Bucket string `yaml:"bucket"` |
||||
Region string `yaml:"region"` |
||||
} |
||||
|
||||
func getConfig() Config { |
||||
filePath := "config/config.yaml" |
||||
|
||||
if !fileExists(filePath) { |
||||
log.Fatal("ERROR: valid config/config.yaml is required. Copy config/config-example.yaml to config/config.yaml and edit.") |
||||
} |
||||
|
||||
yamlFile, err := ioutil.ReadFile(filePath) |
||||
|
||||
var config Config |
||||
err = yaml.Unmarshal(yamlFile, &config) |
||||
if err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
// fmt.Printf("%+v\n", config)
|
||||
|
||||
return config |
||||
} |
||||
|
||||
func checkConfig(config Config) { |
||||
if config.S3.Enabled && config.IPFS.Enabled { |
||||
log.Panicln("S3 and IPFS support cannot be enabled at the same time. Choose one.") |
||||
} |
||||
|
||||
if config.S3.Enabled { |
||||
if config.S3.AccessKey == "" || config.S3.Secret == "" { |
||||
log.Panicln("S3 support requires an access key and secret.") |
||||
} |
||||
|
||||
if config.S3.Region == "" || config.S3.Endpoint == "" { |
||||
log.Panicln("S3 support requires a region and endpoint.") |
||||
} |
||||
|
||||
if config.S3.Bucket == "" { |
||||
log.Panicln("S3 support requires a bucket created for storing public video segments.") |
||||
} |
||||
} |
||||
|
||||
// if !fileExists(config.PrivateHLSPath) {
|
||||
// os.MkdirAll(path.Join(config.PrivateHLSPath, strconv.Itoa(0)), 0777)
|
||||
// }
|
||||
|
||||
// if !fileExists(config.PublicHLSPath) {
|
||||
// os.MkdirAll(path.Join(config.PublicHLSPath, strconv.Itoa(0)), 0777)
|
||||
// }
|
||||
|
||||
if !fileExists(config.FFMpegPath) { |
||||
log.Panicln(fmt.Sprintf("ffmpeg does not exist at %s.", config.FFMpegPath)) |
||||
} |
||||
|
||||
if config.VideoSettings.EncoderPreset == "" { |
||||
log.Panicln("A video encoder preset is required to be set in the config file.") |
||||
} |
||||
|
||||
} |
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
package config |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"io/ioutil" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
"gopkg.in/yaml.v2" |
||||
|
||||
"github.com/gabek/owncast/utils" |
||||
) |
||||
|
||||
//Config contains a reference to the configuration
|
||||
var Config *config |
||||
|
||||
type config struct { |
||||
IPFS ipfs `yaml:"ipfs"` |
||||
PublicHLSPath string `yaml:"publicHLSPath"` |
||||
PrivateHLSPath string `yaml:"privateHLSPath"` |
||||
VideoSettings videoSettings `yaml:"videoSettings"` |
||||
Files files `yaml:"files"` |
||||
FFMpegPath string `yaml:"ffmpegPath"` |
||||
WebServerPort int `yaml:"webServerPort"` |
||||
S3 s3 `yaml:"s3"` |
||||
EnableOfflineImage bool `yaml:"enableOfflineImage"` |
||||
} |
||||
|
||||
type videoSettings struct { |
||||
ChunkLengthInSeconds int `yaml:"chunkLengthInSeconds"` |
||||
StreamingKey string `yaml:"streamingKey"` |
||||
EncoderPreset string `yaml:"encoderPreset"` |
||||
StreamQualities []streamQuality `yaml:"streamQualities"` |
||||
EnablePassthrough bool `yaml:"passthrough"` |
||||
OfflineImage string `yaml:"offlineImage"` |
||||
} |
||||
|
||||
type streamQuality struct { |
||||
Bitrate int `yaml:"bitrate"` |
||||
} |
||||
|
||||
type files struct { |
||||
MaxNumberInPlaylist int `yaml:"maxNumberInPlaylist"` |
||||
} |
||||
|
||||
type ipfs struct { |
||||
Enabled bool `yaml:"enabled"` |
||||
Gateway string `yaml:"gateway"` |
||||
} |
||||
|
||||
//s3 is for configuring the s3 integration
|
||||
type s3 struct { |
||||
Enabled bool `yaml:"enabled"` |
||||
Endpoint string `yaml:"endpoint"` |
||||
AccessKey string `yaml:"accessKey"` |
||||
Secret string `yaml:"secret"` |
||||
Bucket string `yaml:"bucket"` |
||||
Region string `yaml:"region"` |
||||
} |
||||
|
||||
func (c *config) load(filePath string) error { |
||||
if !utils.DoesFileExists(filePath) { |
||||
log.Fatal("ERROR: valid config/config.yaml is required. Copy config-example.yaml to config.yaml and edit") |
||||
} |
||||
|
||||
yamlFile, err := ioutil.ReadFile(filePath) |
||||
if err != nil { |
||||
log.Printf("yamlFile.Get err #%v ", err) |
||||
return err |
||||
} |
||||
|
||||
if err := yaml.Unmarshal(yamlFile, c); err != nil { |
||||
log.Fatalf("Unmarshal: %v", err) |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (c *config) verifySettings() error { |
||||
if c.S3.Enabled && c.IPFS.Enabled { |
||||
return errors.New("s3 and IPFS support cannot be enabled at the same time; choose one") |
||||
} |
||||
|
||||
if c.S3.Enabled { |
||||
if c.S3.AccessKey == "" || c.S3.Secret == "" { |
||||
return errors.New("s3 support requires an access key and secret") |
||||
} |
||||
|
||||
if c.S3.Region == "" || c.S3.Endpoint == "" { |
||||
return errors.New("s3 support requires a region and endpoint") |
||||
} |
||||
|
||||
if c.S3.Bucket == "" { |
||||
return errors.New("s3 support requires a bucket created for storing public video segments") |
||||
} |
||||
} |
||||
|
||||
// if !fileExists(config.PrivateHLSPath) {
|
||||
// os.MkdirAll(path.Join(config.PrivateHLSPath, strconv.Itoa(0)), 0777)
|
||||
// }
|
||||
|
||||
// if !fileExists(config.PublicHLSPath) {
|
||||
// os.MkdirAll(path.Join(config.PublicHLSPath, strconv.Itoa(0)), 0777)
|
||||
// }
|
||||
|
||||
if !utils.DoesFileExists(c.FFMpegPath) { |
||||
return fmt.Errorf("ffmpeg does not exist at: %s", c.FFMpegPath) |
||||
} |
||||
|
||||
if c.VideoSettings.EncoderPreset == "" { |
||||
return errors.New("a video encoder preset is required to be set in the config file") |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
//Load tries to load the configuration file
|
||||
func Load(filePath string) error { |
||||
Config = new(config) |
||||
|
||||
if err := Config.load(filePath); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return Config.verifySettings() |
||||
} |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"net/http" |
||||
"path" |
||||
|
||||
"github.com/gabek/owncast/core" |
||||
"github.com/gabek/owncast/router/middleware" |
||||
"github.com/gabek/owncast/utils" |
||||
) |
||||
|
||||
//IndexHandler handles the default index route
|
||||
func IndexHandler(w http.ResponseWriter, r *http.Request) { |
||||
middleware.EnableCors(&w) |
||||
|
||||
http.ServeFile(w, r, path.Join("webroot", r.URL.Path)) |
||||
|
||||
if path.Ext(r.URL.Path) == ".m3u8" { |
||||
middleware.DisableCache(&w) |
||||
|
||||
clientID := utils.GenerateClientIDFromRequest(r) |
||||
core.SetClientActive(clientID) |
||||
} |
||||
} |
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
"github.com/gabek/owncast/core" |
||||
"github.com/gabek/owncast/router/middleware" |
||||
) |
||||
|
||||
//GetStatus gets the status of the server
|
||||
func GetStatus(w http.ResponseWriter, r *http.Request) { |
||||
middleware.EnableCors(&w) |
||||
|
||||
status := core.GetStatus() |
||||
|
||||
json.NewEncoder(w).Encode(status) |
||||
} |
@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
|
||||
package chat |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"github.com/gabek/owncast/core" |
||||
"github.com/gabek/owncast/models" |
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"golang.org/x/net/websocket" |
||||
) |
||||
|
||||
//Server represents the server which handles the chat
|
||||
type Server struct { |
||||
Pattern string |
||||
Messages []models.ChatMessage |
||||
Clients map[string]*Client |
||||
|
||||
addCh chan *Client |
||||
delCh chan *Client |
||||
sendAllCh chan models.ChatMessage |
||||
pingCh chan models.PingMessage |
||||
doneCh chan bool |
||||
errCh chan error |
||||
} |
||||
|
||||
//NewServer creates a new chat server
|
||||
func NewServer(pattern string) *Server { |
||||
messages := []models.ChatMessage{} |
||||
clients := make(map[string]*Client) |
||||
addCh := make(chan *Client) |
||||
delCh := make(chan *Client) |
||||
sendAllCh := make(chan models.ChatMessage) |
||||
pingCh := make(chan models.PingMessage) |
||||
doneCh := make(chan bool) |
||||
errCh := make(chan error) |
||||
|
||||
// Demo messages only. Remove me eventually!!!
|
||||
messages = append(messages, models.ChatMessage{"Tom Nook", "I'll be there with Bells on! Ho ho!", "https://gamepedia.cursecdn.com/animalcrossingpocketcamp_gamepedia_en/thumb/4/4f/Timmy_Icon.png/120px-Timmy_Icon.png?version=87b38d7d6130411d113486c2db151385", "demo-message-1", "ChatMessage"}) |
||||
messages = append(messages, models.ChatMessage{"Redd", "Fool me once, shame on you. Fool me twice, stop foolin' me.", "https://vignette.wikia.nocookie.net/animalcrossing/images/3/3d/Redd2.gif/revision/latest?cb=20100710004252", "demo-message-2", "ChatMessage"}) |
||||
messages = append(messages, models.ChatMessage{"Kevin", "You just caught me before I was about to go work out weeweewee!", "https://vignette.wikia.nocookie.net/animalcrossing/images/2/20/NH-Kevin_poster.png/revision/latest/scale-to-width-down/100?cb=20200410185817", "demo-message-3", "ChatMessage"}) |
||||
messages = append(messages, models.ChatMessage{"Isabelle", " Isabelle is the mayor's highly capable secretary. She can be forgetful sometimes, but you can always count on her for information about the town. She wears her hair up in a bun that makes her look like a shih tzu. Mostly because she is one! She also has a twin brother named Digby.", "https://dodo.ac/np/images/thumb/7/7b/IsabelleTrophyWiiU.png/200px-IsabelleTrophyWiiU.png", "demo-message-4", "ChatMessage"}) |
||||
messages = append(messages, models.ChatMessage{"Judy", "myohmy, I'm dancing my dreams away.", "https://vignette.wikia.nocookie.net/animalcrossing/images/5/50/NH-Judy_poster.png/revision/latest/scale-to-width-down/100?cb=20200522063219", "demo-message-5", "ChatMessage"}) |
||||
messages = append(messages, models.ChatMessage{"Blathers", "Blathers is an owl with brown feathers. His face is white and he has a yellow beak. His arms are wing shaped and he has yellow talons. His eyes are very big with small black irises. He also has big pink cheek circles on his cheeks. His belly appears to be checkered in diamonds with light brown and white squares, similar to an argyle vest, which is traditionally associated with academia. His green bowtie further alludes to his academic nature.", "https://vignette.wikia.nocookie.net/animalcrossing/images/b/b3/NH-character-Blathers.png/revision/latest?cb=20200229053519", "demo-message-6", "ChatMessage"}) |
||||
|
||||
server := &Server{ |
||||
pattern, |
||||
messages, |
||||
clients, |
||||
addCh, |
||||
delCh, |
||||
sendAllCh, |
||||
pingCh, |
||||
doneCh, |
||||
errCh, |
||||
} |
||||
|
||||
ticker := time.NewTicker(30 * time.Second) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-ticker.C: |
||||
server.ping() |
||||
} |
||||
} |
||||
}() |
||||
|
||||
return server |
||||
} |
||||
|
||||
//Add adds a client to the server
|
||||
func (s *Server) Add(c *Client) { |
||||
s.addCh <- c |
||||
} |
||||
|
||||
//Remove removes a client from the server
|
||||
func (s *Server) Remove(c *Client) { |
||||
s.delCh <- c |
||||
} |
||||
|
||||
//SendToAll sends a message to all of the connected clients
|
||||
func (s *Server) SendToAll(msg models.ChatMessage) { |
||||
s.sendAllCh <- msg |
||||
} |
||||
|
||||
//Done marks the server as done
|
||||
func (s *Server) Done() { |
||||
s.doneCh <- true |
||||
} |
||||
|
||||
//Err handles an error
|
||||
func (s *Server) Err(err error) { |
||||
s.errCh <- err |
||||
} |
||||
|
||||
func (s *Server) sendPastMessages(c *Client) { |
||||
for _, msg := range s.Messages { |
||||
c.Write(msg) |
||||
} |
||||
} |
||||
|
||||
func (s *Server) sendAll(msg models.ChatMessage) { |
||||
for _, c := range s.Clients { |
||||
c.Write(msg) |
||||
} |
||||
} |
||||
|
||||
func (s *Server) ping() { |
||||
// fmt.Println("Start pinging....", len(s.clients))
|
||||
|
||||
ping := models.PingMessage{MessageType: "PING"} |
||||
for _, c := range s.Clients { |
||||
c.pingch <- ping |
||||
} |
||||
} |
||||
|
||||
// Listen and serve.
|
||||
// It serves client connection and broadcast request.
|
||||
func (s *Server) Listen() { |
||||
// websocket handler
|
||||
onConnected := func(ws *websocket.Conn) { |
||||
defer func() { |
||||
err := ws.Close() |
||||
if err != nil { |
||||
s.errCh <- err |
||||
} |
||||
}() |
||||
|
||||
client := NewClient(ws, s) |
||||
s.Add(client) |
||||
client.Listen() |
||||
} |
||||
|
||||
http.Handle(s.Pattern, websocket.Handler(onConnected)) |
||||
|
||||
log.Printf("Starting the websocket listener on: %s", s.Pattern) |
||||
|
||||
for { |
||||
select { |
||||
// add new a client
|
||||
case c := <-s.addCh: |
||||
s.Clients[c.id] = c |
||||
|
||||
core.SetClientActive(c.id) |
||||
s.sendPastMessages(c) |
||||
|
||||
// remove a client
|
||||
case c := <-s.delCh: |
||||
delete(s.Clients, c.id) |
||||
core.RemoveClient(c.id) |
||||
|
||||
// broadcast a message to all clients
|
||||
case msg := <-s.sendAllCh: |
||||
log.Println("Sending a message to all:", msg) |
||||
s.Messages = append(s.Messages, msg) |
||||
s.sendAll(msg) |
||||
|
||||
case ping := <-s.pingCh: |
||||
fmt.Println("PING?", ping) |
||||
|
||||
case err := <-s.errCh: |
||||
log.Println("Error:", err.Error()) |
||||
|
||||
case <-s.doneCh: |
||||
return |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"fmt" |
||||
) |
||||
|
||||
// the following are injected at build-time
|
||||
var ( |
||||
//GitCommit is the commit which this version of owncast is running
|
||||
GitCommit = "unknown" |
||||
//BuildVersion is the version
|
||||
BuildVersion = "0.0.0" |
||||
//BuildType is the type of build
|
||||
BuildType = "localdev" |
||||
) |
||||
|
||||
//GetVersion gets the version string
|
||||
func GetVersion() string { |
||||
return fmt.Sprintf("Owncast v%s-%s (%s)", BuildVersion, BuildType, GitCommit) |
||||
} |
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"os" |
||||
"path" |
||||
"strconv" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/gabek/owncast/config" |
||||
"github.com/gabek/owncast/core/ffmpeg" |
||||
"github.com/gabek/owncast/models" |
||||
"github.com/gabek/owncast/utils" |
||||
) |
||||
|
||||
var ( |
||||
_stats *models.Stats |
||||
_storage models.ChunkStorageProvider |
||||
) |
||||
|
||||
//Start starts up the core processing
|
||||
func Start() error { |
||||
resetDirectories() |
||||
|
||||
if err := setupStats(); err != nil { |
||||
log.Println("failed to setup the stats") |
||||
return err |
||||
} |
||||
|
||||
if err := setupStorage(); err != nil { |
||||
log.Println("failed to setup the storage") |
||||
return err |
||||
} |
||||
|
||||
if err := createInitialOfflineState(); err != nil { |
||||
log.Println("failed to create the initial offline state") |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func createInitialOfflineState() error { |
||||
// Provide default files
|
||||
if !utils.DoesFileExists("webroot/thumbnail.jpg") { |
||||
if err := utils.Copy("static/logo.png", "webroot/thumbnail.jpg"); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return ffmpeg.ShowStreamOfflineState() |
||||
} |
||||
|
||||
func resetDirectories() { |
||||
log.Println("Resetting file directories to a clean slate.") |
||||
|
||||
// Wipe the public, web-accessible hls data directory
|
||||
os.RemoveAll(config.Config.PublicHLSPath) |
||||
os.RemoveAll(config.Config.PrivateHLSPath) |
||||
os.MkdirAll(config.Config.PublicHLSPath, 0777) |
||||
os.MkdirAll(config.Config.PrivateHLSPath, 0777) |
||||
|
||||
// Remove the previous thumbnail
|
||||
os.Remove("webroot/thumbnail.jpg") |
||||
|
||||
// Create private hls data dirs
|
||||
if !config.Config.VideoSettings.EnablePassthrough || len(config.Config.VideoSettings.StreamQualities) == 0 { |
||||
for index := range config.Config.VideoSettings.StreamQualities { |
||||
os.MkdirAll(path.Join(config.Config.PrivateHLSPath, strconv.Itoa(index)), 0777) |
||||
os.MkdirAll(path.Join(config.Config.PublicHLSPath, strconv.Itoa(index)), 0777) |
||||
} |
||||
} else { |
||||
os.MkdirAll(path.Join(config.Config.PrivateHLSPath, strconv.Itoa(0)), 0777) |
||||
os.MkdirAll(path.Join(config.Config.PublicHLSPath, strconv.Itoa(0)), 0777) |
||||
} |
||||
} |
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
package ffmpeg |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"os" |
||||
) |
||||
|
||||
//VerifyFFMpegPath verifies that the path exists, is a file, and is executable
|
||||
func VerifyFFMpegPath(path string) error { |
||||
stat, err := os.Stat(path) |
||||
|
||||
if os.IsNotExist(err) { |
||||
return errors.New("ffmpeg path does not exist") |
||||
} |
||||
|
||||
if err != nil { |
||||
return fmt.Errorf("error while verifying the ffmpeg path: %s", err.Error()) |
||||
} |
||||
|
||||
if stat.IsDir() { |
||||
return errors.New("ffmpeg path can not be a folder") |
||||
} |
||||
|
||||
mode := stat.Mode() |
||||
//source: https://stackoverflow.com/a/60128480
|
||||
if mode&0111 == 0 { |
||||
return errors.New("ffmpeg path is not executable") |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
package playlist |
||||
|
||||
import ( |
||||
"io/ioutil" |
||||
"path" |
||||
"path/filepath" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/radovskyb/watcher" |
||||
|
||||
"github.com/gabek/owncast/config" |
||||
"github.com/gabek/owncast/core/ffmpeg" |
||||
"github.com/gabek/owncast/models" |
||||
"github.com/gabek/owncast/utils" |
||||
) |
||||
|
||||
var ( |
||||
_storage models.ChunkStorageProvider |
||||
variants []models.Variant |
||||
) |
||||
|
||||
//StartVideoContentMonitor starts the video content monitor
|
||||
func StartVideoContentMonitor(storage models.ChunkStorageProvider) error { |
||||
_storage = storage |
||||
|
||||
pathToMonitor := config.Config.PrivateHLSPath |
||||
|
||||
// Create at least one structure to store the segments for the different stream variants
|
||||
variants = make([]models.Variant, len(config.Config.VideoSettings.StreamQualities)) |
||||
if len(config.Config.VideoSettings.StreamQualities) > 0 && !config.Config.VideoSettings.EnablePassthrough { |
||||
for index := range variants { |
||||
variants[index] = models.Variant{ |
||||
VariantIndex: index, |
||||
Segments: make(map[string]*models.Segment), |
||||
} |
||||
} |
||||
} else { |
||||
variants[0] = models.Variant{ |
||||
VariantIndex: 0, |
||||
Segments: make(map[string]*models.Segment), |
||||
} |
||||
} |
||||
|
||||
// log.Printf("Using directory %s for storing files with %d variants...\n", pathToMonitor, len(variants))
|
||||
|
||||
w := watcher.New() |
||||
|
||||
go func() { |
||||
for { |
||||
select { |
||||
case event := <-w.Event: |
||||
|
||||
relativePath := utils.GetRelativePathFromAbsolutePath(event.Path) |
||||
if path.Ext(relativePath) == ".tmp" { |
||||
continue |
||||
} |
||||
|
||||
// Ignore removals
|
||||
if event.Op == watcher.Remove { |
||||
continue |
||||
} |
||||
|
||||
// fmt.Println(event.Op, relativePath)
|
||||
|
||||
// Handle updates to the master playlist by copying it to webroot
|
||||
if relativePath == path.Join(config.Config.PrivateHLSPath, "stream.m3u8") { |
||||
utils.Copy(event.Path, path.Join(config.Config.PublicHLSPath, "stream.m3u8")) |
||||
|
||||
} else if filepath.Ext(event.Path) == ".m3u8" { |
||||
// Handle updates to playlists, but not the master playlist
|
||||
updateVariantPlaylist(event.Path) |
||||
|
||||
} else if filepath.Ext(event.Path) == ".ts" { |
||||
segment, err := getSegmentFromPath(event.Path) |
||||
if err != nil { |
||||
log.Println("failed to get the segment from path") |
||||
panic(err) |
||||
} |
||||
|
||||
newObjectPathChannel := make(chan string, 1) |
||||
go func() { |
||||
newObjectPath, err := storage.Save(path.Join(config.Config.PrivateHLSPath, segment.RelativeUploadPath), 0) |
||||
if err != nil { |
||||
log.Println("failed to save the file to the chunk storage") |
||||
panic(err) |
||||
} |
||||
|
||||
newObjectPathChannel <- newObjectPath |
||||
}() |
||||
|
||||
newObjectPath := <-newObjectPathChannel |
||||
segment.RemoteID = newObjectPath |
||||
// fmt.Println("Uploaded", segment.RelativeUploadPath, "as", newObjectPath)
|
||||
|
||||
variants[segment.VariantIndex].Segments[filepath.Base(segment.RelativeUploadPath)] = &segment |
||||
|
||||
// Force a variant's playlist to be updated after a file is uploaded.
|
||||
associatedVariantPlaylist := strings.ReplaceAll(event.Path, path.Base(event.Path), "stream.m3u8") |
||||
updateVariantPlaylist(associatedVariantPlaylist) |
||||
} |
||||
case err := <-w.Error: |
||||
panic(err) |
||||
case <-w.Closed: |
||||
return |
||||
} |
||||
} |
||||
}() |
||||
|
||||
// Watch the hls segment storage folder recursively for changes.
|
||||
w.FilterOps(watcher.Write, watcher.Rename, watcher.Create) |
||||
|
||||
if err := w.AddRecursive(pathToMonitor); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return w.Start(time.Millisecond * 200) |
||||
} |
||||
|
||||
func getSegmentFromPath(fullDiskPath string) (models.Segment, error) { |
||||
segment := models.Segment{ |
||||
FullDiskPath: fullDiskPath, |
||||
RelativeUploadPath: utils.GetRelativePathFromAbsolutePath(fullDiskPath), |
||||
} |
||||
|
||||
index, err := strconv.Atoi(segment.RelativeUploadPath[0:1]) |
||||
if err != nil { |
||||
return segment, err |
||||
} |
||||
|
||||
segment.VariantIndex = index |
||||
|
||||
return segment, nil |
||||
} |
||||
|
||||
func getVariantIndexFromPath(fullDiskPath string) (int, error) { |
||||
return strconv.Atoi(fullDiskPath[0:1]) |
||||
} |
||||
|
||||
func updateVariantPlaylist(fullPath string) error { |
||||
relativePath := utils.GetRelativePathFromAbsolutePath(fullPath) |
||||
variantIndex, err := getVariantIndexFromPath(relativePath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
variant := variants[variantIndex] |
||||
|
||||
playlistBytes, err := ioutil.ReadFile(fullPath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
playlistString := string(playlistBytes) |
||||
// fmt.Println("Rewriting playlist", relativePath, "to", path.Join(config.Config.PublicHLSPath, relativePath))
|
||||
|
||||
playlistString = _storage.GenerateRemotePlaylist(playlistString, variant) |
||||
|
||||
return ffmpeg.WritePlaylist(playlistString, path.Join(config.Config.PublicHLSPath, relativePath)) |
||||
} |
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
package rtmp |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io" |
||||
"net" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
yutmp "github.com/yutopp/go-rtmp" |
||||
) |
||||
|
||||
var ( |
||||
//IsConnected whether there is a connection or not
|
||||
_isConnected = false |
||||
) |
||||
|
||||
//Start starts the rtmp service, listening on port 1935
|
||||
func Start() { |
||||
port := 1935 |
||||
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf(":%d", port)) |
||||
if err != nil { |
||||
log.Panicf("Failed to resolve the tcp address for the rtmp service: %+v", err) |
||||
} |
||||
|
||||
listener, err := net.ListenTCP("tcp", tcpAddr) |
||||
if err != nil { |
||||
log.Panicf("Failed to acquire the tcp listener: %+v", err) |
||||
} |
||||
|
||||
srv := yutmp.NewServer(&yutmp.ServerConfig{ |
||||
OnConnect: func(conn net.Conn) (io.ReadWriteCloser, *yutmp.ConnConfig) { |
||||
l := log.StandardLogger() |
||||
l.SetLevel(log.WarnLevel) |
||||
|
||||
return conn, &yutmp.ConnConfig{ |
||||
Handler: &Handler{}, |
||||
|
||||
ControlState: yutmp.StreamControlStateConfig{ |
||||
DefaultBandwidthWindowSize: 6 * 1024 * 1024 / 8, |
||||
}, |
||||
|
||||
Logger: l, |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
log.Printf("RTMP server is listening for incoming stream on port: %d", port) |
||||
if err := srv.Serve(listener); err != nil { |
||||
log.Panicf("Failed to serve the rtmp service: %+v", err) |
||||
} |
||||
} |
||||
|
||||
//IsConnected gets whether there is an rtmp connection or not
|
||||
//this is only a getter since it is controlled by the rtmp handler
|
||||
func IsConnected() bool { |
||||
return _isConnected |
||||
} |
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"io/ioutil" |
||||
"math" |
||||
"os" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/gabek/owncast/models" |
||||
"github.com/gabek/owncast/utils" |
||||
) |
||||
|
||||
const ( |
||||
statsFilePath = "stats.json" |
||||
) |
||||
|
||||
func setupStats() error { |
||||
s, err := getSavedStats() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
_stats = &s |
||||
|
||||
statsSaveTimer := time.NewTicker(1 * time.Minute) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-statsSaveTimer.C: |
||||
if err := saveStatsToFile(); err != nil { |
||||
panic(err) |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
|
||||
staleViewerPurgeTimer := time.NewTicker(3 * time.Second) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-staleViewerPurgeTimer.C: |
||||
purgeStaleViewers() |
||||
} |
||||
} |
||||
}() |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func purgeStaleViewers() { |
||||
for clientID, lastConnectedtime := range _stats.Clients { |
||||
timeSinceLastActive := time.Since(lastConnectedtime).Minutes() |
||||
if timeSinceLastActive > 2 { |
||||
RemoveClient(clientID) |
||||
} |
||||
} |
||||
} |
||||
|
||||
//IsStreamConnected checks if the stream is connected or not
|
||||
func IsStreamConnected() bool { |
||||
if !_stats.StreamConnected { |
||||
return false |
||||
} |
||||
|
||||
// Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available.
|
||||
// So account for that with an artificial buffer.
|
||||
timeSinceLastConnected := time.Since(_stats.LastConnectTime).Seconds() |
||||
if timeSinceLastConnected < 10 { |
||||
return false |
||||
} |
||||
|
||||
return _stats.StreamConnected |
||||
} |
||||
|
||||
//SetClientActive sets a client as active and connected
|
||||
func SetClientActive(clientID string) { |
||||
// if _, ok := s.clients[clientID]; !ok {
|
||||
// fmt.Println("Marking client active:", clientID, s.GetViewerCount()+1, "clients connected.")
|
||||
// }
|
||||
|
||||
_stats.Clients[clientID] = time.Now() |
||||
_stats.SessionMaxViewerCount = int(math.Max(float64(len(_stats.Clients)), float64(_stats.SessionMaxViewerCount))) |
||||
_stats.OverallMaxViewerCount = int(math.Max(float64(_stats.SessionMaxViewerCount), float64(_stats.OverallMaxViewerCount))) |
||||
} |
||||
|
||||
//RemoveClient removes a client from the active clients record
|
||||
func RemoveClient(clientID string) { |
||||
log.Println("Removing the client:", clientID) |
||||
|
||||
delete(_stats.Clients, clientID) |
||||
} |
||||
|
||||
func saveStatsToFile() error { |
||||
jsonData, err := json.Marshal(_stats) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
f, err := os.Create(statsFilePath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
defer f.Close() |
||||
|
||||
if _, err := f.Write(jsonData); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func getSavedStats() (models.Stats, error) { |
||||
result := models.Stats{ |
||||
Clients: make(map[string]time.Time), |
||||
} |
||||
|
||||
if !utils.DoesFileExists(statsFilePath) { |
||||
return result, nil |
||||
} |
||||
|
||||
jsonFile, err := ioutil.ReadFile(statsFilePath) |
||||
if err != nil { |
||||
return result, nil |
||||
} |
||||
|
||||
if err := json.Unmarshal(jsonFile, &result); err != nil { |
||||
return result, nil |
||||
} |
||||
|
||||
return result, nil |
||||
} |
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"time" |
||||
|
||||
"github.com/gabek/owncast/config" |
||||
"github.com/gabek/owncast/core/ffmpeg" |
||||
"github.com/gabek/owncast/models" |
||||
) |
||||
|
||||
//GetStatus gets the status of the system
|
||||
func GetStatus() models.Status { |
||||
if _stats == nil { |
||||
return models.Status{} |
||||
} |
||||
|
||||
return models.Status{ |
||||
Online: IsStreamConnected(), |
||||
ViewerCount: len(_stats.Clients), |
||||
OverallMaxViewerCount: _stats.OverallMaxViewerCount, |
||||
SessionMaxViewerCount: _stats.SessionMaxViewerCount, |
||||
} |
||||
} |
||||
|
||||
//SetStreamAsConnected sets the stream as connected
|
||||
func SetStreamAsConnected() { |
||||
_stats.StreamConnected = true |
||||
_stats.LastConnectTime = time.Now() |
||||
|
||||
timeSinceDisconnect := time.Since(_stats.LastDisconnectTime).Minutes() |
||||
if timeSinceDisconnect > 15 { |
||||
_stats.SessionMaxViewerCount = 0 |
||||
} |
||||
|
||||
chunkPath := config.Config.PublicHLSPath |
||||
if usingExternalStorage { |
||||
chunkPath = config.Config.PrivateHLSPath |
||||
} |
||||
|
||||
ffmpeg.StartThumbnailGenerator(chunkPath) |
||||
} |
||||
|
||||
//SetStreamAsDisconnected sets the stream as disconnected
|
||||
func SetStreamAsDisconnected() { |
||||
_stats.StreamConnected = false |
||||
_stats.LastDisconnectTime = time.Now() |
||||
|
||||
if config.Config.EnableOfflineImage { |
||||
ffmpeg.ShowStreamOfflineState() |
||||
} |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"github.com/gabek/owncast/config" |
||||
"github.com/gabek/owncast/core/playlist" |
||||
"github.com/gabek/owncast/core/storageproviders" |
||||
) |
||||
|
||||
var ( |
||||
usingExternalStorage = false |
||||
) |
||||
|
||||
func setupStorage() error { |
||||
if config.Config.IPFS.Enabled { |
||||
_storage = &storageproviders.IPFSStorage{} |
||||
usingExternalStorage = true |
||||
} else if config.Config.S3.Enabled { |
||||
_storage = &storageproviders.S3Storage{} |
||||
usingExternalStorage = true |
||||
} |
||||
|
||||
if usingExternalStorage { |
||||
if err := _storage.Setup(); err != nil { |
||||
return err |
||||
} |
||||
|
||||
go playlist.StartVideoContentMonitor(_storage) |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
package models |
||||
|
||||
//PingMessage represents a ping message between the client and server
|
||||
type PingMessage struct { |
||||
MessageType string `json:"type"` |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
package models |
||||
|
||||
//Segment represents a segment of the live stream
|
||||
type Segment struct { |
||||
VariantIndex int // The bitrate variant
|
||||
FullDiskPath string // Where it lives on disk
|
||||
RelativeUploadPath string // Path it should have remotely
|
||||
RemoteID string // Used for IPFS
|
||||
} |
||||
|
||||
//Variant represents a single bitrate variant and the segments that make it up
|
||||
type Variant struct { |
||||
VariantIndex int |
||||
Segments map[string]*Segment |
||||
} |
||||
|
||||
//GetSegmentForFilename gets the segment for the provided filename
|
||||
func (v *Variant) GetSegmentForFilename(filename string) *Segment { |
||||
return v.Segments[filename] |
||||
} |
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
package models |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
//Stats holds the stats for the system
|
||||
type Stats struct { |
||||
SessionMaxViewerCount int `json:"sessionMaxViewerCount"` |
||||
OverallMaxViewerCount int `json:"overallMaxViewerCount"` |
||||
LastDisconnectTime time.Time `json:"lastDisconnectTime"` |
||||
|
||||
StreamConnected bool `json:"-"` |
||||
LastConnectTime time.Time `json:"-"` |
||||
Clients map[string]time.Time `json:"-"` |
||||
} |
@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
|
||||
package main |
||||
package models |
||||
|
||||
//Status represents the status of the system
|
||||
type Status struct { |
||||
Online bool `json:"online"` |
||||
ViewerCount int `json:"viewerCount"` |
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
package models |
||||
|
||||
//ChunkStorageProvider is how a chunk storage provider should be implemented
|
||||
type ChunkStorageProvider interface { |
||||
Setup() error |
||||
Save(filePath string, retryCount int) (string, error) |
||||
GenerateRemotePlaylist(playlist string, variant Variant) string |
||||
} |
@ -1,146 +0,0 @@
@@ -1,146 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"io/ioutil" |
||||
"path" |
||||
"path/filepath" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/radovskyb/watcher" |
||||
) |
||||
|
||||
type Segment struct { |
||||
VariantIndex int // The bitrate variant
|
||||
FullDiskPath string // Where it lives on disk
|
||||
RelativeUploadPath string // Path it should have remotely
|
||||
RemoteID string // Used for IPFS
|
||||
} |
||||
|
||||
type Variant struct { |
||||
VariantIndex int |
||||
Segments map[string]*Segment |
||||
} |
||||
|
||||
func (v *Variant) getSegmentForFilename(filename string) *Segment { |
||||
return v.Segments[filename] |
||||
// for _, segment := range v.Segments {
|
||||
// if path.Base(segment.FullDiskPath) == filename {
|
||||
// return &segment
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
} |
||||
|
||||
func getSegmentFromPath(fullDiskPath string) Segment { |
||||
segment := Segment{} |
||||
segment.FullDiskPath = fullDiskPath |
||||
segment.RelativeUploadPath = getRelativePathFromAbsolutePath(fullDiskPath) |
||||
index, error := strconv.Atoi(segment.RelativeUploadPath[0:1]) |
||||
verifyError(error) |
||||
segment.VariantIndex = index |
||||
|
||||
return segment |
||||
} |
||||
|
||||
func getVariantIndexFromPath(fullDiskPath string) int { |
||||
index, error := strconv.Atoi(fullDiskPath[0:1]) |
||||
verifyError(error) |
||||
return index |
||||
} |
||||
|
||||
var variants []Variant |
||||
|
||||
func updateVariantPlaylist(fullPath string) { |
||||
relativePath := getRelativePathFromAbsolutePath(fullPath) |
||||
variantIndex := getVariantIndexFromPath(relativePath) |
||||
variant := variants[variantIndex] |
||||
|
||||
playlistBytes, err := ioutil.ReadFile(fullPath) |
||||
verifyError(err) |
||||
playlistString := string(playlistBytes) |
||||
// fmt.Println("Rewriting playlist", relativePath, "to", path.Join(configuration.PublicHLSPath, relativePath))
|
||||
|
||||
playlistString = storage.GenerateRemotePlaylist(playlistString, variant) |
||||
|
||||
writePlaylist(playlistString, path.Join(configuration.PublicHLSPath, relativePath)) |
||||
} |
||||
|
||||
func monitorVideoContent(pathToMonitor string, configuration Config, storage ChunkStorage) { |
||||
// Create at least one structure to store the segments for the different stream variants
|
||||
variants = make([]Variant, len(configuration.VideoSettings.StreamQualities)) |
||||
if len(configuration.VideoSettings.StreamQualities) > 0 && !configuration.VideoSettings.EnablePassthrough { |
||||
for index := range variants { |
||||
variants[index] = Variant{index, make(map[string]*Segment)} |
||||
} |
||||
} else { |
||||
variants[0] = Variant{0, make(map[string]*Segment)} |
||||
} |
||||
// log.Printf("Using directory %s for storing files with %d variants...\n", pathToMonitor, len(variants))
|
||||
|
||||
w := watcher.New() |
||||
|
||||
go func() { |
||||
for { |
||||
select { |
||||
case event := <-w.Event: |
||||
|
||||
relativePath := getRelativePathFromAbsolutePath(event.Path) |
||||
if path.Ext(relativePath) == ".tmp" { |
||||
continue |
||||
} |
||||
|
||||
// Ignore removals
|
||||
if event.Op == watcher.Remove { |
||||
continue |
||||
} |
||||
|
||||
// fmt.Println(event.Op, relativePath)
|
||||
|
||||
// Handle updates to the master playlist by copying it to webroot
|
||||
if relativePath == path.Join(configuration.PrivateHLSPath, "stream.m3u8") { |
||||
copy(event.Path, path.Join(configuration.PublicHLSPath, "stream.m3u8")) |
||||
|
||||
} else if filepath.Ext(event.Path) == ".m3u8" { |
||||
// Handle updates to playlists, but not the master playlist
|
||||
updateVariantPlaylist(event.Path) |
||||
} else if filepath.Ext(event.Path) == ".ts" { |
||||
segment := getSegmentFromPath(event.Path) |
||||
|
||||
newObjectPathChannel := make(chan string, 1) |
||||
go func() { |
||||
newObjectPath := storage.Save(path.Join(configuration.PrivateHLSPath, segment.RelativeUploadPath), 0) |
||||
newObjectPathChannel <- newObjectPath |
||||
}() |
||||
newObjectPath := <-newObjectPathChannel |
||||
segment.RemoteID = newObjectPath |
||||
// fmt.Println("Uploaded", segment.RelativeUploadPath, "as", newObjectPath)
|
||||
|
||||
variants[segment.VariantIndex].Segments[filepath.Base(segment.RelativeUploadPath)] = &segment |
||||
|
||||
// Force a variant's playlist to be updated after a file is uploaded.
|
||||
associatedVariantPlaylist := strings.ReplaceAll(event.Path, path.Base(event.Path), "stream.m3u8") |
||||
updateVariantPlaylist(associatedVariantPlaylist) |
||||
} |
||||
case err := <-w.Error: |
||||
log.Fatalln(err) |
||||
case <-w.Closed: |
||||
return |
||||
} |
||||
} |
||||
}() |
||||
|
||||
// Watch the hls segment storage folder recursively for changes.
|
||||
w.FilterOps(watcher.Write, watcher.Rename, watcher.Create) |
||||
|
||||
if err := w.AddRecursive(pathToMonitor); err != nil { |
||||
log.Fatalln(err) |
||||
} |
||||
|
||||
if err := w.Start(time.Millisecond * 200); err != nil { |
||||
log.Fatalln(err) |
||||
} |
||||
} |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
package middleware |
||||
|
||||
import ( |
||||
"net/http" |
||||
) |
||||
|
||||
//EnableCors enables the cors header on the responses
|
||||
func EnableCors(w *http.ResponseWriter) { |
||||
(*w).Header().Set("Access-Control-Allow-Origin", "*") |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
package middleware |
||||
|
||||
import ( |
||||
"net/http" |
||||
) |
||||
|
||||
//DisableCache writes the disable cache header on the responses
|
||||
func DisableCache(w *http.ResponseWriter) { |
||||
(*w).Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") |
||||
(*w).Header().Set("Expires", "0") |
||||
} |
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
package router |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"github.com/gabek/owncast/config" |
||||
"github.com/gabek/owncast/controllers" |
||||
"github.com/gabek/owncast/core/chat" |
||||
"github.com/gabek/owncast/core/rtmp" |
||||
) |
||||
|
||||
//Start starts the router for the http, ws, and rtmp
|
||||
func Start() error { |
||||
// websocket server
|
||||
chatServer := chat.NewServer("/entry") |
||||
go chatServer.Listen() |
||||
|
||||
// start the rtmp server
|
||||
go rtmp.Start() |
||||
|
||||
// static files
|
||||
http.HandleFunc("/", controllers.IndexHandler) |
||||
|
||||
// status of the system
|
||||
http.HandleFunc("/status", controllers.GetStatus) |
||||
|
||||
port := config.Config.WebServerPort |
||||
|
||||
log.Printf("Starting public web server on port: %d", port) |
||||
|
||||
return http.ListenAndServe(fmt.Sprintf(":%d", port), nil) |
||||
} |
@ -1,48 +0,0 @@
@@ -1,48 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"io" |
||||
"net" |
||||
"strconv" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
log "github.com/sirupsen/logrus" |
||||
"github.com/yutopp/go-rtmp" |
||||
) |
||||
|
||||
func startRTMPService() { |
||||
port := 1935 |
||||
log.Printf("RTMP server is listening for incoming stream on port %d.\n", port) |
||||
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", ":"+strconv.Itoa(port)) |
||||
if err != nil { |
||||
log.Panicf("Failed: %+v", err) |
||||
} |
||||
|
||||
listener, err := net.ListenTCP("tcp", tcpAddr) |
||||
if err != nil { |
||||
log.Panicf("Failed: %+v", err) |
||||
} |
||||
|
||||
srv := rtmp.NewServer(&rtmp.ServerConfig{ |
||||
OnConnect: func(conn net.Conn) (io.ReadWriteCloser, *rtmp.ConnConfig) { |
||||
l := log.StandardLogger() |
||||
l.SetLevel(logrus.WarnLevel) |
||||
|
||||
h := &Handler{} |
||||
|
||||
return conn, &rtmp.ConnConfig{ |
||||
Handler: h, |
||||
|
||||
ControlState: rtmp.StreamControlStateConfig{ |
||||
DefaultBandwidthWindowSize: 6 * 1024 * 1024 / 8, |
||||
}, |
||||
|
||||
Logger: l, |
||||
} |
||||
}, |
||||
}) |
||||
if err := srv.Serve(listener); err != nil { |
||||
log.Panicf("Failed: %+v", err) |
||||
} |
||||
} |
@ -1,164 +0,0 @@
@@ -1,164 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
|
||||
"golang.org/x/net/websocket" |
||||
) |
||||
|
||||
// Chat server.
|
||||
type Server struct { |
||||
pattern string |
||||
messages []*ChatMessage |
||||
clients map[string]*Client |
||||
addCh chan *Client |
||||
delCh chan *Client |
||||
sendAllCh chan *ChatMessage |
||||
pingCh chan *PingMessage |
||||
doneCh chan bool |
||||
errCh chan error |
||||
} |
||||
|
||||
// Create new chat server.
|
||||
func NewServer(pattern string) *Server { |
||||
messages := []*ChatMessage{} |
||||
clients := make(map[string]*Client) |
||||
addCh := make(chan *Client) |
||||
delCh := make(chan *Client) |
||||
sendAllCh := make(chan *ChatMessage) |
||||
pingCh := make(chan *PingMessage) |
||||
doneCh := make(chan bool) |
||||
errCh := make(chan error) |
||||
|
||||
// Demo messages only. Remove me eventually!!!
|
||||
messages = append(messages, &ChatMessage{"Tom Nook", "I'll be there with Bells on! Ho ho!", "https://gamepedia.cursecdn.com/animalcrossingpocketcamp_gamepedia_en/thumb/4/4f/Timmy_Icon.png/120px-Timmy_Icon.png?version=87b38d7d6130411d113486c2db151385", "demo-message-1", "ChatMessage"}) |
||||
messages = append(messages, &ChatMessage{"Redd", "Fool me once, shame on you. Fool me twice, stop foolin' me.", "https://vignette.wikia.nocookie.net/animalcrossing/images/3/3d/Redd2.gif/revision/latest?cb=20100710004252", "demo-message-2", "ChatMessage"}) |
||||
messages = append(messages, &ChatMessage{"Kevin", "You just caught me before I was about to go work out weeweewee!", "https://vignette.wikia.nocookie.net/animalcrossing/images/2/20/NH-Kevin_poster.png/revision/latest/scale-to-width-down/100?cb=20200410185817", "demo-message-3", "ChatMessage"}) |
||||
messages = append(messages, &ChatMessage{"Isabelle", " Isabelle is the mayor's highly capable secretary. She can be forgetful sometimes, but you can always count on her for information about the town. She wears her hair up in a bun that makes her look like a shih tzu. Mostly because she is one! She also has a twin brother named Digby.", "https://dodo.ac/np/images/thumb/7/7b/IsabelleTrophyWiiU.png/200px-IsabelleTrophyWiiU.png", "demo-message-4", "ChatMessage"}) |
||||
messages = append(messages, &ChatMessage{"Judy", "myohmy, I'm dancing my dreams away.", "https://vignette.wikia.nocookie.net/animalcrossing/images/5/50/NH-Judy_poster.png/revision/latest/scale-to-width-down/100?cb=20200522063219", "demo-message-5", "ChatMessage"}) |
||||
messages = append(messages, &ChatMessage{"Blathers", "Blathers is an owl with brown feathers. His face is white and he has a yellow beak. His arms are wing shaped and he has yellow talons. His eyes are very big with small black irises. He also has big pink cheek circles on his cheeks. His belly appears to be checkered in diamonds with light brown and white squares, similar to an argyle vest, which is traditionally associated with academia. His green bowtie further alludes to his academic nature.", "https://vignette.wikia.nocookie.net/animalcrossing/images/b/b3/NH-character-Blathers.png/revision/latest?cb=20200229053519", "demo-message-6", "ChatMessage"}) |
||||
|
||||
server := &Server{ |
||||
pattern, |
||||
messages, |
||||
clients, |
||||
addCh, |
||||
delCh, |
||||
sendAllCh, |
||||
pingCh, |
||||
doneCh, |
||||
errCh, |
||||
} |
||||
|
||||
ticker := time.NewTicker(30 * time.Second) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-ticker.C: |
||||
server.ping() |
||||
} |
||||
} |
||||
}() |
||||
|
||||
return server |
||||
} |
||||
|
||||
func (s *Server) ClientCount() int { |
||||
return len(s.clients) |
||||
} |
||||
|
||||
func (s *Server) Add(c *Client) { |
||||
s.addCh <- c |
||||
} |
||||
|
||||
func (s *Server) Del(c *Client) { |
||||
s.delCh <- c |
||||
} |
||||
|
||||
func (s *Server) SendAll(msg *ChatMessage) { |
||||
s.sendAllCh <- msg |
||||
} |
||||
|
||||
func (s *Server) Done() { |
||||
s.doneCh <- true |
||||
} |
||||
|
||||
func (s *Server) Err(err error) { |
||||
s.errCh <- err |
||||
} |
||||
|
||||
func (s *Server) sendPastMessages(c *Client) { |
||||
for _, msg := range s.messages { |
||||
c.Write(msg) |
||||
} |
||||
} |
||||
|
||||
func (s *Server) sendAll(msg *ChatMessage) { |
||||
for _, c := range s.clients { |
||||
c.Write(msg) |
||||
} |
||||
} |
||||
|
||||
func (s *Server) ping() { |
||||
// fmt.Println("Start pinging....", len(s.clients))
|
||||
|
||||
ping := &PingMessage{"PING"} |
||||
for _, c := range s.clients { |
||||
c.pingch <- ping |
||||
} |
||||
} |
||||
|
||||
// Listen and serve.
|
||||
// It serves client connection and broadcast request.
|
||||
func (s *Server) Listen() { |
||||
// websocket handler
|
||||
onConnected := func(ws *websocket.Conn) { |
||||
defer func() { |
||||
err := ws.Close() |
||||
if err != nil { |
||||
s.errCh <- err |
||||
} |
||||
}() |
||||
|
||||
client := NewClient(ws, s) |
||||
s.Add(client) |
||||
client.Listen() |
||||
} |
||||
http.Handle(s.pattern, websocket.Handler(onConnected)) |
||||
|
||||
for { |
||||
select { |
||||
|
||||
// Add new a client
|
||||
case c := <-s.addCh: |
||||
s.clients[c.id] = c |
||||
viewerAdded(c.id) |
||||
s.sendPastMessages(c) |
||||
|
||||
// del a client
|
||||
case c := <-s.delCh: |
||||
delete(s.clients, c.id) |
||||
viewerRemoved(c.id) |
||||
|
||||
// broadcast message for all clients
|
||||
case msg := <-s.sendAllCh: |
||||
log.Println("Send all:", msg) |
||||
s.messages = append(s.messages, msg) |
||||
s.sendAll(msg) |
||||
|
||||
case ping := <-s.pingCh: |
||||
fmt.Println("PING?", ping) |
||||
|
||||
case err := <-s.errCh: |
||||
log.Println("Error:", err.Error()) |
||||
|
||||
case <-s.doneCh: |
||||
return |
||||
} |
||||
} |
||||
|
||||
} |
@ -1,152 +0,0 @@
@@ -1,152 +0,0 @@
|
||||
/* |
||||
Viewer counting doesn't just count the number of websocket clients that are currently connected, |
||||
because people may be watching the stream outside of the web browser via any HLS video client. |
||||
Instead we keep track of requests and consider each unique IP as a "viewer". |
||||
As a signal, however, we do use the websocket disconnect from a client as a signal that a viewer |
||||
dropped and we call ViewerDisconnected(). |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"io/ioutil" |
||||
"math" |
||||
"os" |
||||
"time" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
type Stats struct { |
||||
streamConnected bool `json:"-"` |
||||
SessionMaxViewerCount int `json:"sessionMaxViewerCount"` |
||||
OverallMaxViewerCount int `json:"overallMaxViewerCount"` |
||||
LastDisconnectTime time.Time `json:"lastDisconnectTime"` |
||||
lastConnectTime time.Time `json:"-"` |
||||
clients map[string]time.Time |
||||
} |
||||
|
||||
func (s *Stats) Setup() { |
||||
s.clients = make(map[string]time.Time) |
||||
|
||||
statsSaveTimer := time.NewTicker(1 * time.Minute) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-statsSaveTimer.C: |
||||
s.save() |
||||
} |
||||
} |
||||
}() |
||||
|
||||
staleViewerPurgeTimer := time.NewTicker(3 * time.Second) |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-staleViewerPurgeTimer.C: |
||||
s.purgeStaleViewers() |
||||
} |
||||
} |
||||
}() |
||||
} |
||||
|
||||
func (s *Stats) purgeStaleViewers() { |
||||
for clientID, lastConnectedtime := range s.clients { |
||||
timeSinceLastActive := time.Since(lastConnectedtime).Minutes() |
||||
if timeSinceLastActive > 2 { |
||||
s.ViewerDisconnected(clientID) |
||||
} |
||||
|
||||
} |
||||
} |
||||
|
||||
func (s *Stats) IsStreamConnected() bool { |
||||
if !s.streamConnected { |
||||
return false |
||||
} |
||||
|
||||
// Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available.
|
||||
// So account for that with an artificial buffer.
|
||||
timeSinceLastConnected := time.Since(s.lastConnectTime).Seconds() |
||||
if timeSinceLastConnected < 10 { |
||||
return false |
||||
} |
||||
|
||||
return s.streamConnected |
||||
} |
||||
|
||||
func (s *Stats) GetViewerCount() int { |
||||
return len(s.clients) |
||||
} |
||||
|
||||
func (s *Stats) GetSessionMaxViewerCount() int { |
||||
return s.SessionMaxViewerCount |
||||
} |
||||
|
||||
func (s *Stats) GetOverallMaxViewerCount() int { |
||||
return s.OverallMaxViewerCount |
||||
} |
||||
|
||||
func (s *Stats) SetClientActive(clientID string) { |
||||
// if _, ok := s.clients[clientID]; !ok {
|
||||
// fmt.Println("Marking client active:", clientID, s.GetViewerCount()+1, "clients connected.")
|
||||
// }
|
||||
|
||||
s.clients[clientID] = time.Now() |
||||
s.SessionMaxViewerCount = int(math.Max(float64(s.GetViewerCount()), float64(s.SessionMaxViewerCount))) |
||||
s.OverallMaxViewerCount = int(math.Max(float64(s.SessionMaxViewerCount), float64(s.OverallMaxViewerCount))) |
||||
|
||||
} |
||||
|
||||
func (s *Stats) ViewerDisconnected(clientID string) { |
||||
log.Println("Removed client", clientID) |
||||
|
||||
delete(s.clients, clientID) |
||||
} |
||||
|
||||
func (s *Stats) StreamConnected() { |
||||
s.streamConnected = true |
||||
s.lastConnectTime = time.Now() |
||||
|
||||
timeSinceDisconnect := time.Since(s.LastDisconnectTime).Minutes() |
||||
if timeSinceDisconnect > 15 { |
||||
s.SessionMaxViewerCount = 0 |
||||
} |
||||
} |
||||
|
||||
func (s *Stats) StreamDisconnected() { |
||||
s.streamConnected = false |
||||
s.LastDisconnectTime = time.Now() |
||||
} |
||||
|
||||
func (s *Stats) save() { |
||||
jsonData, err := json.Marshal(&s) |
||||
verifyError(err) |
||||
|
||||
f, err := os.Create("config/stats.json") |
||||
defer f.Close() |
||||
|
||||
verifyError(err) |
||||
|
||||
_, err = f.Write(jsonData) |
||||
verifyError(err) |
||||
} |
||||
|
||||
func getSavedStats() *Stats { |
||||
filePath := "config/stats.json" |
||||
|
||||
if !fileExists(filePath) { |
||||
return &Stats{} |
||||
} |
||||
|
||||
jsonFile, err := ioutil.ReadFile(filePath) |
||||
|
||||
var stats Stats |
||||
err = json.Unmarshal(jsonFile, &stats) |
||||
if err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
return &stats |
||||
} |
@ -1,104 +0,0 @@
@@ -1,104 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"os" |
||||
"path" |
||||
"path/filepath" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
func getTempPipePath() string { |
||||
return filepath.Join(os.TempDir(), "streampipe.flv") |
||||
} |
||||
|
||||
func fileExists(name string) bool { |
||||
if _, err := os.Stat(name); err != nil { |
||||
if os.IsNotExist(err) { |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
func getRelativePathFromAbsolutePath(path string) string { |
||||
pathComponents := strings.Split(path, "/") |
||||
variant := pathComponents[len(pathComponents)-2] |
||||
file := pathComponents[len(pathComponents)-1] |
||||
return filepath.Join(variant, file) |
||||
} |
||||
|
||||
func verifyError(e error) { |
||||
if e != nil { |
||||
log.Panic(e) |
||||
} |
||||
} |
||||
|
||||
func copy(src, dst string) { |
||||
input, err := ioutil.ReadFile(src) |
||||
if err != nil { |
||||
fmt.Println(err) |
||||
return |
||||
} |
||||
|
||||
if err := ioutil.WriteFile(dst, input, 0644); err != nil { |
||||
fmt.Println("Error creating", dst) |
||||
fmt.Println(err) |
||||
return |
||||
} |
||||
} |
||||
|
||||
func resetDirectories(configuration Config) { |
||||
log.Println("Resetting file directories to a clean slate.") |
||||
|
||||
// Wipe the public, web-accessible hls data directory
|
||||
os.RemoveAll(configuration.PublicHLSPath) |
||||
os.RemoveAll(configuration.PrivateHLSPath) |
||||
os.MkdirAll(configuration.PublicHLSPath, 0777) |
||||
os.MkdirAll(configuration.PrivateHLSPath, 0777) |
||||
|
||||
// Remove the previous thumbnail
|
||||
os.Remove("webroot/thumbnail.jpg") |
||||
|
||||
// Create private hls data dirs
|
||||
if !configuration.VideoSettings.EnablePassthrough || len(configuration.VideoSettings.StreamQualities) == 0 { |
||||
for index := range configuration.VideoSettings.StreamQualities { |
||||
os.MkdirAll(path.Join(configuration.PrivateHLSPath, strconv.Itoa(index)), 0777) |
||||
os.MkdirAll(path.Join(configuration.PublicHLSPath, strconv.Itoa(index)), 0777) |
||||
} |
||||
} else { |
||||
os.MkdirAll(path.Join(configuration.PrivateHLSPath, strconv.Itoa(0)), 0777) |
||||
os.MkdirAll(path.Join(configuration.PublicHLSPath, strconv.Itoa(0)), 0777) |
||||
} |
||||
} |
||||
|
||||
func createInitialOfflineState() { |
||||
// Provide default files
|
||||
if !fileExists("webroot/thumbnail.jpg") { |
||||
copy("static/logo.png", "webroot/thumbnail.jpg") |
||||
} |
||||
|
||||
showStreamOfflineState(configuration) |
||||
} |
||||
|
||||
func getClientIDFromRequest(req *http.Request) string { |
||||
var clientID string |
||||
xForwardedFor := req.Header.Get("X-FORWARDED-FOR") |
||||
if xForwardedFor != "" { |
||||
clientID = xForwardedFor |
||||
} else { |
||||
ipAddressString := req.RemoteAddr |
||||
ipAddressComponents := strings.Split(ipAddressString, ":") |
||||
ipAddressComponents[len(ipAddressComponents)-1] = "" |
||||
clientID = strings.Join(ipAddressComponents, ":") |
||||
} |
||||
|
||||
// fmt.Println("IP address determined to be", ipAddress)
|
||||
|
||||
return clientID + req.UserAgent() |
||||
} |
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
package utils |
||||
|
||||
import ( |
||||
"net/http" |
||||
"strings" |
||||
) |
||||
|
||||
//GenerateClientIDFromRequest generates a client id from the provided request
|
||||
func GenerateClientIDFromRequest(req *http.Request) string { |
||||
var clientID string |
||||
|
||||
xForwardedFor := req.Header.Get("X-FORWARDED-FOR") |
||||
if xForwardedFor != "" { |
||||
clientID = xForwardedFor |
||||
} else { |
||||
ipAddressString := req.RemoteAddr |
||||
ipAddressComponents := strings.Split(ipAddressString, ":") |
||||
ipAddressComponents[len(ipAddressComponents)-1] = "" |
||||
clientID = strings.Join(ipAddressComponents, ":") |
||||
} |
||||
|
||||
// fmt.Println("IP address determined to be", ipAddress)
|
||||
|
||||
return clientID + req.UserAgent() |
||||
} |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
package utils |
||||
|
||||
import ( |
||||
"io/ioutil" |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
) |
||||
|
||||
//GetTemporaryPipePath gets the temporary path for the streampipe.flv file
|
||||
func GetTemporaryPipePath() string { |
||||
return filepath.Join(os.TempDir(), "streampipe.flv") |
||||
} |
||||
|
||||
//DoesFileExists checks if the file exists
|
||||
func DoesFileExists(name string) bool { |
||||
if _, err := os.Stat(name); err != nil { |
||||
if os.IsNotExist(err) { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
//GetRelativePathFromAbsolutePath gets the relative path from the provided absolute path
|
||||
func GetRelativePathFromAbsolutePath(path string) string { |
||||
pathComponents := strings.Split(path, "/") |
||||
variant := pathComponents[len(pathComponents)-2] |
||||
file := pathComponents[len(pathComponents)-1] |
||||
|
||||
return filepath.Join(variant, file) |
||||
} |
||||
|
||||
//Copy copies the
|
||||
func Copy(source, destination string) error { |
||||
input, err := ioutil.ReadFile(source) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return ioutil.WriteFile(destination, input, 0644) |
||||
} |
Loading…
Reference in new issue