Project restructure (#18)
* 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>
This commit is contained in:
parent
b0768de6c0
commit
487bd12444
5
.gitignore
vendored
5
.gitignore
vendored
@ -15,10 +15,11 @@
|
||||
vendor/
|
||||
|
||||
#owncast specific
|
||||
config/config.yaml
|
||||
config/stats.json
|
||||
/config.yaml
|
||||
/stats.json
|
||||
owncast
|
||||
webroot/thumbnail.jpg
|
||||
webroot/hls
|
||||
webroot/static/content.md
|
||||
hls/
|
||||
dist/
|
||||
|
@ -77,7 +77,7 @@ The goal is to have a single service that you can run and it works out of the bo
|
||||
### Installation
|
||||
|
||||
1. **TODO: Once it's installable add directions here.**
|
||||
1. Copy config/config-example.yaml to config/config.yaml.
|
||||
1. Copy config-example.yaml to config.yaml.
|
||||
1. Edit the config file and point it to `ffmpeg`
|
||||
1. Set a custom streaming key by editing `streamingKey` in your config.
|
||||
|
||||
@ -91,8 +91,8 @@ The goal is to have a single service that you can run and it works out of the bo
|
||||
|
||||
If you want a simpler way to run an instance of owncast you can run it in a container with the supplied Dockerfile.
|
||||
|
||||
1. Copy `config/config-example.yaml` to `config/config.yaml`
|
||||
1. Edit `config/config.yaml` and change the path of ffmpeg to `/usr/bin/ffmpeg`
|
||||
1. Copy `config-example.yaml` to `config.yaml`
|
||||
1. Edit `config.yaml` and change the path of ffmpeg to `/usr/bin/ffmpeg`
|
||||
1. Make any other config changes.
|
||||
1. Run `docker build -t owncast .` and wait. It may take a few minutes to build.
|
||||
1. Run `docker run -p 8080:8080 -p 1935:1935 -it owncast`
|
||||
@ -162,7 +162,7 @@ Here's a list of some things you can do to increase performance and make things
|
||||
1. Install the [Go toolchain](https://golang.org/dl/).
|
||||
1. Clone the repo. `git clone https://github.com/gabek/owncast`
|
||||
1. Follow the above [Getting Started](#getting-started) instructions, making sure ffmpeg exists and your config file is set.
|
||||
1. `go run *.go` on the first run will download the required packages needed for the application to build.
|
||||
1. `go run main.go` on the first run will download the required packages needed for the application to build.
|
||||
1. It will start running the same as in the above [Usage](#usage) instructions and you can point [OBS to your localhost](#usage-with-obs) instance of it.
|
||||
|
||||
## Roadmap
|
||||
|
@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
type ChunkStorage interface {
|
||||
Setup(config Config)
|
||||
Save(filePath string, retryCount int) string
|
||||
GenerateRemotePlaylist(playlist string, variant Variant) string
|
||||
}
|
112
config.go
112
config.go
@ -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.")
|
||||
}
|
||||
|
||||
}
|
127
config/config.go
Normal file
127
config/config.go
Normal file
@ -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()
|
||||
}
|
24
controllers/index.go
Normal file
24
controllers/index.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
18
controllers/status.go
Normal file
18
controllers/status.go
Normal file
@ -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)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -6,24 +6,26 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/websocket"
|
||||
|
||||
"github.com/gabek/owncast/models"
|
||||
"github.com/gabek/owncast/utils"
|
||||
)
|
||||
|
||||
const channelBufSize = 100
|
||||
|
||||
// Chat client.
|
||||
//Client represents a chat client.
|
||||
type Client struct {
|
||||
id string
|
||||
ws *websocket.Conn
|
||||
server *Server
|
||||
ch chan *ChatMessage
|
||||
pingch chan *PingMessage
|
||||
ch chan models.ChatMessage
|
||||
pingch chan models.PingMessage
|
||||
|
||||
doneCh chan bool
|
||||
}
|
||||
|
||||
// Create new chat client.
|
||||
//NewClient creates a new chat client
|
||||
func NewClient(ws *websocket.Conn, server *Server) *Client {
|
||||
|
||||
if ws == nil {
|
||||
log.Panicln("ws cannot be nil")
|
||||
}
|
||||
@ -32,27 +34,29 @@ func NewClient(ws *websocket.Conn, server *Server) *Client {
|
||||
log.Panicln("server cannot be nil")
|
||||
}
|
||||
|
||||
ch := make(chan *ChatMessage, channelBufSize)
|
||||
ch := make(chan models.ChatMessage, channelBufSize)
|
||||
doneCh := make(chan bool)
|
||||
pingch := make(chan *PingMessage)
|
||||
clientID := getClientIDFromRequest(ws.Request())
|
||||
pingch := make(chan models.PingMessage)
|
||||
clientID := utils.GenerateClientIDFromRequest(ws.Request())
|
||||
|
||||
return &Client{clientID, ws, server, ch, pingch, doneCh}
|
||||
}
|
||||
|
||||
func (c *Client) Conn() *websocket.Conn {
|
||||
//GetConnection gets the connection for the client
|
||||
func (c *Client) GetConnection() *websocket.Conn {
|
||||
return c.ws
|
||||
}
|
||||
|
||||
func (c *Client) Write(msg *ChatMessage) {
|
||||
func (c *Client) Write(msg models.ChatMessage) {
|
||||
select {
|
||||
case c.ch <- msg:
|
||||
default:
|
||||
c.server.Del(c)
|
||||
err := fmt.Errorf("client %d is disconnected.", c.id)
|
||||
c.server.Err(err)
|
||||
c.server.Remove(c)
|
||||
c.server.Err(fmt.Errorf("client %s is disconnected", c.id))
|
||||
}
|
||||
}
|
||||
|
||||
//Done marks the client as done
|
||||
func (c *Client) Done() {
|
||||
c.doneCh <- true
|
||||
}
|
||||
@ -78,7 +82,7 @@ func (c *Client) listenWrite() {
|
||||
|
||||
// receive done request
|
||||
case <-c.doneCh:
|
||||
c.server.Del(c)
|
||||
c.server.Remove(c)
|
||||
c.doneCh <- true // for listenRead method
|
||||
return
|
||||
}
|
||||
@ -92,20 +96,20 @@ func (c *Client) listenRead() {
|
||||
|
||||
// receive done request
|
||||
case <-c.doneCh:
|
||||
c.server.Del(c)
|
||||
c.server.Remove(c)
|
||||
c.doneCh <- true // for listenWrite method
|
||||
return
|
||||
|
||||
// read data from websocket connection
|
||||
default:
|
||||
var msg ChatMessage
|
||||
err := websocket.JSON.Receive(c.ws, &msg)
|
||||
if err == io.EOF {
|
||||
var msg models.ChatMessage
|
||||
|
||||
if err := websocket.JSON.Receive(c.ws, &msg); err == io.EOF {
|
||||
c.doneCh <- true
|
||||
} else if err != nil {
|
||||
c.server.Err(err)
|
||||
} else {
|
||||
c.server.SendAll(&msg)
|
||||
c.server.SendToAll(msg)
|
||||
}
|
||||
}
|
||||
}
|
170
core/chat/server.go
Normal file
170
core/chat/server.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
20
core/constants.go
Normal file
20
core/constants.go
Normal file
@ -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)
|
||||
}
|
76
core/core.go
Normal file
76
core/core.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -10,17 +10,21 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/utils"
|
||||
)
|
||||
|
||||
func showStreamOfflineState(configuration Config) {
|
||||
fmt.Println("----- Stream offline! Showing offline state!")
|
||||
//ShowStreamOfflineState generates and shows the stream's offline state
|
||||
func ShowStreamOfflineState() error {
|
||||
log.Println("----- Stream offline! Showing offline state!")
|
||||
|
||||
var outputDir = configuration.PublicHLSPath
|
||||
var variantPlaylistPath = configuration.PublicHLSPath
|
||||
var outputDir = config.Config.PublicHLSPath
|
||||
var variantPlaylistPath = config.Config.PublicHLSPath
|
||||
|
||||
if configuration.IPFS.Enabled || configuration.S3.Enabled {
|
||||
outputDir = configuration.PrivateHLSPath
|
||||
variantPlaylistPath = configuration.PrivateHLSPath
|
||||
if config.Config.IPFS.Enabled || config.Config.S3.Enabled {
|
||||
outputDir = config.Config.PrivateHLSPath
|
||||
variantPlaylistPath = config.Config.PrivateHLSPath
|
||||
}
|
||||
|
||||
outputDir = path.Join(outputDir, "%v")
|
||||
@ -30,12 +34,12 @@ func showStreamOfflineState(configuration Config) {
|
||||
var streamMaps = make([]string, 0)
|
||||
var videoMapsString = ""
|
||||
var streamMappingString = ""
|
||||
if configuration.VideoSettings.EnablePassthrough || len(configuration.VideoSettings.StreamQualities) == 0 {
|
||||
fmt.Println("Enabling passthrough video")
|
||||
if config.Config.VideoSettings.EnablePassthrough || len(config.Config.VideoSettings.StreamQualities) == 0 {
|
||||
log.Println("Enabling passthrough video for offline state")
|
||||
videoMapsString = "-b:v 1200k -b:a 128k" // Since we're compositing multiple sources we can't infer bitrate, so pick something reasonable.
|
||||
streamMaps = append(streamMaps, fmt.Sprintf("v:%d", 0))
|
||||
} else {
|
||||
for index, quality := range configuration.VideoSettings.StreamQualities {
|
||||
for index, quality := range config.Config.VideoSettings.StreamQualities {
|
||||
maxRate := math.Floor(float64(quality.Bitrate) * 0.8)
|
||||
videoMaps = append(videoMaps, fmt.Sprintf("-map v:0 -c:v:%d libx264 -b:v:%d %dk -maxrate %dk -bufsize %dk", index, index, int(quality.Bitrate), int(maxRate), int(maxRate)))
|
||||
streamMaps = append(streamMaps, fmt.Sprintf("v:%d", index))
|
||||
@ -51,13 +55,13 @@ func showStreamOfflineState(configuration Config) {
|
||||
"-hide_banner",
|
||||
// "-stream_loop 100",
|
||||
// "-fflags", "+genpts",
|
||||
"-i", configuration.VideoSettings.OfflineImage,
|
||||
"-i", config.Config.VideoSettings.OfflineImage,
|
||||
"-i", "webroot/thumbnail.jpg",
|
||||
"-filter_complex", "\"[0:v]scale=2640:2360[bg];[bg][1:v]overlay=200:250:enable='between(t,0,3)'\"",
|
||||
videoMapsString, // All the different video variants
|
||||
"-f hls",
|
||||
// "-hls_list_size " + strconv.Itoa(configuration.Files.MaxNumberInPlaylist),
|
||||
"-hls_time 4", // + strconv.Itoa(configuration.VideoSettings.ChunkLengthInSeconds),
|
||||
// "-hls_list_size " + strconv.Itoa(config.Config.Files.MaxNumberInPlaylist),
|
||||
"-hls_time 4", // + strconv.Itoa(config.Config.VideoSettings.ChunkLengthInSeconds),
|
||||
"-hls_playlist_type", "event",
|
||||
"-master_pl_name", "stream.m3u8",
|
||||
"-strftime 1",
|
||||
@ -66,7 +70,7 @@ func showStreamOfflineState(configuration Config) {
|
||||
"-tune", "zerolatency",
|
||||
"-g " + strconv.Itoa(framerate*2), " -keyint_min " + strconv.Itoa(framerate*2), // multiply your output frame rate * 2. For example, if your input is -framerate 30, then use -g 60
|
||||
"-framerate " + strconv.Itoa(framerate),
|
||||
"-preset " + configuration.VideoSettings.EncoderPreset,
|
||||
"-preset " + config.Config.VideoSettings.EncoderPreset,
|
||||
"-sc_threshold 0", // don't create key frames on scene change - only according to -g
|
||||
"-profile:v", "main", // Main – for standard definition (SD) to 640×480, High – for high definition (HD) to 1920×1080
|
||||
// "-movflags +faststart",
|
||||
@ -80,29 +84,30 @@ func showStreamOfflineState(configuration Config) {
|
||||
|
||||
ffmpegFlagsString := strings.Join(ffmpegFlags, " ")
|
||||
|
||||
ffmpegCmd := configuration.FFMpegPath + " " + ffmpegFlagsString
|
||||
ffmpegCmd := config.Config.FFMpegPath + " " + ffmpegFlagsString
|
||||
|
||||
// fmt.Println(ffmpegCmd)
|
||||
// log.Println(ffmpegCmd)
|
||||
|
||||
_, err := exec.Command("sh", "-c", ffmpegCmd).Output()
|
||||
fmt.Println(err)
|
||||
verifyError(err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func startFfmpeg(configuration Config) {
|
||||
var outputDir = configuration.PublicHLSPath
|
||||
var variantPlaylistPath = configuration.PublicHLSPath
|
||||
//Start starts the ffmpeg process
|
||||
func Start() error {
|
||||
var outputDir = config.Config.PublicHLSPath
|
||||
var variantPlaylistPath = config.Config.PublicHLSPath
|
||||
|
||||
if configuration.IPFS.Enabled || configuration.S3.Enabled {
|
||||
outputDir = configuration.PrivateHLSPath
|
||||
variantPlaylistPath = configuration.PrivateHLSPath
|
||||
if config.Config.IPFS.Enabled || config.Config.S3.Enabled {
|
||||
outputDir = config.Config.PrivateHLSPath
|
||||
variantPlaylistPath = config.Config.PrivateHLSPath
|
||||
}
|
||||
|
||||
outputDir = path.Join(outputDir, "%v")
|
||||
var variantPlaylistName = path.Join(variantPlaylistPath, "%v", "stream.m3u8")
|
||||
|
||||
log.Printf("Starting transcoder saving to /%s.", variantPlaylistName)
|
||||
pipePath := getTempPipePath()
|
||||
pipePath := utils.GetTemporaryPipePath()
|
||||
|
||||
var videoMaps = make([]string, 0)
|
||||
var streamMaps = make([]string, 0)
|
||||
@ -112,8 +117,8 @@ func startFfmpeg(configuration Config) {
|
||||
var streamMappingString = ""
|
||||
var profileString = ""
|
||||
|
||||
if configuration.VideoSettings.EnablePassthrough || len(configuration.VideoSettings.StreamQualities) == 0 {
|
||||
fmt.Println("Enabling passthrough video")
|
||||
if config.Config.VideoSettings.EnablePassthrough || len(config.Config.VideoSettings.StreamQualities) == 0 {
|
||||
log.Println("Enabling passthrough video for stream")
|
||||
streamMaps = append(streamMaps, fmt.Sprintf("v:%d,a:%d", 0, 0))
|
||||
videoMaps = append(videoMaps, "-map v:0 -c:v copy")
|
||||
videoMapsString = strings.Join(videoMaps, " ")
|
||||
@ -121,7 +126,7 @@ func startFfmpeg(configuration Config) {
|
||||
audioMapsString = strings.Join(audioMaps, " ") + " -c:a copy" // Pass through audio for all the variants, don't reencode
|
||||
|
||||
} else {
|
||||
for index, quality := range configuration.VideoSettings.StreamQualities {
|
||||
for index, quality := range config.Config.VideoSettings.StreamQualities {
|
||||
maxRate := math.Floor(float64(quality.Bitrate) * 0.8)
|
||||
videoMaps = append(videoMaps, fmt.Sprintf("-map v:0 -c:v:%d libx264 -b:v:%d %dk -maxrate %dk -bufsize %dk", index, index, int(quality.Bitrate), int(maxRate), int(maxRate)))
|
||||
streamMaps = append(streamMaps, fmt.Sprintf("v:%d,a:%d", index, index))
|
||||
@ -148,15 +153,15 @@ func startFfmpeg(configuration Config) {
|
||||
"-framerate " + strconv.Itoa(framerate),
|
||||
"-g " + strconv.Itoa(framerate*2), " -keyint_min " + strconv.Itoa(framerate*2), // multiply your output frame rate * 2. For example, if your input is -framerate 30, then use -g 60
|
||||
// "-r 25",
|
||||
"-preset " + configuration.VideoSettings.EncoderPreset,
|
||||
"-preset " + config.Config.VideoSettings.EncoderPreset,
|
||||
"-sc_threshold 0", // don't create key frames on scene change - only according to -g
|
||||
profileString,
|
||||
"-movflags +faststart",
|
||||
"-pix_fmt yuv420p",
|
||||
"-f hls",
|
||||
"-hls_list_size " + strconv.Itoa(configuration.Files.MaxNumberInPlaylist),
|
||||
"-hls_list_size " + strconv.Itoa(config.Config.Files.MaxNumberInPlaylist),
|
||||
"-hls_delete_threshold 10", // Keep 10 unreferenced segments on disk before they're deleted.
|
||||
"-hls_time " + strconv.Itoa(configuration.VideoSettings.ChunkLengthInSeconds),
|
||||
"-hls_time " + strconv.Itoa(config.Config.VideoSettings.ChunkLengthInSeconds),
|
||||
"-strftime 1",
|
||||
"-use_localtime 1",
|
||||
"-hls_segment_filename " + path.Join(outputDir, "stream-%Y%m%d-%s.ts"),
|
||||
@ -170,26 +175,26 @@ func startFfmpeg(configuration Config) {
|
||||
|
||||
ffmpegFlagsString := strings.Join(ffmpegFlags, " ")
|
||||
|
||||
ffmpegCmd := "cat " + pipePath + " | " + configuration.FFMpegPath + " " + ffmpegFlagsString
|
||||
ffmpegCmd := "cat " + pipePath + " | " + config.Config.FFMpegPath + " " + ffmpegFlagsString
|
||||
|
||||
// fmt.Println(ffmpegCmd)
|
||||
|
||||
_, err := exec.Command("sh", "-c", ffmpegCmd).Output()
|
||||
fmt.Println(err)
|
||||
verifyError(err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func writePlaylist(data string, filePath string) {
|
||||
//WritePlaylist writes the playlist to disk
|
||||
func WritePlaylist(data string, filePath string) error {
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_, err = f.WriteString(data)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
if _, err := f.WriteString(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,28 +1,34 @@
|
||||
package main
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
)
|
||||
|
||||
func startThumbnailGenerator(chunkPath string) {
|
||||
//StartThumbnailGenerator starts generating thumbnails
|
||||
func StartThumbnailGenerator(chunkPath string) {
|
||||
// Every 20 seconds create a thumbnail from the most
|
||||
// recent video segment.
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
quit := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
fireThumbnailGenerator(chunkPath)
|
||||
if err := fireThumbnailGenerator(chunkPath); err != nil {
|
||||
log.Errorln("Unable to generate thumbnail:", err)
|
||||
}
|
||||
case <-quit:
|
||||
//TODO: evaluate if this is ever stopped
|
||||
log.Println("thumbnail generator has stopped")
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
@ -30,18 +36,14 @@ func startThumbnailGenerator(chunkPath string) {
|
||||
}()
|
||||
}
|
||||
|
||||
func fireThumbnailGenerator(chunkPath string) {
|
||||
framePath := path.Join(chunkPath, "0")
|
||||
files, err := ioutil.ReadDir(framePath)
|
||||
|
||||
func fireThumbnailGenerator(chunkPath string) error {
|
||||
// JPG takes less time to encode than PNG
|
||||
outputFile := path.Join("webroot", "thumbnail.jpg")
|
||||
|
||||
// fmt.Println("Generating thumbnail from", framePath, "to", outputFile)
|
||||
|
||||
framePath := path.Join(chunkPath, "0")
|
||||
files, err := ioutil.ReadDir(framePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
var modTime time.Time
|
||||
@ -63,13 +65,13 @@ func fireThumbnailGenerator(chunkPath string) {
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
mostRecentFile := path.Join(framePath, names[0])
|
||||
|
||||
thumbnailCmdFlags := []string{
|
||||
configuration.FFMpegPath,
|
||||
config.Config.FFMpegPath,
|
||||
"-y", // Overwrite file
|
||||
"-threads 1", // Low priority processing
|
||||
"-t 1", // Pull from frame 1
|
||||
@ -83,8 +85,9 @@ func fireThumbnailGenerator(chunkPath string) {
|
||||
|
||||
// fmt.Println(ffmpegCmd)
|
||||
|
||||
_, err = exec.Command("sh", "-c", ffmpegCmd).Output()
|
||||
if err != nil {
|
||||
log.Errorln("Unable to generate thumbnail: ", err)
|
||||
if _, err := exec.Command("sh", "-c", ffmpegCmd).Output(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
32
core/ffmpeg/verifyInstall.go
Normal file
32
core/ffmpeg/verifyInstall.go
Normal file
@ -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
|
||||
}
|
163
core/playlist/monitor.go
Normal file
163
core/playlist/monitor.go
Normal file
@ -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))
|
||||
}
|
@ -1,79 +1,91 @@
|
||||
package main
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/yutopp/go-flv"
|
||||
flvtag "github.com/yutopp/go-flv/tag"
|
||||
"github.com/yutopp/go-rtmp"
|
||||
yutmp "github.com/yutopp/go-rtmp"
|
||||
rtmpmsg "github.com/yutopp/go-rtmp/message"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/core/ffmpeg"
|
||||
"github.com/gabek/owncast/utils"
|
||||
)
|
||||
|
||||
var _ rtmp.Handler = (*Handler)(nil)
|
||||
var _ yutmp.Handler = (*Handler)(nil)
|
||||
|
||||
// Handler An RTMP connection handler
|
||||
type Handler struct {
|
||||
rtmp.DefaultHandler
|
||||
yutmp.DefaultHandler
|
||||
flvFile *os.File
|
||||
flvEnc *flv.Encoder
|
||||
}
|
||||
|
||||
func (h *Handler) OnServe(conn *rtmp.Conn) {
|
||||
//OnServe handles the "OnServe" of the rtmp service
|
||||
func (h *Handler) OnServe(conn *yutmp.Conn) {
|
||||
}
|
||||
|
||||
//OnConnect handles the "OnConnect" of the rtmp service
|
||||
func (h *Handler) OnConnect(timestamp uint32, cmd *rtmpmsg.NetConnectionConnect) error {
|
||||
// log.Printf("OnConnect: %#v", cmd)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnCreateStream handles the "OnCreateStream" of the rtmp service
|
||||
func (h *Handler) OnCreateStream(timestamp uint32, cmd *rtmpmsg.NetConnectionCreateStream) error {
|
||||
// log.Printf("OnCreateStream: %#v", cmd)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnPublish handles the "OnPublish" of the rtmp service
|
||||
func (h *Handler) OnPublish(timestamp uint32, cmd *rtmpmsg.NetStreamPublish) error {
|
||||
// log.Printf("OnPublish: %#v", cmd)
|
||||
log.Println("Incoming stream connected.")
|
||||
|
||||
if cmd.PublishingName != configuration.VideoSettings.StreamingKey {
|
||||
if cmd.PublishingName != config.Config.VideoSettings.StreamingKey {
|
||||
return errors.New("invalid streaming key; rejecting incoming stream")
|
||||
}
|
||||
|
||||
if stats.IsStreamConnected() {
|
||||
if _isConnected {
|
||||
return errors.New("stream already running; can not overtake an existing stream")
|
||||
}
|
||||
|
||||
// Record streams as FLV
|
||||
p := getTempPipePath()
|
||||
p := utils.GetTemporaryPipePath()
|
||||
syscall.Mkfifo(p, 0666)
|
||||
|
||||
f, err := os.OpenFile(p, os.O_RDWR, os.ModeNamedPipe)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create flv file")
|
||||
return err
|
||||
}
|
||||
h.flvFile = f
|
||||
|
||||
enc, err := flv.NewEncoder(f, flv.FlagsAudio|flv.FlagsVideo)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return errors.Wrap(err, "Failed to create flv encoder")
|
||||
return err
|
||||
}
|
||||
h.flvEnc = enc
|
||||
|
||||
go startFfmpeg(configuration)
|
||||
//TODO: why is this turned into a goroutine?
|
||||
go ffmpeg.Start()
|
||||
|
||||
streamConnected()
|
||||
_isConnected = true
|
||||
core.SetStreamAsConnected()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnSetDataFrame handles the setting of the data frame
|
||||
func (h *Handler) OnSetDataFrame(timestamp uint32, data *rtmpmsg.NetStreamSetDataFrame) error {
|
||||
r := bytes.NewReader(data.Payload)
|
||||
|
||||
@ -96,6 +108,7 @@ func (h *Handler) OnSetDataFrame(timestamp uint32, data *rtmpmsg.NetStreamSetDat
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnAudio handles when we get audio from the rtmp service
|
||||
func (h *Handler) OnAudio(timestamp uint32, payload io.Reader) error {
|
||||
var audio flvtag.AudioData
|
||||
if err := flvtag.DecodeAudioData(payload, &audio); err != nil {
|
||||
@ -129,6 +142,7 @@ func (h *Handler) OnAudio(timestamp uint32, payload io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnVideo handles when we video from the rtmp service
|
||||
func (h *Handler) OnVideo(timestamp uint32, payload io.Reader) error {
|
||||
var video flvtag.VideoData
|
||||
if err := flvtag.DecodeVideoData(payload, &video); err != nil {
|
||||
@ -161,12 +175,14 @@ func (h *Handler) OnVideo(timestamp uint32, payload io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//OnClose handles the closing of the rtmp connection
|
||||
func (h *Handler) OnClose() {
|
||||
log.Printf("OnClose")
|
||||
log.Printf("OnClose of the rtmp service")
|
||||
|
||||
if h.flvFile != nil {
|
||||
_ = h.flvFile.Close()
|
||||
}
|
||||
|
||||
streamDisconnected()
|
||||
_isConnected = false
|
||||
core.SetStreamAsDisconnected()
|
||||
}
|
58
core/rtmp/rtmp.go
Normal file
58
core/rtmp/rtmp.go
Normal file
@ -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
|
||||
}
|
135
core/stats.go
Normal file
135
core/stats.go
Normal file
@ -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
|
||||
}
|
51
core/status.go
Normal file
51
core/status.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
31
core/storage.go
Normal file
31
core/storage.go
Normal file
@ -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
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package storageproviders
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@ -27,8 +27,12 @@ import (
|
||||
"github.com/ipfs/go-ipfs/core/node/libp2p"
|
||||
"github.com/ipfs/go-ipfs/plugin/loader"
|
||||
"github.com/ipfs/go-ipfs/repo/fsrepo"
|
||||
|
||||
ownconfig "github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
)
|
||||
|
||||
//IPFSStorage is the ipfs implementation of the ChunkStorageProvider
|
||||
type IPFSStorage struct {
|
||||
ipfs *icore.CoreAPI
|
||||
node *core.IpfsNode
|
||||
@ -38,27 +42,32 @@ type IPFSStorage struct {
|
||||
gateway string
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) Setup(config Config) {
|
||||
//Setup sets up the ipfs storage for saving the video to ipfs
|
||||
func (s *IPFSStorage) Setup() error {
|
||||
log.Println("Setting up IPFS for external storage of video. Please wait..")
|
||||
|
||||
s.gateway = config.IPFS.Gateway
|
||||
s.gateway = ownconfig.Config.IPFS.Gateway
|
||||
|
||||
s.ctx = context.Background()
|
||||
|
||||
ipfsInstance, node, _ := s.createIPFSInstance()
|
||||
ipfsInstance, node, err := s.createIPFSInstance()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.ipfs = ipfsInstance
|
||||
s.node = node
|
||||
|
||||
s.createIPFSDirectory("./hls")
|
||||
return s.createIPFSDirectory("./hls")
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) Save(filePath string, retryCount int) string {
|
||||
//Save saves the file to the ipfs storage
|
||||
func (s *IPFSStorage) Save(filePath string, retryCount int) (string, error) {
|
||||
someFile, err := getUnixfsNode(filePath)
|
||||
defer someFile.Close()
|
||||
|
||||
if err != nil {
|
||||
log.Panicln(fmt.Errorf("Could not get File: %s", err))
|
||||
return "", err
|
||||
}
|
||||
defer someFile.Close()
|
||||
|
||||
opts := []options.UnixfsAddOption{
|
||||
options.Unixfs.Pin(false),
|
||||
@ -68,30 +77,33 @@ func (s *IPFSStorage) Save(filePath string, retryCount int) string {
|
||||
}
|
||||
|
||||
cidFile, err := (*s.ipfs).Unixfs().Add(s.ctx, someFile, opts...)
|
||||
|
||||
if err != nil {
|
||||
log.Panicln(fmt.Errorf("Could not add File: %s", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// fmt.Printf("Added file to IPFS with CID %s\n", cidFile.String())
|
||||
|
||||
newHash := s.addFileToDirectory(cidFile, filepath.Base(filePath))
|
||||
newHash, err := s.addFileToDirectory(cidFile, filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.gateway + newHash
|
||||
return s.gateway + newHash, nil
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) GenerateRemotePlaylist(playlist string, variant Variant) string {
|
||||
//GenerateRemotePlaylist implements the 'GenerateRemotePlaylist' method
|
||||
func (s *IPFSStorage) GenerateRemotePlaylist(playlist string, variant models.Variant) string {
|
||||
var newPlaylist = ""
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(playlist))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line[0:1] != "#" {
|
||||
fullRemotePath := variant.getSegmentForFilename(line)
|
||||
fullRemotePath := variant.GetSegmentForFilename(line)
|
||||
if fullRemotePath != nil {
|
||||
line = fullRemotePath.RemoteID
|
||||
} else {
|
||||
line = ""
|
||||
} else {
|
||||
line = fullRemotePath.RemoteID
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,8 +136,6 @@ func setupPlugins(externalPluginsPath string) error {
|
||||
func createNode(ctx context.Context, repoPath string) (icore.CoreAPI, *core.IpfsNode, error) {
|
||||
// Open the repo
|
||||
repo, err := fsrepo.Open(repoPath)
|
||||
verifyError(err)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -189,8 +199,7 @@ func spawnEphemeral(ctx context.Context) (icore.CoreAPI, *core.IpfsNode, error)
|
||||
}
|
||||
|
||||
// Spawning an ephemeral IPFS node
|
||||
coreAPI, node, err := createNode(ctx, repoPath)
|
||||
return coreAPI, node, err
|
||||
return createNode(ctx, repoPath)
|
||||
}
|
||||
|
||||
func connectToPeers(ctx context.Context, ipfs icore.CoreAPI, peers []string) error {
|
||||
@ -242,13 +251,12 @@ func getUnixfsNode(path string) (files.Node, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) addFileToDirectory(originalFileHashToModifyPath path.Path, filename string) string {
|
||||
func (s *IPFSStorage) addFileToDirectory(originalFileHashToModifyPath path.Path, filename string) (string, error) {
|
||||
// fmt.Println("directoryToAddTo: "+s.directoryHash, "filename: "+filename, "originalFileHashToModifyPath: "+originalFileHashToModifyPath.String())
|
||||
directoryToAddToPath := path.New(s.directoryHash)
|
||||
newDirectoryHash, err := (*s.ipfs).Object().AddLink(s.ctx, directoryToAddToPath, filename, originalFileHashToModifyPath)
|
||||
|
||||
verifyError(err)
|
||||
return newDirectoryHash.String() + "/" + filename
|
||||
return newDirectoryHash.String() + "/" + filename, err
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) createIPFSInstance() (*icore.CoreAPI, *core.IpfsNode, error) {
|
||||
@ -293,12 +301,19 @@ func (s *IPFSStorage) startIPFSNode() { //} icore.CoreAPI {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IPFSStorage) createIPFSDirectory(directoryName string) {
|
||||
func (s *IPFSStorage) createIPFSDirectory(directoryName string) error {
|
||||
directory, err := getUnixfsNode(directoryName)
|
||||
verifyError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer directory.Close()
|
||||
|
||||
newlyCreatedDirectoryHash, err := (*s.ipfs).Unixfs().Add(s.ctx, directory)
|
||||
verifyError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.directoryHash = newlyCreatedDirectoryHash.String()
|
||||
|
||||
return nil
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package storageproviders
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@ -11,8 +11,12 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/models"
|
||||
)
|
||||
|
||||
//S3Storage is the s3 implementation of the ChunkStorageProvider
|
||||
type S3Storage struct {
|
||||
sess *session.Session
|
||||
host string
|
||||
@ -24,27 +28,30 @@ type S3Storage struct {
|
||||
s3Secret string
|
||||
}
|
||||
|
||||
func (s *S3Storage) Setup(configuration Config) {
|
||||
//Setup sets up the s3 storage for saving the video to s3
|
||||
func (s *S3Storage) Setup() error {
|
||||
log.Println("Setting up S3 for external storage of video...")
|
||||
|
||||
s.s3Endpoint = configuration.S3.Endpoint
|
||||
s.s3Region = configuration.S3.Region
|
||||
s.s3Bucket = configuration.S3.Bucket
|
||||
s.s3AccessKey = configuration.S3.AccessKey
|
||||
s.s3Secret = configuration.S3.Secret
|
||||
s.s3Endpoint = config.Config.S3.Endpoint
|
||||
s.s3Region = config.Config.S3.Region
|
||||
s.s3Bucket = config.Config.S3.Bucket
|
||||
s.s3AccessKey = config.Config.S3.AccessKey
|
||||
s.s3Secret = config.Config.S3.Secret
|
||||
|
||||
s.sess = s.connectAWS()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Save(filePath string, retryCount int) string {
|
||||
//Save saves the file to the s3 bucket
|
||||
func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
|
||||
// fmt.Println("Saving", filePath)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
defer file.Close()
|
||||
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploader := s3manager.NewUploader(s.sess)
|
||||
|
||||
@ -55,30 +62,31 @@ func (s *S3Storage) Save(filePath string, retryCount int) string {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
log.Errorln("error uploading:", err.Error())
|
||||
if retryCount < 4 {
|
||||
log.Println("Retrying...")
|
||||
s.Save(filePath, retryCount+1)
|
||||
return s.Save(filePath, retryCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
// fmt.Println("Uploaded", filePath, "to", response.Location)
|
||||
|
||||
return response.Location
|
||||
return response.Location, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) GenerateRemotePlaylist(playlist string, variant Variant) string {
|
||||
//GenerateRemotePlaylist implements the 'GenerateRemotePlaylist' method
|
||||
func (s *S3Storage) GenerateRemotePlaylist(playlist string, variant models.Variant) string {
|
||||
var newPlaylist = ""
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(playlist))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line[0:1] != "#" {
|
||||
fullRemotePath := variant.getSegmentForFilename(line)
|
||||
if fullRemotePath != nil {
|
||||
line = fullRemotePath.RemoteID
|
||||
} else {
|
||||
fullRemotePath := variant.GetSegmentForFilename(line)
|
||||
if fullRemotePath == nil {
|
||||
line = ""
|
||||
} else {
|
||||
line = fullRemotePath.RemoteID
|
||||
}
|
||||
}
|
||||
|
1
go.mod
1
go.mod
@ -11,7 +11,6 @@ require (
|
||||
github.com/libp2p/go-libp2p-peer v0.2.0
|
||||
github.com/libp2p/go-libp2p-peerstore v0.2.6
|
||||
github.com/multiformats/go-multiaddr v0.2.2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/radovskyb/watcher v1.0.7
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
github.com/yutopp/go-flv v0.2.0
|
||||
|
122
main.go
122
main.go
@ -1,121 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gabek/owncast/config"
|
||||
"github.com/gabek/owncast/core"
|
||||
"github.com/gabek/owncast/router"
|
||||
)
|
||||
|
||||
// Build-time injected values
|
||||
var GitCommit string = "unknown"
|
||||
var BuildVersion string = "0.0.0"
|
||||
var BuildType string = "localdev"
|
||||
|
||||
var storage ChunkStorage
|
||||
var configuration = getConfig()
|
||||
var server *Server
|
||||
var stats *Stats
|
||||
|
||||
var usingExternalStorage = false
|
||||
|
||||
func main() {
|
||||
// logrus.SetReportCaller(true)
|
||||
log.StandardLogger().Printf("Owncast v%s/%s (%s)", BuildVersion, BuildType, GitCommit)
|
||||
log.Println(core.GetVersion())
|
||||
|
||||
checkConfig(configuration)
|
||||
resetDirectories(configuration)
|
||||
//TODO: potentially load the config from a flag like:
|
||||
//configFile := flag.String("configFile", "config.yaml", "Config File full path. Defaults to current folder")
|
||||
// flag.Parse()
|
||||
|
||||
stats = getSavedStats()
|
||||
stats.Setup()
|
||||
|
||||
if configuration.IPFS.Enabled {
|
||||
storage = &IPFSStorage{}
|
||||
usingExternalStorage = true
|
||||
} else if configuration.S3.Enabled {
|
||||
storage = &S3Storage{}
|
||||
usingExternalStorage = true
|
||||
if err := config.Load("config.yaml"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if usingExternalStorage {
|
||||
storage.Setup(configuration)
|
||||
go monitorVideoContent(configuration.PrivateHLSPath, configuration, storage)
|
||||
// starts the core
|
||||
if err := core.Start(); err != nil {
|
||||
log.Println("failed to start the core package")
|
||||
panic(err)
|
||||
}
|
||||
|
||||
createInitialOfflineState()
|
||||
go startRTMPService()
|
||||
|
||||
startWebServer()
|
||||
}
|
||||
|
||||
func startWebServer() {
|
||||
// websocket server
|
||||
server = NewServer("/entry")
|
||||
go server.Listen()
|
||||
|
||||
// static files
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
http.ServeFile(w, r, path.Join("webroot", r.URL.Path))
|
||||
|
||||
if path.Ext(r.URL.Path) == ".m3u8" {
|
||||
clientID := getClientIDFromRequest(r)
|
||||
stats.SetClientActive(clientID)
|
||||
disableCache(&w)
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/status", getStatus)
|
||||
|
||||
log.Printf("Starting public web server on port %d", configuration.WebServerPort)
|
||||
|
||||
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(configuration.WebServerPort), nil))
|
||||
}
|
||||
|
||||
func enableCors(w *http.ResponseWriter) {
|
||||
(*w).Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
func disableCache(w *http.ResponseWriter) {
|
||||
(*w).Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
(*w).Header().Set("Expires", "0")
|
||||
}
|
||||
|
||||
func getStatus(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
|
||||
status := Status{
|
||||
Online: stats.IsStreamConnected(),
|
||||
ViewerCount: stats.GetViewerCount(),
|
||||
OverallMaxViewerCount: stats.GetOverallMaxViewerCount(),
|
||||
SessionMaxViewerCount: stats.GetSessionMaxViewerCount(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func streamConnected() {
|
||||
stats.StreamConnected()
|
||||
|
||||
chunkPath := configuration.PublicHLSPath
|
||||
if usingExternalStorage {
|
||||
chunkPath = configuration.PrivateHLSPath
|
||||
}
|
||||
startThumbnailGenerator(chunkPath)
|
||||
}
|
||||
|
||||
func streamDisconnected() {
|
||||
stats.StreamDisconnected()
|
||||
if configuration.EnableOfflineImage {
|
||||
showStreamOfflineState(configuration)
|
||||
if err := router.Start(); err != nil {
|
||||
log.Println("failed to start/run the router")
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func viewerAdded(clientID string) {
|
||||
stats.SetClientActive(clientID)
|
||||
}
|
||||
|
||||
func viewerRemoved(clientID string) {
|
||||
stats.ViewerDisconnected(clientID)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package main
|
||||
package models
|
||||
|
||||
//ChatMessage represents a single chat message
|
||||
type ChatMessage struct {
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
@ -8,10 +9,8 @@ type ChatMessage struct {
|
||||
MessageType string `json:"type"`
|
||||
}
|
||||
|
||||
func (s *ChatMessage) String() string {
|
||||
//String converts the chat message to string
|
||||
//TODO: is this required? or can we remove it
|
||||
func (s ChatMessage) String() string {
|
||||
return s.Author + " says " + s.Body
|
||||
}
|
||||
|
||||
type PingMessage struct {
|
||||
MessageType string `json:"type"`
|
||||
}
|
6
models/pingMessage.go
Normal file
6
models/pingMessage.go
Normal file
@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
//PingMessage represents a ping message between the client and server
|
||||
type PingMessage struct {
|
||||
MessageType string `json:"type"`
|
||||
}
|
20
models/playlist.go
Normal file
20
models/playlist.go
Normal file
@ -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]
|
||||
}
|
16
models/stats.go
Normal file
16
models/stats.go
Normal file
@ -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 @@
|
||||
package main
|
||||
package models
|
||||
|
||||
//Status represents the status of the system
|
||||
type Status struct {
|
||||
Online bool `json:"online"`
|
||||
ViewerCount int `json:"viewerCount"`
|
8
models/storageProvider.go
Normal file
8
models/storageProvider.go
Normal file
@ -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 @@
|
||||
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)
|
||||
}
|
||||
}
|
10
router/middleware/cors.go
Normal file
10
router/middleware/cors.go
Normal file
@ -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", "*")
|
||||
}
|
11
router/middleware/disableCache.go
Normal file
11
router/middleware/disableCache.go
Normal file
@ -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")
|
||||
}
|
35
router/router.go
Normal file
35
router/router.go
Normal file
@ -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)
|
||||
}
|
48
rtmp.go
48
rtmp.go
@ -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)
|
||||
}
|
||||
}
|
@ -37,12 +37,12 @@ build() {
|
||||
|
||||
echo "Building ${NAME} (${OS}/${ARCH}) release..."
|
||||
|
||||
mkdir -p dist/${NAME}/config
|
||||
mkdir -p dist/${NAME}
|
||||
mkdir -p dist/${NAME}/webroot/static
|
||||
mkdir -p dist/${NAME}/static
|
||||
|
||||
# Default files
|
||||
cp config/config-example.yaml dist/${NAME}/config/config.yaml
|
||||
cp config-example.yaml dist/${NAME}/config.yaml
|
||||
cp webroot/static/content-example.md dist/${NAME}/webroot/static/content.md
|
||||
cp webroot/img/logo.png dist/${NAME}/static/logo.png
|
||||
|
||||
@ -50,7 +50,7 @@ build() {
|
||||
cp -R doc/ dist/${NAME}/doc/
|
||||
cp README.md dist/${NAME}
|
||||
|
||||
env CGO_ENABLED=0 GOOS=$OS GOARCH=$ARCH go build -ldflags "-X main.GitCommit=$GIT_COMMIT -X main.BuildVersion=$VERSION -X main.BuildType=$NAME" -a -o dist/$NAME/owncast
|
||||
env CGO_ENABLED=0 GOOS=$OS GOARCH=$ARCH go build -ldflags "-X core.GitCommit=$GIT_COMMIT -X core.BuildVersion=$VERSION -X core.BuildType=$NAME" -a -o dist/$NAME/owncast
|
||||
|
||||
pushd dist/${NAME} >> /dev/null
|
||||
zip -r -q -8 ../owncast-$NAME-$VERSION.zip .
|
||||
|
164
server.go
164
server.go
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
152
stats.go
152
stats.go
@ -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
|
||||
}
|
104
utils.go
104
utils.go
@ -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()
|
||||
}
|
25
utils/clientId.go
Normal file
25
utils/clientId.go
Normal file
@ -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()
|
||||
}
|
43
utils/utils.go
Normal file
43
utils/utils.go
Normal file
@ -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…
x
Reference in New Issue
Block a user