Add playlist-generation

This commit is contained in:
Torjus Håkestad 2021-09-14 22:51:36 +02:00
parent 6e15d9835c
commit 584ffffe83
10 changed files with 192 additions and 23 deletions

View File

@ -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{})

View File

@ -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
}

View File

@ -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

15
go.mod
View File

@ -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
)

12
go.sum
View File

@ -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=

33
m3u/m3u.go Normal file
View File

@ -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)
}

42
m3u/m3u_test.go Normal file
View File

@ -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)
}
})
}

View File

@ -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
}

View File

@ -2,7 +2,9 @@
<html>
<ol>
{{ range .Streams }}
<li>{{ .URL }}</li>
<li>
<a href="{{ .Path }}">{{ .Name }}</a>
</li>
{{ end }}
</ol>
</html>

View File

@ -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)
}
}