commit 3e77835583f48e152b2d6abb6dc03102d89bcded Author: Alexey Khit Date: Thu Aug 18 09:19:00 2022 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0d80b2fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ + +.idea/ + +.tmp/ + +go2rtc.yaml diff --git a/README.md b/README.md new file mode 100644 index 00000000..ab619993 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# go2rtc + +**go2rtc** - ultimate camera streaming application with support RTSP, WebRTC, FFmpeg, RTMP, etc. + +- zero-dependency and zero-config small app for all OS (Windows, macOS, Linux, ARM, etc.) +- zero-delay for all supported protocols (lowest possible streaming latency) +- zero-load on CPU for supported codecs +- on the fly transcoding for unsupported codecs via FFmpeg +- multi-source two-way [codecs negotiation](#codecs-negotiation) +- streaming from private networks via Ngrok or SSH-tunnels + +## Codecs negotiation + +For example, you want to watch stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your browser. + +- this camera support codecs **H264, H265** for send video, and you select `H264` in camera settings +- this camera support codecs **AAC, PCMU, PCMA** for send audio (from mic), and you select `AAC/16000` in camera settings +- this camera support codecs **AAC, PCMU, PCMA** for receive audio (to speaker), you don't need to select them +- your browser support codecs **H264, VP8, VP9, AV1** for receive video, you don't need to select them +- your browser support codecs **OPUS, PCMU, PCMA** for send and receive audio, you don't need to select them +- you can't get camera audio directly, because their audio codecs doesn't match with your browser codecs + - so you decide to use transcoding via FFmpeg and add this setting to config YAML file + - you have chosen `OPUS/48000/2` codec, because it is higher quality than the PCMU/8000 or PCMA/8000 +- now you have stream with two sources - **RTSP and FFmpeg** + +`go2rtc` automatically match codecs for you browser and all your stream sources. This called **multi-source two-way codecs negotiation**. And this is one of the main features of this app. + +**PS.** You can select PCMU or PCMA codec in camera setting and don't use transcoding at all. Or you can select AAC codec for main stream and PCMU codec for second stream and add both RTSP to YAML config, this also will work fine. + +```yaml +streams: + dahua: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + - ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif#audio=opus +``` + +![](codecs.svg) + +## Configuration + +Create file `go2rtc.yaml` next to the app. Modules: + +- [Streams](#streams) + +### Streams + +**go2rtc** support different stream source types. You can setup only one link as stream source or multiple. + +- [RTSP/RTSPS](#rtsp-source) - most cameras on market +- [RTMP](#rtmp-source) +- [FFmpeg/Exec](#ffmpeg-source) - FFmpeg integration +- [Hass](#hass-source) - Home Assistant integration + +#### RTSP source + +- Support **RTSP and RTSPS** links with multiple video and audio tracks +- Support **2 way audio** ONLY for [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) cameras (back channel connection) + +**Attention:** proprietary 2 way audio standards are not supported! + +```yaml +streams: + rtsp_camera: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0 +``` + +If your camera support two RTSP links - you can add both of them as sources. This is useful when streams has different codecs, as example AAC audio with main stream and PCMU/PCMA audio with second stream: + +**Attention:** Dahua cameras has different capabilities for different RTSP links. For example, it has support multiple codecs for two way audio with `&proto=Onvif` in link and only one coded without it. + +```yaml +streams: + onvif_camera: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1 +``` + +#### RTMP source + +You can get stream from RTMP server, for example [Frigate](https://docs.frigate.video/configuration/rtmp). Support ONLY `H264` video codec without audio. + +```yaml +streams: + rtmp_stream: rtmp://192.168.1.123/live/camera1 +``` + +#### FFmpeg source + +You can get any stream or file or device via FFmpeg and push it to go2rtc via RTSP protocol. + +Format: `ffmpeg:{input}#{params}`. Examples: + +```yaml +streams: + # [FILE] all tracks will be copied without transcoding codecs + file1: ffmpeg:~/media/BigBuckBunny.mp4 + + # [FILE] video will be transcoded to H264, audio will be skipped + file2: ffmpeg:~/media/BigBuckBunny.mp4#video=h264 + + # [FILE] video will be copied, audio will be transcoded to pcmu + file3: ffmpeg:~/media/BigBuckBunny.mp4#video=copy&audio=pcmu + + # [HLS] video will be copied, audio will be skipped + hls: ffmpeg:https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear5/prog_index.m3u8#video=copy + + # [MJPEG] video will be transcoded to H264 + mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg?stream=half&fps=15#video=h264 + + # [RTSP] video and audio will be copied + rtsp: ffmpeg:rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0#video=copy&audio=copy +``` + +All trascoding formats has built-in templates. But you can override them via YAML config: + +```yaml +ffmpeg: + bin: ffmpeg # path to ffmpeg binary + link: -hide_banner -i {input} # if input is link + file: -hide_banner -re -stream_loop -1 -i {input} # if input not link + rtsp: -hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input} # if input is RTSP link + output: -rtsp_transport tcp -f rtsp {output} # output + + h264: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1" + h264/ultra: "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency" + h264/high: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency" + h265: "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency" + opus: "-codec:a libopus -ar 48000 -ac 2" + pcmu: "-codec:a pcm_mulaw -ar 8000 -ac 1" + pcmu/16000: "-codec:a pcm_mulaw -ar 16000 -ac 1" + pcmu/48000: "-codec:a pcm_mulaw -ar 48000 -ac 1" + pcma: "-codec:a pcm_alaw -ar 8000 -ac 1" + pcma/16000: "-codec:a pcm_alaw -ar 16000 -ac 1" + pcma/48000: "-codec:a pcm_alaw -ar 48000 -ac 1" + aac/16000: "-codec:a aac -ar 16000 -ac 1" +``` + +#### Exec source + +FFmpeg source just a shortcut to exec source. You can get any stream or file or device via FFmpeg or GStreamer and push it to go2rtc via RTSP protocol: + +```yaml +streams: + stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i ~/media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output} +``` + +#### Hass source + +Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files. + +- support ONLY [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI + +```yaml +hass: + config: "~/.homeassistant" + +streams: + generic_camera: hass:Camera1 # Settings > Integrations > Integration Name +``` + +### API server + +```yaml +api: + listen: ":3000" # HTTP API port + base_path: "" # API prefix for serve on suburl + static_dir: "www" # folder for static files +``` + +### RTSP server + +```yaml +rtsp: + listen: ":554" +``` + +### WebRTC server + +```yaml +webrtc: + listen: ":8555" # address of your local server (TCP) + candidates: + - 216.58.210.174:8555 # if you have static public IP-address + - 192.168.1.123:8555 # ip you have problems with UDP in LAN + - stun # if you have dynamic public IP-address (auto discovery via STUN) + ice_servers: + - urls: [stun:stun.l.google.com:19302] + - urls: [turn:123.123.123.123:3478] + username: your_user + credential: your_pass +``` + +### Ngrok + +```yaml +ngrok: + command: ngrok tcp 8555 --authtoken eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw +``` + +or + +```yaml +ngrok: + command: ngrok start --all --config ngrok.yml +``` + +### Log + +```yaml +log: + level: info # default level + api: trace + exec: debug + ngrok: info + rtsp: warn + streams: error + webrtc: fatal +``` diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 00000000..d3d1388c --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,4 @@ +**Project layout** + +- https://github.com/golang-standards/project-layout +- https://github.com/micro/micro diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 00000000..a33d6bb2 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/gorilla/websocket" + "github.com/rs/zerolog" + "net" + "net/http" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + BasePath string `yaml:"base_path"` + StaticDir string `yaml:"static_dir"` + } `yaml:"api"` + } + + // default config + cfg.Mod.Listen = ":3000" + cfg.Mod.StaticDir = "www" + + // load config from YAML + app.LoadConfig(&cfg) + + if cfg.Mod.Listen == "" { + return + } + + basePath = cfg.Mod.BasePath + log = app.GetLogger("api") + + if cfg.Mod.StaticDir != "" { + fileServer = http.FileServer(http.Dir(cfg.Mod.StaticDir)) + HandleFunc("/", fileServerHandlder) + } + + HandleFunc("/api/stack", stackHandler) + HandleFunc("/api/stats", statsHandler) + HandleFunc("/api/ws", apiWS) + + // ensure we can listen without errors + listener, err := net.Listen("tcp", cfg.Mod.Listen) + if err != nil { + log.Fatal().Err(err).Msg("[api] listen") + } + + log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen") + + go func() { + s := http.Server{} + if err = s.Serve(listener); err != nil { + log.Fatal().Err(err).Msg("[api] Serve") + } + }() +} + +func HandleFunc(pattern string, handler http.HandlerFunc) { + http.HandleFunc(basePath+pattern, handler) +} + +func HandleWS(msgType string, handler WSHandler) { + wsHandlers[msgType] = handler +} + +var basePath string +var fileServer http.Handler +var log zerolog.Logger +var wsHandlers = make(map[string]WSHandler) + +func fileServerHandlder(w http.ResponseWriter, r *http.Request) { + if basePath != "" { + r.URL.Path = r.URL.Path[len(basePath):] + } + fileServer.ServeHTTP(w, r) +} + +func statsHandler(w http.ResponseWriter, _ *http.Request) { + v := map[string]interface{}{ + "streams": streams.Streams, + } + data, err := json.Marshal(v) + if err != nil { + log.Error().Err(err).Msg("[api.stats] marshal") + } + if _, err = w.Write(data); err != nil { + log.Error().Err(err).Msg("[api.stats] write") + } +} + +func apiWS(w http.ResponseWriter, r *http.Request) { + ctx := new(Context) + if err := ctx.Upgrade(w, r); err != nil { + log.Error().Err(err).Msg("[api.ws] upgrade") + return + } + defer ctx.Close() + + for { + msg := new(streamer.Message) + if err := ctx.Conn.ReadJSON(msg); err != nil { + if websocket.IsUnexpectedCloseError( + err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, + ) { + log.Error().Err(err).Msg("[api.ws] readJSON") + } + return + } + + handler := wsHandlers[msg.Type] + if handler != nil { + handler(ctx, msg) + } + } +} diff --git a/cmd/api/stack.go b/cmd/api/stack.go new file mode 100644 index 00000000..13e4e2ec --- /dev/null +++ b/cmd/api/stack.go @@ -0,0 +1,52 @@ +package api + +import ( + "bytes" + "fmt" + "net/http" + "runtime" +) + +var stackSkip = [][]byte{ + // debug.go + []byte("github.com/AlexxIT/go2rtc/cmd/debug.handler"), + + // cmd.go + []byte("github.com/AlexxIT/go2rtc/cmd.Run"), + []byte("created by os/signal.Notify"), + + // api.go + []byte("created by github.com/AlexxIT/go2rtc/cmd/api.Init"), + []byte("created by net/http.(*connReader).startBackgroundRead"), + []byte("created by net/http.(*Server).Serve"), + + []byte("created by github.com/AlexxIT/go2rtc/cmd/rtsp.Init"), +} + +func stackHandler(w http.ResponseWriter, r *http.Request) { + sep := []byte("\n\n") + buf := make([]byte, 65535) + i := 0 + n := runtime.Stack(buf, true) + skipped := 0 + for _, item := range bytes.Split(buf[:n], sep) { + for _, skip := range stackSkip { + if bytes.Contains(item, skip) { + item = nil + skipped++ + break + } + } + if item != nil { + i += copy(buf[i:], item) + i += copy(buf[i:], sep) + } + } + i += copy(buf[i:], fmt.Sprintf( + "Total: %d, Skipped: %d", runtime.NumGoroutine(), skipped), + ) + + if _, err := w.Write(buf[:i]); err != nil { + panic(err) + } +} diff --git a/cmd/api/ws.go b/cmd/api/ws.go new file mode 100644 index 00000000..7fc51343 --- /dev/null +++ b/cmd/api/ws.go @@ -0,0 +1,67 @@ +package api + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/gorilla/websocket" + "net/http" + "sync" +) + +type WSHandler func(ctx *Context, msg *streamer.Message) + +var apiWsUp = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 512000, +} + +type Context struct { + Conn *websocket.Conn + Request *http.Request + Consumer interface{} // TODO: rewrite + + onClose []func() + mu sync.Mutex +} + +func (ctx *Context) Upgrade(w http.ResponseWriter, r *http.Request) (err error) { + ctx.Conn, err = apiWsUp.Upgrade(w, r, nil) + ctx.Request = r + return +} + +func (ctx *Context) Close() { + for _, f := range ctx.onClose { + f() + } + _ = ctx.Conn.Close() +} + +func (ctx *Context) Write(msg interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + var err error + + switch msg := msg.(type) { + case *streamer.Message: + err = ctx.Conn.WriteJSON(msg) + case []byte: + err = ctx.Conn.WriteMessage(websocket.BinaryMessage, msg) + default: + return + } + + if err != nil { + //panic(err) // TODO: fix panic + } +} + +func (ctx *Context) Error(err error) { + ctx.Write(&streamer.Message{ + Type: "error", Value: err.Error(), + }) +} + +func (ctx *Context) OnClose(f func()) { + ctx.onClose = append(ctx.onClose, f) +} diff --git a/cmd/app/app.go b/cmd/app/app.go new file mode 100644 index 00000000..73174c87 --- /dev/null +++ b/cmd/app/app.go @@ -0,0 +1,68 @@ +package app + +import ( + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" + "io" + "os" + "runtime" +) + +func Init() { + data, _ = os.ReadFile("go2rtc.yaml") + + var cfg struct { + Mod map[string]string `yaml:"log"` + } + + LoadConfig(&cfg) + + var writer io.Writer = os.Stdout + + // styles + format := cfg.Mod["format"] + if format != "json" { + writer = zerolog.ConsoleWriter{ + Out: writer, TimeFormat: "15:04:05.000", + NoColor: writer != os.Stdout || format == "text", + } + } + + zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs + + lvl, err := zerolog.ParseLevel(cfg.Mod["level"]) + if err != nil || lvl == zerolog.NoLevel { + lvl = zerolog.InfoLevel + } + + log = zerolog.New(writer).With().Timestamp().Logger().Level(lvl) + + modules = cfg.Mod + + log.Info().Msgf("go2rtc %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func LoadConfig(v interface{}) { + if data != nil { + _ = yaml.Unmarshal(data, v) + } +} + +func GetLogger(module string) zerolog.Logger { + lvl, err := zerolog.ParseLevel(modules[module]) + if err != nil { + return log + } + return log.Level(lvl) +} + +// internal + +// data - config content +var data []byte + +// log - main logger +var log zerolog.Logger + +// modules log levels +var modules map[string]string diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 00000000..357a9d65 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/exec" + "github.com/AlexxIT/go2rtc/cmd/ffmpeg" + "github.com/AlexxIT/go2rtc/cmd/hass" + "github.com/AlexxIT/go2rtc/cmd/mse" + "github.com/AlexxIT/go2rtc/cmd/ngrok" + "github.com/AlexxIT/go2rtc/cmd/rtmp" + "github.com/AlexxIT/go2rtc/cmd/rtsp" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/cmd/webrtc" + "os" + "os/signal" +) + +func Run() { + app.Init() // init config and logs + streams.Init() // load streams list + + rtsp.Init() // add support RTSP client and RTSP server + rtmp.Init() // add support RTMP client + exec.Init() // add support exec scheme (depends on RTSP server) + ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme) + hass.Init() // add support hass scheme + + api.Init() // init HTTP API server + + webrtc.Init() + mse.Init() + + ngrok.Init() + + c := make(chan os.Signal) + signal.Notify(c) + <-c + + println("exit OK") +} diff --git a/cmd/exec/exec.go b/cmd/exec/exec.go new file mode 100644 index 00000000..1a2cc44a --- /dev/null +++ b/cmd/exec/exec.go @@ -0,0 +1,85 @@ +package exec + +import ( + "crypto/md5" + "encoding/hex" + "errors" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/rtsp" + "github.com/AlexxIT/go2rtc/cmd/streams" + pkg "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/rs/zerolog" + "os" + "os/exec" + "strings" + "time" +) + +func Init() { + // depends on RTSP server + if rtsp.Port == "" { + return + } + + rtsp.OnProducer = func(prod streamer.Producer) bool { + if conn := prod.(*pkg.Conn); conn != nil { + if waiter := waiters[conn.URL.Path]; waiter != nil { + waiter <- prod + return true + } + } + return false + } + + streams.HandleFunc("exec", Handle) + + log = app.GetLogger("exec") + + // TODO: add sync.Mutex + waiters = map[string]chan streamer.Producer{} +} + +func Handle(url string) (streamer.Producer, error) { + sum := md5.Sum([]byte(url)) + path := "/" + hex.EncodeToString(sum[:]) + + url = strings.Replace( + url, "{output}", "rtsp://localhost:"+rtsp.Port+path, 1, + ) + + // remove `exec:` + args := strings.Split(url[5:], " ") + cmd := exec.Command(args[0], args[1:]...) + + if log.Trace().Enabled() { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + ch := make(chan streamer.Producer) + + waiters[path] = ch + defer delete(waiters, path) + + log.Debug().Str("url", url).Msg("[exec] run") + + if err := cmd.Start(); err != nil { + log.Error().Err(err).Str("url", url).Msg("[exec]") + return nil, err + } + + select { + case <-time.After(time.Second * 10): + _ = cmd.Process.Kill() + log.Error().Str("url", url).Msg("[exec] timeout") + return nil, errors.New("timeout") + case prod := <-ch: + return prod, nil + } +} + +// internal + +var log zerolog.Logger +var waiters map[string]chan streamer.Producer diff --git a/cmd/ffmpeg/README.md b/cmd/ffmpeg/README.md new file mode 100644 index 00000000..c21b7e65 --- /dev/null +++ b/cmd/ffmpeg/README.md @@ -0,0 +1,6 @@ +## Useful links + +- https://superuser.com/questions/564402/explanation-of-x264-tune +- https://stackoverflow.com/questions/33624016/why-sliced-thread-affect-so-much-on-realtime-encoding-using-ffmpeg-x264 +- https://codec.fandom.com/ru/wiki/X264_-_%D0%BE%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5_%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%B9_%D0%BA%D0%BE%D0%B4%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F +- https://html5test.com/ diff --git a/cmd/ffmpeg/ffmpeg.go b/cmd/ffmpeg/ffmpeg.go new file mode 100644 index 00000000..62b87e6e --- /dev/null +++ b/cmd/ffmpeg/ffmpeg.go @@ -0,0 +1,112 @@ +package ffmpeg + +import ( + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/exec" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "net/url" + "strings" +) + +func Init() { + var cfg struct { + Mod map[string]string `yaml:"ffmpeg"` + } + + // defaults + + cfg.Mod = map[string]string{ + "bin": "ffmpeg", + + // inputs + "link": "-hide_banner -i {input}", + "rtsp": "-hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input}", + "file": "-hide_banner -re -stream_loop -1 -i {input}", + + // output + "out": "-rtsp_transport tcp -f rtsp {output}", + + // `-g 30` - group of picture, GOP, keyframe interval + // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1` + // `-tune zerolatency` - for minimal latency + // `-profile main -level 4.1` - most used streaming profile + "h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1", + "h264/ultra": "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency", + "h264/high": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency", + "h265": "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency", + "opus": "-codec:a libopus -ar 48000 -ac 2", + "pcmu": "-codec:a pcm_mulaw -ar 8000 -ac 1", + "pcmu/16000": "-codec:a pcm_mulaw -ar 16000 -ac 1", + "pcmu/48000": "-codec:a pcm_mulaw -ar 48000 -ac 1", + "pcma": "-codec:a pcm_alaw -ar 8000 -ac 1", + "pcma/16000": "-codec:a pcm_alaw -ar 16000 -ac 1", + "pcma/48000": "-codec:a pcm_alaw -ar 48000 -ac 1", + "aac/16000": "-codec:a aac -ar 16000 -ac 1", + } + + app.LoadConfig(&cfg) + + tpl := cfg.Mod + + streams.HandleFunc("ffmpeg", func(s string) (streamer.Producer, error) { + s = s[7:] // remove `ffmpeg:` + + var query url.Values + if i := strings.IndexByte(s, '#'); i > 0 { + query, _ = url.ParseQuery(s[i+1:]) + s = s[:i] + } + + var template string + switch { + case strings.HasPrefix(s, "rtsp"): + template = tpl["rtsp"] + case strings.Contains(s, "://"): + template = tpl["link"] + default: + template = tpl["file"] + } + + s = "exec:" + tpl["bin"] + " " + + strings.Replace(template, "{input}", s, 1) + + if query != nil { + for _, raw := range query["raw"] { + s += " " + raw + } + + // TODO: multiple codecs via -map + // s += fmt.Sprintf(" -map 0:v:0 -c:v:%d copy", i) + + for _, video := range query["video"] { + if video == "copy" { + s += " -codec:v copy" + } else { + s += " " + tpl[video] + } + } + + for _, audio := range query["audio"] { + if audio == "copy" { + s += " -codec:v copy" + } else { + s += " " + tpl[audio] + } + } + + if query["video"] == nil { + s += " -vn" + } + if query["audio"] == nil { + s += " -an" + } + } else { + s += " -c copy" + } + + s += " " + tpl["out"] + + return exec.Handle(s) + }) +} diff --git a/cmd/hass/hass.go b/cmd/hass/hass.go new file mode 100644 index 00000000..9ad70783 --- /dev/null +++ b/cmd/hass/hass.go @@ -0,0 +1,71 @@ +package hass + +import ( + "encoding/json" + "fmt" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "os" + "path" +) + +func Init() { + var conf struct { + Mod struct { + Config string `yaml:"config"` + } `yaml:"hass"` + } + + app.LoadConfig(&conf) + + filename := path.Join(conf.Mod.Config, ".storage/core.config_entries") + data, err := os.ReadFile(filename) + if err != nil { + return + } + + ent := new(entries) + if err = json.Unmarshal(data, ent); err != nil { + return + } + + urls := map[string]string{} + + for _, entrie := range ent.Data.Entries { + switch entrie.Domain { + case "generic": + if entrie.Options.StreamSource != "" { + urls[entrie.Title] = entrie.Options.StreamSource + } + } + } + + streams.HandleFunc("hass", func(url string) (streamer.Producer, error) { + if hurl := urls[url[5:]]; hurl != "" { + return streams.GetProducer(hurl) + } + return nil, fmt.Errorf("can't get url: %s", url) + }) +} + +type entries struct { + Data struct { + Entries []struct { + Title string `json:"title"` + Domain string `json:"domain"` + Data struct { + ClientID string `json:"iOSPairingId"` + ClientPrivate string `json:"iOSDeviceLTSK"` + ClientPublic string `json:"iOSDeviceLTPK"` + DeviceID string `json:"AccessoryPairingID"` + DevicePublic string `json:"AccessoryLTPK"` + DeviceHost string `json:"AccessoryIP"` + DevicePort uint16 `json:"AccessoryPort"` + } `json:"data"` + Options struct { + StreamSource string `json:"stream_source"` + } + } `json:"entries"` + } `json:"data"` +} diff --git a/cmd/mse/mse.go b/cmd/mse/mse.go new file mode 100644 index 00000000..8aa78de6 --- /dev/null +++ b/cmd/mse/mse.go @@ -0,0 +1,42 @@ +package mse + +import ( + "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/mse" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/rs/zerolog/log" +) + +func Init() { + api.HandleWS("mse", handler) +} + +func handler(ctx *api.Context, msg *streamer.Message) { + url := ctx.Request.URL.Query().Get("url") + stream := streams.Get(url) + if stream == nil { + return + } + + cons := new(mse.Consumer) + cons.UserAgent = ctx.Request.UserAgent() + cons.RemoteAddr = ctx.Request.RemoteAddr + cons.Listen(func(msg interface{}) { + switch msg.(type) { + case *streamer.Message, []byte: + ctx.Write(msg) + } + }) + if err := stream.AddConsumer(cons); err != nil { + log.Warn().Err(err).Msg("[api.mse] Add consumer") + ctx.Error(err) + return + } + + ctx.OnClose(func() { + stream.RemoveConsumer(cons) + }) + + cons.Init() +} diff --git a/cmd/ngrok/ngrok.go b/cmd/ngrok/ngrok.go new file mode 100644 index 00000000..cba3cc5a --- /dev/null +++ b/cmd/ngrok/ngrok.go @@ -0,0 +1,83 @@ +package ngrok + +import ( + "fmt" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/webrtc" + "github.com/AlexxIT/go2rtc/pkg/ngrok" + "github.com/rs/zerolog" + "net" + "strings" +) + +func Init() { + var cfg struct { + Log struct { + Level string `yaml:"ngrok"` + } `yaml:"log"` + Mod struct { + Cmd string `yaml:"command"` + } `yaml:"ngrok"` + } + + app.LoadConfig(&cfg) + + if cfg.Mod.Cmd == "" { + return + } + + log = app.GetLogger(cfg.Log.Level) + + ngr, err := ngrok.NewNgrok(cfg.Mod.Cmd) + if err != nil { + log.Error().Err(err).Msg("[ngrok] start") + } + + ngr.Listen(func(msg interface{}) { + if msg := msg.(*ngrok.Message); msg != nil { + if strings.HasPrefix(msg.Line, "ERROR:") { + log.Warn().Msg("[ngrok] " + msg.Line) + } else { + log.Debug().Msg("[ngrok] " + msg.Line) + } + + // Addr: "//localhost:8555", URL: "tcp://1.tcp.eu.ngrok.io:12345" + if msg.Addr == "//localhost:"+webrtc.Port && strings.HasPrefix(msg.URL, "tcp://") { + // don't know if really necessary use IP + address, err := ConvertHostToIP(msg.URL[6:]) + if err != nil { + log.Warn().Err(err).Msg("[ngrok] add candidate") + return + } + webrtc.AddCandidate(address) + } + } + }) + + go func() { + if err = ngr.Serve(); err != nil { + log.Error().Err(err).Msg("[ngrok] run") + } + }() + +} + +var log zerolog.Logger + +func ConvertHostToIP(address string) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", err + } + + ip, err := net.LookupIP(host) + if err != nil { + return "", err + } + + if len(ip) == 0 { + return "", fmt.Errorf("can't resolve: %s", host) + } + + return ip[0].String() + ":" + port, nil +} diff --git a/cmd/rtmp/rtmp.go b/cmd/rtmp/rtmp.go new file mode 100644 index 00000000..9697aecd --- /dev/null +++ b/cmd/rtmp/rtmp.go @@ -0,0 +1,19 @@ +package rtmp + +import ( + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/rtmp" + "github.com/AlexxIT/go2rtc/pkg/streamer" +) + +func Init() { + streams.HandleFunc("rtmp", handle) +} + +func handle(url string) (streamer.Producer, error) { + conn := rtmp.NewClient(url) + if err := conn.Dial(); err != nil { + return nil, err + } + return conn, nil +} diff --git a/cmd/rtsp/rtsp.go b/cmd/rtsp/rtsp.go new file mode 100644 index 00000000..73bb8afd --- /dev/null +++ b/cmd/rtsp/rtsp.go @@ -0,0 +1,201 @@ +package rtsp + +import ( + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/rs/zerolog" + "net" +) + +func Init() { + var conf struct { + Mod struct { + Listen string `yaml:"listen"` + } `yaml:"rtsp"` + } + + // default config + conf.Mod.Listen = ":554" + + app.LoadConfig(&conf) + + log = app.GetLogger("rtsp") + + // RTSP client support + streams.HandleFunc("rtsp", rtspHandler) + streams.HandleFunc("rtsps", rtspHandler) + + // RTSP server support + address := conf.Mod.Listen + if address != "" { + _, Port, _ = net.SplitHostPort(address) + + go worker(address) + } +} + +var Port string + +var OnProducer func(conn streamer.Producer) bool // TODO: maybe rewrite... + +// internal + +var log zerolog.Logger + +func rtspHandler(url string) (streamer.Producer, error) { + conn, err := rtsp.NewClient(url) + if err != nil { + return nil, err + } + + if log.Trace().Enabled() { + conn.Listen(func(msg interface{}) { + switch msg := msg.(type) { + case *tcp.Request: + log.Trace().Msgf("[rtsp] client request:\n%s", msg) + case *tcp.Response: + log.Trace().Msgf("[rtsp] client response:\n%s", msg) + } + }) + } + + if err = conn.Dial(); err != nil { + return nil, err + } + if err = conn.Describe(); err != nil { + return nil, err + } + + return conn, nil +} + +func worker(address string) { + srv, err := tcp.NewServer(address) + if err != nil { + log.Error().Err(err).Msg("[rtsp] listen") + return + } + + log.Info().Str("addr", address).Msg("[rtsp] listen") + + srv.Listen(func(msg interface{}) { + switch msg.(type) { + case net.Conn: + var name string + var onDisconnect func() + + trace := log.Trace().Enabled() + + conn := rtsp.NewServer(msg.(net.Conn)) + conn.Listen(func(msg interface{}) { + if trace { + switch msg := msg.(type) { + case *tcp.Request: + log.Trace().Msgf("[rtsp] server request:\n%s", msg) + case *tcp.Response: + log.Trace().Msgf("[rtsp] server response:\n%s", msg) + } + } + + switch msg { + case rtsp.MethodDescribe: + name = conn.URL.Path[1:] + + log.Debug().Str("stream", name).Msg("[rtsp] new consumer") + + stream := streams.Get(name) // TODO: rewrite + if stream == nil { + return + } + + initMedias(conn) + + if err = stream.AddConsumer(conn); err != nil { + log.Warn().Err(err).Str("stream", name).Msg("[rtsp]") + return + } + + onDisconnect = func() { + stream.RemoveConsumer(conn) + } + + case rtsp.MethodAnnounce: + if OnProducer != nil { + if OnProducer(conn) { + return + } + } + + name = conn.URL.Path[1:] + + log.Debug().Str("stream", name).Msg("[rtsp] new producer") + + str := streams.Get(conn.URL.Path[1:]) + if str == nil { + return + } + + str.AddProducer(conn) + + onDisconnect = func() { + str.RemoveProducer(conn) + } + + case streamer.StatePlaying: + log.Debug().Str("stream", name).Msg("[rtsp] start") + } + }) + + if err = conn.Accept(); err != nil { + log.Warn().Err(err).Msg("[rtsp] accept") + return + } + + if err = conn.Handle(); err != nil { + //log.Warn().Err(err).Msg("[rtsp] handle server") + } + + if onDisconnect != nil { + onDisconnect() + } + + log.Debug().Str("stream", name).Msg("[rtsp] disconnect") + } + }) + + srv.Serve() +} + +func initMedias(conn *rtsp.Conn) { + // set media candidates from query list + for key, value := range conn.URL.Query() { + switch key { + case streamer.KindVideo, streamer.KindAudio: + for _, value := range value { + media := &streamer.Media{ + Kind: key, Direction: streamer.DirectionRecvonly, + } + + switch value { + case "", "copy": // pass empty codecs list + default: + codec := streamer.NewCodec(value) + media.Codecs = append(media.Codecs, codec) + } + + conn.Medias = append(conn.Medias, media) + } + } + } + + // set default media candidates if query is empty + if conn.Medias == nil { + conn.Medias = []*streamer.Media{ + {Kind: streamer.KindVideo, Direction: streamer.DirectionRecvonly}, + {Kind: streamer.KindAudio, Direction: streamer.DirectionRecvonly}, + } + } +} diff --git a/cmd/streams/handlers.go b/cmd/streams/handlers.go new file mode 100644 index 00000000..aad7be11 --- /dev/null +++ b/cmd/streams/handlers.go @@ -0,0 +1,27 @@ +package streams + +import ( + "fmt" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "strings" +) + +type Handler func(url string) (streamer.Producer, error) + +var handlers map[string]Handler + +func HandleFunc(scheme string, handler Handler) { + if handlers == nil { + handlers = make(map[string]Handler) + } + handlers[scheme] = handler +} + +func GetProducer(url string) (streamer.Producer, error) { + i := strings.IndexByte(url, ':') + handler := handlers[url[:i]] + if handler == nil { + return nil, fmt.Errorf("unsupported scheme: %s", url) + } + return handler(url) +} \ No newline at end of file diff --git a/cmd/streams/producer.go b/cmd/streams/producer.go new file mode 100644 index 00000000..289a5625 --- /dev/null +++ b/cmd/streams/producer.go @@ -0,0 +1,89 @@ +package streams + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "strings" +) + +type state byte + +const ( + stateNone state = iota + stateMedias + stateTracks + stateStart +) + +type Producer struct { + streamer.Element + + url string + element streamer.Producer + tracks []*streamer.Track + + state state +} + +func (p *Producer) GetMedias() []*streamer.Media { + if p.state == stateNone { + i := strings.IndexByte(p.url, ':') + handler := handlers[p.url[:i]] + if handler == nil { + log.Warn().Str("url", p.url).Msg("[streams] unsupported scheme") + return nil + } + + log.Debug().Str("url", p.url).Msg("[streams] probe producer") + + var err error + p.element, err = handler(p.url) + if err != nil { + log.Error().Err(err).Str("url", p.url).Msg("[streams] probe producer") + return nil + } + + p.state = stateMedias + } + + return p.element.GetMedias() +} + +func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track { + if p.state == stateMedias { + p.state = stateTracks + } + + track := p.element.GetTrack(media, codec) + + for _, t := range p.tracks { + if track == t { + return track + } + } + + p.tracks = append(p.tracks, track) + + return track +} + +// internals + +func (p *Producer) start() { + if p.state != stateTracks { + return + } + + log.Debug().Str("url", p.url).Msg("[streams] start producer") + + p.state = stateStart + go p.element.Start() +} + +func (p *Producer) stop() { + log.Debug().Str("url", p.url).Msg("[streams] stop producer") + + _ = p.element.Stop() + p.element = nil + p.tracks = nil + p.state = stateNone +} diff --git a/cmd/streams/stream.go b/cmd/streams/stream.go new file mode 100644 index 00000000..e0f52d9d --- /dev/null +++ b/cmd/streams/stream.go @@ -0,0 +1,164 @@ +package streams + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/streamer" +) + +type Consumer struct { + element streamer.Consumer + tracks []*streamer.Track +} + +type Stream struct { + producers []*Producer + consumers []*Consumer +} + +func newStream(source interface{}) *Stream { + s := new(Stream) + + switch source := source.(type) { + case string: + prod := &Producer{url: source} + s.producers = append(s.producers, prod) + case []interface{}: + for _, source := range source { + prod := &Producer{url: source.(string)} + s.producers = append(s.producers, prod) + } + case map[string]interface{}: + return newStream(source["url"]) + default: + panic("wrong source type") + } + + return s +} + +func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) { + ic := len(s.consumers) + + consumer := &Consumer{element: cons} + + // Step 1. Get consumer medias + for icc, consMedia := range cons.GetMedias() { + log.Trace().Stringer("media", consMedia). + Msgf("[streams] consumer:%d:%d candidate", ic, icc) + + producers: + for ip, prod := range s.producers { + // Step 2. Get producer medias (not tracks yet) + for ipc, prodMedia := range prod.GetMedias() { + log.Trace().Stringer("media", prodMedia). + Msgf("[streams] producer:%d:%d candidate", ip, ipc) + + // Step 3. Match consumer/producer codecs list + prodCodec := prodMedia.MatchMedia(consMedia) + if prodCodec != nil { + log.Trace().Stringer("codec", prodCodec). + Msgf("[streams] match producer:%d:%d => consumer:%d:%d", ip, ipc, ic, icc) + + // Step 4. Get producer track + prodTrack := prod.GetTrack(prodMedia, prodCodec) + + // Step 5. Add track to consumer and get new track + consTrack := consumer.element.AddTrack(consMedia, prodTrack) + + consumer.tracks = append(consumer.tracks, consTrack) + break producers + } + } + } + } + + // can't match tracks for consumer + if len(consumer.tracks) == 0 { + return nil + } + + s.consumers = append(s.consumers, consumer) + + for _, prod := range s.producers { + prod.start() + } + + return nil +} + +func (s *Stream) RemoveConsumer(cons streamer.Consumer) { + for i, consumer := range s.consumers { + if consumer.element == cons { + // remove consumer pads from all producers + for _, track := range consumer.tracks { + track.Unbind() + } + // remove consumer from slice + s.removeConsumer(i) + break + } + } + + for _, producer := range s.producers { + var sink bool + for _, track := range producer.tracks { + if len(track.Sink) > 0 { + sink = true + } + } + if !sink { + producer.stop() + } + } +} + +func (s *Stream) AddProducer(prod streamer.Producer) { + panic("not implemented") +} + +func (s *Stream) RemoveProducer(prod streamer.Producer) { + panic("not implemented") +} + +func (s *Stream) MarshalJSON() ([]byte, error) { + var v []interface{} + for _, prod := range s.producers { + if prod.element != nil { + v = append(v, prod.element) + } + } + for _, cons := range s.consumers { + // cons.element always not nil + v = append(v, cons.element) + } + if len(v) == 0 { + v = nil + } + return json.Marshal(v) +} + +func (s *Stream) removeConsumer(i int) { + switch { + case len(s.consumers) == 1: // only one element + s.consumers = nil + case i == 0: // first element + s.consumers = s.consumers[1:] + case i == len(s.consumers)-1: // last element + s.consumers = s.consumers[:i] + default: // middle element + s.consumers = append(s.consumers[:i], s.consumers[i+1:]...) + } +} + +func (s *Stream) removeProducer(i int) { + switch { + case len(s.producers) == 1: // only one element + s.producers = nil + case i == 0: // first element + s.producers = s.producers[1:] + case i == len(s.producers)-1: // last element + s.producers = s.producers[:i] + default: // middle element + s.producers = append(s.producers[:i], s.producers[i+1:]...) + } +} diff --git a/cmd/streams/stream_test.go b/cmd/streams/stream_test.go new file mode 100644 index 00000000..241bcb9f --- /dev/null +++ b/cmd/streams/stream_test.go @@ -0,0 +1,134 @@ +package streams + +import ( + "github.com/AlexxIT/go2rtc/pkg/fake" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +// Google Chrome 104.0.5112.79 +const chrome = `v=0 +o=- 0 0 IN IP4 0.0.0.0 +s=- +t=0 0 +m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 110 112 113 126 +a=sendrecv +a=rtpmap:111 opus/48000/2 +a=rtpmap:63 red/48000/2 +a=rtpmap:103 ISAC/16000 +a=rtpmap:104 ISAC/32000 +a=rtpmap:9 G722/8000 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:110 telephone-event/48000 +a=rtpmap:112 telephone-event/32000 +a=rtpmap:113 telephone-event/16000 +a=rtpmap:126 telephone-event/8000 +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 35 36 37 38 39 40 41 42 114 115 116 117 118 43 +a=recvonly +a=rtpmap:96 VP8/90000 +a=rtpmap:97 rtx/90000 +a=rtpmap:98 VP9/90000 +a=rtpmap:99 rtx/90000 +a=rtpmap:100 VP9/90000 +a=rtpmap:101 rtx/90000 +a=rtpmap:102 VP9/90000 +a=rtpmap:122 rtx/90000 +a=rtpmap:127 H264/90000 +a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f +a=rtpmap:121 rtx/90000 +a=rtpmap:125 H264/90000 +a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f +a=rtpmap:107 rtx/90000 +a=rtpmap:108 H264/90000 +a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f +a=rtpmap:109 rtx/90000 +a=rtpmap:124 H264/90000 +a=fmtp:124 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f +a=rtpmap:120 rtx/90000 +a=rtpmap:123 H264/90000 +a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f +a=rtpmap:119 rtx/90000 +a=rtpmap:35 H264/90000 +a=fmtp:35 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f +a=rtpmap:36 rtx/90000 +a=rtpmap:37 H264/90000 +a=fmtp:37 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=f4001f +a=rtpmap:38 rtx/90000 +a=rtpmap:39 H264/90000 +a=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=f4001f +a=rtpmap:40 rtx/90000 +a=rtpmap:41 AV1/90000 +a=rtpmap:42 rtx/90000 +a=rtpmap:114 H264/90000 +a=fmtp:114 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f +a=rtpmap:115 rtx/90000 +a=rtpmap:116 red/90000 +a=rtpmap:117 rtx/90000 +a=rtpmap:118 ulpfec/90000 +a=rtpmap:43 flexfec-03/90000 +` + +const dahuaSimple = `v=0 +o=- 0 0 IN IP4 0.0.0.0 +s=- +t=0 0 +m=video 0 RTP/AVP 96 +a=control:trackID=0 +a=rtpmap:96 H264/90000 +a=fmtp:96 packetization-mode=1;profile-level-id=42401E;sprop-parameter-sets=Z0JAHqaAoD2QAA==,aM48gAA= +a=recvonly +m=audio 0 RTP/AVP 97 +a=control:trackID=1 +a=rtpmap:97 MPEG4-GENERIC/16000 +a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408 +a=recvonly +m=audio 0 RTP/AVP 8 +a=control:trackID=5 +a=rtpmap:8 PCMA/8000 +a=sendonly +` + +const ffmpegPCMU48000 = `v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=- +t=0 0 +m=audio 0 RTP/AVP 96 +b=AS:384 +a=rtpmap:96 PCMU/48000/1 +a=control:streamid=0 +` + +func TestRouting(t *testing.T) { + prod := &fake.Producer{} + prod.Medias, _ = streamer.UnmarshalRTSPSDP([]byte(dahuaSimple)) + assert.Len(t, prod.Medias, 3) + + HandleFunc("fake", func(url string) (streamer.Producer, error) { + return prod, nil + }) + + cons := &fake.Consumer{} + cons.Medias, _ = streamer.UnmarshalSDP([]byte(chrome)) + assert.Len(t, cons.Medias, 3) + + // setup stream with one producer + stream := newStream("fake:") + + // main check: + err := stream.AddConsumer(cons) + assert.Nil(t, err) + + assert.Len(t, prod.Tracks, 2) + assert.Len(t, cons.Tracks, 2) + + time.Sleep(time.Second) + + assert.Greater(t, prod.SendPackets,0) + assert.Greater(t, cons.RecvPackets,0) + + assert.Greater(t, prod.RecvPackets,0) + assert.Greater(t, cons.SendPackets,0) +} diff --git a/cmd/streams/streams.go b/cmd/streams/streams.go new file mode 100644 index 00000000..63c08e36 --- /dev/null +++ b/cmd/streams/streams.go @@ -0,0 +1,28 @@ +package streams + +import ( + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/rs/zerolog" +) + +var Streams = map[string]*Stream{} + +func Init() { + var cfg struct { + Mod map[string]interface{} `yaml:"streams"` + } + + app.LoadConfig(&cfg) + + log = app.GetLogger("streams") + + for name, item := range cfg.Mod { + Streams[name] = newStream(item) + } +} + +func Get(name string) *Stream { + return Streams[name] +} + +var log zerolog.Logger diff --git a/cmd/webrtc/webrtc.go b/cmd/webrtc/webrtc.go new file mode 100644 index 00000000..71746e4f --- /dev/null +++ b/cmd/webrtc/webrtc.go @@ -0,0 +1,265 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v3" + "github.com/rs/zerolog" + "io/ioutil" + "net" + "net/http" + "strings" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + Candidates []string `yaml:"candidates"` + IceServers []pion.ICEServer `yaml:"ice_servers"` + } `yaml:"webrtc"` + } + + cfg.Mod.IceServers = []pion.ICEServer{ + {URLs: []string{"stun:stun.l.google.com:19302"}}, + } + + app.LoadConfig(&cfg) + + log = app.GetLogger("webrtc") + + address := cfg.Mod.Listen + pionAPI, err := webrtc.NewAPI(address) + if pionAPI == nil { + log.Error().Err(err).Msg("[webrtc] Init API") + return + } + + if err != nil { + log.Warn().Err(err).Msg("[webrtc] Listen") + } else if address != "" { + log.Info().Str("addr", address).Msg("[webrtc] Listen") + _, Port, _ = net.SplitHostPort(address) + } + + pionConf := pion.Configuration{ + ICEServers: cfg.Mod.IceServers, + SDPSemantics: pion.SDPSemanticsUnifiedPlanWithFallback, + } + + NewPConn = func() (*pion.PeerConnection, error) { + return pionAPI.NewPeerConnection(pionConf) + } + + candidates = cfg.Mod.Candidates + + api.HandleFunc("/api/webrtc", apiHandler) + api.HandleFunc("/api/webrtc/camera", cameraHandler) + api.HandleWS(webrtc.MsgTypeOffer, offerHandler) + api.HandleWS(webrtc.MsgTypeCandidate, candidateHandler) +} + +func AddCandidate(address string) { + log.Info().Str("addr", address).Msg("[webrtc] new candidate") + candidates = append(candidates, address) +} + +var Port string +var log zerolog.Logger +var candidates []string + +var NewPConn func() (*pion.PeerConnection, error) + +func apiHandler(w http.ResponseWriter, r *http.Request) { + url := r.URL.Query().Get("url") + stream := streams.Get(url) + if stream == nil { + return + } + + // get offer + offer, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Error().Err(err).Msg("[webrtc] read offer") + return + } + + // create new webrtc instance + cons := new(webrtc.Conn) + cons.Conn, err = NewPConn() + if err != nil { + log.Error().Err(err).Msg("[webrtc] new conn") + return + } + + cons.UserAgent = r.UserAgent() + cons.Listen(func(msg interface{}) { + if msg == streamer.StateNull { + stream.RemoveConsumer(cons) + } + }) + + if err = stream.AddConsumer(cons); err != nil { + log.Warn().Err(err).Msg("[api.webrtc] add consumer") + return + } + + cons.Init() + + // exchange sdp with waiting all candidates + answer, err := cons.ExchangeSDP(string(offer), true) + + // send SDP to client + if _, err = w.Write([]byte(answer)); err != nil { + log.Error().Err(err).Msg("[api.webrtc] send answer") + } +} + +func cameraHandler(w http.ResponseWriter, r *http.Request) { + url := r.URL.Query().Get("url") + stream := streams.Get(url) + if stream == nil { + return + } + + // get offer + offer, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Error().Err(err).Msg("[webrtc] read offer") + return + } + + // create new webrtc instance + conn := new(webrtc.Conn) + conn.Conn, err = NewPConn() + if err != nil { + log.Error().Err(err).Msg("[webrtc] new conn") + return + } + + conn.UserAgent = r.UserAgent() + conn.Listen(func(msg interface{}) { + switch msg.(type) { + case pion.PeerConnectionState: + if msg == pion.PeerConnectionStateDisconnected { + stream.RemoveConsumer(conn) + } + case streamer.Track: + //stream.AddProducer(conn) + } + }) + + conn.Init() + + // exchange sdp with waiting all candidates + answer, err := conn.ExchangeSDP(string(offer), true) + + // send SDP to client + if _, err = w.Write([]byte(answer)); err != nil { + log.Error().Err(err).Msg("[api.webrtc] send answer") + } +} + +func offerHandler(ctx *api.Context, msg *streamer.Message) { + name := ctx.Request.URL.Query().Get("url") + stream := streams.Get(name) + if stream == nil { + return + } + + log.Debug().Str("stream", name).Msg("[webrtc] new consumer") + + var err error + + // create new webrtc instance + conn := new(webrtc.Conn) + conn.Conn, err = NewPConn() + if err != nil { + log.Error().Err(err).Msg("[webrtc] new conn") + return + } + + conn.UserAgent = ctx.Request.UserAgent() + conn.Listen(func(msg interface{}) { + switch msg := msg.(type) { + case streamer.EventType: + if msg == streamer.StateNull { + stream.RemoveConsumer(conn) + } + case *streamer.Message: + // subscribe on webrtc server candidates + log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] local") + ctx.Write(msg) + } + }) + + // 1. SetOffer, so we can get remote client codecs + offer := msg.Value.(string) + log.Trace().Msgf("[webrtc] offer:\n%s", offer) + + if err = conn.SetOffer(offer); err != nil { + log.Warn().Err(err).Msg("[api.webrtc] set offer") + ctx.Error(err) + return + } + + // 2. AddConsumer, so we get new tracks + if err = stream.AddConsumer(conn); err != nil { + log.Warn().Err(err).Msg("[api.webrtc] add consumer") + ctx.Error(err) + return + } + + conn.Init() + + // exchange sdp without waiting all candidates + //answer, err := conn.ExchangeSDP(offer, false) + answer, err := conn.GetAnswer() + log.Trace().Msgf("[webrtc] answer\n%s", answer) + + if err != nil { + log.Error().Err(err).Msg("[webrtc] get answer") + ctx.Error(err) + return + } + + ctx.Write(&streamer.Message{ + Type: webrtc.MsgTypeAnswer, Value: answer, + }) + + for _, address := range candidates { + if strings.HasPrefix(address, "stun:") { + ip, err := webrtc.GetPublicIP() + if err != nil { + log.Warn().Err(err).Msg("[webrtc] public IP") + continue + } + address = ip.String() + address[4:] + } + + cand, err := webrtc.NewCandidate(address) + if err != nil { + log.Warn().Err(err).Msg("[webrtc] candidate") + continue + } + + conn.Fire(&streamer.Message{ + Type: webrtc.MsgTypeCandidate, Value: cand, + }) + } + + ctx.Consumer = conn +} + +func candidateHandler(ctx *api.Context, msg *streamer.Message) { + if ctx.Consumer == nil { + return + } + if conn := ctx.Consumer.(*webrtc.Conn); conn != nil { + log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] Remote") + conn.Push(msg) + } +} diff --git a/codecs.svg b/codecs.svg new file mode 100644 index 00000000..26f5cbd0 --- /dev/null +++ b/codecs.svg @@ -0,0 +1,4 @@ + + + +
go2rtc Stream
go2rtc Stream
Consumer: WebRTC (Google Chrome)
Consumer: WebRTC (Google Chrome)
Media: video, recvonly
Media: video, recvonly
Codec: H264
Codec: H264
Codec: VP8
Codec: VP8
Codec: VP9
Codec: VP9
Codec: AV1
Codec: AV1
Media: audio, sendrecv
Media: audio, sendrecv
Codec: OPUS/48000/2
Codec: OPUS/48000/2
Codec: PCMU/8000
Codec: PCMU/8000
Codec: PCMA/8000
Codec: PCMA/8000
Producer: RTSP (IPCam Dahua)
Producer: RTSP (IPCam Dahua)
Media: video, sendonly
Media: video, sendonly
Codec: H264
Codec: H264
Media: audio, sendonly
Media: audio, sendonly
Codec: AAC/16000
Codec: AAC/16000
Media: audio, recvonly
Media: audio, recvonly
Codec: PCMA/8000
Codec: PCMA/8000
Producer: RTSP FFmpeg
Producer: RTSP FFmpeg
Media: audio, sendonly
Media: audio, sendonly
Codec: OPUS/48000/2
Codec: OPUS/48000/2
FFmpeg
FFmpeg
Codec: PCMA/8000
Codec: PCMA/8000
Text is not SVG - cannot display
\ No newline at end of file diff --git a/examples/rtsp_client.go b/examples/rtsp_client.go new file mode 100644 index 00000000..6b5a885b --- /dev/null +++ b/examples/rtsp_client.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/pion/rtp" + "os" + "time" +) + +func main() { + client, err := rtsp.NewClient(os.Args[1]) + if err != nil { + panic(err) + } + + if err = client.Dial(); err != nil { + panic(err) + } + if err = client.Describe(); err != nil { + panic(err) + } + + for _, media := range client.GetMedias() { + fmt.Printf("Media: %v\n", media) + + if media.AV() { + track := client.GetTrack(media, media.Codecs[0]) + fmt.Printf("Track: %v, %v\n", track, track.Codec) + + track.Bind(func(packet *rtp.Packet) error { + nalUnitType := packet.Payload[0] & 0x1F + fmt.Printf( + "[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d\n", + track.Codec.Name, nalUnitType, len(packet.Payload), packet.Timestamp, + packet.PayloadType, packet.SSRC, + ) + return nil + }) + } + } + + if err = client.Play(); err != nil { + panic(err) + } + + time.AfterFunc(time.Second*5, func() { + if err = client.Close(); err != nil { + panic(err) + } + }) + + if err = client.Handle(); err != nil { + panic(err) + } + + fmt.Println("The End") +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..f4b9f624 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/AlexxIT/go2rtc + +go 1.17 + +require ( + github.com/deepch/vdk v0.0.19 + github.com/gorilla/websocket v1.5.0 + github.com/pion/ice/v2 v2.2.6 + github.com/pion/interceptor v0.1.11 + github.com/pion/logging v0.2.2 + github.com/pion/rtcp v1.2.9 + github.com/pion/rtp v1.7.13 + github.com/pion/sdp/v3 v3.0.5 + github.com/pion/stun v0.3.5 + github.com/pion/webrtc/v3 v3.1.43 + github.com/rs/zerolog v1.27.0 + github.com/stretchr/testify v1.7.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pion/datachannel v1.5.2 // indirect + github.com/pion/dtls/v2 v2.1.5 // indirect + github.com/pion/mdns v0.0.5 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.8.2 // indirect + github.com/pion/srtp/v2 v2.0.10 // indirect + github.com/pion/transport v0.13.1 // indirect + github.com/pion/turn/v2 v2.0.8 // indirect + github.com/pion/udp v0.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 // indirect + golang.org/x/net v0.0.0-20220630215102-69896b714898 // indirect + golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 // indirect +) + +replace github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..457cad44 --- /dev/null +++ b/go.sum @@ -0,0 +1,221 @@ +github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e h1:NAgHHZB+JUN3/J4/yq1q1EAc8xwJ8bb/Qp0AcjkfzAA= +github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e/go.mod h1:KqQ/KU3hOc4a62l/jPRH5Hiz5fhTq5cGCl8IqeCxWQI= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= +github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pion/datachannel v1.4.21/go.mod h1:oiNyP4gHx2DIwRzX/MFyH0Rz/Gz05OgBlayAI2hAWjg= +github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E= +github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ= +github.com/pion/dtls/v2 v2.0.1/go.mod h1:uMQkz2W0cSqY00xav7WByQ4Hb+18xeQh2oH2fRezr5U= +github.com/pion/dtls/v2 v2.0.2/go.mod h1:27PEO3MDdaCfo21heT59/vsdmZc0zMt9wQPcSlLu/1I= +github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus= +github.com/pion/dtls/v2 v2.1.5 h1:jlh2vtIyUBShchoTDqpCCqiYCyRFJ/lvf/gQ8TALs+c= +github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY= +github.com/pion/ice v0.7.18 h1:KbAWlzWRUdX9SmehBh3gYpIFsirjhSQsCw6K2MjYMK0= +github.com/pion/ice v0.7.18/go.mod h1:+Bvnm3nYC6Nnp7VV6glUkuOfToB/AtMRZpOU8ihuf4c= +github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig= +github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE= +github.com/pion/interceptor v0.1.11 h1:00U6OlqxA3FFB50HSg25J/8cWi7P6FbSzw4eFn24Bvs= +github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0= +github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw= +github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g= +github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k= +github.com/pion/randutil v0.0.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.3/go.mod h1:zGhIv0RPRF0Z1Wiij22pUt5W/c9fevqSzT4jje/oK7I= +github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U= +github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo= +github.com/pion/rtp v1.6.0/go.mod h1:QgfogHsMBVE/RFNno467U/KBqfUywEH+HK+0rtnwsdI= +github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA= +github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= +github.com/pion/sctp v1.7.10/go.mod h1:EhpTUQu1/lcK3xI+eriS6/96fWetHGCvBi9MSsnaBN0= +github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= +github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA= +github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= +github.com/pion/sdp/v2 v2.4.0/go.mod h1:L2LxrOpSTJbAns244vfPChbciR/ReU1KWfG04OpkR7E= +github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU= +github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw= +github.com/pion/srtp v1.5.1 h1:9Q3jAfslYZBt+C69SI/ZcONJh9049JUHZWYRRf5KEKw= +github.com/pion/srtp v1.5.1/go.mod h1:B+QgX5xPeQTNc1CJStJPHzOlHK66ViMDWTT0HZTCkcA= +github.com/pion/srtp/v2 v2.0.9/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4= +github.com/pion/srtp/v2 v2.0.10 h1:b8ZvEuI+mrL8hbr/f1YiJFB34UMrOac3R3N1yq2UN0w= +github.com/pion/srtp/v2 v2.0.10/go.mod h1:XEeSWaK9PfuMs7zxXyiN252AHPbH12NX5q/CFDWtUuA= +github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg= +github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA= +github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= +github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8= +github.com/pion/transport v0.10.0/go.mod h1:BnHnUipd0rZQyTVB2SBGojFHT9CBt5C5TcsJSQGkvSE= +github.com/pion/transport v0.10.1/go.mod h1:PBis1stIILMiis0PewDw91WJeLJkyIMcEk+DwKOzf4A= +github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= +github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A= +github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g= +github.com/pion/transport v0.13.1 h1:/UH5yLeQtwm2VZIPjxwnNFxjS4DFhyLfS4GlfuKUzfA= +github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg= +github.com/pion/turn/v2 v2.0.4/go.mod h1:1812p4DcGVbYVBTiraUmP50XoKye++AMkbfp+N27mog= +github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw= +github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw= +github.com/pion/udp v0.1.0/go.mod h1:BPELIjbwE9PRbd/zxI/KYBnbo7B6+oA6YuEaNE8lths= +github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o= +github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= +github.com/pion/webrtc/v2 v2.2.26/go.mod h1:XMZbZRNHyPDe1gzTIHFcQu02283YO45CbiwFgKvXnmc= +github.com/pion/webrtc/v3 v3.1.41/go.mod h1:sUcW9SFPEWerDqGOBmdYEMfRvbdd7rgwo4bNzfsXww4= +github.com/pion/webrtc/v3 v3.1.43 h1:YT3ZTO94UT4kSBvZnRAH82+0jJPUruiKr9CEstdlQzk= +github.com/pion/webrtc/v3 v3.1.43/go.mod h1:G/J8k0+grVsjC/rjCZ24AKoCCxcFFODgh7zThNZGs0M= +github.com/pkg/errors v0.9.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/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= +github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 h1:y+mHpWoQJNAHt26Nhh6JP7hvM71IRZureyvZhoVALIs= +golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220630215102-69896b714898 h1:K7wO6V1IrczY9QOQ2WkVpw4JQSwCd52UsxVEirZUfiw= +golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 h1:wEZYwx+kK+KlZ0hpvP2Ls1Xr4+RWnlzGFwPP0aiDjIU= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 00000000..7682b4b4 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/cmd" +) + +func main() { + cmd.Run() +} diff --git a/pkg/fake/consumer.go b/pkg/fake/consumer.go new file mode 100644 index 00000000..79abbe3b --- /dev/null +++ b/pkg/fake/consumer.go @@ -0,0 +1,47 @@ +package fake + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/rtp" + "time" +) + +type Consumer struct { + streamer.Element + Medias []*streamer.Media + Tracks []*streamer.Track + + RecvPackets int + SendPackets int +} + +func (c *Consumer) GetMedias() []*streamer.Media { + return c.Medias +} + +func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track { + switch track.Direction { + case streamer.DirectionSendonly: + track = track.Bind(func(packet *rtp.Packet) error { + if track.Codec.PayloadType != packet.PayloadType { + panic("wrong payload type") + } + c.RecvPackets++ + return nil + }) + case streamer.DirectionRecvonly: + go func() { + for { + pkt := &rtp.Packet{} + pkt.PayloadType = track.Codec.PayloadType + if err := track.WriteRTP(pkt); err != nil { + return + } + c.SendPackets++ + time.Sleep(time.Second) + } + }() + } + c.Tracks = append(c.Tracks, track) + return track +} diff --git a/pkg/fake/producer.go b/pkg/fake/producer.go new file mode 100644 index 00000000..6c9f992e --- /dev/null +++ b/pkg/fake/producer.go @@ -0,0 +1,62 @@ +package fake + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/rtp" + "time" +) + +type Producer struct { + streamer.Element + Medias []*streamer.Media + Tracks []*streamer.Track + + RecvPackets int + SendPackets int +} + +func (p *Producer) GetMedias() []*streamer.Media { + return p.Medias +} + +func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track { + if !streamer.Contains(p.Medias, media, codec) { + panic("you shall not pass!") + } + + track := &streamer.Track{Codec: codec, Direction: media.Direction} + + switch media.Direction { + case streamer.DirectionSendonly: + track2 := track.Bind(func(packet *rtp.Packet) error { + p.RecvPackets++ + return nil + }) + p.Tracks = append(p.Tracks, track2) + case streamer.DirectionRecvonly: + p.Tracks = append(p.Tracks, track) + } + + return track +} + +func (p *Producer) Start() error { + for { + for _, track := range p.Tracks { + if track.Direction != streamer.DirectionSendonly { + continue + } + pkt := &rtp.Packet{} + pkt.PayloadType = track.Codec.PayloadType + if err := track.WriteRTP(pkt); err != nil { + return err + } + p.SendPackets++ + } + time.Sleep(time.Second) + } +} + +func (p *Producer) Stop() error { + panic("not implemented") +} diff --git a/pkg/h264/README.md b/pkg/h264/README.md new file mode 100644 index 00000000..8b44d478 --- /dev/null +++ b/pkg/h264/README.md @@ -0,0 +1,27 @@ +## WebRTC + +Video codec | Media string | Device +----------------|--------------|------- +H.264/baseline! | avc1.42E0xx | Chromecast +H.264/baseline! | avc1.42E0xx | Chrome/Safari WebRTC +H.264/baseline! | avc1.42C0xx | FFmpeg ultrafast +H.264/baseline! | avc1.4240xx | Dahua H264B +H.264/baseline | avc1.4200xx | Chrome WebRTC +H.264/main! | avc1.4D40xx | Chromecast +H.264/main! | avc1.4D40xx | FFmpeg superfast main +H.264/main! | avc1.4D40xx | Dahua H264 +H.264/main | avc1.4D00xx | Chrome WebRTC +H.264/high! | avc1.640Cxx | Safari WebRTC +H.264/high | avc1.6400xx | Chromecast +H.264/high | avc1.6400xx | FFmpeg superfast + +## Useful Links + +- [RTP Payload Format for H.264 Video](https://datatracker.ietf.org/doc/html/rfc6184) +- [The H264 Sequence parameter set](https://www.cardinalpeak.com/blog/the-h-264-sequence-parameter-set) +- [H.264 Video Types (Microsoft)](https://docs.microsoft.com/en-us/windows/win32/directshow/h-264-video-types) +- [Automatic Generation of H.264 Parameter Sets to Recover Video File Fragments](https://arxiv.org/pdf/2104.14522.pdf) +- [Chromium sources](https://chromium.googlesource.com/external/webrtc/+/HEAD/common_video/h264) +- [AVC levels](https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels) +- [AVC profiles table](https://developer.mozilla.org/ru/docs/Web/Media/Formats/codecs_parameter) +- [Supported Media for Google Cast](https://developers.google.com/cast/docs/media) diff --git a/pkg/h264/golomb/golomb_reader.go b/pkg/h264/golomb/golomb_reader.go new file mode 100644 index 00000000..80167cd7 --- /dev/null +++ b/pkg/h264/golomb/golomb_reader.go @@ -0,0 +1,87 @@ +package golomb + +import "bytes" + +type Reader struct { + r *bytes.Reader + b byte + shift byte +} + +func NewReader(b []byte) *Reader { + return &Reader{ + r: bytes.NewReader(b), + } +} + +func (g *Reader) ReadBit() (b byte, err error) { + if g.shift == 0 { + if g.b, err = g.r.ReadByte(); err != nil { + return 0, err + } + g.shift = 7 + } else { + g.shift-- + } + b = (g.b >> g.shift) & 0b1 + return +} + +func (g *Reader) ReadBits(n byte) (res uint, err error) { + var b byte + for i := n - 1; i != 255; i-- { + if b, err = g.ReadBit(); err != nil { + return + } + res |= uint(b) << i + } + return +} + +func (g *Reader) ReadUEGolomb() (res uint, err error) { + var b uint + var i byte + for i = 0; i < 32; i++ { + if b, err = g.ReadBits(1); err != nil { + return + } + if b != 0 { + break + } + } + if res, err = g.ReadBits(i); err != nil { + return + } + res += (1 << i) - 1 + return +} + +func (g *Reader) ReadSEGolomb() (res int, err error) { + var b uint + if b, err = g.ReadUEGolomb(); err != nil { + return + } + if b%2 == 0 { + res = -int(b >> 1) + } else { + res = int(b>>1) + } + return +} + +func (g *Reader) ReadByte() (byte, error) { + return g.r.ReadByte() +} + +func (g *Reader) End() bool { + // if only one bit in next byte left + if g.shift == 0 && g.r.Len() == 1 { + b, _ := g.r.ReadByte() + _ = g.r.UnreadByte() + return b == 0x80 + } + if g.r.Len() == 0 { + //panic("not implemented") + } + return false +} diff --git a/pkg/h264/golomb/golomb_writer.go b/pkg/h264/golomb/golomb_writer.go new file mode 100644 index 00000000..fd2f2ad0 --- /dev/null +++ b/pkg/h264/golomb/golomb_writer.go @@ -0,0 +1,56 @@ +package golomb + +import "math/bits" + +type Writer struct { + buf []byte + b byte // last byte + i int // last byte index + shift byte +} + +func NewWriter() *Writer { + return &Writer{i: -1} +} + +func (g *Writer) WriteBit(b byte) { + if g.shift == 0 { + g.buf = append(g.buf, 0) + g.b = 0 + g.i++ + g.shift = 7 + } else { + g.shift-- + } + g.b |= b << g.shift + g.buf[g.i] = g.b +} + +func (g *Writer) WriteBits(b, n byte) { + for i := n - 1; i != 255; i-- { + g.WriteBit((b >> i) & 0b1) + } +} + +func (g *Writer) WriteByte(b byte) { + g.buf = append(g.buf, b) + g.i++ +} + +func (g *Writer) WriteUEGolomb(b byte) { + b++ + n := uint8(bits.Len8(b))*2 - 1 + g.WriteBits(b, n) +} + +func (g *Writer) WriteSEGolomb(b int8) { + if b > 0 { + g.WriteUEGolomb(byte(b)*2 - 1) + } else { + g.WriteUEGolomb(byte(-b) * 2) + } +} + +func (g *Writer) Bytes() []byte { + return g.buf +} diff --git a/pkg/h264/helper.go b/pkg/h264/helper.go new file mode 100644 index 00000000..e4fafd38 --- /dev/null +++ b/pkg/h264/helper.go @@ -0,0 +1,53 @@ +package h264 + +import ( + "encoding/base64" + "encoding/binary" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "strings" +) + +const ( + NALUTypePFrame = 1 + NALUTypeIFrame = 5 + NALUTypeSPS = 7 + NALUTypePPS = 8 + + PayloadTypeAVC = 255 +) + +func NALUType(b []byte) byte { + return b[4] & 0x1F +} + +func EncodeAVC(raw []byte) (avc []byte) { + avc = make([]byte, len(raw)+4) + binary.BigEndian.PutUint32(avc, uint32(len(raw))) + copy(avc[4:], raw) + return +} + +func IsAVC(codec *streamer.Codec) bool { + return codec.PayloadType == PayloadTypeAVC +} + +func GetParameterSet(fmtp string) (sps, pps []byte) { + if fmtp == "" { + return + } + + s := streamer.Between(fmtp, "sprop-parameter-sets=", ";") + if s == "" { + return + } + + i := strings.IndexByte(s, ',') + if i < 0 { + return + } + + sps, _ = base64.StdEncoding.DecodeString(s[:i]) + pps, _ = base64.StdEncoding.DecodeString(s[i+1:]) + + return +} diff --git a/pkg/h264/payloader.go b/pkg/h264/payloader.go new file mode 100644 index 00000000..7e0cfebc --- /dev/null +++ b/pkg/h264/payloader.go @@ -0,0 +1,202 @@ +package h264 + +import "encoding/binary" + +// Payloader payloads H264 packets +type Payloader struct { + IsAVC bool + spsNalu, ppsNalu []byte +} + +const ( + stapaNALUType = 24 + fuaNALUType = 28 + fubNALUType = 29 + spsNALUType = 7 + ppsNALUType = 8 + audNALUType = 9 + fillerNALUType = 12 + + fuaHeaderSize = 2 + //stapaHeaderSize = 1 + //stapaNALULengthSize = 2 + + naluTypeBitmask = 0x1F + naluRefIdcBitmask = 0x60 + //fuStartBitmask = 0x80 + //fuEndBitmask = 0x40 + + outputStapAHeader = 0x78 +) + +//func annexbNALUStartCode() []byte { return []byte{0x00, 0x00, 0x00, 0x01} } + +func emitNalus(nals []byte, isAVC bool, emit func([]byte)) { + if !isAVC { + nextInd := func(nalu []byte, start int) (indStart int, indLen int) { + zeroCount := 0 + + for i, b := range nalu[start:] { + if b == 0 { + zeroCount++ + continue + } else if b == 1 { + if zeroCount >= 2 { + return start + i - zeroCount, zeroCount + 1 + } + } + zeroCount = 0 + } + return -1, -1 + } + + nextIndStart, nextIndLen := nextInd(nals, 0) + if nextIndStart == -1 { + emit(nals) + } else { + for nextIndStart != -1 { + prevStart := nextIndStart + nextIndLen + nextIndStart, nextIndLen = nextInd(nals, prevStart) + if nextIndStart != -1 { + emit(nals[prevStart:nextIndStart]) + } else { + // Emit until end of stream, no end indicator found + emit(nals[prevStart:]) + } + } + } + } else { + for { + end := 4 + binary.BigEndian.Uint32(nals) + emit(nals[4:end]) + if int(end) >= len(nals) { + break + } + nals = nals[end:] + } + } +} + +// Payload fragments a H264 packet across one or more byte arrays +func (p *Payloader) Payload(mtu uint16, payload []byte) [][]byte { + var payloads [][]byte + if len(payload) == 0 { + return payloads + } + + emitNalus(payload, p.IsAVC, func(nalu []byte) { + if len(nalu) == 0 { + return + } + + naluType := nalu[0] & naluTypeBitmask + naluRefIdc := nalu[0] & naluRefIdcBitmask + + switch { + case naluType == audNALUType || naluType == fillerNALUType: + return + case naluType == spsNALUType: + p.spsNalu = nalu + return + case naluType == ppsNALUType: + p.ppsNalu = nalu + return + case p.spsNalu != nil && p.ppsNalu != nil: + // Pack current NALU with SPS and PPS as STAP-A + spsLen := make([]byte, 2) + binary.BigEndian.PutUint16(spsLen, uint16(len(p.spsNalu))) + + ppsLen := make([]byte, 2) + binary.BigEndian.PutUint16(ppsLen, uint16(len(p.ppsNalu))) + + stapANalu := []byte{outputStapAHeader} + stapANalu = append(stapANalu, spsLen...) + stapANalu = append(stapANalu, p.spsNalu...) + stapANalu = append(stapANalu, ppsLen...) + stapANalu = append(stapANalu, p.ppsNalu...) + if len(stapANalu) <= int(mtu) { + out := make([]byte, len(stapANalu)) + copy(out, stapANalu) + payloads = append(payloads, out) + } + + p.spsNalu = nil + p.ppsNalu = nil + } + + // Single NALU + if len(nalu) <= int(mtu) { + out := make([]byte, len(nalu)) + copy(out, nalu) + payloads = append(payloads, out) + return + } + + // FU-A + maxFragmentSize := int(mtu) - fuaHeaderSize + + // The FU payload consists of fragments of the payload of the fragmented + // NAL unit so that if the fragmentation unit payloads of consecutive + // FUs are sequentially concatenated, the payload of the fragmented NAL + // unit can be reconstructed. The NAL unit type octet of the fragmented + // NAL unit is not included as such in the fragmentation unit payload, + // but rather the information of the NAL unit type octet of the + // fragmented NAL unit is conveyed in the F and NRI fields of the FU + // indicator octet of the fragmentation unit and in the type field of + // the FU header. An FU payload MAY have any number of octets and MAY + // be empty. + + naluData := nalu + // According to the RFC, the first octet is skipped due to redundant information + naluDataIndex := 1 + naluDataLength := len(nalu) - naluDataIndex + naluDataRemaining := naluDataLength + + if min(maxFragmentSize, naluDataRemaining) <= 0 { + return + } + + for naluDataRemaining > 0 { + currentFragmentSize := min(maxFragmentSize, naluDataRemaining) + out := make([]byte, fuaHeaderSize+currentFragmentSize) + + // +---------------+ + // |0|1|2|3|4|5|6|7| + // +-+-+-+-+-+-+-+-+ + // |F|NRI| Type | + // +---------------+ + out[0] = fuaNALUType + out[0] |= naluRefIdc + + // +---------------+ + // |0|1|2|3|4|5|6|7| + // +-+-+-+-+-+-+-+-+ + // |S|E|R| Type | + // +---------------+ + + out[1] = naluType + if naluDataRemaining == naluDataLength { + // Set start bit + out[1] |= 1 << 7 + } else if naluDataRemaining-currentFragmentSize == 0 { + // Set end bit + out[1] |= 1 << 6 + } + + copy(out[fuaHeaderSize:], naluData[naluDataIndex:naluDataIndex+currentFragmentSize]) + payloads = append(payloads, out) + + naluDataRemaining -= currentFragmentSize + naluDataIndex += currentFragmentSize + } + }) + + return payloads +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/h264/ps/pps.go b/pkg/h264/ps/pps.go new file mode 100644 index 00000000..9b91bcae --- /dev/null +++ b/pkg/h264/ps/pps.go @@ -0,0 +1,127 @@ +package ps + +import ( + "errors" + "github.com/AlexxIT/go2rtc/pkg/h264/golomb" +) + +const PPSHeader = 0x68 + +// https://www.itu.int/rec/T-REC-H.264 +// 7.3.2.2 Picture parameter set RBSP syntax + +type PPS struct{} + +func (p *PPS) Marshal() []byte { + w := golomb.NewWriter() + + // this is typical PPS for most H264 cameras + w.WriteByte(PPSHeader) + w.WriteUEGolomb(0) // pic_parameter_set_id + w.WriteUEGolomb(0) // seq_parameter_set_id + w.WriteBit(1) // entropy_coding_mode_flag + w.WriteBit(0) // bottom_field_pic_order_in_frame_present_flag + w.WriteUEGolomb(0) // num_slice_groups_minus1 + w.WriteUEGolomb(0) // num_ref_idx_l0_default_active_minus1 + w.WriteUEGolomb(0) // num_ref_idx_l1_default_active_minus1 + w.WriteBit(0) // weighted_pred_flag + w.WriteBits(0, 2) // weighted_bipred_idc + w.WriteSEGolomb(0) // pic_init_qp_minus26 + w.WriteSEGolomb(0) // pic_init_qs_minus26 + w.WriteSEGolomb(0) // chroma_qp_index_offset + w.WriteBit(1) // deblocking_filter_control_present_flag + w.WriteBit(0) // constrained_intra_pred_flag + w.WriteBit(0) // redundant_pic_cnt_present_flag + + w.WriteBit(1) // rbsp_trailing_bits() + + return w.Bytes() +} + +func (p *PPS) Unmarshal(data []byte) (err error) { + r := golomb.NewReader(data) + + var b byte + var u uint + + if b, err = r.ReadByte(); err != nil { + return + } + if b&0x1F != 8 { + err = errors.New("not PPS data") + return + } + + // pic_parameter_set_id + if u, err = r.ReadUEGolomb(); err != nil { + return + } + // seq_parameter_set_id + if u, err = r.ReadUEGolomb(); err != nil { + return + } + // entropy_coding_mode_flag + if b, err = r.ReadBit(); err != nil { + return + } + // bottom_field_pic_order_in_frame_present_flag + if b, err = r.ReadBit(); err != nil { + return + } + + // num_slice_groups_minus1 + if u, err = r.ReadUEGolomb(); err != nil { + return + } + if u > 0 { + //panic("not implemented") + return nil + } + + // num_ref_idx_l0_default_active_minus1 + if _, err = r.ReadUEGolomb(); err != nil { + return + } + // num_ref_idx_l1_default_active_minus1 + if _, err = r.ReadUEGolomb(); err != nil { + return + } + // weighted_pred_flag + if _, err = r.ReadBit(); err != nil { + return + } + // weighted_bipred_idc + if _, err = r.ReadBits(2); err != nil { + return + } + // pic_init_qp_minus26 + if _, err = r.ReadSEGolomb(); err != nil { + return + } + // pic_init_qs_minus26 + if _, err = r.ReadSEGolomb(); err != nil { + return + } + // chroma_qp_index_offset + if _, err = r.ReadSEGolomb(); err != nil { + return + } + // deblocking_filter_control_present_flag + if _, err = r.ReadBit(); err != nil { + return + } + // constrained_intra_pred_flag + if _, err = r.ReadBit(); err != nil { + return + } + // redundant_pic_cnt_present_flag + if _, err = r.ReadBit(); err != nil { + return + } + + if !r.End() { + //panic("not implemented") + } + + return +} diff --git a/pkg/h264/ps/sps.go b/pkg/h264/ps/sps.go new file mode 100644 index 00000000..7ea1e858 --- /dev/null +++ b/pkg/h264/ps/sps.go @@ -0,0 +1,279 @@ +package ps + +import ( + "errors" + "github.com/AlexxIT/go2rtc/pkg/h264/golomb" +) + +const firstByte = 0x67 + +// Google to "h264 specification pdf" +// https://www.itu.int/rec/dologin_pub.asp?lang=e&id=T-REC-H.264-201602-S!!PDF-E&type=items + +type SPS struct { + Profile string + ProfileIDC uint8 + ProfileIOP uint8 + LevelIDC uint8 + Width uint16 + Height uint16 +} + +func NewSPS(profile string, level uint8, width uint16, height uint16) *SPS { + s := &SPS{ + Profile: profile, LevelIDC: level, Width: width, Height: height, + } + s.ProfileIDC, s.ProfileIOP = DecodeProfile(profile) + return s +} + +// https://www.cardinalpeak.com/blog/the-h-264-sequence-parameter-set + +func (s *SPS) Marshal() []byte { + w := golomb.NewWriter() + + // this is typical SPS for most H264 cameras + w.WriteByte(firstByte) + w.WriteByte(s.ProfileIDC) + w.WriteByte(s.ProfileIOP) + w.WriteByte(s.LevelIDC) + + w.WriteUEGolomb(0) // seq_parameter_set_id (0) + w.WriteUEGolomb(0) // log2_max_frame_num_minus4 (depends) + w.WriteUEGolomb(0) // pic_order_cnt_type (0 or 2) + w.WriteUEGolomb(0) // log2_max_pic_order_cnt_lsb_minus4 (depends) + w.WriteUEGolomb(1) // num_ref_frames (1) + w.WriteBit(0) // gaps_in_frame_num_value_allowed_flag (0) + + w.WriteUEGolomb(uint8(s.Width>>4) - 1) // pic_width_in_mbs_minus_1 + w.WriteUEGolomb(uint8(s.Height>>4) - 1) // pic_height_in_map_units_minus_1 + + w.WriteBit(1) // frame_mbs_only_flag (1) + w.WriteBit(1) // direct_8x8_inference_flag (1) + w.WriteBit(0) // frame_cropping_flag (0 is OK) + w.WriteBit(0) // vui_prameters_present_flag (0 is OK) + w.WriteBit(1) // rbsp_stop_one_bit + + return w.Bytes() +} + +func (s *SPS) Unmarshal(data []byte) (err error) { + r := golomb.NewReader(data) + + var b byte + var u uint + + if b, err = r.ReadByte(); err != nil { + return + } + if b&0x1F != 7 { + err = errors.New("not SPS data") + return + } + + if s.ProfileIDC, err = r.ReadByte(); err != nil { + return + } + if s.ProfileIOP, err = r.ReadByte(); err != nil { + return + } + if s.LevelIDC, err = r.ReadByte(); err != nil { + return + } + + s.Profile = EncodeProfile(s.ProfileIDC, s.ProfileIOP) + + u, err = r.ReadUEGolomb() // seq_parameter_set_id + + if s.ProfileIDC == 100 || s.ProfileIDC == 110 || s.ProfileIDC == 122 || + s.ProfileIDC == 244 || s.ProfileIDC == 44 || s.ProfileIDC == 83 || + s.ProfileIDC == 86 || s.ProfileIDC == 118 || s.ProfileIDC == 128 || + s.ProfileIDC == 138 || s.ProfileIDC == 139 || s.ProfileIDC == 134 || + s.ProfileIDC == 135 { + var n byte + + u, err = r.ReadUEGolomb() // chroma_format_idc + if u == 3 { + b, err = r.ReadBit() // separate_colour_plane_flag + n = 12 + } else { + n = 8 + } + + u, err = r.ReadUEGolomb() // bit_depth_luma_minus8 + u, err = r.ReadUEGolomb() // bit_depth_chroma_minus8 + b, err = r.ReadBit() // qpprime_y_zero_transform_bypass_flag + + b, err = r.ReadBit() // seq_scaling_matrix_present_flag + if b > 0 { + for i := byte(0); i < n; i++ { + b, err = r.ReadBit() // seq_scaling_list_present_flag[i] + if b > 0 { + panic("not implemented") + } + } + } + } + + u, err = r.ReadUEGolomb() // log2_max_frame_num_minus4 + + u, err = r.ReadUEGolomb() // pic_order_cnt_type + switch u { + case 0: + u, err = r.ReadUEGolomb() // log2_max_pic_order_cnt_lsb_minus4 + case 1: + b, err = r.ReadBit() // delta_pic_order_always_zero_flag + _, err = r.ReadSEGolomb() // offset_for_non_ref_pic + _, err = r.ReadSEGolomb() // offset_for_top_to_bottom_field + u, err = r.ReadUEGolomb() // num_ref_frames_in_pic_order_cnt_cycle + for i := byte(0); i < b; i++ { + _, err = r.ReadSEGolomb() // offset_for_ref_frame[i] + } + } + + u, err = r.ReadUEGolomb() // num_ref_frames + b, err = r.ReadBit() // gaps_in_frame_num_value_allowed_flag + + u, err = r.ReadUEGolomb() // pic_width_in_mbs_minus_1 + s.Width = uint16(u+1) << 4 + u, err = r.ReadUEGolomb() // pic_height_in_map_units_minus_1 + s.Height = uint16(u+1) << 4 + + b, err = r.ReadBit() // frame_mbs_only_flag + if b == 0 { + _, err = r.ReadBit() + } + + b, err = r.ReadBit() // direct_8x8_inference_flag + + b, err = r.ReadBit() // frame_cropping_flag + if b > 0 { + u, err = r.ReadUEGolomb() // frame_crop_left_offset + s.Width -= uint16(u) << 1 + u, err = r.ReadUEGolomb() // frame_crop_right_offset + s.Width -= uint16(u) << 1 + u, err = r.ReadUEGolomb() // frame_crop_top_offset + s.Height -= uint16(u) << 1 + u, err = r.ReadUEGolomb() // frame_crop_bottom_offset + s.Height -= uint16(u) << 1 + } + + b, err = r.ReadBit() // vui_prameters_present_flag + if b > 0 { + b, err = r.ReadBit() // vui_prameters_present_flag + if b > 0 { + u, err = r.ReadBits(8) // aspect_ratio_idc + if b == 255 { + u, err = r.ReadBits(16) // sar_width + u, err = r.ReadBits(16) // sar_height + } + } + + b, err = r.ReadBit() // overscan_info_present_flag + if b > 0 { + b, err = r.ReadBit() // overscan_appropriate_flag + } + + b, err = r.ReadBit() // video_signal_type_present_flag + if b > 0 { + u, err = r.ReadBits(3) // video_format + b, err = r.ReadBit() // video_full_range_flag + + b, err = r.ReadBit() // colour_description_present_flag + if b > 0 { + u, err = r.ReadBits(8) // colour_primaries + u, err = r.ReadBits(8) // transfer_characteristics + u, err = r.ReadBits(8) // matrix_coefficients + } + } + + b, err = r.ReadBit() // chroma_loc_info_present_flag + if b > 0 { + u, err = r.ReadUEGolomb() // chroma_sample_loc_type_top_field + u, err = r.ReadUEGolomb() // chroma_sample_loc_type_bottom_field + } + + b, err = r.ReadBit() // timing_info_present_flag + if b > 0 { + u, err = r.ReadBits(32) // num_units_in_tick + u, err = r.ReadBits(32) // time_scale + b, err = r.ReadBit() // fixed_frame_rate_flag + } + + b, err = r.ReadBit() // nal_hrd_parameters_present_flag + if b > 0 { + //panic("not implemented") + return nil + } + + b, err = r.ReadBit() // vcl_hrd_parameters_present_flag + if b > 0 { + //panic("not implemented") + return nil + } + + // if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) + // b, err = r.ReadBit() // low_delay_hrd_flag + + b, err = r.ReadBit() // pic_struct_present_flag + + b, err = r.ReadBit() // bitstream_restriction_flag + if b > 0 { + b, err = r.ReadBit() // motion_vectors_over_pic_boundaries_flag + u, err = r.ReadUEGolomb() // max_bytes_per_pic_denom + u, err = r.ReadUEGolomb() // max_bits_per_mb_denom + u, err = r.ReadUEGolomb() // log2_max_mv_length_horizontal + u, err = r.ReadUEGolomb() // log2_max_mv_length_vertical + u, err = r.ReadUEGolomb() // max_num_reorder_frames + u, err = r.ReadUEGolomb() // max_dec_frame_buffering + } + } + + b, err = r.ReadBit() // rbsp_stop_one_bit + + return +} + +func EncodeProfile(idc, iop byte) string { + // https://datatracker.ietf.org/doc/html/rfc6184#page-41 + switch { + // 4240xx 42C0xx 42E0xx + case idc == 0x42 && iop&0b01001111 == 0b01000000: + return "CB" + case idc == 0x4D && iop&0b10001111 == 0b10000000: + return "CB" + case idc == 0x58 && iop&0b11001111 == 0b11000000: + return "CB" + // 4200xx + case idc == 0x42 && iop&0b01001111 == 0: + return "B" + case idc == 0x58 && iop&0b11001111 == 0b10000000: + return "B" + // 4d40xx + case idc == 0x4D && iop&0b10101111 == 0: + return "M" + case idc == 0x58 && iop&0b11001111 == 0: + return "E" + case idc == 0x64 && iop == 0: + return "H" + case idc == 0x6E && iop == 0: + return "H10" + } + return "" +} + +func DecodeProfile(profile string) (idc, iop byte) { + switch profile { + case "CB": + return 0x42, 0b01000000 + case "B": + return 0x42, 0 // 66 + case "M": + return 0x4D, 0 // 77 + case "E": + return 0x58, 0 // 88 + case "H": + return 0x64, 0 + } + return 0, 0 +} diff --git a/pkg/h264/ps/sps_pps_test.go b/pkg/h264/ps/sps_pps_test.go new file mode 100644 index 00000000..2102c3ba --- /dev/null +++ b/pkg/h264/ps/sps_pps_test.go @@ -0,0 +1,56 @@ +package ps + +import ( + "bytes" + "testing" +) + +func TestUnmarshalSPS(t *testing.T) { + raw := []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2} + s := SPS{} + if err := s.Unmarshal(raw); err != nil { + t.Fatal(err) + } + raw2 := s.Marshal() + if bytes.Compare(raw, raw2) != 0 { + t.Fatal() + } +} + +func TestUnmarshalPPS(t *testing.T) { + raw := []byte{0x68, 0xce, 0x38, 0x80} + p := PPS{} + if err := p.Unmarshal(raw); err != nil { + t.Fatal(err) + } + raw2 := p.Marshal() + if bytes.Compare(raw, raw2) != 0 { + t.Fatal() + } +} + +func TestUnmarshalPPS2(t *testing.T) { + raw := []byte{72, 238, 60, 128} + p := PPS{} + if err := p.Unmarshal(raw); err != nil { + t.Fatal(err) + } + raw2 := p.Marshal() + if bytes.Compare(raw, raw2) != 0 { + t.Fatal() + } +} + +func TestSafari(t *testing.T) { + // CB66, L3.1: chrome, edge, safari, android chrome + s := EncodeProfile(0x42, 0xE0) + t.Logf("Profile: %s, Level: %d", s, 0x1F) + + // B66, L3.1: chrome, edge + s = EncodeProfile(0x42, 0x00) + t.Logf("Profile: %s, Level: %d", s, 0x1F) + + // M77, L3.1: chrome, edge + s = EncodeProfile(0x4D, 0x00) + t.Logf("Profile: %s, Level: %d", s, 0x1F) +} diff --git a/pkg/h264/rtp.go b/pkg/h264/rtp.go new file mode 100644 index 00000000..cc6db343 --- /dev/null +++ b/pkg/h264/rtp.go @@ -0,0 +1,113 @@ +package h264 + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" +) + +const RTPPacketVersionAVC = 0 + +func RTPDepay(track *streamer.Track) streamer.WrapperFunc { + depack := &codecs.H264Packet{IsAVC: true} + + sps, pps := GetParameterSet(track.Codec.FmtpLine) + sps = EncodeAVC(sps) + pps = EncodeAVC(pps) + + var buffer []byte + + return func(push streamer.WriterFunc) streamer.WriterFunc { + return func(packet *rtp.Packet) error { + //println(packet.SequenceNumber, packet.Payload[0]&0x1F, packet.Payload[0], packet.Payload[1], packet.Marker, packet.Timestamp) + + data, err := depack.Unmarshal(packet.Payload) + if len(data) == 0 || err != nil { + return nil + } + + naluType := NALUType(data) + //println(naluType, len(data)) + + switch naluType { + case NALUTypeSPS: + //println("new SPS") + sps = data + return nil + case NALUTypePPS: + //println("new PPS") + pps = data + return nil + } + + // ffmpeg with `-tune zerolatency` enable option `-x264opts sliced-threads=1` + // and every NALU will be sliced to multiple NALUs + if !packet.Marker { + buffer = append(buffer, data...) + return nil + } + + if buffer != nil { + buffer = append(buffer, data...) + data = buffer + buffer = nil + } + + var clone rtp.Packet + + if naluType == NALUTypeIFrame { + clone = *packet + clone.Version = RTPPacketVersionAVC + clone.Payload = sps + if err = push(&clone); err != nil { + return err + } + + clone = *packet + clone.Version = RTPPacketVersionAVC + clone.Payload = pps + if err = push(&clone); err != nil { + return err + } + } + + clone = *packet + clone.Version = RTPPacketVersionAVC + clone.Payload = data + return push(&clone) + } + } +} + +func RTPPay(mtu uint16) streamer.WrapperFunc { + payloader := &Payloader{IsAVC: true} + sequencer := rtp.NewRandomSequencer() + mtu -= 12 // rtp.Header size + + return func(push streamer.WriterFunc) streamer.WriterFunc { + return func(packet *rtp.Packet) error { + if packet.Version == RTPPacketVersionAVC { + payloads := payloader.Payload(mtu, packet.Payload) + for i, payload := range payloads { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: i == len(payloads)-1, + //PayloadType: packet.PayloadType, + SequenceNumber: sequencer.NextSequenceNumber(), + Timestamp: packet.Timestamp, + //SSRC: packet.SSRC, + }, + Payload: payload, + } + if err := push(&clone); err != nil { + return err + } + } + return nil + } + + return push(packet) + } + } +} diff --git a/pkg/mse/consumer.go b/pkg/mse/consumer.go new file mode 100644 index 00000000..77e5ab2b --- /dev/null +++ b/pkg/mse/consumer.go @@ -0,0 +1,131 @@ +package mse + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/deepch/vdk/av" + "github.com/deepch/vdk/codec/h264parser" + "github.com/deepch/vdk/format/mp4f" + "github.com/pion/rtp" + "time" +) + +const MsgTypeMSE = "mse" + +type Consumer struct { + streamer.Element + + UserAgent string + RemoteAddr string + + muxer *mp4f.Muxer + streams []av.CodecData + start bool + + send int +} + +func (c *Consumer) GetMedias() []*streamer.Media { + return []*streamer.Media{ + { + Kind: streamer.KindVideo, + Direction: streamer.DirectionRecvonly, + Codecs: []*streamer.Codec{ + {Name: streamer.CodecH264, ClockRate: 90000}, + }, + }, { + Kind: streamer.KindAudio, + Direction: streamer.DirectionRecvonly, + Codecs: []*streamer.Codec{ + {Name: streamer.CodecAAC, ClockRate: 16000}, + }, + }, + } +} + +func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track { + codec := track.Codec + switch codec.Name { + case streamer.CodecH264: + idx := int8(len(c.streams)) + + sps, pps := h264.GetParameterSet(codec.FmtpLine) + stream, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps) + if err != nil { + return nil + } + c.streams = append(c.streams, stream) + + pkt := av.Packet{Idx: idx, CompositionTime: time.Millisecond} + + ts2time := time.Second / time.Duration(codec.ClockRate) + + push := func(packet *rtp.Packet) error { + if packet.Version != h264.RTPPacketVersionAVC { + return nil + } + + switch h264.NALUType(packet.Payload) { + case h264.NALUTypeIFrame: + c.start = true + pkt.IsKeyFrame = true + case h264.NALUTypePFrame: + if !c.start { + return nil + } + default: + return nil + } + + pkt.Data = packet.Payload + newTime := time.Duration(packet.Timestamp) * ts2time + if pkt.Time > 0 { + pkt.Duration = newTime - pkt.Time + } + pkt.Time = newTime + + for _, buf := range c.muxer.WritePacketV5(pkt) { + c.send += len(buf) + c.Fire(buf) + } + + return nil + } + + if !h264.IsAVC(codec) { + wrapper := h264.RTPDepay(track) + push = wrapper(push) + } + + return track.Bind(push) + } + + panic("unsupported codec") +} + +func (c *Consumer) Init() { + c.muxer = mp4f.NewMuxer(nil) + if err := c.muxer.WriteHeader(c.streams); err != nil { + return + } + + codecs, buf := c.muxer.GetInit(c.streams) + c.Fire(&streamer.Message{Type: MsgTypeMSE, Value: codecs}) + + c.send += len(buf) + c.Fire(buf) +} + +// + +func (c *Consumer) MarshalJSON() ([]byte, error) { + v := map[string]interface{}{ + "type": "MSE server consumer", + "send": c.send, + "remote_addr": c.RemoteAddr, + "user_agent": c.UserAgent, + } + + return json.Marshal(v) +} diff --git a/pkg/ngrok/ngrok.go b/pkg/ngrok/ngrok.go new file mode 100644 index 00000000..b26453ef --- /dev/null +++ b/pkg/ngrok/ngrok.go @@ -0,0 +1,79 @@ +package ngrok + +import ( + "bufio" + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "io" + "os/exec" + "strings" +) + +type Ngrok struct { + streamer.Element + + Tunnels map[string]string + + reader *bufio.Reader +} + +type Message struct { + Msg string `json:"msg"` + Addr string `json:"addr"` + URL string `json:"url"` + Line string +} + +func NewNgrok(command interface{}) (*Ngrok, error) { + var arg []string + switch command.(type) { + case string: + arg = strings.Split(command.(string), " ") + case []string: + arg = command.([]string) + } + + arg = append(arg, "--log", "stdout", "--log-format", "json") + + cmd := exec.Command(arg[0], arg[1:]...) + + r, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cmd.Stderr = cmd.Stdout + + n := &Ngrok{ + Tunnels: map[string]string{}, + reader: bufio.NewReader(r), + } + + if err = cmd.Start(); err != nil { + return nil, err + } + + return n, nil +} + +func (n *Ngrok) Serve() error { + for { + line, _, err := n.reader.ReadLine() + if err != nil { + if err != io.EOF { + return err + } + return nil + } + + msg := new(Message) + _ = json.Unmarshal(line, msg) + + if msg.Msg == "started tunnel" { + n.Tunnels[msg.Addr] = msg.URL + } + + msg.Line = string(line) + + n.Fire(msg) + } +} diff --git a/pkg/rtmp/client.go b/pkg/rtmp/client.go new file mode 100644 index 00000000..47acd132 --- /dev/null +++ b/pkg/rtmp/client.go @@ -0,0 +1,142 @@ +package rtmp + +import ( + "encoding/base64" + "encoding/binary" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/deepch/vdk/av" + "github.com/deepch/vdk/codec/h264parser" + "github.com/deepch/vdk/format/rtmp" + "github.com/pion/rtp" + "time" +) + +type Client struct { + streamer.Element + + URI string + + medias []*streamer.Media + tracks []*streamer.Track + + conn *rtmp.Conn + closed bool +} + +func NewClient(uri string) *Client { + return &Client{URI: uri} +} + +func (c *Client) Dial() (err error) { + c.conn, err = rtmp.Dial(c.URI) + if err != nil { + return + } + + // important to get SPS/PPS + streams, err := c.conn.Streams() + if err != nil { + return + } + + for _, stream := range streams { + switch stream.Type() { + case av.H264: + cd := stream.(h264parser.CodecData) + fmtp := "sprop-parameter-sets=" + + base64.StdEncoding.EncodeToString(cd.RecordInfo.SPS[0]) + "," + + base64.StdEncoding.EncodeToString(cd.RecordInfo.PPS[0]) + + codec := &streamer.Codec{ + Name: streamer.CodecH264, + ClockRate: 90000, + FmtpLine: fmtp, + PayloadType: h264.PayloadTypeAVC, + } + + media := &streamer.Media{ + Kind: streamer.KindVideo, + Direction: streamer.DirectionSendonly, + Codecs: []*streamer.Codec{codec}, + } + c.medias = append(c.medias, media) + + track := &streamer.Track{ + Codec: codec, Direction: media.Direction, + } + c.tracks = append(c.tracks, track) + + case av.AAC: + panic("not implemented") + default: + panic("unsupported codec") + } + } + + c.Fire(streamer.StateReady) + + return +} + +func (c *Client) Handle() (err error) { + defer c.Fire(streamer.StateNull) + + c.Fire(streamer.StatePlaying) + + for { + var pkt av.Packet + pkt, err = c.conn.ReadPacket() + if err != nil { + if c.closed { + return nil + } + return + } + + track := c.tracks[int(pkt.Idx)] + + timestamp := uint32(pkt.Time / time.Duration(track.Codec.ClockRate)) + + var payloads [][]byte + if track.Codec.Name == streamer.CodecH264 { + payloads = splitAVC(pkt.Data) + } else { + payloads = [][]byte{pkt.Data} + } + + for _, payload := range payloads { + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: payload, + } + _ = track.WriteRTP(packet) + } + } +} + +func (c *Client) Close() error { + if c.conn == nil { + return nil + } + c.closed = true + return c.conn.Close() +} + +func splitAVC(data []byte) [][]byte { + var nals [][]byte + for { + // get AVC length + size := int(binary.BigEndian.Uint32(data)) + + // check if multiple items in one packet + if size+4 < len(data) { + nals = append(nals, data[:size+4]) + data = data[size+4:] + } else { + nals = append(nals, data) + break + } + } + return nals +} diff --git a/pkg/rtmp/streamer.go b/pkg/rtmp/streamer.go new file mode 100644 index 00000000..7d1aeaa9 --- /dev/null +++ b/pkg/rtmp/streamer.go @@ -0,0 +1,26 @@ +package rtmp + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" +) + +func (c *Client) GetMedias() []*streamer.Media { + return c.medias +} + +func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track { + for _, track := range c.tracks { + if track.Codec == codec { + return track + } + } + panic("wrong codec") +} + +func (c *Client) Start() error { + return c.Handle() +} + +func (c *Client) Stop() error { + return c.Close() +} diff --git a/pkg/rtsp/README.md b/pkg/rtsp/README.md new file mode 100644 index 00000000..ddaafdb7 --- /dev/null +++ b/pkg/rtsp/README.md @@ -0,0 +1,3 @@ +## Useful links + +- https://www.kurento.org/blog/rtp-i-intro-rtp-and-sdp \ No newline at end of file diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go new file mode 100644 index 00000000..131f2f8f --- /dev/null +++ b/pkg/rtsp/conn.go @@ -0,0 +1,696 @@ +package rtsp + +import ( + "bufio" + "bytes" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtcp" + "github.com/pion/rtp" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + ProtoRTSP = "RTSP/1.0" + MethodOptions = "OPTIONS" + MethodSetup = "SETUP" + MethodTeardown = "TEARDOWN" + MethodDescribe = "DESCRIBE" + MethodPlay = "PLAY" + MethodPause = "PAUSE" + MethodAnnounce = "ANNOUNCE" + MethodRecord = "RECORD" +) + +type Mode byte + +const ( + ModeUnknown Mode = iota + ModeClientProducer + ModeServerUnknown + ModeServerProducer + ModeServerConsumer +) + +type Conn struct { + streamer.Element + + // public + + Medias []*streamer.Media + Session string + UserAgent string + URL *url.URL + + // internal + + auth *tcp.Auth + conn net.Conn + reader *bufio.Reader + sequence int + + mode Mode + + tracks []*streamer.Track + channels map[byte]*streamer.Track + + // stats + + receive int + send int +} + +func NewClient(uri string) (*Conn, error) { + var err error + + c := new(Conn) + c.URL, err = url.Parse(uri) + if err != nil { + return nil, err + } + + if strings.IndexByte(c.URL.Host, ':') < 0 { + c.URL.Host += ":554" + } + + // remove UserInfo from URL + c.auth = tcp.NewAuth(c.URL.User) + c.mode = ModeClientProducer + c.URL.User = nil + + return c, nil +} + +func NewServer(conn net.Conn) *Conn { + c := new(Conn) + c.conn = conn + c.mode = ModeServerUnknown + c.reader = bufio.NewReader(conn) + return c +} + +func (c *Conn) Dial() (err error) { + //if c.state != StateClientInit { + // panic("wrong state") + //} + + c.conn, err = net.DialTimeout( + "tcp", c.URL.Host, 10*time.Second, + ) + if err != nil { + return + } + + var tlsConf *tls.Config + switch c.URL.Scheme { + case "rtsps": + tlsConf = &tls.Config{ServerName: c.URL.Hostname()} + case "rtspx": + c.URL.Scheme = "rtsps" + tlsConf = &tls.Config{InsecureSkipVerify: true} + } + if tlsConf != nil { + tlsConn := tls.Client(c.conn, tlsConf) + if err = tlsConn.Handshake(); err != nil { + return err + } + c.conn = tlsConn + } + + c.reader = bufio.NewReader(c.conn) + + return nil +} + +// Request sends only Request +func (c *Conn) Request(req *tcp.Request) error { + if req.Proto == "" { + req.Proto = ProtoRTSP + } + + if req.Header == nil { + req.Header = make(map[string][]string) + } + + c.sequence++ + req.Header.Set("CSeq", strconv.Itoa(c.sequence)) + + c.auth.Write(req) + + if c.Session != "" { + req.Header.Set("Session", c.Session) + } + + if req.Body != nil { + val := strconv.Itoa(len(req.Body)) + req.Header.Set("Content-Length", val) + } + + c.Fire(req) + + return req.Write(c.conn) +} + +// Do send Request and receive and process Response +func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { + if err := c.Request(req); err != nil { + return nil, err + } + + res, err := tcp.ReadResponse(c.reader) + if err != nil { + return nil, err + } + + c.Fire(res) + + if res.StatusCode == http.StatusUnauthorized { + switch c.auth.Method { + case tcp.AuthNone: + return nil, errors.New("user/pass not provided") + case tcp.AuthUnknown: + if c.auth.Read(res) { + return c.Do(req) + } + case tcp.AuthBasic, tcp.AuthDigest: + return nil, errors.New("wrong user/pass") + } + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("wrong response on %s", req.Method) + } + + return res, nil +} + +func (c *Conn) Response(res *tcp.Response) error { + if res.Proto == "" { + res.Proto = ProtoRTSP + } + + if res.Status == "" { + res.Status = "200 OK" + } + + if res.Header == nil { + res.Header = make(map[string][]string) + } + + if res.Request != nil && res.Request.Header != nil { + seq := res.Request.Header.Get("CSeq") + if seq != "" { + res.Header.Set("CSeq", seq) + } + } + + if c.Session != "" { + res.Header.Set("Session", c.Session) + } + + if res.Body != nil { + val := strconv.Itoa(len(res.Body)) + res.Header.Set("Content-Length", val) + } + + c.Fire(res) + + return res.Write(c.conn) +} + +func (c *Conn) Options() error { + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + + res, err := c.Do(req) + if err != nil { + return err + } + + if val := res.Header.Get("Content-Base"); val != "" { + c.URL, err = url.Parse(val) + if err != nil { + return err + } + } + + return nil +} + +func (c *Conn) Describe() error { + // 5.3 Back channel connection + // https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf + req := &tcp.Request{ + Method: MethodDescribe, + URL: c.URL, + Header: map[string][]string{ + "Accept": {"application/sdp"}, + "Require": {"www.onvif.org/ver20/backchannel"}, + }, + } + + res, err := c.Do(req) + if err != nil { + return err + } + + // fix bug in Sonoff camera SDP "o=- 1 1 IN IP4 rom t_rtsplin" + // TODO: make some universal fix + if i := bytes.Index(res.Body, []byte("rom t_rtsplin")); i > 0 { + res.Body[i+3] = '_' + } + + c.Medias, err = streamer.UnmarshalRTSPSDP(res.Body) + if err != nil { + return err + } + + c.mode = ModeClientProducer + + return nil +} + +//func (c *Conn) Announce() (err error) { +// req := &tcp.Request{ +// Method: MethodAnnounce, +// URL: c.URL, +// Header: map[string][]string{ +// "Content-Type": {"application/sdp"}, +// }, +// } +// +// //req.Body, err = c.sdp.Marshal() +// if err != nil { +// return +// } +// +// _, err = c.Do(req) +// +// return +//} + +func (c *Conn) Setup() error { + for _, media := range c.Medias { + _, err := c.SetupMedia(media, media.Codecs[0]) + if err != nil { + return err + } + } + + return nil +} + +func (c *Conn) SetupMedia( + media *streamer.Media, codec *streamer.Codec, +) (*streamer.Track, error) { + ch := c.GetChannel(media) + if ch < 0 { + return nil, fmt.Errorf("wrong media: %v", media) + } + + trackURL, err := url.Parse(media.Control) + if err != nil { + return nil, err + } + trackURL = c.URL.ResolveReference(trackURL) + + req := &tcp.Request{ + Method: MethodSetup, + URL: trackURL, + Header: map[string][]string{ + "Transport": {fmt.Sprintf( + // i - RTP (data channel) + // i+1 - RTCP (control channel) + "RTP/AVP/TCP;unicast;interleaved=%d-%d", ch*2, ch*2+1, + )}, + }, + } + + var res *tcp.Response + res, err = c.Do(req) + if err != nil { + return nil, err + } + + if c.Session == "" { + // Session: 216525287999;timeout=60 + if s := res.Header.Get("Session"); s != "" { + if j := strings.IndexByte(s, ';'); j > 0 { + s = s[:j] + } + c.Session = s + } + } + + // we send our `interleaved`, but camera can answer with another + + // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 + s := res.Header.Get("Transport") + s, ok1, ok2 := between(s, "RTP/AVP/TCP;unicast;interleaved=", "-") + if !ok1 || !ok2 { + panic("wrong response") + } + + ch, err = strconv.Atoi(s) + if err != nil { + return nil, err + } + + track := &streamer.Track{ + Codec: codec, Direction: media.Direction, + } + + switch track.Direction { + case streamer.DirectionSendonly: + if c.channels == nil { + c.channels = make(map[byte]*streamer.Track) + } + c.channels[byte(ch)] = track + + case streamer.DirectionRecvonly: + track = c.bindTrack(track, byte(ch), codec.PayloadType) + } + + c.tracks = append(c.tracks, track) + + return track, nil +} + +func (c *Conn) Play() (err error) { + req := &tcp.Request{Method: MethodPlay, URL: c.URL} + return c.Request(req) +} + +func (c *Conn) Teardown() (err error) { + //if c.state != StateClientPlay { + // panic("wrong state") + //} + + req := &tcp.Request{Method: MethodTeardown, URL: c.URL} + return c.Request(req) +} + +func (c *Conn) Close() error { + if c.conn == nil { + return nil + } + if err := c.Teardown(); err != nil { + return err + } + conn := c.conn + c.conn = nil + return conn.Close() +} + +const transport = "RTP/AVP/TCP;unicast;interleaved=" + +func (c *Conn) Accept() error { + //if c.state != StateServerInit { + // panic("wrong state") + //} + + for { + req, err := tcp.ReadRequest(c.reader) + if err != nil { + return err + } + + c.Fire(req) + + // Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN + // Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN + switch req.Method { + case MethodOptions: + c.URL = req.URL + c.UserAgent = req.Header.Get("User-Agent") + + res := &tcp.Response{ + Header: map[string][]string{ + "Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"}, + }, + Request: req, + } + if err = c.Response(res); err != nil { + return err + } + + case MethodAnnounce: + if req.Header.Get("Content-Type") != "application/sdp" { + return errors.New("wrong content type") + } + + c.Medias, err = streamer.UnmarshalRTSPSDP(req.Body) + if err != nil { + return err + } + + // TODO: fix someday... + c.channels = map[byte]*streamer.Track{} + for i, media := range c.Medias { + track := &streamer.Track{ + Codec: media.Codecs[0], Direction: media.Direction, + } + c.tracks = append(c.tracks, track) + c.channels[byte(i<<1)] = track + } + + c.mode = ModeServerProducer + c.Fire(MethodAnnounce) + + res := &tcp.Response{Request: req} + if err = c.Response(res); err != nil { + return err + } + + case MethodDescribe: + c.mode = ModeServerConsumer + c.Fire(MethodDescribe) + + if c.tracks == nil { + res := &tcp.Response{ + Status: "404 Not Found", + Request: req, + } + return c.Response(res) + } + + res := &tcp.Response{ + Header: map[string][]string{ + "Content-Type": {"application/sdp"}, + }, + Request: req, + } + + // convert tracks to real output medias medias + var medias []*streamer.Media + for _, track := range c.tracks { + media := &streamer.Media{ + Kind: streamer.GetKind(track.Codec.Name), + Direction: streamer.DirectionSendonly, + Codecs: []*streamer.Codec{track.Codec}, + } + medias = append(medias, media) + } + + res.Body, err = streamer.MarshalSDP(medias) + if err != nil { + return err + } + + if err = c.Response(res); err != nil { + return err + } + + case MethodSetup: + tr := req.Header.Get("Transport") + + res := &tcp.Response{ + Header: map[string][]string{}, + Request: req, + } + + if tr[:len(transport)] == transport { + c.Session = "1" // TODO: fixme + res.Header.Set("Transport", tr[:len(transport)+3]) + } else { + res.Status = "461 Unsupported transport" + } + + if err = c.Response(res); err != nil { + return err + } + + case MethodRecord, MethodPlay: + res := &tcp.Response{Request: req} + return c.Response(res) + + default: + return fmt.Errorf("unsupported method: %s", req.Method) + } + } +} + +func (c *Conn) Handle() (err error) { + defer func() { + if c.conn == nil { + err = nil + } + //c.Fire(streamer.StateNull) + }() + + //c.Fire(streamer.StatePlaying) + + for { + // we can read: + // 1. RTP interleaved: `$` + 1B channel number + 2B size + // 2. RTSP response: RTSP/1.0 200 OK + // 3. RTSP request: OPTIONS ... + var buf4 []byte // `$` + 1B channel number + 2B size + buf4, err = c.reader.Peek(4) + if err != nil { + return + } + + if buf4[0] != '$' { + if string(buf4) == "RTSP" { + var res *tcp.Response + res, err = tcp.ReadResponse(c.reader) + if err != nil { + return + } + + c.Fire(res) + } else { + var req *tcp.Request + req, err = tcp.ReadRequest(c.reader) + if err != nil { + return + } + + c.Fire(req) + } + continue + } + + // hope that the odd channels are always RTCP + channelID := buf4[1] + + // get data size + size := int(binary.BigEndian.Uint16(buf4[2:])) + + if _, err = c.reader.Discard(4); err != nil { + return + } + + // init memory for data + buf := make([]byte, size) + if _, err = io.ReadFull(c.reader, buf); err != nil { + return + } + + c.receive += size + + if channelID&1 == 0 { + packet := &rtp.Packet{} + if err = packet.Unmarshal(buf); err != nil { + return errors.New("wrong RTP data") + } + + track := c.channels[channelID] + if track != nil { + _ = track.WriteRTP(packet) + //return fmt.Errorf("wrong channelID: %d", channelID) + } else { + panic("wrong channelID") + } + } else { + msg := &RTCP{Channel: channelID} + + if err = msg.Header.Unmarshal(buf); err != nil { + return errors.New("wrong RTCP data") + } + + msg.Packets, err = rtcp.Unmarshal(buf) + if err != nil { + return errors.New("wrong RTCP data") + } + + c.Fire(msg) + } + } +} + +func (c *Conn) GetChannel(media *streamer.Media) int { + for i, m := range c.Medias { + if m == media { + return i + } + } + return -1 +} + +func (c *Conn) bindTrack( + track *streamer.Track, channel uint8, payloadType uint8, +) *streamer.Track { + push := func(packet *rtp.Packet) error { + if c.conn == nil { + return nil + } + packet.Header.PayloadType = payloadType + //packet.Header.PayloadType = 100 + //packet.Header.PayloadType = 8 + //packet.Header.PayloadType = 106 + + size := packet.MarshalSize() + + data := make([]byte, 4+size) + data[0] = '$' + data[1] = channel + //data[1] = 10 + binary.BigEndian.PutUint16(data[2:], uint16(size)) + + if _, err := packet.MarshalTo(data[4:]); err != nil { + return nil + } + + if _, err := c.conn.Write(data); err != nil { + return err + } + + c.send += size + + return nil + } + + return track.Bind(push) +} + +type RTCP struct { + Channel byte + Header rtcp.Header + Packets []rtcp.Packet +} + +func between(s, sub1, sub2 string) (res string, ok1 bool, ok2 bool) { + i := strings.Index(s, sub1) + if i >= 0 { + ok1 = true + s = s[i+len(sub1):] + } + + i = strings.Index(s, sub2) + if i >= 0 { + return s[:i], ok1, true + } + + return s, ok1, false +} diff --git a/pkg/rtsp/streamer.go b/pkg/rtsp/streamer.go new file mode 100644 index 00000000..74211403 --- /dev/null +++ b/pkg/rtsp/streamer.go @@ -0,0 +1,123 @@ +package rtsp + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "strconv" +) + +// Element Producer + +func (c *Conn) GetMedias() []*streamer.Media { + return c.Medias +} + +func (c *Conn) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track { + for _, track := range c.tracks { + if track.Codec == codec { + return track + } + } + + track, err := c.SetupMedia(media, codec) + if err != nil { + return nil + } + return track +} + +func (c *Conn) Start() error { + if c.mode == ModeServerProducer { + return nil + } + + if err := c.Play(); err != nil { + return err + } + return c.Handle() +} + +func (c *Conn) Stop() error { + return c.Close() +} + +// Consumer + +func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track { + switch track.Direction { + // send our track to RTSP consumer (ex. FFmpeg) + case streamer.DirectionSendonly: + i := len(c.tracks) + channelID := byte(i << 1) + + codec := track.Codec.Clone() + codec.PayloadType = uint8(96 + i) + + for i, m := range c.Medias { + if m == media { + media.Codecs = []*streamer.Codec{codec} + c.Medias[i] = media + break + } + } + + track = c.bindTrack(track, channelID, codec.PayloadType) + track.Codec = codec + c.tracks = append(c.tracks, track) + + return track + + case streamer.DirectionRecvonly: + panic("not implemented") + } + + panic("wrong direction") +} + +// + +func (c *Conn) MarshalJSON() ([]byte, error) { + v := map[string]interface{}{ + streamer.JSONReceive: c.receive, + streamer.JSONSend: c.send, + } + switch c.mode { + case ModeUnknown: + v[streamer.JSONType] = "RTSP unknown" + case ModeClientProducer: + v[streamer.JSONType] = "RTSP client producer" + case ModeServerProducer: + v[streamer.JSONType] = "RTSP server producer" + case ModeServerConsumer: + v[streamer.JSONType] = "RTSP server consumer" + } + //if c.URI != "" { + // v["uri"] = c.URI + //} + if c.URL != nil { + v["url"] = c.URL.String() + } + if c.conn != nil { + v[streamer.JSONRemoteAddr] = c.conn.RemoteAddr().String() + } + if c.UserAgent != "" { + v[streamer.JSONUserAgent] = c.UserAgent + } + for i, media := range c.Medias { + k := "media:" + strconv.Itoa(i) + v[k] = media.String() + } + for i, track := range c.tracks { + k := "track:" + strconv.Itoa(int(i>>1)) + v[k] = track.String() + } + //for i, track := range c.tracks { + // k := "track:" + strconv.Itoa(i+1) + // if track.MimeType() == streamer.MimeTypeH264 { + // v[k] = h264.Describe(track.Caps()) + // } else { + // v[k] = track.MimeType() + // } + //} + return json.Marshal(v) +} diff --git a/pkg/streamer/helpers.go b/pkg/streamer/helpers.go new file mode 100644 index 00000000..7e6e7cf7 --- /dev/null +++ b/pkg/streamer/helpers.go @@ -0,0 +1,57 @@ +package streamer + +import ( + "strings" +) + +const ( + JSONType = "type" + JSONRemoteAddr = "remote_addr" + JSONUserAgent = "user_agent" + JSONReceive = "receive" + JSONSend = "send" +) + +// Message - struct for data exchange in Web API +type Message struct { + Type string `json:"type"` + Value interface{} `json:"value,omitempty"` +} + +// other + +func Between(s, sub1, sub2 string) string { + i := strings.Index(s, sub1) + if i < 0 { + return "" + } + s = s[i+len(sub1):] + + if len(sub2) == 1 { + i = strings.IndexByte(s, sub2[0]) + } else { + i = strings.Index(s, sub2) + } + if i >= 0 { + return s[:i] + } + + return s +} + +func Contains(medias []*Media, media *Media, codec *Codec) bool { + var ok1, ok2 bool + for _, m := range medias { + if m == media { + ok1 = true + break + } + } + for _, c := range media.Codecs { + if c == codec { + ok2 = true + break + } + } + return ok1 && ok2 +} diff --git a/pkg/streamer/media.go b/pkg/streamer/media.go new file mode 100644 index 00000000..964af4ce --- /dev/null +++ b/pkg/streamer/media.go @@ -0,0 +1,294 @@ +package streamer + +import ( + "fmt" + "github.com/pion/sdp/v3" + "strconv" + "strings" +) + +const ( + DirectionRecvonly = "recvonly" + DirectionSendonly = "sendonly" + DirectionSendRecv = "sendrecv" +) + +const ( + KindVideo = "video" + KindAudio = "audio" +) + +const ( + CodecH264 = "H264" // payloadType: 96 + CodecH265 = "H265" + CodecVP8 = "VP8" + CodecVP9 = "VP9" + CodecAV1 = "AV1" + + CodecPCMU = "PCMU" // payloadType: 0 + CodecPCMA = "PCMA" // payloadType: 8 + CodecAAC = "MPEG4-GENERIC" + CodecOpus = "OPUS" // payloadType: 111 + CodecG722 = "G722" +) + +func GetKind(name string) string { + switch name { + case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1: + return KindVideo + case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722: + return KindAudio + } + return "" +} + +// Media take best from: +// - deepch/vdk/format/rtsp/sdp.Media +// - pion/sdp.MediaDescription +type Media struct { + Kind string // video, audio + Direction string + Codecs []*Codec + + MID string // TODO: fixme? + Control string // TODO: fixme? +} + +func (m *Media) String() string { + s := fmt.Sprintf("%s, %s", m.Kind, m.Direction) + for _, codec := range m.Codecs { + s += ", " + codec.String() + } + return s +} + +func (m *Media) Clone() *Media { + clone := *m + return &clone +} + +func (m *Media) AV() bool { + return m.Kind == KindVideo || m.Kind == KindAudio +} + +func (m *Media) MatchCodec(codec *Codec) bool { + for _, c := range m.Codecs { + if c.Match(codec) { + return true + } + } + return false +} + +func (m *Media) MatchMedia(media *Media) *Codec { + if m.Kind != media.Kind { + return nil + } + + switch m.Direction { + case DirectionSendonly: + if media.Direction != DirectionRecvonly { + return nil + } + case DirectionRecvonly: + if media.Direction != DirectionSendonly { + return nil + } + default: + panic("wrong direction") + } + + for _, localCodec := range m.Codecs { + if media.Codecs == nil { + return localCodec + } + + for _, remoteCodec := range media.Codecs { + if localCodec.Match(remoteCodec) { + return localCodec + } + } + } + return nil +} + +// Codec take best from: +// - deepch/vdk/av.CodecData +// - pion/webrtc.RTPCodecCapability +type Codec struct { + Name string // H264, PCMU, PCMA, opus... + ClockRate uint32 // 90000, 8000, 16000... + Channels uint16 // 0, 1, 2 + FmtpLine string + PayloadType uint8 +} + +func NewCodec(name string) *Codec { + name = strings.ToUpper(name) + switch name { + case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1: + return &Codec{Name: name, ClockRate: 90000} + case CodecPCMU, CodecPCMA: + return &Codec{Name: name, ClockRate: 8000} + case CodecOpus: + return &Codec{Name: name, ClockRate: 48000, Channels: 2} + } + + panic(fmt.Sprintf("unsupported codec: %s", name)) +} + +func (c *Codec) String() string { + s := fmt.Sprintf("%d %s/%d", c.PayloadType, c.Name, c.ClockRate) + if c.Channels > 0 { + s = fmt.Sprintf("%s/%d", s, c.Channels) + } + return s +} + +func (c *Codec) Clone() *Codec { + clone := *c + return &clone +} + +func (c *Codec) Match(codec *Codec) bool { + return c.Name == codec.Name && + c.ClockRate == codec.ClockRate && + c.Channels == codec.Channels +} + +func UnmarshalSDP(rawSDP []byte) ([]*Media, error) { + sd := &sdp.SessionDescription{} + if err := sd.Unmarshal(rawSDP); err != nil { + return nil, err + } + + var medias []*Media + for _, md := range sd.MediaDescriptions { + media := UnmarshalMedia(md) + + if media.Direction == DirectionSendRecv { + media.Direction = DirectionRecvonly + medias = append(medias, media) + + media = media.Clone() + media.Direction = DirectionSendonly + } + + medias = append(medias, media) + } + + return medias, nil +} + +func UnmarshalRTSPSDP(rawSDP []byte) ([]*Media, error) { + medias, err := UnmarshalSDP(rawSDP) + if err != nil { + return nil, err + } + + // fix bug in ONVIF spec + // https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf + for _, media := range medias { + switch media.Direction { + case DirectionRecvonly, "": + media.Direction = DirectionSendonly + case DirectionSendonly: + media.Direction = DirectionRecvonly + } + } + + return medias, nil +} + +func MarshalSDP(medias []*Media) ([]byte, error) { + sd := &sdp.SessionDescription{} + + payloadType := uint8(96) + + for _, media := range medias { + if media.Codecs == nil { + continue + } + + codec := media.Codecs[0] + md := &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: media.Kind, + Protos: []string{"RTP", "AVP"}, + }, + } + md.WithCodec(payloadType, codec.Name, codec.ClockRate, codec.Channels, codec.FmtpLine) + + sd.MediaDescriptions = append(sd.MediaDescriptions, md) + + payloadType++ + } + + return sd.Marshal() +} + +func UnmarshalMedia(md *sdp.MediaDescription) *Media { + m := &Media{ + Kind: md.MediaName.Media, + } + + for _, attr := range md.Attributes { + switch attr.Key { + case DirectionSendonly, DirectionRecvonly, DirectionSendRecv: + m.Direction = attr.Key + case "control": + m.Control = attr.Value + case "mid": + m.MID = attr.Value + } + } + + for _, format := range md.MediaName.Formats { + m.Codecs = append(m.Codecs, UnmarshalCodec(md, format)) + } + + return m +} + +func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { + c := &Codec{PayloadType: byte(atoi(payloadType))} + + for _, attr := range md.Attributes { + switch { + case c.Name == "" && attr.Key == "rtpmap" && strings.HasPrefix(attr.Value, payloadType): + i := strings.IndexByte(attr.Value, ' ') + ss := strings.Split(attr.Value[i+1:], "/") + + c.Name = strings.ToUpper(ss[0]) + c.ClockRate = uint32(atoi(ss[1])) + + if len(ss) == 3 && ss[2] == "2" { + c.Channels = 2 + } + case c.FmtpLine == "" && attr.Key == "fmtp" && strings.HasPrefix(attr.Value, payloadType): + if i := strings.IndexByte(attr.Value, ' '); i > 0 { + c.FmtpLine = attr.Value[i+1:] + } + } + } + + if c.Name == "" { + switch payloadType { + case "0": + c.Name = "PCMU" + c.ClockRate = 8000 + case "8": + c.Name = "PCMA" + c.ClockRate = 8000 + default: + panic("unknown codec") + } + } + + return c +} + +func atoi(s string) (i int) { + i, _ = strconv.Atoi(s) + return +} diff --git a/pkg/streamer/streamer.go b/pkg/streamer/streamer.go new file mode 100644 index 00000000..9db88998 --- /dev/null +++ b/pkg/streamer/streamer.go @@ -0,0 +1,48 @@ +package streamer + +// States, Queries and Events + +type EventType byte + +const ( + StateNull EventType = iota + StateReady + StatePaused + StatePlaying +) + +// Element base struct for all classes with support feedback +type Element struct { + events []EventFunc +} + +type EventFunc func(msg interface{}) + +func (e *Element) Listen(f EventFunc) { + e.events = append(e.events, f) +} + +func (e *Element) Fire(msg interface{}) { + for _, f := range e.events { + f(msg) + } +} + +func (e *Element) Push(msg interface{}) { +} + +// Producer and Consumer interfaces + +type Producer interface { + Listen(f EventFunc) + GetMedias() []*Media + GetTrack(media *Media, codec *Codec) *Track + Start() error + Stop() error +} + +type Consumer interface { + Listen(f EventFunc) + GetMedias() []*Media + AddTrack(media *Media, track *Track) *Track +} diff --git a/pkg/streamer/track.go b/pkg/streamer/track.go new file mode 100644 index 00000000..14bb40f3 --- /dev/null +++ b/pkg/streamer/track.go @@ -0,0 +1,44 @@ +package streamer + +import ( + "fmt" + "github.com/pion/rtp" +) + +type WriterFunc func(packet *rtp.Packet) error +type WrapperFunc func(push WriterFunc) WriterFunc + +type Track struct { + Codec *Codec + Direction string + Sink map[*Track]WriterFunc +} + +func (t *Track) String() string { + s := t.Codec.String() + s += fmt.Sprintf(", sinks=%d", len(t.Sink)) + return s +} + +func (t *Track) WriteRTP(p *rtp.Packet) error { + for _, f := range t.Sink { + _ = f(p) + } + return nil +} + +func (t *Track) Bind(w WriterFunc) *Track { + if t.Sink == nil { + t.Sink = map[*Track]WriterFunc{} + } + + clone := &Track{ + Codec: t.Codec, Direction: t.Direction, Sink: t.Sink, + } + t.Sink[clone] = w + return clone +} + +func (t *Track) Unbind() { + delete(t.Sink, t) +} diff --git a/pkg/tcp/auth.go b/pkg/tcp/auth.go new file mode 100644 index 00000000..27cb2e94 --- /dev/null +++ b/pkg/tcp/auth.go @@ -0,0 +1,104 @@ +package tcp + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" +) + +type Auth struct { + Method byte + user string + pass string + header string + h1nonce string +} + +const ( + AuthNone byte = iota + AuthUnknown + AuthBasic + AuthDigest +) + +func NewAuth(user *url.Userinfo) *Auth { + a := new(Auth) + a.user = user.Username() + a.pass, _ = user.Password() + if a.user != "" { + a.Method = AuthUnknown + } + return a +} + +func (a *Auth) Read(res *Response) bool { + auth := res.Header.Get("WWW-Authenticate") + if len(auth) < 6 { + return false + } + + switch auth[:6] { + case "Basic ": + a.header = "Basic " + B64(a.user, a.pass) + a.Method = AuthBasic + return true + case "Digest": + realm := Between(auth, `realm="`, `"`) + nonce := Between(auth, `nonce="`, `"`) + + a.h1nonce = HexMD5(a.user, realm, a.pass) + ":" + nonce + a.header = fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s"`, + a.user, realm, nonce, + ) + a.Method = AuthDigest + return true + default: + return false + } +} + +func (a *Auth) Write(req *Request) { + if a == nil { + return + } + + switch a.Method { + case AuthBasic: + req.Header.Set("Authorization", a.header) + case AuthDigest: + uri := req.URL.RequestURI() + h2 := HexMD5(req.Method, uri) + response := HexMD5(a.h1nonce, h2) + header := a.header + fmt.Sprintf( + `, uri="%s", response="%s"`, uri, response, + ) + req.Header.Set("Authorization", header) + } +} + +func Between(s, sub1, sub2 string) string { + i := strings.Index(s, sub1) + if i < 0 { + return "" + } + s = s[i+len(sub1):] + i = strings.Index(s, sub2) + if i < 0 { + return "" + } + return s[:i] +} + +func HexMD5(s ...string) string { + b := md5.Sum([]byte(strings.Join(s, ":"))) + return hex.EncodeToString(b[:]) +} + +func B64(s ...string) string { + b := []byte(strings.Join(s, ":")) + return base64.StdEncoding.EncodeToString(b) +} diff --git a/pkg/tcp/server.go b/pkg/tcp/server.go new file mode 100644 index 00000000..5a0186d8 --- /dev/null +++ b/pkg/tcp/server.go @@ -0,0 +1,36 @@ +package tcp + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "net" +) + +type Server struct { + streamer.Element + + listener net.Listener + closed bool +} + +func NewServer(address string) (srv *Server, err error) { + srv = &Server{} + srv.listener, err = net.Listen("tcp", address) + return +} + +func (s *Server) Serve() { + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + go func() { + s.Fire(conn) + _ = conn.Close() + }() + } +} + +func (s *Server) Close() error { + return s.listener.Close() +} diff --git a/pkg/tcp/textproto.go b/pkg/tcp/textproto.go new file mode 100644 index 00000000..4e35ff9b --- /dev/null +++ b/pkg/tcp/textproto.go @@ -0,0 +1,147 @@ +package tcp + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/textproto" + "net/url" + "strconv" + "strings" +) + +const EndLine = "\r\n" + +// Response like http.Response, but with any proto +type Response struct { + Status string + StatusCode int + Proto string + Header textproto.MIMEHeader + Body []byte + Request *Request +} + +func (r Response) String() string { + s := r.Proto + " " + r.Status + EndLine + for k, v := range r.Header { + s += k + ": " + v[0] + EndLine + } + s += EndLine + if r.Body != nil { + s += string(r.Body) + } + return s +} + +func (r *Response) Write(w io.Writer) (err error) { + _, err = w.Write([]byte(r.String())) + return +} + +func ReadResponse(r *bufio.Reader) (*Response, error) { + tp := textproto.NewReader(r) + + line, err := tp.ReadLine() + if err != nil { + return nil, err + } + + ss := strings.SplitN(line, " ", 3) + if len(ss) != 3 { + return nil, errors.New("malformed response") + } + + res := &Response{ + Status: ss[1] + " " + ss[2], + Proto: ss[0], + } + + res.StatusCode, err = strconv.Atoi(ss[1]) + if err != nil { + return nil, err + } + + res.Header, err = tp.ReadMIMEHeader() + if err != nil { + return nil, err + } + + if val := res.Header.Get("Content-Length"); val != "" { + var i int + i, err = strconv.Atoi(val) + res.Body = make([]byte, i) + if _, err = io.ReadAtLeast(r, res.Body, i); err != nil { + return nil, err + } + } + + return res, nil +} + +// Request like http.Request, but with any proto +type Request struct { + Method string + URL *url.URL + Proto string + Header textproto.MIMEHeader + Body []byte +} + +func (r *Request) String() string { + s := r.Method + " " + r.URL.String() + " " + r.Proto + EndLine + for k, v := range r.Header { + s += k + ": " + v[0] + EndLine + } + s += EndLine + if r.Body != nil { + s += string(r.Body) + } + return s +} + +func (r *Request) Write(w io.Writer) (err error) { + _, err = w.Write([]byte(r.String())) + return +} + +func ReadRequest(r *bufio.Reader) (*Request, error) { + tp := textproto.NewReader(r) + + line, err := tp.ReadLine() + if err != nil { + return nil, err + } + + ss := strings.SplitN(line, " ", 3) + if len(ss) != 3 { + return nil, fmt.Errorf("wrong request: %s", line) + } + + req := &Request{ + Method: ss[0], + Proto: ss[2], + } + + req.URL, err = url.Parse(ss[1]) + if err != nil { + return nil, err + } + + req.Header, err = tp.ReadMIMEHeader() + if err != nil { + return nil, err + } + + if val := req.Header.Get("Content-Length"); val != "" { + var i int + i, err = strconv.Atoi(val) + req.Body = make([]byte, i) + if _, err = io.ReadAtLeast(r, req.Body, i); err != nil { + return nil, err + } + } + + return req, nil +} diff --git a/pkg/tcp/textproto_test.go b/pkg/tcp/textproto_test.go new file mode 100644 index 00000000..a8981c4c --- /dev/null +++ b/pkg/tcp/textproto_test.go @@ -0,0 +1,30 @@ +package tcp + +import ( + "bufio" + "bytes" + "net/http" + "testing" +) + +func assert(t *testing.T, one, two interface{}) { + if one != two { + t.FailNow() + } +} + +func TestName(t *testing.T) { + data := []byte(`RTSP/1.0 401 Unauthorized +WWW-Authenticate: Digest realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + +`) + + buf := bytes.NewBuffer(data) + r := bufio.NewReader(buf) + + res, err := ReadResponse(r) + assert(t, err, nil) + + assert(t, res.StatusCode, http.StatusUnauthorized) +} diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go new file mode 100644 index 00000000..0e8e03c8 --- /dev/null +++ b/pkg/webrtc/api.go @@ -0,0 +1,104 @@ +package webrtc + +import ( + "github.com/pion/interceptor" + "github.com/pion/webrtc/v3" + "net" +) + +func NewAPI(address string) (*webrtc.API, error) { + // for debug logs add to env: `PION_LOG_DEBUG=all` + m := &webrtc.MediaEngine{} + //if err := m.RegisterDefaultCodecs(); err != nil { + // return nil, err + //} + if err := RegisterDefaultCodecs(m); err != nil { + return nil, err + } + + i := &interceptor.Registry{} + if err := webrtc.RegisterDefaultInterceptors(m, i); err != nil { + return nil, err + } + + if address == "" { + return webrtc.NewAPI( + webrtc.WithMediaEngine(m), + webrtc.WithInterceptorRegistry(i), + ), nil + } + + ln, err := net.Listen("tcp", address) + if err != nil { + return webrtc.NewAPI( + webrtc.WithMediaEngine(m), + webrtc.WithInterceptorRegistry(i), + ), err + } + + s := webrtc.SettingEngine{ + //LoggerFactory: customLoggerFactory{}, + } + s.SetNetworkTypes([]webrtc.NetworkType{ + webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6, + webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6, + }) + + tcpMux := webrtc.NewICETCPMux(nil, ln, 8) + s.SetICETCPMux(tcpMux) + + return webrtc.NewAPI( + webrtc.WithMediaEngine(m), + webrtc.WithInterceptorRegistry(i), + webrtc.WithSettingEngine(s), + ), nil +} + +func RegisterDefaultCodecs(m *webrtc.MediaEngine) error { + for _, codec := range []webrtc.RTPCodecParameters{ + { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", nil}, + PayloadType: 101, //111, + }, { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypePCMU, 8000, 0, "", nil}, + PayloadType: 0, + }, { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypePCMA, 8000, 0, "", nil}, + PayloadType: 8, + }, + } { + if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil { + return err + } + } + + videoRTCPFeedback := []webrtc.RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}, {"nack", "pli"}} + for _, codec := range []webrtc.RTPCodecParameters{ + // macOS Google Chrome 103.0.5060.134 + { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH264, 90000, 0, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", videoRTCPFeedback}, + PayloadType: 96, //102, + }, { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH264, 90000, 0, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", videoRTCPFeedback}, + PayloadType: 97, //125, + }, { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH264, 90000, 0, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032", videoRTCPFeedback}, + PayloadType: 98, //123, + }, + // macOS Safari 15.1 + { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH264, 90000, 0, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", videoRTCPFeedback}, + PayloadType: 99, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH265, 90000, 0, "", videoRTCPFeedback}, + PayloadType: 100, + }, + } { + if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go new file mode 100644 index 00000000..b533edd9 --- /dev/null +++ b/pkg/webrtc/conn.go @@ -0,0 +1,195 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/webrtc/v3" +) + +const ( + MsgTypeOffer = "webrtc/offer" + MsgTypeOfferComplete = "webrtc/offer-complete" + MsgTypeAnswer = "webrtc/answer" + MsgTypeCandidate = "webrtc/candidate" +) + +type Conn struct { + streamer.Element + + UserAgent string + + Conn *webrtc.PeerConnection + + medias []*streamer.Media + tracks []*streamer.Track + + receive int + send int +} + +func (c *Conn) Init() { + c.Conn.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate != nil { + c.Fire(&streamer.Message{ + Type: MsgTypeCandidate, Value: candidate.ToJSON().Candidate, + }) + } + }) + + c.Conn.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + for _, track := range c.tracks { + if track.Direction != streamer.DirectionRecvonly { + continue + } + if track.Codec.PayloadType != uint8(remote.PayloadType()) { + continue + } + + for { + packet, _, err := remote.ReadRTP() + if err != nil { + return + } + if len(packet.Payload) == 0 { + continue + } + c.receive += len(packet.Payload) + _ = track.WriteRTP(packet) + } + } + + panic("something wrong") + }) + + c.Conn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + c.Fire(state) + + // TODO: remove + switch state { + case webrtc.PeerConnectionStateConnected: + c.Fire(streamer.StatePlaying) + case webrtc.PeerConnectionStateDisconnected: + c.Fire(streamer.StateNull) + case webrtc.PeerConnectionStateFailed: + _ = c.Conn.Close() + } + }) +} + +func (c *Conn) ExchangeSDP(offer string, complete bool) (answer string, err error) { + sdOffer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, SDP: offer, + } + if err = c.Conn.SetRemoteDescription(sdOffer); err != nil { + return + } + + //for _, tr := range c.Conn.GetTransceivers() { + // switch tr.Direction() { + // case webrtc.RTPTransceiverDirectionSendonly: + // // disable transceivers if we don't have track + // // make direction=inactive + // // don't really necessary, but anyway + // if tr.Sender() == nil { + // if err = tr.Stop(); err != nil { + // return + // } + // } + // case webrtc.RTPTransceiverDirectionRecvonly: + // // TODO: change codecs list + // caps := webrtc.RTPCodecCapability{ + // MimeType: webrtc.MimeTypePCMU, + // ClockRate: 8000, + // } + // codecs := []webrtc.RTPCodecParameters{ + // {RTPCodecCapability: caps}, + // } + // if err = tr.SetCodecPreferences(codecs); err != nil { + // return + // } + // } + //} + + var sdAnswer webrtc.SessionDescription + sdAnswer, err = c.Conn.CreateAnswer(nil) + if err != nil { + return + } + + //var sd *sdp.SessionDescription + //sd, err = sdAnswer.Unmarshal() + //for _, media := range sd.MediaDescriptions { + // if media.MediaName.Media != "audio" { + // continue + // } + // for i, attr := range media.Attributes { + // if attr.Key == "sendonly" { + // attr.Key = "inactive" + // media.Attributes[i] = attr + // break + // } + // } + //} + //var b []byte + //b, err = sd.Marshal() + //sdAnswer.SDP = string(b) + + if err = c.Conn.SetLocalDescription(sdAnswer); err != nil { + return + } + + if complete { + <-webrtc.GatheringCompletePromise(c.Conn) + return c.Conn.LocalDescription().SDP, nil + } + + return sdAnswer.SDP, nil +} + +func (c *Conn) SetOffer(offer string) (err error) { + sdOffer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, SDP: offer, + } + if err = c.Conn.SetRemoteDescription(sdOffer); err != nil { + return + } + rawSDP := []byte(c.Conn.RemoteDescription().SDP) + c.medias, err = streamer.UnmarshalSDP(rawSDP) + return +} + +func (c *Conn) GetAnswer() (answer string, err error) { + for _, tr := range c.Conn.GetTransceivers() { + if tr.Direction() != webrtc.RTPTransceiverDirectionSendonly { + continue + } + + // disable transceivers if we don't have track + // make direction=inactive + // don't really necessary, but anyway + if tr.Sender() == nil { + if err = tr.Stop(); err != nil { + return + } + } + } + + var sdAnswer webrtc.SessionDescription + sdAnswer, err = c.Conn.CreateAnswer(nil) + if err != nil { + return + } + + if err = c.Conn.SetLocalDescription(sdAnswer); err != nil { + return + } + + return sdAnswer.SDP, nil +} + +func (c *Conn) remote() string { + for _, trans := range c.Conn.GetTransceivers() { + pair, _ := trans.Receiver().Transport().ICETransport().GetSelectedCandidatePair() + return pair.Remote.String() + } + return "" +} diff --git a/pkg/webrtc/helper.go b/pkg/webrtc/helper.go new file mode 100644 index 00000000..2ee008d7 --- /dev/null +++ b/pkg/webrtc/helper.go @@ -0,0 +1,85 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/ice/v2" + "github.com/pion/stun" + "github.com/pion/webrtc/v3" + "net" + "strconv" +) + +func NewCandidate(address string) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", err + } + + i, err := strconv.Atoi(port) + if err != nil { + return "", err + } + + cand, err := ice.NewCandidateHost(&ice.CandidateHostConfig{ + Network: "tcp", + Address: host, + Port: i, + Component: ice.ComponentRTP, + TCPType: ice.TCPTypePassive, + }) + if err != nil { + return "", err + } + + return "candidate:" + cand.Marshal(), nil +} + +// GetPublicIP example from https://github.com/pion/stun +func GetPublicIP() (net.IP, error) { + c, err := stun.Dial("udp", "stun.l.google.com:19302") + if err != nil { + return nil, err + } + + var res stun.Event + + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + if err = c.Do(message, func(e stun.Event) { res = e }); err != nil { + return nil, err + } + + if res.Error != nil { + return nil, res.Error + } + + var xorAddr stun.XORMappedAddress + if err = xorAddr.GetFrom(res.Message); err != nil { + return nil, err + } + + return xorAddr.IP, nil +} + +func MimeType(codec *streamer.Codec) string { + switch codec.Name { + case streamer.CodecH264: + return webrtc.MimeTypeH264 + case streamer.CodecH265: + return webrtc.MimeTypeH265 + case streamer.CodecVP8: + return webrtc.MimeTypeVP8 + case streamer.CodecVP9: + return webrtc.MimeTypeVP9 + case streamer.CodecAV1: + return webrtc.MimeTypeAV1 + case streamer.CodecPCMU: + return webrtc.MimeTypePCMU + case streamer.CodecPCMA: + return webrtc.MimeTypePCMA + case streamer.CodecOpus: + return webrtc.MimeTypeOpus + case streamer.CodecG722: + return webrtc.MimeTypeG722 + } + panic("not implemented") +} diff --git a/pkg/webrtc/streamer.go b/pkg/webrtc/streamer.go new file mode 100644 index 00000000..cd21ff42 --- /dev/null +++ b/pkg/webrtc/streamer.go @@ -0,0 +1,125 @@ +package webrtc + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/streamer" + "github.com/pion/rtp" + "github.com/pion/webrtc/v3" +) + +// Consumer + +func (c *Conn) GetMedias() []*streamer.Media { + return c.medias +} + +func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track { + switch track.Direction { + // send our track to WebRTC consumer + case streamer.DirectionSendonly: + codec := track.Codec + + // webrtc.codecParametersFuzzySearch + caps := webrtc.RTPCodecCapability{ + MimeType: MimeType(codec), + Channels: codec.Channels, + ClockRate: codec.ClockRate, + } + + if codec.Name == streamer.CodecH264 { + // don't know if this really neccessary + // I have tested multiple browsers and H264 profile has no effect on anything + caps.SDPFmtpLine = "packetization-mode=1;profile-level-id=42e01f" + } + + // important to use same streamID so JS will automatically + // join two tracks as one source/stream + trackLocal, err := webrtc.NewTrackLocalStaticRTP( + caps, caps.MimeType[:5], "go2rtc", + ) + if err != nil { + return nil + } + + if _, err = c.Conn.AddTrack(trackLocal); err != nil { + return nil + } + + push := func(packet *rtp.Packet) error { + c.send += packet.MarshalSize() + return trackLocal.WriteRTP(packet) + } + + if codec.Name == streamer.CodecH264 { + wrapper := h264.RTPPay(1200) + push = wrapper(push) + + if codec.PayloadType != 255 { + wrapper = h264.RTPDepay(track) + push = wrapper(push) + } + } + + track = track.Bind(push) + c.tracks = append(c.tracks, track) + return track + + // receive track from WebRTC consumer (microphone, backchannel, two way audio) + case streamer.DirectionRecvonly: + for _, tr := range c.Conn.GetTransceivers() { + if tr.Mid() != media.MID { + continue + } + + codec := track.Codec + caps := webrtc.RTPCodecCapability{ + MimeType: MimeType(codec), + ClockRate: codec.ClockRate, + Channels: codec.Channels, + } + codecs := []webrtc.RTPCodecParameters{ + {RTPCodecCapability: caps}, + } + if err := tr.SetCodecPreferences(codecs); err != nil { + return nil + } + + c.tracks = append(c.tracks, track) + return track + } + } + + panic("wrong direction") +} + +// + +func (c *Conn) Push(msg interface{}) { + if msg := msg.(*streamer.Message); msg != nil { + if msg.Type == MsgTypeCandidate { + _ = c.Conn.AddICECandidate(webrtc.ICECandidateInit{ + Candidate: msg.Value.(string), + }) + } + } +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + v := map[string]interface{}{ + streamer.JSONType: "WebRTC server consumer", + streamer.JSONRemoteAddr: c.remote(), + } + + if c.receive > 0 { + v[streamer.JSONReceive] = c.receive + } + if c.send > 0 { + v[streamer.JSONSend] = c.send + } + if c.UserAgent != "" { + v[streamer.JSONUserAgent] = c.UserAgent + } + + return json.Marshal(v) +} diff --git a/pkg/webrtc/webrtc_test.go b/pkg/webrtc/webrtc_test.go new file mode 100644 index 00000000..c243d70b --- /dev/null +++ b/pkg/webrtc/webrtc_test.go @@ -0,0 +1,56 @@ +package webrtc + +import ( + "github.com/pion/ice/v2" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v3" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestName(t *testing.T) { + i, _ := ice.NewCandidateHost(&ice.CandidateHostConfig{ + Network: "tcp", + Address: "192.168.1.123", + Port: 8555, + Component: ice.ComponentRTP, + TCPType: ice.TCPTypePassive, + }) + a := i.Marshal() + println(a) +} + +func TestPublicIP(t *testing.T) { + ip, err := GetPublicIP() + assert.Nil(t, err) + assert.NotNil(t, ip) + t.Logf("your public IP: %s", ip.String()) +} + +func TestMedia(t *testing.T) { + codec := webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + }, + PayloadType: 96, + } + + md := &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: "video", Protos: []string{"RTP", "AVP"}, + }, + } + md.WithCodec( + uint8(codec.PayloadType), codec.MimeType[6:], codec.ClockRate, + codec.Channels, codec.SDPFmtpLine, + ) + + sd := &sdp.SessionDescription{ + MediaDescriptions: []*sdp.MediaDescription{md}, + } + data, err := sd.Marshal() + assert.Nil(t, err) + assert.NotNil(t, data) +} diff --git a/scripts/build.cmd b/scripts/build.cmd new file mode 100644 index 00000000..5af8aeea --- /dev/null +++ b/scripts/build.cmd @@ -0,0 +1,47 @@ +@ECHO OFF + +@SET GOOS=windows +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_win64.exe +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=windows +@SET GOARCH=386 +@SET FILENAME=go2rtc_win32.exe +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=linux +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_linux_amd64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=linux +@SET GOARCH=386 +@SET FILENAME=go2rtc_linux_i386 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=linux +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_linux_arm64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=linux +@SET GOARCH=arm +@SET GOARM=7 +@SET FILENAME=go2rtc_linux_arm +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=linux +@SET GOARCH=mipsle +@SET FILENAME=go2rtc_linux_mipsel +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=darwin +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_mac_amd64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + +@SET GOOS=darwin +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_mac_arm64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% diff --git a/www/README.md b/www/README.md new file mode 100644 index 00000000..2a0ab57b --- /dev/null +++ b/www/README.md @@ -0,0 +1,49 @@ +# HTML5 + +**1. Autoplay video tag** + +[Video auto play is not working](https://stackoverflow.com/questions/17994666/video-auto-play-is-not-working-in-safari-and-chrome-desktop-browser) + +> Recently many browsers can only autoplay the videos with sound off, so you'll need to add muted attribute to the video tag too + +```html + + +``` + +**2. [Safari] pc.createOffer** + +Don't work in Desktop Safari: + +```js +pc.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true}) +``` + +Should be replaced with: + +```js +pc.addTransceiver('video', {direction: 'recvonly'}); +pc.addTransceiver('audio', {direction: 'recvonly'}); +pc.createOffer(); +``` + +**3. pc.ontrack** + +TODO + +```js +pc.ontrack = ev => { + const video = document.getElementById('video'); + + // when audio track not exist in Chrome + if (ev.streams.length === 0) return; + + // when audio track not exist in Firefox + if (ev.streams[0].id[0] === '{') return; + + // when stream already init + if (video.srcObject !== null) return; + + video.srcObject = ev.streams[0]; +} +``` \ No newline at end of file diff --git a/www/codecs.html b/www/codecs.html new file mode 100644 index 00000000..eeb8468c --- /dev/null +++ b/www/codecs.html @@ -0,0 +1,59 @@ + + + + + go2rtc - WebRTC + + + +
+ + + \ No newline at end of file diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..47ccc7d3 --- /dev/null +++ b/www/index.html @@ -0,0 +1,43 @@ + + + + + + go2rtc + + + +
+ + + \ No newline at end of file diff --git a/www/mse.html b/www/mse.html new file mode 100644 index 00000000..76de96d3 --- /dev/null +++ b/www/mse.html @@ -0,0 +1,119 @@ + + + + + go2rtc - MSE + + + + + + + + diff --git a/www/webrtc-async.html b/www/webrtc-async.html new file mode 100644 index 00000000..5f6a7b36 --- /dev/null +++ b/www/webrtc-async.html @@ -0,0 +1,124 @@ + + + + + go2rtc - WebRTC + + + + + + + + + + \ No newline at end of file diff --git a/www/webrtc-sync.html b/www/webrtc-sync.html new file mode 100644 index 00000000..f7bc3eb8 --- /dev/null +++ b/www/webrtc-sync.html @@ -0,0 +1,64 @@ + + + + + go2rtc - WebRTC + + + + + + + + \ No newline at end of file