diff --git a/cmd/dogtamer.go b/cmd/dogtamer.go index e9bd8de..5fc4c50 100644 --- a/cmd/dogtamer.go +++ b/cmd/dogtamer.go @@ -71,6 +71,7 @@ func ActionServe(c *cli.Context) error { // Setup RTMP-server s := server.NewRTMPServer(ctx, cfg.RTMPListenAddr) s.Logger = logger + s.Hostname = cfg.Hostname // Setup web-server webDone := make(chan struct{}) diff --git a/config/config.go b/config/config.go index 3012174..9b1fcbd 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ import ( var ErrNotFound = errors.New("no config file found") type Config struct { + Hostname string `toml:"Hostname"` RTMPListenAddr string `toml:"RTMPListenAddr"` HTTPServerEnable bool `toml:"HTTPServerEnable"` HTTPListenAddr string `toml:"HTTPListenAddr"` @@ -36,6 +37,7 @@ func FromReader(r io.Reader) (*Config, error) { c.HTTPServerEnable = false c.HTTPListenAddr = ":8077" c.LogLevel = "INFO" + c.Hostname = "localhost" decoder := toml.NewDecoder(r) if err := decoder.Decode(&c); err != nil { @@ -56,6 +58,9 @@ func (c *Config) Verify() error { } func (c *Config) UpdateFromEnv() error { + if hostname, found := os.LookupEnv("DOGTAMER_HOSTNAME"); found { + c.Hostname = hostname + } if loglevel, found := os.LookupEnv("DOGTAMER_LOGLEVEL"); found { c.LogLevel = loglevel } diff --git a/dogtamer.example.toml b/dogtamer.example.toml index 909957a..a0d5b10 100644 --- a/dogtamer.example.toml +++ b/dogtamer.example.toml @@ -1,5 +1,10 @@ # dogtamer example config file +# Hostname used when generating m3u playlist +# Default: "localhost +# ENV: DOGTAMER_HOSTNAME +Hostname = "localhost" + # Address to listen to for incoming rtmp connections # Default: ":5566" # ENV: DOGTAMER_RTMPLISTENADDR diff --git a/go.mod b/go.mod index 94fd72d..d24cc31 100644 --- a/go.mod +++ b/go.mod @@ -2,15 +2,18 @@ module github.uio.no/torjus/dogtamer go 1.17 -require github.com/urfave/cli/v2 v2.3.0 +require ( + github.com/dustin/go-humanize v1.0.0 + github.com/go-chi/chi/v5 v5.0.4 + github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 + github.com/pelletier/go-toml v1.9.3 + github.com/urfave/cli/v2 v2.3.0 + go.uber.org/atomic v1.9.0 + go.uber.org/zap v1.19.0 +) require ( github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect - github.com/dustin/go-humanize v1.0.0 // indirect - github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 // indirect - github.com/pelletier/go-toml v1.9.3 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.7.0 // indirect - go.uber.org/zap v1.19.0 // indirect ) diff --git a/go.sum b/go.sum index 6d64f17..1e7a736 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,16 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/go-chi/chi/v5 v5.0.4 h1:5e494iHzsYBiyXQAHHuI4tyJS9M3V84OuX3ufIIGHFo= +github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -14,7 +18,9 @@ github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 h1:PnxRU8L8Y2q82vFC2Qd github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590/go.mod h1:XmAOs6UJXpNXRwKk+KY/nv5kL6xXYXyellk+A1pTlko= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -25,12 +31,14 @@ github.com/spf13/pflag v1.0.4-0.20181223182923-24fa6976df40/go.mod h1:DYY7MBk1bd github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= @@ -38,6 +46,7 @@ go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95a go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -46,12 +55,15 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11 h1:Yq9t9jnGoR+dBuitxdo9l6Q7xh/zOyNnYUtDKaQ3x0E= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/m3u/m3u.go b/m3u/m3u.go new file mode 100644 index 0000000..f7834f8 --- /dev/null +++ b/m3u/m3u.go @@ -0,0 +1,33 @@ +package m3u + +import ( + "bytes" + "fmt" + "io" +) + +type Playlist struct { + items []*PlaylistItem +} + +type PlaylistItem struct { + Name string + Path string + Time int +} + +func (p Playlist) WriteTo(w io.Writer) (int64, error) { + var buf bytes.Buffer + buf.Write([]byte("#EXTM3U\n")) + for i, item := range p.items { + buf.WriteString(fmt.Sprintf("#EXTINF:%d,%s\n%s", item.Time, item.Name, item.Path)) + if i+1 < len(p.items) { + buf.WriteString("\n") + } + } + return io.Copy(w, &buf) +} + +func (p *Playlist) Add(item *PlaylistItem) { + p.items = append(p.items, item) +} diff --git a/m3u/m3u_test.go b/m3u/m3u_test.go new file mode 100644 index 0000000..e16c3fb --- /dev/null +++ b/m3u/m3u_test.go @@ -0,0 +1,42 @@ +package m3u_test + +import ( + "bytes" + "testing" + + "github.uio.no/torjus/dogtamer/m3u" +) + +func TestPlaylist(t *testing.T) { + t.Run("TestWriteSingle", func(t *testing.T) { + var p m3u.Playlist + p.Add(&m3u.PlaylistItem{Name: "TestItem", Path: "rtmp://localhost:5566/view/test", Time: -1}) + + var buf bytes.Buffer + _, err := p.WriteTo(&buf) + if err != nil { + t.Fatalf("Unable to write playlist: %s", err) + } + + expected := "#EXTM3U\n#EXTINF:-1,TestItem\nrtmp://localhost:5566/view/test" + if buf.String() != expected { + t.Errorf("Output does not match expected. Got '%s' want '%s'", buf.String(), expected) + } + }) + t.Run("TestWriteMultiple", func(t *testing.T) { + var p m3u.Playlist + p.Add(&m3u.PlaylistItem{Name: "TestItem", Path: "rtmp://localhost:5566/view/test", Time: -1}) + p.Add(&m3u.PlaylistItem{Name: "TestTwo", Path: "rtmp://localhost:5566/view/testtwo", Time: 5}) + var buf bytes.Buffer + _, err := p.WriteTo(&buf) + if err != nil { + t.Fatalf("Unable to write playlist: %s", err) + } + + expected := "#EXTM3U\n#EXTINF:-1,TestItem\nrtmp://localhost:5566/view/test\n#EXTINF:5,TestTwo\nrtmp://localhost:5566/view/testtwo" + if buf.String() != expected { + t.Errorf("Output does not match expected. Got '%s' want '%s'", buf.String(), expected) + } + }) + +} diff --git a/server/rtmp.go b/server/rtmp.go index d74f798..9ef3e13 100644 --- a/server/rtmp.go +++ b/server/rtmp.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "fmt" "io" "net" "regexp" @@ -16,6 +17,8 @@ import ( "go.uber.org/zap" ) +var ErrNoSuchItem error = fmt.Errorf("no such stream") + type RTMPClient struct { c *rtmp.Conn nc net.Conn @@ -238,6 +241,7 @@ func (client *RTMPClient) handleClient() { type RTMPServer struct { ListenAddr string + Hostname string Logger *zap.SugaredLogger streamsLock sync.Mutex streams map[string]*Stream @@ -248,6 +252,7 @@ type RTMPServer struct { func NewRTMPServer(ctx context.Context, addr string) *RTMPServer { serverCtx, cancel := context.WithCancel(ctx) rs := &RTMPServer{ + Hostname: "localhost", ListenAddr: addr, Logger: zap.NewNop().Sugar(), streams: make(map[string]*Stream), @@ -358,3 +363,31 @@ func (rs *RTMPServer) handleConn(c *rtmp.Conn, nc net.Conn) { // Stream URL is invalid, disconnect nc.Close() } + +type StreamInfo struct { + Name string + Path string +} + +func (s *RTMPServer) List() []*StreamInfo { + var results []*StreamInfo + _, port, _ := net.SplitHostPort(s.ListenAddr) + + for _, stream := range s.streams { + results = append(results, &StreamInfo{Name: stream.Name, Path: fmt.Sprintf("rtmp://%s:%s/view/%s", s.Hostname, port, stream.Name)}) + } + return results +} + +func (s *RTMPServer) GetInfo(name string) (*StreamInfo, error) { + stream, ok := s.streams[name] + if !ok { + return nil, ErrNoSuchItem + } + _, port, _ := net.SplitHostPort(s.ListenAddr) + + return &StreamInfo{ + Name: stream.Name, + Path: fmt.Sprintf("rtmp://%s:%s/view/%s", s.Hostname, port, stream.Name), + }, nil +} diff --git a/server/templates/index.html b/server/templates/index.html index ff26ad2..611aaaf 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -1,8 +1,10 @@ - +
    - {{ range .Streams }} -
  1. {{ .URL }}
  2. - {{ end }} + {{ range .Streams }} +
  3. + {{ .Name }} +
  4. + {{ end }}
\ No newline at end of file diff --git a/server/web.go b/server/web.go index 297aa59..ce23ded 100644 --- a/server/web.go +++ b/server/web.go @@ -7,6 +7,8 @@ import ( "net/http" "time" + "github.com/go-chi/chi/v5" + "github.uio.no/torjus/dogtamer/m3u" "go.uber.org/zap" ) @@ -28,9 +30,12 @@ func NewWebServer(ctx context.Context, rs *RTMPServer) *WebServer { } func (ws *WebServer) Serve() error { + r := chi.NewRouter() + r.Get("/", ws.IndexHandler) + r.Get("/playlist/{name}", ws.PlaylistHandler) ws.httpServer = &http.Server{ Addr: ws.ListenAddr, - Handler: http.HandlerFunc(ws.IndexHandler), + Handler: r, } go func() { @@ -52,22 +57,19 @@ func (ws *WebServer) Serve() error { } func (ws *WebServer) IndexHandler(w http.ResponseWriter, r *http.Request) { - data := struct { + + var data struct { Streams []struct { Name string - URL template.URL + Path template.URL } - }{} - - for _, s := range ws.rtmpServer.streams { - stream := struct { + } + streams := ws.rtmpServer.List() + for _, stream := range streams { + data.Streams = append(data.Streams, struct { Name string - URL template.URL - }{ - Name: s.Name, - URL: template.URL(fmt.Sprintf("rtmp://localhost:5566/view/%s", s.Name)), - } - data.Streams = append(data.Streams, stream) + Path template.URL + }{Name: stream.Name, Path: template.URL(fmt.Sprintf("/playlist/%s", stream.Name))}) } tmpl := template.Must(template.ParseFiles("server/templates/index.html")) @@ -76,3 +78,34 @@ func (ws *WebServer) IndexHandler(w http.ResponseWriter, r *http.Request) { tmpl.Execute(w, data) } + +func (ws *WebServer) PlaylistHandler(w http.ResponseWriter, r *http.Request) { + streamName := chi.URLParam(r, "name") + if streamName == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + streamInfo, err := ws.rtmpServer.GetInfo(streamName) + if err != nil { + if err == ErrNoSuchItem { + ws.Logger.Debugw("Client requested non-existing playlist", "stream_name", streamName, "error", err) + w.WriteHeader(http.StatusNotFound) + return + } + ws.Logger.Warnw("Error getting stream info", "stream_name", streamName, "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + var p m3u.Playlist + p.Add(&m3u.PlaylistItem{Name: streamInfo.Name, Time: -1, Path: streamInfo.Path}) + + w.Header().Add("Content-Type", "application/mpegurl") + w.Header().Add("Content-Disposition", "attachment; filename=stream.m3u") + w.WriteHeader(http.StatusOK) + + _, err = p.WriteTo(w) + if err != nil { + ws.Logger.Warnw("error generating playlist", "stream_name", streamName, "error", err) + } +}