├── .github ├── CODEOWNERS ├── banner_dark.png ├── banner_light.png └── workflows │ ├── build.yaml │ ├── docker.yaml │ └── test.yaml ├── .gitignore ├── .gitmodules ├── .goreleaser.yaml ├── LICENSE ├── NOTICE ├── README.md ├── build ├── ingress │ └── Dockerfile └── test │ ├── Dockerfile │ └── entrypoint.sh ├── cmd ├── rtmprelay │ └── main.go ├── server │ ├── main.go │ └── test.go └── whiptest │ └── main.go ├── go.mod ├── go.sum ├── magefile.go ├── pkg ├── config │ └── config.go ├── errors │ └── errors.go ├── ipc │ ├── ipc.pb.go │ ├── ipc.proto │ └── ipc_grpc.pb.go ├── lksdk_output │ ├── lksdk_output.go │ ├── media_watchdog.go │ ├── media_watchdog_test.go │ └── track_watchdog.go ├── media │ ├── input.go │ ├── output.go │ ├── pipeline.go │ ├── rtmp │ │ └── appsrc.go │ ├── splice_processor.go │ ├── urlpull │ │ └── source.go │ ├── video_output_bin.go │ ├── webrtc_sink.go │ └── whip │ │ ├── appsrc.go │ │ └── whipsrc.go ├── params │ ├── params.go │ ├── params_test.go │ ├── presets.go │ └── presets_test.go ├── rtmp │ ├── relay_handler.go │ └── server.go ├── service │ ├── cmd.go │ ├── debug.go │ ├── handler.go │ ├── process_manager.go │ ├── relay.go │ ├── service.go │ └── session_manager.go ├── stats │ ├── media.go │ ├── media_track_stats.go │ └── monitor.go ├── types │ ├── session_api.go │ ├── stats.go │ └── types.go ├── utils │ ├── blocking_queue.go │ ├── handler_logger.go │ ├── media_serialization.go │ ├── output_synchronizer.go │ ├── prerollbuffer.go │ ├── rtcp.go │ └── rtcp_test.go └── whip │ ├── relay_handler.go │ ├── relay_media_sink.go │ ├── relay_whip_track_handler.go │ ├── sdk_media_sink.go │ ├── sdk_whip_track_handler.go │ ├── server.go │ ├── utils.go │ └── whip_handler.go ├── renovate.json ├── test ├── config-sample.yaml ├── integration.go ├── integration_test.go ├── rtmp.go ├── url.go └── whip.go └── version └── version.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @livekit/media-devs 2 | -------------------------------------------------------------------------------- /.github/banner_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/ingress/56301ded4518ea8d113a4f14922d5b1e68536235/.github/banner_dark.png -------------------------------------------------------------------------------- /.github/banner_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/ingress/56301ded4518ea8d113a4f14922d5b1e68536235/.github/banner_light.png -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Build 16 | on: 17 | workflow_dispatch: 18 | push: 19 | branches: [ main ] 20 | pull_request: 21 | branches: [ main ] 22 | env: 23 | GOVERSION: "1.21.5" 24 | GSTVERSION: "1.22.5" 25 | 26 | jobs: 27 | integration: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | lfs: true 33 | 34 | - name: Build docker image 35 | run: docker build -t ingress -f ./build/ingress/Dockerfile --build-arg GOVERSION=$GOVERSION --build-arg GSTVERSION=$GSTVERSION . 36 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Release to Docker 16 | 17 | # Controls when the action will run. 18 | on: 19 | workflow_dispatch: 20 | push: 21 | # only publish on version tags 22 | tags: 23 | - 'v*.*.*' 24 | 25 | env: 26 | GOVERSION: "1.21.5" 27 | GSTVERSION: "1.22.5" 28 | 29 | jobs: 30 | docker: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - uses: actions/cache@v4 36 | with: 37 | path: | 38 | ~/go/pkg/mod 39 | ~/go/bin 40 | ~/bin/protoc 41 | ~/.cache 42 | key: ${{ runner.os }}-ingress-${{ hashFiles('**/go.sum') }} 43 | restore-keys: ${{ runner.os }}-ingress 44 | 45 | - name: Docker metadata 46 | id: docker-md 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: livekit/ingress 50 | # generate Docker tags based on the following events/attributes 51 | tags: | 52 | type=semver,pattern=v{{version}} 53 | type=semver,pattern=v{{major}}.{{minor}} 54 | 55 | - name: Set up Go 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: "1.21" 59 | 60 | - name: Download Go modules 61 | run: go mod download 62 | 63 | - name: Set up QEMU 64 | uses: docker/setup-qemu-action@v3 65 | 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v3 68 | 69 | - name: Login to DockerHub 70 | uses: docker/login-action@v3 71 | with: 72 | username: ${{ secrets.DOCKERHUB_USERNAME }} 73 | password: ${{ secrets.DOCKERHUB_TOKEN }} 74 | 75 | - name: Build and push 76 | uses: docker/build-push-action@v6 77 | with: 78 | context: . 79 | file: ./build/ingress/Dockerfile 80 | push: true 81 | platforms: linux/amd64,linux/arm64 82 | tags: ${{ steps.docker-md.outputs.tags }} 83 | labels: ${{ steps.docker-md.outputs.labels }} 84 | build-args: | 85 | GOVERSION=${{ env.GOVERSION }} 86 | GSTVERSION=${{ env.GSTVERSION }} 87 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Test 16 | on: 17 | workflow_dispatch: 18 | pull_request: 19 | branches: [ main ] 20 | 21 | jobs: 22 | test: 23 | runs-on: buildjet-8vcpu-ubuntu-2204 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Build docker image 29 | run: | 30 | docker build \ 31 | -t ingress-test \ 32 | -f ./build/test/Dockerfile . 33 | 34 | - name: Run tests 35 | run: | 36 | docker run --rm \ 37 | -e GITHUB_WORKFLOW=1 \ 38 | ingress-test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | bin/ 4 | 5 | test/config.yaml 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/livekit-whip-bot"] 2 | path = test/livekit-whip-bot 3 | url = https://github.com/cloudwebrtc/livekit-whip-bot 4 | branch = test_devices 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | before: 16 | hooks: 17 | - go mod tidy 18 | builds: 19 | - id: ingress 20 | env: 21 | - CGO_ENABLED=0 22 | main: ./cmd/server 23 | binary: ingress 24 | goarm: 25 | - "7" 26 | goarch: 27 | - amd64 28 | - arm64 29 | - arm 30 | goos: 31 | - linux 32 | - windows 33 | archives: 34 | - format_overrides: 35 | - goos: windows 36 | format: zip 37 | replacements: 38 | darwin: mac 39 | files: 40 | - LICENSE 41 | - 'autocomplete/*' 42 | release: 43 | github: 44 | owner: livekit 45 | name: ingress 46 | draft: true 47 | prerelease: auto 48 | changelog: 49 | sort: asc 50 | filters: 51 | exclude: 52 | - '^docs:' 53 | - '^test:' 54 | gomod: 55 | proxy: false 56 | checksum: 57 | name_template: 'checksums.txt' 58 | snapshot: 59 | name_template: "{{ incpatch .Version }}-next" 60 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 LiveKit, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /build/ingress/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | ARG GSTVERSION 16 | 17 | FROM livekit/gstreamer:$GSTVERSION-dev 18 | 19 | ARG TARGETPLATFORM 20 | ARG GOVERSION 21 | 22 | WORKDIR /workspace 23 | 24 | # install go 25 | RUN apt-get update && apt-get install -y curl 26 | 27 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 28 | curl -L -o /tmp/go.tar.gz "https://go.dev/dl/go$GOVERSION.linux-$GOARCH.tar.gz" 29 | RUN tar -C /usr/local -xzf /tmp/go.tar.gz 30 | ENV PATH="$PATH:/usr/local/go/bin" 31 | 32 | # download go modules 33 | COPY go.mod . 34 | COPY go.sum . 35 | RUN go mod download 36 | 37 | # copy source 38 | COPY cmd/ cmd/ 39 | COPY pkg/ pkg/ 40 | COPY version/ version/ 41 | 42 | # build 43 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 44 | CGO_ENABLED=1 GOOS=linux GOARCH=${GOARCH} GO111MODULE=on go build -a -o ingress ./cmd/server 45 | 46 | FROM livekit/gstreamer:$GSTVERSION-prod 47 | 48 | # install wget for health check 49 | RUN apt-get update && apt-get install -y wget 50 | 51 | # clean up 52 | RUN apt-get clean && \ 53 | rm -rf /var/lib/apt/lists/* 54 | 55 | # copy binary 56 | COPY --from=0 /workspace/ingress /bin/ 57 | 58 | # run 59 | ENTRYPOINT ["ingress"] 60 | -------------------------------------------------------------------------------- /build/test/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM livekit/gstreamer:1.22.5-dev 16 | 17 | ARG TARGETPLATFORM 18 | 19 | WORKDIR /workspace 20 | 21 | # install deps 22 | RUN apt-get update && \ 23 | apt-get install -y \ 24 | curl 25 | 26 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 27 | curl -L -o /tmp/go.tar.gz "https://go.dev/dl/go1.21.5.linux-$GOARCH.tar.gz" 28 | RUN tar -C /usr/local -xzf /tmp/go.tar.gz 29 | ENV PATH="$PATH:/usr/local/go/bin" 30 | 31 | # download go modules 32 | COPY go.mod . 33 | COPY go.sum . 34 | RUN go mod download 35 | 36 | # copy source 37 | COPY pkg/ pkg/ 38 | COPY version/ version/ 39 | 40 | COPY build/test/entrypoint.sh . 41 | ENTRYPOINT ["./entrypoint.sh"] 42 | -------------------------------------------------------------------------------- /build/test/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2023 LiveKit, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -exo pipefail 17 | 18 | 19 | # Run tests 20 | if [[ -z "${GITHUB_WORKFLOW}" ]]; then 21 | exec go test -v -timeout 20m ./pkg/... 22 | else 23 | go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest 24 | exec go tool test2json -p ingress go test -test.v=true -test.timeout 20m ./pkg/... 2>&1 | "$HOME"/go/bin/gotestfmt 25 | fi 26 | -------------------------------------------------------------------------------- /cmd/rtmprelay/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "os/signal" 21 | "syscall" 22 | 23 | "github.com/livekit/ingress/pkg/config" 24 | "github.com/livekit/ingress/pkg/rtmp" 25 | "github.com/livekit/ingress/pkg/service" 26 | "github.com/livekit/protocol/logger" 27 | ) 28 | 29 | func main() { 30 | conf := &config.Config{ 31 | RTMPPort: 1935, 32 | HTTPRelayPort: 9090, 33 | } 34 | 35 | rtmpServer := rtmp.NewRTMPServer() 36 | relay := service.NewRelay(rtmpServer) 37 | 38 | err := rtmpServer.Start(conf, nil) 39 | if err != nil { 40 | panic(fmt.Sprintf("Failed starting RTMP server %s", err)) 41 | } 42 | err = relay.Start(conf) 43 | if err != nil { 44 | panic(fmt.Sprintf("Failed starting RTMP relay %s", err)) 45 | } 46 | 47 | killChan := make(chan os.Signal, 1) 48 | signal.Notify(killChan, syscall.SIGINT) 49 | 50 | sig := <-killChan 51 | logger.Infow("exit requested, shutting down", "signal", sig) 52 | rtmpServer.Stop() 53 | } 54 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "fmt" 21 | "io/ioutil" 22 | "net/http" 23 | "os" 24 | "os/signal" 25 | "syscall" 26 | "time" 27 | 28 | "github.com/urfave/cli/v2" 29 | "google.golang.org/protobuf/encoding/protojson" 30 | 31 | "github.com/livekit/ingress/pkg/config" 32 | "github.com/livekit/ingress/pkg/errors" 33 | "github.com/livekit/ingress/pkg/params" 34 | "github.com/livekit/ingress/pkg/rtmp" 35 | "github.com/livekit/ingress/pkg/service" 36 | "github.com/livekit/ingress/pkg/whip" 37 | "github.com/livekit/ingress/version" 38 | "github.com/livekit/protocol/livekit" 39 | "github.com/livekit/protocol/logger" 40 | "github.com/livekit/protocol/redis" 41 | "github.com/livekit/protocol/rpc" 42 | "github.com/livekit/protocol/tracer" 43 | "github.com/livekit/psrpc" 44 | ) 45 | 46 | func main() { 47 | app := &cli.App{ 48 | Name: "ingress", 49 | Usage: "LiveKit Ingress", 50 | Version: version.Version, 51 | Description: "import streamed media to LiveKit", 52 | Commands: []*cli.Command{ 53 | { 54 | Name: "run-handler", 55 | Description: "runs a request in a new process", 56 | Flags: []cli.Flag{ 57 | &cli.StringFlag{ 58 | Name: "info", 59 | }, 60 | &cli.StringFlag{ 61 | Name: "config-body", 62 | }, 63 | &cli.StringFlag{ 64 | Name: "token", 65 | }, 66 | &cli.StringFlag{ 67 | Name: "relay-token", 68 | }, 69 | &cli.StringFlag{ 70 | Name: "ws-url", 71 | }, 72 | &cli.StringFlag{ 73 | Name: "logging-fields", 74 | }, 75 | &cli.StringFlag{ 76 | Name: "extra-params", 77 | }, 78 | }, 79 | Action: runHandler, 80 | Hidden: true, 81 | }, 82 | }, 83 | Flags: []cli.Flag{ 84 | &cli.StringFlag{ 85 | Name: "config", 86 | Usage: "LiveKit Ingress yaml config file", 87 | EnvVars: []string{"INGRESS_CONFIG_FILE"}, 88 | }, 89 | &cli.StringFlag{ 90 | Name: "config-body", 91 | Usage: "LiveKit Ingress yaml config body", 92 | EnvVars: []string{"INGRESS_CONFIG_BODY"}, 93 | }, 94 | }, 95 | Action: runService, 96 | } 97 | 98 | if err := app.Run(os.Args); err != nil { 99 | logger.Infow("process excited", "error", err) 100 | } 101 | } 102 | 103 | func runService(c *cli.Context) error { 104 | conf, err := getConfig(c, true) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | rc, err := redis.GetRedisClient(conf.Redis) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | bus := psrpc.NewRedisMessageBus(rc) 115 | psrpcClient, err := rpc.NewIOInfoClient(bus) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | stopChan := make(chan os.Signal, 1) 121 | signal.Notify(stopChan, syscall.SIGTERM, syscall.SIGQUIT) 122 | 123 | killChan := make(chan os.Signal, 1) 124 | signal.Notify(killChan, syscall.SIGINT) 125 | 126 | var rtmpsrv *rtmp.RTMPServer 127 | var whipsrv *whip.WHIPServer 128 | if conf.RTMPPort > 0 { 129 | // Run RTMP server 130 | rtmpsrv = rtmp.NewRTMPServer() 131 | } 132 | if conf.WHIPPort > 0 { 133 | psrpcWHIPClient, err := rpc.NewIngressHandlerClient(bus) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | whipsrv = whip.NewWHIPServer(psrpcWHIPClient) 139 | } 140 | 141 | svc, err := service.NewService(conf, psrpcClient, bus, rtmpsrv, whipsrv, service.NewCmd, "") 142 | if err != nil { 143 | return err 144 | } 145 | 146 | svc.StartDebugHandlers() 147 | 148 | err = setupHealthHandlers(conf, svc) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | relay := service.NewRelay(rtmpsrv, whipsrv) 154 | 155 | if rtmpsrv != nil { 156 | err = rtmpsrv.Start(conf, svc.HandleRTMPPublishRequest) 157 | if err != nil { 158 | return err 159 | } 160 | } 161 | if whipsrv != nil { 162 | err = whipsrv.Start(conf, svc.HandleWHIPPublishRequest, svc.GetHealthHandlers()) 163 | if err != nil { 164 | return err 165 | } 166 | } 167 | 168 | err = relay.Start(conf) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | go func() { 174 | select { 175 | case sig := <-stopChan: 176 | logger.Infow("exit requested, finishing all ingress then shutting down", "signal", sig) 177 | svc.Stop(false) 178 | 179 | case sig := <-killChan: 180 | logger.Infow("exit requested, stopping all ingress and shutting down", "signal", sig) 181 | svc.Stop(true) 182 | relay.Stop() 183 | if rtmpsrv != nil { 184 | rtmpsrv.Stop() 185 | } 186 | if whipsrv != nil { 187 | whipsrv.Stop() 188 | } 189 | 190 | } 191 | }() 192 | 193 | return svc.Run() 194 | } 195 | 196 | func setupHealthHandlers(conf *config.Config, svc *service.Service) error { 197 | if conf.HealthPort == 0 { 198 | return nil 199 | } 200 | 201 | mux := http.NewServeMux() 202 | mux.HandleFunc("/", svc.HealthHandler) 203 | mux.HandleFunc("/availability", svc.AvailabilityHandler) 204 | 205 | go func() { 206 | _ = http.ListenAndServe(fmt.Sprintf(":%d", conf.HealthPort), mux) 207 | }() 208 | 209 | return nil 210 | } 211 | 212 | func runHandler(c *cli.Context) error { 213 | conf, err := getConfig(c, false) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | ctx, cancel := context.WithCancel(context.Background()) 219 | defer cancel() 220 | 221 | ctx, span := tracer.Start(ctx, "Handler.New") 222 | defer span.End() 223 | logger.Debugw("handler launched") 224 | 225 | rc, err := redis.GetRedisClient(conf.Redis) 226 | if err != nil { 227 | span.RecordError(err) 228 | return err 229 | } 230 | 231 | info := &livekit.IngressInfo{} 232 | infoString := c.String("info") 233 | err = protojson.Unmarshal([]byte(infoString), info) 234 | if err != nil { 235 | span.RecordError(err) 236 | return err 237 | } 238 | 239 | extraParams := c.String("extra-params") 240 | var ep any 241 | switch info.InputType { 242 | case livekit.IngressInput_WHIP_INPUT: 243 | whipParams := params.WhipExtraParams{} 244 | err := json.Unmarshal([]byte(extraParams), &whipParams) 245 | if err != nil { 246 | return err 247 | } 248 | ep = &whipParams 249 | } 250 | 251 | token := c.String("token") 252 | 253 | var handler interface { 254 | Kill() 255 | HandleIngress(ctx context.Context, info *livekit.IngressInfo, wsUrl, token, relayToken string, loggingFields map[string]string, extraParams any) error 256 | } 257 | 258 | bus := psrpc.NewRedisMessageBus(rc) 259 | rpcClient, err := rpc.NewIOInfoClient(bus) 260 | if err != nil { 261 | return err 262 | } 263 | handler = service.NewHandler(conf, rpcClient) 264 | 265 | setupHandlerRPCHandlers(conf, handler.(*service.Handler), bus, info, ep) 266 | 267 | killChan := make(chan os.Signal, 1) 268 | signal.Notify(killChan, syscall.SIGINT) 269 | 270 | go func() { 271 | sig := <-killChan 272 | logger.Infow("exit requested, stopping all ingress and shutting down", "signal", sig) 273 | handler.Kill() 274 | 275 | time.Sleep(10 * time.Second) 276 | // If handler didn't exit cleanly after 10s, cancel the context 277 | cancel() 278 | }() 279 | 280 | wsUrl := conf.WsUrl 281 | if c.String("ws-url") != "" { 282 | wsUrl = c.String("ws-url") 283 | } 284 | 285 | var loggingFields map[string]string 286 | if c.String("logging-fields") != "" { 287 | err = json.Unmarshal([]byte(c.String("logging-fields")), &loggingFields) 288 | if err != nil { 289 | return err 290 | } 291 | } 292 | 293 | err = handler.HandleIngress(ctx, info, wsUrl, token, c.String("relay-token"), loggingFields, ep) 294 | return translateRetryableError(err) 295 | } 296 | 297 | func translateRetryableError(err error) error { 298 | retrErr := errors.RetryableError{} 299 | 300 | if errors.As(err, &retrErr) { 301 | return cli.Exit(err, 1) 302 | } 303 | 304 | return err 305 | } 306 | 307 | func setupHandlerRPCHandlers(conf *config.Config, handler *service.Handler, bus psrpc.MessageBus, info *livekit.IngressInfo, ep any) error { 308 | rpcServer, err := rpc.NewIngressHandlerServer(handler, bus) 309 | if err != nil { 310 | return err 311 | } 312 | 313 | return service.RegisterIngressRpcHandlers(rpcServer, info) 314 | } 315 | 316 | func getConfig(c *cli.Context, initialize bool) (*config.Config, error) { 317 | configFile := c.String("config") 318 | configBody := c.String("config-body") 319 | if configBody == "" { 320 | if configFile == "" { 321 | return nil, errors.ErrNoConfig 322 | } 323 | content, err := ioutil.ReadFile(configFile) 324 | if err != nil { 325 | return nil, err 326 | } 327 | configBody = string(content) 328 | } 329 | 330 | conf, err := config.NewConfig(configBody) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | if initialize { 336 | err = conf.Init() 337 | if err != nil { 338 | return nil, err 339 | } 340 | } 341 | 342 | return conf, nil 343 | } 344 | -------------------------------------------------------------------------------- /cmd/server/test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | -------------------------------------------------------------------------------- /cmd/whiptest/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | 23 | "github.com/livekit/ingress/pkg/config" 24 | "github.com/livekit/ingress/pkg/service" 25 | "github.com/livekit/ingress/pkg/whip" 26 | "github.com/livekit/protocol/logger" 27 | ) 28 | 29 | func main() { 30 | conf := &config.Config{ 31 | WHIPPort: 8080, 32 | HTTPRelayPort: 9090, 33 | } 34 | 35 | whipServer := whip.NewWHIPServer() 36 | relay := service.NewRelay(nil, whipServer) 37 | 38 | err := whipServer.Start(conf, func(streamKey, resourceId, sdpOffer string) error { 39 | logger.Infow("new whip client", "streamKey", streamKey, "resourceId", resourceId) 40 | 41 | sdpPayload := "THIS IS A TEST SDP ANSWER" 42 | 43 | _, err := http.Post(fmt.Sprintf("http://localhost:%d/whip/%s", conf.HTTPRelayPort, resourceId), "application/sdp", bytes.NewReader([]byte(sdpPayload))) 44 | if err != nil { 45 | logger.Errorw("relay POST failed", err) 46 | } 47 | 48 | return nil 49 | }, nil) 50 | if err != nil { 51 | panic(fmt.Sprintf("Failed starting WHIP server %s", err)) 52 | } 53 | err = relay.Start(conf) 54 | if err != nil { 55 | panic(fmt.Sprintf("Failed starting WHIP relay %s", err)) 56 | } 57 | 58 | sdpPayload := "THIS IS A TEST SDP OFFER" 59 | 60 | resp, err := http.Post(fmt.Sprintf("http://localhost:%d/w/%s", conf.WHIPPort, "stream_key"), "application/sdp", bytes.NewReader([]byte(sdpPayload))) 61 | if err != nil { 62 | panic(fmt.Sprintf("relay POST failed %s", err)) 63 | } 64 | defer resp.Body.Close() 65 | 66 | sdpAnswer := bytes.Buffer{} 67 | _, err = io.Copy(&sdpAnswer, resp.Body) 68 | if err != nil { 69 | panic(fmt.Sprintf("can't read response %s", err)) 70 | } 71 | 72 | fmt.Printf("Response status %d %s\n", resp.StatusCode, resp.Status) 73 | fmt.Printf("location %s\n", resp.Header.Get("Location")) 74 | fmt.Println(string(sdpAnswer.Bytes())) 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/livekit/ingress 2 | 3 | go 1.23.2 4 | 5 | replace github.com/go-gst/go-gst => github.com/livekit/go-gst v0.2.34-0.20250213212803-e8b19bcbb3e9 6 | 7 | toolchain go1.24.3 8 | 9 | require ( 10 | github.com/Eyevinn/mp4ff v0.48.0 11 | github.com/aclements/go-moremath v0.0.0-20241023150245-c8bbc672ef66 12 | github.com/frostbyte73/core v0.1.1 13 | github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd 14 | github.com/go-gst/go-gst v1.4.0 15 | github.com/gorilla/mux v1.8.1 16 | github.com/livekit/go-rtmp v0.0.0-20230829211117-1c4f5a5c81ed 17 | github.com/livekit/mageutil v0.0.0-20230125210925-54e8a70427c1 18 | github.com/livekit/mediatransportutil v0.0.0-20241220010243-a2bdee945564 19 | github.com/livekit/protocol v1.37.2-0.20250506041305-86628c07da5e 20 | github.com/livekit/psrpc v0.6.1-0.20250205181828-a0beed2e4126 21 | github.com/livekit/server-sdk-go/v2 v2.4.2 22 | github.com/pion/dtls/v3 v3.0.6 23 | github.com/pion/interceptor v0.1.37 24 | github.com/pion/rtcp v1.2.15 25 | github.com/pion/rtp v1.8.15 26 | github.com/pion/sdp/v3 v3.0.11 27 | github.com/pion/webrtc/v4 v4.1.0 28 | github.com/prometheus/client_golang v1.22.0 29 | github.com/sirupsen/logrus v1.9.3 30 | github.com/stretchr/testify v1.10.0 31 | github.com/urfave/cli/v2 v2.27.6 32 | github.com/yutopp/go-flv v0.3.1 33 | go.uber.org/atomic v1.11.0 34 | golang.org/x/image v0.27.0 35 | google.golang.org/grpc v1.72.0 36 | google.golang.org/protobuf v1.36.6 37 | gopkg.in/yaml.v3 v3.0.1 38 | ) 39 | 40 | require ( 41 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.0-20241127180247-a33202765966.1 // indirect 42 | buf.build/go/protoyaml v0.3.1 // indirect 43 | cel.dev/expr v0.20.0 // indirect 44 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 45 | github.com/benbjohnson/clock v1.3.5 // indirect 46 | github.com/beorn7/perks v1.0.1 // indirect 47 | github.com/bep/debounce v1.2.1 // indirect 48 | github.com/bufbuild/protovalidate-go v0.8.0 // indirect 49 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 50 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 51 | github.com/davecgh/go-spew v1.1.1 // indirect 52 | github.com/dennwc/iters v1.0.1 // indirect 53 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 54 | github.com/fsnotify/fsnotify v1.8.0 // indirect 55 | github.com/gammazero/deque v1.0.0 // indirect 56 | github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c // indirect 57 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 58 | github.com/go-logr/logr v1.4.2 // indirect 59 | github.com/go-logr/stdr v1.2.2 // indirect 60 | github.com/google/cel-go v0.22.1 // indirect 61 | github.com/google/uuid v1.6.0 // indirect 62 | github.com/gorilla/websocket v1.5.3 // indirect 63 | github.com/hashicorp/errwrap v1.1.0 // indirect 64 | github.com/hashicorp/go-multierror v1.1.0 // indirect 65 | github.com/jxskiss/base62 v1.1.0 // indirect 66 | github.com/klauspost/compress v1.18.0 // indirect 67 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 68 | github.com/lithammer/shortuuid/v4 v4.2.0 // indirect 69 | github.com/mackerelio/go-osstat v0.2.5 // indirect 70 | github.com/magefile/mage v1.15.0 // indirect 71 | github.com/mitchellh/mapstructure v1.5.0 // indirect 72 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 73 | github.com/nats-io/nats.go v1.38.0 // indirect 74 | github.com/nats-io/nkeys v0.4.9 // indirect 75 | github.com/nats-io/nuid v1.0.1 // indirect 76 | github.com/pion/datachannel v1.5.10 // indirect 77 | github.com/pion/ice/v4 v4.0.10 // indirect 78 | github.com/pion/logging v0.2.3 // indirect 79 | github.com/pion/mdns/v2 v2.0.7 // indirect 80 | github.com/pion/randutil v0.1.0 // indirect 81 | github.com/pion/sctp v1.8.39 // indirect 82 | github.com/pion/srtp/v3 v3.0.4 // indirect 83 | github.com/pion/stun/v3 v3.0.0 // indirect 84 | github.com/pion/transport/v3 v3.0.7 // indirect 85 | github.com/pion/turn/v4 v4.0.0 // indirect 86 | github.com/pkg/errors v0.9.1 // indirect 87 | github.com/pmezard/go-difflib v1.0.0 // indirect 88 | github.com/prometheus/client_model v0.6.1 // indirect 89 | github.com/prometheus/common v0.62.0 // indirect 90 | github.com/prometheus/procfs v0.15.1 // indirect 91 | github.com/puzpuzpuz/xsync/v3 v3.5.0 // indirect 92 | github.com/redis/go-redis/v9 v9.7.0 // indirect 93 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 94 | github.com/stoewer/go-strcase v1.3.0 // indirect 95 | github.com/twitchtv/twirp v8.1.3+incompatible // indirect 96 | github.com/wlynxg/anet v0.0.5 // indirect 97 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 98 | github.com/yutopp/go-amf0 v0.1.0 // indirect 99 | github.com/zeebo/xxh3 v1.0.2 // indirect 100 | go.uber.org/multierr v1.11.0 // indirect 101 | go.uber.org/zap v1.27.0 // indirect 102 | go.uber.org/zap/exp v0.3.0 // indirect 103 | golang.org/x/crypto v0.36.0 // indirect 104 | golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect 105 | golang.org/x/net v0.38.0 // indirect 106 | golang.org/x/sync v0.14.0 // indirect 107 | golang.org/x/sys v0.31.0 // indirect 108 | golang.org/x/text v0.25.0 // indirect 109 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 110 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 111 | ) 112 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build mage 16 | // +build mage 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "encoding/json" 23 | "fmt" 24 | "go/build" 25 | "os" 26 | "os/exec" 27 | "strings" 28 | 29 | "github.com/livekit/mageutil" 30 | ) 31 | 32 | var Default = Build 33 | 34 | const ( 35 | imageName = "livekit/ingress" 36 | gstVersion = "1.22.5" 37 | goVersion = "1.21.5" 38 | ) 39 | 40 | var plugins = []string{"gstreamer", "gst-plugins-base", "gst-plugins-good", "gst-plugins-bad", "gst-plugins-ugly", "gst-libav"} 41 | 42 | type packageInfo struct { 43 | Dir string 44 | } 45 | 46 | func Proto() error { 47 | ctx := context.Background() 48 | fmt.Println("generating protobuf") 49 | 50 | // parse go mod output 51 | pkgOut, err := mageutil.Out(ctx, "go list -json -m github.com/livekit/protocol") 52 | if err != nil { 53 | return err 54 | } 55 | pi := packageInfo{} 56 | if err = json.Unmarshal(pkgOut, &pi); err != nil { 57 | return err 58 | } 59 | 60 | _, err = mageutil.GetToolPath("protoc") 61 | if err != nil { 62 | return err 63 | } 64 | protocGoPath, err := mageutil.GetToolPath("protoc-gen-go") 65 | if err != nil { 66 | return err 67 | } 68 | protocGrpcGoPath, err := mageutil.GetToolPath("protoc-gen-go-grpc") 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // generate grpc-related protos 74 | return mageutil.RunDir(ctx, "pkg/ipc", fmt.Sprintf( 75 | "protoc"+ 76 | " --go_out ."+ 77 | " --go-grpc_out ."+ 78 | " --go_opt=paths=source_relative"+ 79 | " --go-grpc_opt=paths=source_relative"+ 80 | " --plugin=go=%s"+ 81 | " --plugin=go-grpc=%s"+ 82 | " -I%s -I=. ipc.proto", 83 | protocGoPath, protocGrpcGoPath, pi.Dir, 84 | )) 85 | } 86 | 87 | func Bootstrap() error { 88 | brewPrefix, err := getBrewPrefix() 89 | if err != nil { 90 | return err 91 | } 92 | 93 | for _, plugin := range plugins { 94 | if _, err := os.Stat(fmt.Sprintf("%s%s", brewPrefix, plugin)); err != nil { 95 | if err = run(fmt.Sprintf("brew install %s", plugin)); err != nil { 96 | return err 97 | } 98 | } 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func Build() error { 105 | gopath := os.Getenv("GOPATH") 106 | if gopath == "" { 107 | gopath = build.Default.GOPATH 108 | } 109 | 110 | return run(fmt.Sprintf("go build -a -o %s/bin/ingress ./cmd/server", gopath)) 111 | } 112 | 113 | func Test() error { 114 | return run("go test -v ./pkg/...") 115 | } 116 | 117 | func BuildDocker() error { 118 | return mageutil.Run(context.Background(), 119 | fmt.Sprintf("docker pull livekit/gstreamer:%s-dev", gstVersion), 120 | fmt.Sprintf("docker pull livekit/gstreamer:%s-prod", gstVersion), 121 | fmt.Sprintf("docker build --no-cache -t %s:latest -f build/ingress/Dockerfile --build-arg GSTVERSION=%s --build-arg GOVERSION=%s .", imageName, gstVersion, goVersion), 122 | ) 123 | } 124 | 125 | func BuildDockerLinux() error { 126 | return mageutil.Run(context.Background(), 127 | fmt.Sprintf("docker pull livekit/gstreamer:%s-dev", gstVersion), 128 | fmt.Sprintf("docker pull livekit/gstreamer:%s-prod", gstVersion), 129 | fmt.Sprintf("docker build --no-cache --platform linux/amd64 -t %s:latest -f build/ingress/Dockerfile --build-arg GSTVERSION=%s --build-arg GOVERSION=%s .", imageName, gstVersion, goVersion), 130 | ) 131 | } 132 | 133 | func Integration(configFile string) error { 134 | if err := Build(); err != nil { 135 | return err 136 | } 137 | 138 | return Retest(configFile) 139 | } 140 | 141 | func Retest(configFile string) error { 142 | err := WhipClient() 143 | if err != nil { 144 | return err 145 | } 146 | 147 | cmd := exec.Command("go", "test", "-v", "-count=1", "--tags=integration", "./test/...") 148 | 149 | brewPrefix, err := getBrewPrefix() 150 | if err != nil { 151 | return err 152 | } 153 | 154 | var sb strings.Builder 155 | sb.WriteString("GST_PLUGIN_PATH=") 156 | for i, plugin := range plugins { 157 | if i > 0 { 158 | sb.WriteString(":") 159 | } 160 | sb.WriteString(brewPrefix) 161 | sb.WriteString(plugin) 162 | } 163 | 164 | confStr, err := os.ReadFile(configFile) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | cmd.Env = append(os.Environ(), sb.String(), "GST_DEBUG=3", fmt.Sprintf("INGRESS_CONFIG_BODY=%s", string(confStr))) 170 | cmd.Stdout = os.Stdout 171 | cmd.Stderr = os.Stderr 172 | return cmd.Run() 173 | } 174 | 175 | func Publish(ingressId string) error { 176 | return run(fmt.Sprintf("gst-launch-1.0 -v flvmux name=mux ! rtmp2sink location=rtmp://localhost:1935/live/%s audiotestsrc freq=200 ! faac ! mux. videotestsrc pattern=ball is-live=true ! video/x-raw,width=1280,height=720 ! x264enc speed-preset=3 ! mux.", ingressId)) 177 | } 178 | 179 | func WhipClient() error { 180 | return run("go build -C ./test/livekit-whip-bot/cmd/whip-client/ ./...") 181 | } 182 | 183 | // helpers 184 | 185 | func getBrewPrefix() (string, error) { 186 | out, err := exec.Command("brew", "--prefix").Output() 187 | if err != nil { 188 | return "", err 189 | } 190 | return fmt.Sprintf("%s/Cellar/", strings.TrimSpace(string(out))), nil 191 | } 192 | 193 | func run(commands ...string) error { 194 | for _, command := range commands { 195 | args := strings.Split(command, " ") 196 | if err := runArgs(args...); err != nil { 197 | return err 198 | } 199 | } 200 | return nil 201 | } 202 | 203 | func runArgs(args ...string) error { 204 | cmd := exec.Command(args[0], args[1:]...) 205 | cmd.Stdout = os.Stdout 206 | cmd.Stderr = os.Stderr 207 | return cmd.Run() 208 | } 209 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "os" 19 | 20 | "github.com/sirupsen/logrus" 21 | "gopkg.in/yaml.v3" 22 | 23 | "github.com/livekit/ingress/pkg/errors" 24 | "github.com/livekit/mediatransportutil/pkg/rtcconfig" 25 | "github.com/livekit/protocol/logger" 26 | "github.com/livekit/protocol/logger/medialogutils" 27 | "github.com/livekit/protocol/redis" 28 | "github.com/livekit/protocol/utils" 29 | "github.com/livekit/psrpc" 30 | lksdk "github.com/livekit/server-sdk-go/v2" 31 | ) 32 | 33 | const ( 34 | DefaultRTMPPort int = 1935 35 | DefaultWHIPPort = 8080 36 | DefaultHTTPRelayPort = 9090 37 | ) 38 | 39 | var ( 40 | DefaultICEPortRange = []uint16{2000, 4000} 41 | ) 42 | 43 | type Config struct { 44 | *ServiceConfig `yaml:",inline"` 45 | *InternalConfig `yaml:",inline"` 46 | } 47 | 48 | type ServiceConfig struct { 49 | Redis *redis.RedisConfig `yaml:"redis"` // required 50 | ApiKey string `yaml:"api_key"` // required (env LIVEKIT_API_KEY) 51 | ApiSecret string `yaml:"api_secret"` // required (env LIVEKIT_API_SECRET) 52 | WsUrl string `yaml:"ws_url"` // required (env LIVEKIT_WS_URL) 53 | 54 | HealthPort int `yaml:"health_port"` 55 | DebugHandlerPort int `yaml:"debug_handler_port"` 56 | PrometheusPort int `yaml:"prometheus_port"` 57 | RTMPPort int `yaml:"rtmp_port"` // -1 to disable RTMP 58 | WHIPPort int `yaml:"whip_port"` // -1 to disable WHIP 59 | HTTPRelayPort int `yaml:"http_relay_port"` 60 | Logging logger.Config `yaml:"logging"` 61 | Development bool `yaml:"development"` 62 | 63 | // Used for WHIP transport 64 | RTCConfig rtcconfig.RTCConfig `yaml:"rtc_config"` 65 | 66 | // CPU costs for various ingress types 67 | CPUCost CPUCostConfig `yaml:"cpu_cost"` 68 | } 69 | 70 | type InternalConfig struct { 71 | // internal 72 | ServiceName string `yaml:"service_name"` 73 | NodeID string `yaml:"node_id"` // Do not provide, will be overwritten 74 | } 75 | 76 | type CPUCostConfig struct { 77 | RTMPCpuCost float64 `yaml:"rtmp_cpu_cost"` 78 | WHIPCpuCost float64 `yaml:"whip_cpu_cost"` 79 | WHIPBypassTranscodingCpuCost float64 `yaml:"whip_bypass_transcoding_cpu_cost"` 80 | URLCpuCost float64 `yaml:"url_cpu_cost"` 81 | MinIdleRatio float64 `yaml:"min_idle_ratio"` // Target idle cpu ratio when deciding availability for new requests 82 | } 83 | 84 | func NewConfig(confString string) (*Config, error) { 85 | conf := &Config{ 86 | ServiceConfig: &ServiceConfig{ 87 | ApiKey: os.Getenv("LIVEKIT_API_KEY"), 88 | ApiSecret: os.Getenv("LIVEKIT_API_SECRET"), 89 | WsUrl: os.Getenv("LIVEKIT_WS_URL"), 90 | }, 91 | InternalConfig: &InternalConfig{ 92 | ServiceName: "ingress", 93 | }, 94 | } 95 | if confString != "" { 96 | if err := yaml.Unmarshal([]byte(confString), conf); err != nil { 97 | return nil, errors.ErrCouldNotParseConfig(err) 98 | } 99 | } 100 | 101 | if conf.Redis == nil { 102 | return nil, psrpc.NewErrorf(psrpc.InvalidArgument, "redis configuration is required") 103 | } 104 | 105 | return conf, nil 106 | } 107 | func (conf *ServiceConfig) InitDefaults() error { 108 | if conf.RTMPPort == 0 { 109 | conf.RTMPPort = DefaultRTMPPort 110 | } 111 | if conf.HTTPRelayPort == 0 { 112 | conf.HTTPRelayPort = DefaultHTTPRelayPort 113 | } 114 | if conf.WHIPPort == 0 { 115 | conf.WHIPPort = DefaultWHIPPort 116 | } 117 | 118 | err := conf.InitWhipConf() 119 | if err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | } 125 | 126 | func (c *ServiceConfig) InitWhipConf() error { 127 | if c.WHIPPort <= 0 { 128 | return nil 129 | } 130 | 131 | if c.RTCConfig.UDPPort.Start == 0 && c.RTCConfig.ICEPortRangeStart == 0 { 132 | c.RTCConfig.UDPPort.Start = 7885 133 | } 134 | 135 | // Validate will set the NodeIP 136 | err := c.RTCConfig.Validate(c.Development) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (conf *Config) Init() error { 145 | conf.NodeID = utils.NewGuid("NE_") 146 | 147 | err := conf.InitDefaults() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if err := conf.InitLogger(); err != nil { 153 | return err 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *Config) InitLogger(values ...interface{}) error { 160 | zl, err := logger.NewZapLogger(&c.Logging) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | values = append(c.getLoggerValues(), values...) 166 | l := zl.WithValues(values...) 167 | logger.SetLogger(l, c.ServiceName) 168 | lksdk.SetLogger(medialogutils.NewOverrideLogger(nil)) 169 | 170 | return nil 171 | } 172 | 173 | // To use with zap logger 174 | func (c *Config) getLoggerValues() []interface{} { 175 | return []interface{}{"nodeID", c.NodeID} 176 | } 177 | 178 | // To use with logrus 179 | func (c *Config) GetLoggerFields() logrus.Fields { 180 | fields := logrus.Fields{ 181 | "logger": c.ServiceName, 182 | } 183 | v := c.getLoggerValues() 184 | for i := 0; i < len(v); i += 2 { 185 | fields[v[i].(string)] = v[i+1] 186 | } 187 | 188 | return fields 189 | } 190 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package errors 16 | 17 | import ( 18 | "errors" 19 | "io" 20 | 21 | "github.com/go-gst/go-gst/gst" 22 | "google.golang.org/grpc/status" 23 | 24 | "github.com/livekit/psrpc" 25 | ) 26 | 27 | var ( 28 | ErrNoConfig = psrpc.NewErrorf(psrpc.InvalidArgument, "missing config") 29 | ErrInvalidAudioOptions = psrpc.NewErrorf(psrpc.InvalidArgument, "invalid audio options") 30 | ErrInvalidVideoOptions = psrpc.NewErrorf(psrpc.InvalidArgument, "invalid video options") 31 | ErrInvalidAudioPreset = psrpc.NewErrorf(psrpc.InvalidArgument, "invalid audio encoding preset") 32 | ErrInvalidVideoPreset = psrpc.NewErrorf(psrpc.InvalidArgument, "invalid video encoding preset") 33 | ErrSourceNotReady = psrpc.NewErrorf(psrpc.FailedPrecondition, "source encoder not ready") 34 | ErrUnsupportedDecodeFormat = psrpc.NewErrorf(psrpc.NotAcceptable, "unsupported format for the source media") 35 | ErrUnsupportedEncodeFormat = psrpc.NewErrorf(psrpc.InvalidArgument, "unsupported mime type for encoder") 36 | ErrUnsupportedURLFormat = psrpc.NewErrorf(psrpc.InvalidArgument, "unsupported URL type") 37 | ErrDuplicateTrack = psrpc.NewErrorf(psrpc.NotAcceptable, "more than 1 track with given media kind") 38 | ErrUnableToAddPad = psrpc.NewErrorf(psrpc.Internal, "could not add pads to bin") 39 | ErrMissingResourceId = psrpc.NewErrorf(psrpc.InvalidArgument, "missing resource ID") 40 | ErrInvalidRelayToken = psrpc.NewErrorf(psrpc.PermissionDenied, "invalid token") 41 | ErrIngressNotFound = psrpc.NewErrorf(psrpc.NotFound, "ingress not found") 42 | ErrServerCapacityExceeded = psrpc.NewErrorf(psrpc.ResourceExhausted, "server capacity exceeded") 43 | ErrServerShuttingDown = psrpc.NewErrorf(psrpc.Unavailable, "server shutting down") 44 | ErrIngressClosing = psrpc.NewErrorf(psrpc.Unavailable, "ingress closing") 45 | ErrMissingStreamKey = psrpc.NewErrorf(psrpc.InvalidArgument, "missing stream key") 46 | ErrPrerollBufferReset = psrpc.NewErrorf(psrpc.Internal, "preroll buffer reset") 47 | ErrInvalidSimulcast = psrpc.NewErrorf(psrpc.NotAcceptable, "invalid simulcast configuration") 48 | ErrSimulcastTranscode = psrpc.NewErrorf(psrpc.NotAcceptable, "simulcast is not supported when transcoding") 49 | ErrRoomDisconnected = psrpc.NewErrorf(psrpc.NotAcceptable, "room disonnected") 50 | ErrInvalidWHIPRestartRequest = psrpc.NewErrorf(psrpc.InvalidArgument, "whip restart request was invalid") 51 | ErrRoomDisconnectedUnexpectedly = RetryableError{psrpc.NewErrorf(psrpc.Unavailable, "room disonnected unexpectedly")} 52 | ) 53 | 54 | type RetryableError struct { 55 | psrpcErr psrpc.Error 56 | } 57 | 58 | func (err RetryableError) Error() string { 59 | return err.psrpcErr.Error() 60 | } 61 | 62 | func (err RetryableError) Code() psrpc.ErrorCode { 63 | return err.psrpcErr.Code() 64 | } 65 | 66 | func (err RetryableError) ToHttp() int { 67 | return err.psrpcErr.ToHttp() 68 | } 69 | 70 | func (err RetryableError) GRPCStatus() *status.Status { 71 | return err.psrpcErr.GRPCStatus() 72 | } 73 | 74 | func (err RetryableError) Unwrap() error { 75 | return err.psrpcErr 76 | } 77 | 78 | func New(err string) error { 79 | return errors.New(err) 80 | } 81 | 82 | func Is(err, target error) bool { 83 | return errors.Is(err, target) 84 | } 85 | 86 | func As(err error, target any) bool { 87 | return errors.As(err, target) 88 | } 89 | 90 | func ErrCouldNotParseConfig(err error) psrpc.Error { 91 | return psrpc.NewErrorf(psrpc.InvalidArgument, "could not parse config: %v", err) 92 | } 93 | 94 | func ErrFromGstFlowReturn(ret gst.FlowReturn) psrpc.Error { 95 | return psrpc.NewErrorf(psrpc.Internal, "GST Flow Error %d (%s)", ret, ret.String()) 96 | } 97 | 98 | func ErrHttpRelayFailure(statusCode int) psrpc.Error { 99 | // Any failure in the relay between the handler and the service is treated as internal 100 | 101 | return psrpc.NewErrorf(psrpc.Internal, "HTTP request failed with code %d", statusCode) 102 | } 103 | 104 | func ErrUnsupportedDecodeMimeType(mimeType string) psrpc.Error { 105 | return psrpc.NewErrorf(psrpc.NotAcceptable, "unsupported mime type (%s) for the source media", mimeType) 106 | } 107 | 108 | func ErrorToGstFlowReturn(err error) gst.FlowReturn { 109 | switch { 110 | case err == nil: 111 | return gst.FlowOK 112 | case errors.Is(err, io.EOF): 113 | return gst.FlowEOS 114 | default: 115 | return gst.FlowError 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/ipc/ipc.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | import "google/protobuf/empty.proto"; 18 | 19 | package ipc; 20 | option go_package = "github.com/livekit/ingress/pkg/ipc"; 21 | 22 | service IngressHandler { 23 | rpc GetPipelineDot(GstPipelineDebugDotRequest) returns (GstPipelineDebugDotResponse) {}; 24 | rpc GetPProf(PProfRequest) returns (PProfResponse) {}; 25 | rpc GatherMediaStats(GatherMediaStatsRequest) returns (GatherMediaStatsResponse) {}; 26 | rpc UpdateMediaStats(UpdateMediaStatsRequest) returns (google.protobuf.Empty) {}; 27 | } 28 | 29 | message GstPipelineDebugDotRequest {} 30 | 31 | message GstPipelineDebugDotResponse { 32 | string dot_file = 1; 33 | } 34 | 35 | message PProfRequest { 36 | string profile_name = 1; 37 | int32 timeout = 2; 38 | int32 debug = 3; 39 | } 40 | 41 | message PProfResponse { 42 | bytes pprof_file = 1; 43 | } 44 | 45 | message GatherMediaStatsRequest { 46 | } 47 | 48 | message GatherMediaStatsResponse { 49 | MediaStats stats = 1; 50 | } 51 | 52 | message UpdateMediaStatsRequest { 53 | MediaStats stats = 1; 54 | } 55 | 56 | message MediaStats { 57 | map track_stats = 1; 58 | } 59 | 60 | message TrackStats { 61 | uint32 average_bitrate = 1; 62 | uint32 current_bitrate = 2; 63 | uint64 total_packets = 4; 64 | uint64 current_packets = 5; 65 | double total_loss_rate = 6; 66 | double current_loss_rate = 7; 67 | uint64 total_pli = 8; 68 | uint64 current_pli = 9; 69 | 70 | JitterStats jitter = 10; 71 | } 72 | 73 | message JitterStats { 74 | double p50 = 1; // in ms 75 | double p90 = 2; 76 | double p99 = 3; 77 | } 78 | 79 | -------------------------------------------------------------------------------- /pkg/lksdk_output/media_watchdog.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package lksdk_output 16 | 17 | import ( 18 | "sync/atomic" 19 | "time" 20 | 21 | "github.com/frostbyte73/core" 22 | ) 23 | 24 | type MediaWatchdog struct { 25 | bytesSinceLastCheck atomic.Int64 26 | 27 | done core.Fuse 28 | } 29 | 30 | func NewMediaWatchdog(onFire func(), deadline time.Duration) *MediaWatchdog { 31 | w := &MediaWatchdog{} 32 | 33 | ticker := time.NewTicker(deadline) 34 | 35 | go func() { 36 | for { 37 | select { 38 | case <-w.done.Watch(): 39 | ticker.Stop() 40 | return 41 | case <-ticker.C: 42 | b := w.bytesSinceLastCheck.Swap(0) 43 | if b == 0 { 44 | // Do not fire the watchdog again 45 | ticker.Stop() 46 | onFire() 47 | } 48 | } 49 | } 50 | }() 51 | 52 | return w 53 | } 54 | 55 | func (w *MediaWatchdog) Stop() { 56 | w.done.Break() 57 | } 58 | 59 | func (w *MediaWatchdog) MediaReceived(b int64) { 60 | w.bytesSinceLastCheck.Add(b) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/lksdk_output/media_watchdog_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package lksdk_output 16 | 17 | import ( 18 | "sync/atomic" 19 | "testing" 20 | "time" 21 | 22 | "github.com/frostbyte73/core" 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestMediaWatchdog(t *testing.T) { 27 | triggered := testWatchDog(t, 10) 28 | require.False(t, triggered) 29 | 30 | triggered = testWatchDog(t, 0) 31 | require.True(t, triggered) 32 | 33 | } 34 | 35 | func testWatchDog(t *testing.T, mediaReceived int64) bool { 36 | var triggered atomic.Bool 37 | var done core.Fuse 38 | 39 | w := NewMediaWatchdog(func() { 40 | triggered.Store(true) 41 | }, 100*time.Millisecond) 42 | 43 | ticker := time.NewTicker(10 * time.Millisecond) 44 | go func() { 45 | for { 46 | select { 47 | case <-done.Watch(): 48 | return 49 | case <-ticker.C: 50 | if mediaReceived > 0 { 51 | w.MediaReceived(mediaReceived) 52 | } 53 | } 54 | } 55 | }() 56 | 57 | time.Sleep(time.Second) 58 | 59 | done.Break() 60 | ticker.Stop() 61 | w.Stop() 62 | 63 | return triggered.Load() 64 | } 65 | -------------------------------------------------------------------------------- /pkg/lksdk_output/track_watchdog.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package lksdk_output 16 | 17 | import ( 18 | "sync" 19 | "time" 20 | ) 21 | 22 | type TrackWatchdog struct { 23 | deadline time.Duration 24 | onFire func() 25 | 26 | trackLock sync.Mutex 27 | expectedTrackCount int 28 | boundTrackCount int 29 | timer *time.Timer 30 | started bool 31 | } 32 | 33 | func NewTrackWatchdog(onFire func(), deadline time.Duration) *TrackWatchdog { 34 | return &TrackWatchdog{ 35 | onFire: onFire, 36 | deadline: deadline, 37 | started: true, 38 | } 39 | } 40 | 41 | func (w *TrackWatchdog) TrackAdded() { 42 | w.trackLock.Lock() 43 | defer w.trackLock.Unlock() 44 | 45 | w.expectedTrackCount++ 46 | 47 | w.updateTimer() 48 | } 49 | 50 | func (w *TrackWatchdog) TrackBound() { 51 | w.trackLock.Lock() 52 | defer w.trackLock.Unlock() 53 | 54 | w.boundTrackCount++ 55 | 56 | w.updateTimer() 57 | } 58 | 59 | func (w *TrackWatchdog) TrackUnbound() { 60 | w.trackLock.Lock() 61 | defer w.trackLock.Unlock() 62 | 63 | w.boundTrackCount-- 64 | 65 | w.updateTimer() 66 | } 67 | 68 | func (w *TrackWatchdog) Stop() { 69 | w.trackLock.Lock() 70 | defer w.trackLock.Unlock() 71 | 72 | w.started = false 73 | 74 | w.updateTimer() 75 | } 76 | 77 | // Must be called locked 78 | func (w *TrackWatchdog) updateTimer() { 79 | timerMustBeActive := w.started && w.boundTrackCount < w.expectedTrackCount 80 | 81 | if w.timer == nil && timerMustBeActive { 82 | w.timer = time.AfterFunc(w.deadline, w.onFire) 83 | } 84 | 85 | if w.timer != nil && !timerMustBeActive { 86 | w.timer.Stop() 87 | w.timer = nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pkg/media/input.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package media 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | "sync" 21 | 22 | "github.com/frostbyte73/core" 23 | "github.com/go-gst/go-gst/gst" 24 | 25 | "github.com/livekit/ingress/pkg/errors" 26 | "github.com/livekit/ingress/pkg/media/rtmp" 27 | "github.com/livekit/ingress/pkg/media/urlpull" 28 | "github.com/livekit/ingress/pkg/media/whip" 29 | "github.com/livekit/ingress/pkg/params" 30 | "github.com/livekit/ingress/pkg/stats" 31 | "github.com/livekit/ingress/pkg/types" 32 | "github.com/livekit/protocol/ingress" 33 | "github.com/livekit/protocol/livekit" 34 | "github.com/livekit/protocol/logger" 35 | ) 36 | 37 | type Source interface { 38 | GetSources() []*gst.Element 39 | ValidateCaps(*gst.Caps) error 40 | Start(ctx context.Context) error 41 | Close() error 42 | } 43 | 44 | type Input struct { 45 | lock sync.Mutex 46 | 47 | trackStatsGatherer map[types.StreamKind]*stats.MediaTrackStatGatherer 48 | 49 | bin *gst.Bin 50 | source Source 51 | 52 | audioOutput *gst.Pad 53 | videoOutput *gst.Pad 54 | 55 | onOutputReady OutputReadyFunc 56 | closeFuse core.Fuse 57 | closeErr error 58 | } 59 | 60 | type OutputReadyFunc func(pad *gst.Pad, kind types.StreamKind) 61 | 62 | func NewInput(ctx context.Context, p *params.Params, g *stats.LocalMediaStatsGatherer) (*Input, error) { 63 | src, err := CreateSource(ctx, p) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | bin := gst.NewBin("input") 69 | i := &Input{ 70 | bin: bin, 71 | source: src, 72 | trackStatsGatherer: make(map[types.StreamKind]*stats.MediaTrackStatGatherer), 73 | } 74 | 75 | if p.InputType == livekit.IngressInput_URL_INPUT { 76 | // Gather input stats from the pipeline 77 | i.trackStatsGatherer[types.Audio] = g.RegisterTrackStats(stats.InputAudio) 78 | i.trackStatsGatherer[types.Video] = g.RegisterTrackStats(stats.InputVideo) 79 | } 80 | 81 | srcs := src.GetSources() 82 | if len(srcs) == 0 { 83 | return nil, errors.ErrSourceNotReady 84 | } 85 | 86 | for _, src := range srcs { 87 | decodeBin, err := gst.NewElement("decodebin3") 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | if err := bin.AddMany(decodeBin, src); err != nil { 93 | return nil, err 94 | } 95 | 96 | if _, err = decodeBin.Connect("pad-added", i.onPadAdded); err != nil { 97 | return nil, err 98 | } 99 | 100 | if err = src.Link(decodeBin); err != nil { 101 | return nil, err 102 | } 103 | } 104 | 105 | return i, nil 106 | } 107 | 108 | func CreateSource(ctx context.Context, p *params.Params) (Source, error) { 109 | switch p.InputType { 110 | case livekit.IngressInput_RTMP_INPUT: 111 | return rtmp.NewRTMPRelaySource(ctx, p) 112 | case livekit.IngressInput_WHIP_INPUT: 113 | return whip.NewWHIPRelaySource(ctx, p) 114 | case livekit.IngressInput_URL_INPUT: 115 | return urlpull.NewURLSource(ctx, p) 116 | default: 117 | return nil, ingress.ErrInvalidIngressType 118 | } 119 | } 120 | 121 | func (i *Input) OnOutputReady(f OutputReadyFunc) { 122 | i.onOutputReady = f 123 | } 124 | 125 | func (i *Input) Start(ctx context.Context) error { 126 | return i.source.Start(ctx) 127 | } 128 | 129 | func (i *Input) Close() error { 130 | // Make sure Close is idempotent and always return the input error 131 | i.closeFuse.Once(func() { 132 | i.closeErr = i.source.Close() 133 | }) 134 | 135 | return i.closeErr 136 | } 137 | 138 | func (i *Input) onPadAdded(_ *gst.Element, pad *gst.Pad) { 139 | var err error 140 | 141 | defer func() { 142 | if err != nil { 143 | msg := gst.NewErrorMessage(i.bin.Element, err, err.Error(), nil) 144 | i.bin.Element.GetBus().Post(msg) 145 | } 146 | }() 147 | 148 | typefind, err := i.bin.GetElementByName("typefind") 149 | if err == nil && typefind != nil { 150 | var caps interface{} 151 | caps, err = typefind.GetProperty("caps") 152 | if err == nil && caps != nil { 153 | err = i.source.ValidateCaps(caps.(*gst.Caps)) 154 | if err != nil { 155 | logger.Infow("input caps validation failed", "error", err) 156 | return 157 | } 158 | } 159 | } 160 | 161 | // Make sure we emit scte35 markers if available 162 | tsparser, _ := i.bin.GetElementByName("tsdemux0") 163 | if tsparser != nil { 164 | err := tsparser.SetProperty("send-scte35-events", true) 165 | if err != nil { 166 | logger.Errorw("failed setting `send-scte35-events` property", err) 167 | } 168 | } 169 | 170 | // surface callback for first audio and video pads, plug in fakesink on the rest 171 | i.lock.Lock() 172 | newPad := false 173 | var kind types.StreamKind 174 | var ghostPad *gst.GhostPad 175 | if strings.HasPrefix(pad.GetName(), "audio") { 176 | if i.audioOutput == nil { 177 | newPad = true 178 | kind = types.Audio 179 | i.audioOutput = pad 180 | ghostPad = gst.NewGhostPad("audio", pad) 181 | } 182 | } else if strings.HasPrefix(pad.GetName(), "video") { 183 | if i.videoOutput == nil { 184 | newPad = true 185 | kind = types.Video 186 | i.videoOutput = pad 187 | ghostPad = gst.NewGhostPad("video", pad) 188 | } 189 | } 190 | i.lock.Unlock() 191 | 192 | // don't need this pad, link to fakesink 193 | if newPad { 194 | if !i.bin.AddPad(ghostPad.Pad) { 195 | logger.Errorw("failed to add ghost pad", nil) 196 | return 197 | } 198 | pad = ghostPad.Pad 199 | 200 | if i.trackStatsGatherer[kind] != nil { 201 | // Gather bitrate stats from pipeline itself 202 | i.addBitrateProbe(kind) 203 | } 204 | } else { 205 | var sink *gst.Element 206 | 207 | sink, err = gst.NewElement("fakesink") 208 | if err != nil { 209 | logger.Errorw("failed to create fakesink", err) 210 | return 211 | } 212 | var pads []*gst.Pad 213 | 214 | pads, err = sink.GetSinkPads() 215 | pad.Link(pads[0]) 216 | return 217 | } 218 | 219 | if i.onOutputReady != nil { 220 | i.onOutputReady(pad, kind) 221 | } 222 | } 223 | 224 | func (i *Input) addBitrateProbe(kind types.StreamKind) { 225 | // Do a best effort to add probe to retrieve bitrate. 226 | // The multiqueue is generally created in the pipeline before the decoders 227 | mq, err := i.bin.GetElementByName("multiqueue0") 228 | 229 | if err != nil { 230 | // No multiqueue in that pipeline 231 | logger.Debugw("could not retrieve multiqueue element from pipeline", "error", err) 232 | return 233 | } 234 | 235 | pads, err := mq.GetSinkPads() 236 | if err != nil { 237 | logger.Errorw("failed retrieving multiqueue sink pads", err) 238 | return 239 | } 240 | 241 | for _, pad := range pads { 242 | caps := pad.GetCurrentCaps() 243 | if caps != nil && caps.GetSize() > 0 { 244 | gstStruct := caps.GetStructureAt(0) 245 | padKind := getKindFromGstMimeType(gstStruct) 246 | 247 | if padKind == kind { 248 | g := i.trackStatsGatherer[kind] 249 | 250 | pad.AddProbe(gst.PadProbeTypeBuffer, func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { 251 | buffer := info.GetBuffer() 252 | if buffer == nil { 253 | return gst.PadProbeOK 254 | } 255 | 256 | size := buffer.GetSize() 257 | g.MediaReceived(size) 258 | 259 | return gst.PadProbeOK 260 | }) 261 | 262 | return 263 | } 264 | } else { 265 | logger.Debugw("could not retrieve multiqueue pad caps", "error", err) 266 | } 267 | } 268 | 269 | logger.Debugw("no pad on multiqueue with required kind found", "kind", kind) 270 | } 271 | -------------------------------------------------------------------------------- /pkg/media/rtmp/appsrc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rtmp 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | 23 | "github.com/go-gst/go-gst/gst" 24 | "github.com/go-gst/go-gst/gst/app" 25 | "go.uber.org/atomic" 26 | 27 | "github.com/livekit/ingress/pkg/errors" 28 | "github.com/livekit/ingress/pkg/params" 29 | "github.com/livekit/protocol/logger" 30 | "github.com/livekit/protocol/tracer" 31 | ) 32 | 33 | const ( 34 | FlvAppSource = "flvAppSrc" 35 | ) 36 | 37 | type RTMPRelaySource struct { 38 | params *params.Params 39 | 40 | flvSrc *app.Source 41 | writer *appSrcWriter 42 | result chan error 43 | } 44 | 45 | func NewRTMPRelaySource(ctx context.Context, p *params.Params) (*RTMPRelaySource, error) { 46 | ctx, span := tracer.Start(ctx, "RTMPRelaySource.New") 47 | defer span.End() 48 | 49 | s := &RTMPRelaySource{ 50 | params: p, 51 | } 52 | 53 | elem, err := gst.NewElementWithName("appsrc", FlvAppSource) 54 | if err != nil { 55 | logger.Errorw("could not create appsrc", err) 56 | return nil, err 57 | } 58 | if err = elem.SetProperty("caps", gst.NewCapsFromString("video/x-flv")); err != nil { 59 | return nil, err 60 | } 61 | if err = elem.SetProperty("is-live", true); err != nil { 62 | return nil, err 63 | } 64 | elem.SetArg("format", "time") 65 | 66 | s.flvSrc = app.SrcFromElement(elem) 67 | s.writer = newAppSrcWriter(s.flvSrc) 68 | 69 | return s, nil 70 | } 71 | 72 | func (s *RTMPRelaySource) Start(ctx context.Context) error { 73 | ctx, span := tracer.Start(ctx, "RTMPRelaySource.Start") 74 | defer span.End() 75 | 76 | s.result = make(chan error, 1) 77 | 78 | relayUrl := fmt.Sprintf("%s?token=%s", s.params.RelayUrl, s.params.RelayToken) 79 | 80 | req, err := http.NewRequestWithContext(ctx, "GET", relayUrl, nil) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | resp, err := http.DefaultClient.Do(req) 86 | switch { 87 | case err != nil: 88 | return err 89 | case resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 400): 90 | return errors.ErrHttpRelayFailure(resp.StatusCode) 91 | } 92 | 93 | go func() { 94 | defer resp.Body.Close() 95 | 96 | _, err := io.Copy(s.writer, resp.Body) 97 | switch err { 98 | case nil, io.EOF: 99 | err = nil 100 | default: 101 | logger.Infow("error while copying media from relay", err) 102 | } 103 | 104 | logger.Debugw("flv http relay reached end of stream") 105 | 106 | s.flvSrc.EndStream() 107 | 108 | s.result <- err 109 | close(s.result) 110 | }() 111 | 112 | return nil 113 | } 114 | 115 | func (s *RTMPRelaySource) Close() error { 116 | s.writer.Close() 117 | return <-s.result 118 | } 119 | 120 | func (s *RTMPRelaySource) GetSources() []*gst.Element { 121 | return []*gst.Element{s.flvSrc.Element} 122 | } 123 | 124 | func (s *RTMPRelaySource) ValidateCaps(caps *gst.Caps) error { 125 | if caps.GetSize() == 0 { 126 | return errors.ErrUnsupportedDecodeFormat 127 | } 128 | 129 | str := caps.GetStructureAt(0) 130 | if str == nil { 131 | return errors.ErrUnsupportedDecodeFormat 132 | } 133 | 134 | if str.Name() != "video/x-flv" { 135 | return errors.ErrUnsupportedDecodeFormat 136 | } 137 | 138 | return nil 139 | } 140 | 141 | type appSrcWriter struct { 142 | appSrc *app.Source 143 | eos *atomic.Bool 144 | } 145 | 146 | func newAppSrcWriter(flvSrc *app.Source) *appSrcWriter { 147 | return &appSrcWriter{ 148 | appSrc: flvSrc, 149 | eos: atomic.NewBool(false), 150 | } 151 | } 152 | 153 | func (w *appSrcWriter) Write(p []byte) (int, error) { 154 | if w.eos.Load() { 155 | return 0, io.EOF 156 | } 157 | 158 | b := gst.NewBufferFromBytes(p) 159 | 160 | ret := w.appSrc.PushBuffer(b) 161 | switch ret { 162 | case gst.FlowOK, gst.FlowFlushing: 163 | case gst.FlowEOS: 164 | w.Close() 165 | return 0, io.EOF 166 | default: 167 | return 0, errors.ErrFromGstFlowReturn(ret) 168 | } 169 | 170 | return len(p), nil 171 | } 172 | 173 | func (w *appSrcWriter) Close() error { 174 | w.eos.Store(true) 175 | 176 | w.appSrc.EndStream() 177 | 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /pkg/media/splice_processor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package media 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "time" 21 | 22 | "github.com/go-gst/go-gst/gst" 23 | "github.com/livekit/ingress/pkg/lksdk_output" 24 | "github.com/livekit/ingress/pkg/utils" 25 | "github.com/livekit/protocol/livekit" 26 | "github.com/livekit/protocol/logger" 27 | ) 28 | 29 | type Splice struct { 30 | Immediate bool 31 | OutOfNetworkIndicator bool 32 | EventId uint32 33 | EventCancelIndicator bool 34 | RunningTime time.Duration 35 | } 36 | 37 | type SpliceProcessor struct { 38 | outputSync *utils.OutputSynchronizer 39 | sdkOut *lksdk_output.LKSDKOutput 40 | 41 | ctx context.Context 42 | cancel context.CancelFunc 43 | } 44 | 45 | func NewSpliceProcessor(sdkOut *lksdk_output.LKSDKOutput, outputSync *utils.OutputSynchronizer) *SpliceProcessor { 46 | su := &SpliceProcessor{ 47 | sdkOut: sdkOut, 48 | outputSync: outputSync, 49 | } 50 | 51 | su.ctx, su.cancel = context.WithCancel(context.Background()) 52 | 53 | return su 54 | } 55 | 56 | func (su *SpliceProcessor) Close() { 57 | su.cancel() 58 | } 59 | 60 | func (su *SpliceProcessor) ProcessSpliceEvent(ev *gst.Event) error { 61 | ts := ev.ParseMpegtsSection() 62 | if ts == nil { 63 | return nil 64 | } 65 | if ts.SectionType() != gst.MpegtsSectionSCTESIT { 66 | return nil 67 | } 68 | 69 | scteSit := ts.GetSCTESIT() 70 | if scteSit == nil { 71 | return nil 72 | } 73 | 74 | if scteSit.SpliceCommandType() != gst.MpegtsSCTESpliceCommandInsert { 75 | return nil 76 | } 77 | 78 | str, _ := ev.GetStructure().GetValue("running-time-map") 79 | if str == nil { 80 | return nil 81 | } 82 | rMap, ok := str.(*gst.Structure) 83 | if !ok { 84 | return nil 85 | } 86 | 87 | splices := scteSit.Splices() 88 | for _, sp := range splices { 89 | var rTime time.Duration 90 | 91 | if !sp.SpliceImmediateFlag() { 92 | if rMap != nil { 93 | val, _ := rMap.GetValue(fmt.Sprintf("event-%d-splice-time", sp.SpliceEventId())) 94 | if val != nil { 95 | rTime = time.Duration(val.(uint64)) 96 | } 97 | } 98 | if rTime == 0 && scteSit.SpliceTimeSpecified() { 99 | val, _ := rMap.GetValue("splice-time") 100 | if val != nil { 101 | rTime = time.Duration(val.(uint64)) 102 | } 103 | } 104 | } 105 | 106 | sp := &Splice{ 107 | Immediate: sp.SpliceImmediateFlag(), 108 | OutOfNetworkIndicator: sp.OutOfNetworkIndicator(), 109 | EventId: sp.SpliceEventId(), 110 | EventCancelIndicator: sp.SpliceEventCancelIndicator(), 111 | RunningTime: time.Duration(rTime), 112 | } 113 | 114 | err := su.processSplice(sp) 115 | if err != nil { 116 | return err 117 | } 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (su *SpliceProcessor) processSplice(sp *Splice) error { 124 | var attr string 125 | 126 | if sp.OutOfNetworkIndicator { 127 | attr = fmt.Sprintf("%d", sp.EventId) 128 | } 129 | 130 | logger.Debugw("spice event", "eventID", sp.EventId, "outOfNetworkIndicator", sp.OutOfNetworkIndicator, "immediate", sp.Immediate, "runningTime", sp.RunningTime) 131 | 132 | event := func() { 133 | su.sdkOut.UpdateLocalParticipantAttributes(map[string]string{livekit.AttrIngressOutOfNetworkEventID: attr}) 134 | } 135 | 136 | if sp.Immediate { 137 | event() 138 | } else { 139 | err := su.outputSync.ScheduleEvent(su.ctx, sp.RunningTime, event) 140 | if err != nil { 141 | return err 142 | } 143 | } 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/media/urlpull/source.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package urlpull 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | "time" 21 | 22 | "github.com/frostbyte73/core" 23 | "github.com/go-gst/go-gst/gst" 24 | 25 | "github.com/livekit/ingress/pkg/errors" 26 | "github.com/livekit/ingress/pkg/params" 27 | "github.com/livekit/protocol/logger" 28 | ) 29 | 30 | var ( 31 | supportedMimeTypes = []string{ 32 | "audio/x-m4a", 33 | "application/x-hls", 34 | "video/quicktime", 35 | "video/x-matroska", 36 | "video/webm", 37 | "video/mpegts", 38 | "audio/ogg", 39 | "application/x-id3", 40 | "audio/mpeg", 41 | } 42 | ) 43 | 44 | type URLSource struct { 45 | params *params.Params 46 | src *gst.Element 47 | pad *gst.Pad 48 | printStats func() 49 | 50 | done core.Fuse 51 | } 52 | 53 | func NewURLSource(ctx context.Context, p *params.Params) (*URLSource, error) { 54 | var printStats func() 55 | bin := gst.NewBin("input") 56 | 57 | var elem *gst.Element 58 | var err error 59 | if strings.HasPrefix(p.Url, "http://") || strings.HasPrefix(p.Url, "https://") { 60 | elem, err = gst.NewElement("souphttpsrc") 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | err = elem.SetProperty("location", p.Url) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | } else if strings.HasPrefix(p.Url, "srt://") { 71 | elem, err = gst.NewElement("srtclientsrc") 72 | if err != nil { 73 | return nil, err 74 | } 75 | err = elem.SetProperty("uri", p.Url) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | printStats = func() { 81 | str, _ := elem.GetProperty("stats") 82 | if str != nil { 83 | if v, ok := str.(*gst.Structure); ok { 84 | logger.Infow("SRT input stats", "stats", v.String()) 85 | } 86 | } 87 | } 88 | } else { 89 | return nil, errors.ErrUnsupportedURLFormat 90 | } 91 | 92 | queue, err := gst.NewElement("queue2") 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | // Disable buffer count limit and rely on bytes and time limits 98 | if err := queue.SetProperty("max-size-buffers", uint(0)); err != nil { 99 | return nil, err 100 | } 101 | 102 | if strings.HasPrefix(p.Url, "http://") || strings.HasPrefix(p.Url, "https://") { 103 | err = queue.SetProperty("use-buffering", true) 104 | if err != nil { 105 | return nil, err 106 | } 107 | } 108 | 109 | err = bin.AddMany(elem, queue) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | err = elem.Link(queue) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | pad := queue.GetStaticPad("src") 120 | if pad == nil { 121 | return nil, errors.ErrUnableToAddPad 122 | } 123 | 124 | ghostPad := gst.NewGhostPad("src", pad) 125 | if !bin.AddPad(ghostPad.Pad) { 126 | return nil, errors.ErrUnableToAddPad 127 | } 128 | 129 | return &URLSource{ 130 | params: p, 131 | src: bin.Element, 132 | pad: pad, 133 | printStats: printStats, 134 | }, nil 135 | } 136 | 137 | func (u *URLSource) GetSources() []*gst.Element { 138 | return []*gst.Element{ 139 | u.src, 140 | } 141 | } 142 | 143 | func (s *URLSource) ValidateCaps(caps *gst.Caps) error { 144 | if caps.GetSize() == 0 { 145 | return errors.ErrUnsupportedDecodeFormat 146 | } 147 | 148 | str := caps.GetStructureAt(0) 149 | if str == nil { 150 | return errors.ErrUnsupportedDecodeFormat 151 | } 152 | 153 | for _, mime := range supportedMimeTypes { 154 | if str.Name() == mime { 155 | return nil 156 | } 157 | } 158 | 159 | return errors.ErrUnsupportedDecodeMimeType(str.Name()) 160 | } 161 | 162 | func (u *URLSource) Start(ctx context.Context) error { 163 | if u.printStats == nil { 164 | return nil 165 | } 166 | 167 | go func() { 168 | ticker := time.NewTicker(time.Minute) 169 | for { 170 | select { 171 | case <-u.done.Watch(): 172 | ticker.Stop() 173 | return 174 | case <-ticker.C: 175 | u.printStats() 176 | } 177 | } 178 | }() 179 | 180 | return nil 181 | } 182 | 183 | func (u *URLSource) Close() error { 184 | // TODO find a way to send a EOS event without hanging 185 | 186 | u.done.Break() 187 | 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /pkg/media/video_output_bin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package media 16 | 17 | import ( 18 | "github.com/go-gst/go-gst/gst" 19 | 20 | "github.com/livekit/ingress/pkg/errors" 21 | "github.com/livekit/protocol/livekit" 22 | ) 23 | 24 | type VideoOutputBin struct { 25 | bin *gst.Bin 26 | preProcessorElements []*gst.Element 27 | tee *gst.Element 28 | } 29 | 30 | func NewVideoOutputBin(options *livekit.IngressVideoEncodingOptions, outputs []*Output) (*VideoOutputBin, error) { 31 | o := &VideoOutputBin{} 32 | 33 | o.bin = gst.NewBin("video output bin") 34 | 35 | if options.FrameRate > 0 { 36 | videoRate, err := gst.NewElement("videorate") 37 | if err != nil { 38 | return nil, err 39 | } 40 | if err = videoRate.SetProperty("max-rate", int(options.FrameRate)); err != nil { 41 | return nil, err 42 | } 43 | o.preProcessorElements = append(o.preProcessorElements, videoRate) 44 | } 45 | 46 | videoConvert, err := gst.NewElement("videoconvert") 47 | if err != nil { 48 | return nil, err 49 | } 50 | o.preProcessorElements = append(o.preProcessorElements, videoConvert) 51 | 52 | err = o.bin.AddMany(o.preProcessorElements...) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | err = gst.ElementLinkMany(o.preProcessorElements...) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | o.tee, err = gst.NewElement("tee") 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | err = o.bin.Add(o.tee) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | err = o.preProcessorElements[len(o.preProcessorElements)-1].Link(o.tee) 73 | 74 | for _, output := range outputs { 75 | err := o.bin.Add(output.bin.Element) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | err = gst.ElementLinkMany(o.tee, output.bin.Element) 81 | if err != nil { 82 | return nil, err 83 | } 84 | } 85 | 86 | binSink := gst.NewGhostPad("sink", o.preProcessorElements[0].GetStaticPad("sink")) 87 | if !o.bin.AddPad(binSink.Pad) { 88 | return nil, errors.ErrUnableToAddPad 89 | } 90 | 91 | return o, nil 92 | } 93 | 94 | func (o *VideoOutputBin) GetBin() *gst.Bin { 95 | return o.bin 96 | } 97 | -------------------------------------------------------------------------------- /pkg/media/whip/appsrc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "strings" 23 | "time" 24 | 25 | "github.com/frostbyte73/core" 26 | "github.com/go-gst/go-gst/gst" 27 | "github.com/go-gst/go-gst/gst/app" 28 | "github.com/livekit/ingress/pkg/errors" 29 | "github.com/livekit/ingress/pkg/types" 30 | "github.com/livekit/ingress/pkg/utils" 31 | "github.com/livekit/protocol/logger" 32 | "github.com/livekit/protocol/tracer" 33 | "github.com/pion/webrtc/v4" 34 | ) 35 | 36 | type whipAppSource struct { 37 | appSrc *app.Source 38 | trackKind types.StreamKind 39 | relayUrl string 40 | resourceId string 41 | 42 | fuse core.Fuse 43 | result chan error 44 | } 45 | 46 | type readResult struct { 47 | data []byte 48 | ts time.Duration 49 | err error 50 | } 51 | 52 | func NewWHIPAppSource(ctx context.Context, resourceId string, trackKind types.StreamKind, mimeType string, relayUrl string) (*whipAppSource, error) { 53 | ctx, span := tracer.Start(ctx, "WHIPRelaySource.New") 54 | defer span.End() 55 | 56 | w := &whipAppSource{ 57 | trackKind: trackKind, 58 | relayUrl: relayUrl, 59 | resourceId: resourceId, 60 | result: make(chan error, 1), 61 | } 62 | 63 | elem, err := gst.NewElementWithName("appsrc", fmt.Sprintf("%s_%s", WHIPAppSourceLabel, trackKind)) 64 | if err != nil { 65 | logger.Errorw("could not create appsrc", err, "resourceID", w.resourceId, "kind", w.trackKind) 66 | return nil, err 67 | } 68 | caps, err := getCapsForCodec(mimeType) 69 | if err != nil { 70 | return nil, err 71 | } 72 | if err = elem.SetProperty("caps", caps); err != nil { 73 | return nil, err 74 | } 75 | if err = elem.SetProperty("is-live", true); err != nil { 76 | return nil, err 77 | } 78 | elem.SetArg("format", "time") 79 | 80 | w.appSrc = app.SrcFromElement(elem) 81 | 82 | return w, nil 83 | } 84 | 85 | func (w *whipAppSource) Start(ctx context.Context, getCorrectedTs func(time.Duration) time.Duration) error { 86 | ctx, span := tracer.Start(ctx, "RTMPRelaySource.Start") 87 | defer span.End() 88 | 89 | logger.Debugw("starting WHIP app source", "resourceID", w.resourceId, "kind", w.trackKind) 90 | 91 | resp, err := http.Get(w.relayUrl) 92 | switch { 93 | case err != nil: 94 | return err 95 | case resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 400): 96 | return errors.ErrHttpRelayFailure(resp.StatusCode) 97 | } 98 | 99 | go func() { 100 | defer resp.Body.Close() 101 | 102 | err := w.copyRelayedData(resp.Body, getCorrectedTs) 103 | logger.Debugw("WHIP app source relay stopped", "error", err, "resourceID", w.resourceId, "kind", w.trackKind) 104 | 105 | w.appSrc.EndStream() 106 | 107 | w.result <- err 108 | close(w.result) 109 | }() 110 | 111 | return nil 112 | } 113 | 114 | func (w *whipAppSource) Close() <-chan error { 115 | logger.Debugw("WHIP app source relay Close called", "resourceID", w.resourceId, "kind", w.trackKind) 116 | w.fuse.Break() 117 | 118 | w.appSrc.EndStream() 119 | 120 | return w.result 121 | } 122 | 123 | func (w *whipAppSource) GetAppSource() *app.Source { 124 | return w.appSrc 125 | } 126 | 127 | func (w *whipAppSource) readRelayedData(r io.Reader, dataC chan<- readResult) { 128 | var err error 129 | var ts time.Duration 130 | var data []byte 131 | 132 | for err == nil && !w.fuse.IsBroken() { 133 | data, ts, err = utils.DeserializeMediaForRelay(r) 134 | 135 | re := readResult{ 136 | data: data, 137 | ts: ts, 138 | err: err, 139 | } 140 | 141 | select { 142 | case dataC <- re: 143 | case <-w.fuse.Watch(): 144 | } 145 | } 146 | } 147 | 148 | func (w *whipAppSource) copyRelayedData(r io.Reader, getCorrectedTs func(time.Duration) time.Duration) error { 149 | dataC := make(chan readResult, 1) 150 | go w.readRelayedData(r, dataC) 151 | 152 | for { 153 | var re readResult 154 | select { 155 | case re = <-dataC: 156 | case <-w.fuse.Watch(): 157 | return io.EOF 158 | } 159 | 160 | switch re.err { 161 | case nil: 162 | // continue 163 | case io.EOF: 164 | if w.fuse.IsBroken() { 165 | // client closed the peer connection at the same time as it sent the DELETE request 166 | return io.EOF 167 | } else { 168 | // relay stopped without a clean session shutdown 169 | return io.ErrUnexpectedEOF 170 | } 171 | default: 172 | return re.err 173 | } 174 | 175 | ts := getCorrectedTs(re.ts) 176 | 177 | b := gst.NewBufferFromBytes(re.data) 178 | b.SetPresentationTimestamp(gst.ClockTime(ts)) 179 | 180 | ret := w.appSrc.PushBuffer(b) 181 | switch ret { 182 | case gst.FlowOK, gst.FlowFlushing: 183 | // continue 184 | case gst.FlowEOS: 185 | return io.EOF 186 | default: 187 | return errors.ErrFromGstFlowReturn(ret) 188 | } 189 | } 190 | } 191 | 192 | func getCapsForCodec(mimeType string) (*gst.Caps, error) { 193 | mt := strings.ToLower(mimeType) 194 | 195 | switch mt { 196 | case strings.ToLower(webrtc.MimeTypeH264): 197 | return gst.NewCapsFromString("video/x-h264,stream-format=byte-stream,alignment=nal"), nil 198 | case strings.ToLower(webrtc.MimeTypeVP8): 199 | return gst.NewCapsFromString("video/x-vp8"), nil 200 | case strings.ToLower(webrtc.MimeTypeOpus): 201 | return gst.NewCapsFromString("audio/x-opus,channel-mapping-family=0"), nil 202 | } 203 | 204 | return nil, errors.ErrUnsupportedDecodeMimeType(mimeType) 205 | } 206 | -------------------------------------------------------------------------------- /pkg/media/whip/whipsrc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "io" 21 | "sync/atomic" 22 | "time" 23 | 24 | "github.com/go-gst/go-gst/gst" 25 | "github.com/livekit/ingress/pkg/params" 26 | "github.com/livekit/ingress/pkg/types" 27 | "github.com/livekit/protocol/utils" 28 | ) 29 | 30 | // TODO STUN & TURN 31 | // TODO pion log level 32 | 33 | const ( 34 | WHIPAppSourceLabel = "whipAppSrc" 35 | defaultUDPBufferSize = 16_777_216 36 | ) 37 | 38 | type WHIPSource struct { 39 | params *params.Params 40 | resourceId string 41 | startOffset atomic.Int64 42 | trackSrc map[types.StreamKind]*whipAppSource 43 | } 44 | 45 | func NewWHIPRelaySource(ctx context.Context, p *params.Params) (*WHIPSource, error) { 46 | s := &WHIPSource{ 47 | params: p, 48 | trackSrc: make(map[types.StreamKind]*whipAppSource), 49 | resourceId: p.State.ResourceId, 50 | } 51 | 52 | s.startOffset.Store(-1) 53 | 54 | mimeTypes := s.params.ExtraParams.(*params.WhipExtraParams).MimeTypes 55 | for k, v := range mimeTypes { 56 | relayUrl := s.getRelayUrl(k) 57 | t, err := NewWHIPAppSource(ctx, s.resourceId, k, v, relayUrl) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | s.trackSrc[k] = t 63 | } 64 | 65 | return s, nil 66 | } 67 | 68 | func (s *WHIPSource) Start(ctx context.Context) error { 69 | for _, t := range s.trackSrc { 70 | err := t.Start(ctx, s.getCorrctedTimestamp) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func (s *WHIPSource) Close() error { 80 | var errs utils.ErrArray 81 | errChans := make([]<-chan error, 0) 82 | for _, t := range s.trackSrc { 83 | errChans = append(errChans, t.Close()) 84 | } 85 | 86 | for _, errChan := range errChans { 87 | err := <-errChan 88 | 89 | switch err { 90 | case nil, io.EOF: 91 | // success 92 | default: 93 | errs.AppendErr(err) 94 | } 95 | } 96 | 97 | return errs.ToError() 98 | } 99 | 100 | func (s *WHIPSource) GetSources() []*gst.Element { 101 | ret := make([]*gst.Element, 0, len(s.trackSrc)) 102 | 103 | for _, t := range s.trackSrc { 104 | ret = append(ret, t.GetAppSource().Element) 105 | } 106 | 107 | return ret 108 | } 109 | 110 | func (s *WHIPSource) ValidateCaps(*gst.Caps) error { 111 | return nil 112 | } 113 | 114 | func (s *WHIPSource) getRelayUrl(kind types.StreamKind) string { 115 | return fmt.Sprintf("%s/%s?token=%s", s.params.RelayUrl, kind, s.params.RelayToken) 116 | } 117 | 118 | func (s *WHIPSource) getCorrctedTimestamp(ts time.Duration) time.Duration { 119 | s.startOffset.CompareAndSwap(-1, int64(ts)) 120 | 121 | return ts - time.Duration(s.startOffset.Load()) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/params/params_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package params 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/livekit/protocol/livekit" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | func TestPopulateAudioEncodingOptionsDefaults(t *testing.T) { 25 | in := &livekit.IngressAudioEncodingOptions{} 26 | 27 | out, err := populateAudioEncodingOptionsDefaults(in) 28 | require.NoError(t, err) 29 | require.Equal(t, livekit.AudioCodec_OPUS, out.AudioCodec) 30 | require.Equal(t, uint32(2), out.Channels) 31 | require.Equal(t, uint32(96000), out.Bitrate) 32 | 33 | in.Channels = 1 34 | out, err = populateAudioEncodingOptionsDefaults(in) 35 | require.NoError(t, err) 36 | require.Equal(t, livekit.AudioCodec_OPUS, out.AudioCodec) 37 | require.Equal(t, uint32(1), out.Channels) 38 | require.Equal(t, uint32(64000), out.Bitrate) 39 | } 40 | 41 | func TestPopulateVideoEncodingOptionsDefaults(t *testing.T) { 42 | in := &livekit.IngressVideoEncodingOptions{} 43 | 44 | out, err := populateVideoEncodingOptionsDefaults(in) 45 | require.NoError(t, err) 46 | require.Equal(t, livekit.VideoCodec_H264_BASELINE, out.VideoCodec) 47 | require.Equal(t, float64(30), out.FrameRate) 48 | require.Equal(t, expectedDefaultLayers, out.Layers) 49 | 50 | in.FrameRate = 15 51 | in.Layers = []*livekit.VideoLayer{ 52 | &livekit.VideoLayer{ 53 | Width: 1920, 54 | Height: 1080, 55 | Quality: livekit.VideoQuality_HIGH, 56 | }, 57 | &livekit.VideoLayer{ 58 | Width: 480, 59 | Height: 270, 60 | Quality: livekit.VideoQuality_LOW, 61 | }, 62 | } 63 | expected := []*livekit.VideoLayer{ 64 | &livekit.VideoLayer{ 65 | Width: 1920, 66 | Height: 1080, 67 | Bitrate: 2_081_112, 68 | Quality: livekit.VideoQuality_HIGH, 69 | }, 70 | &livekit.VideoLayer{ 71 | Width: 480, 72 | Height: 270, 73 | Bitrate: 260_139, 74 | Quality: livekit.VideoQuality_LOW, 75 | }, 76 | } 77 | 78 | out, err = populateVideoEncodingOptionsDefaults(in) 79 | require.NoError(t, err) 80 | require.Equal(t, livekit.VideoCodec_H264_BASELINE, out.VideoCodec) 81 | require.Equal(t, float64(15), out.FrameRate) 82 | require.Equal(t, expected, out.Layers) 83 | } 84 | -------------------------------------------------------------------------------- /pkg/params/presets.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package params 16 | 17 | import ( 18 | "math" 19 | 20 | "github.com/livekit/ingress/pkg/errors" 21 | "github.com/livekit/protocol/livekit" 22 | ) 23 | 24 | const ( 25 | // reference parameters used to compute bitrate 26 | refBitrate = 3_500_000 27 | refWidth = 1920 28 | refHeight = 1080 29 | refFramerate = 30 30 | ) 31 | 32 | func getOptionsForVideoPreset(preset livekit.IngressVideoEncodingPreset) (*livekit.IngressVideoEncodingOptions, error) { 33 | switch preset { 34 | case livekit.IngressVideoEncodingPreset_H264_720P_30FPS_3_LAYERS: 35 | return &livekit.IngressVideoEncodingOptions{ 36 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 37 | FrameRate: 30, 38 | Layers: computeVideoLayers(&livekit.VideoLayer{ 39 | Quality: livekit.VideoQuality_HIGH, 40 | Width: 1280, 41 | Height: 720, 42 | Bitrate: 1_900_000, 43 | }, 3), 44 | }, nil 45 | case livekit.IngressVideoEncodingPreset_H264_1080P_30FPS_3_LAYERS: 46 | return &livekit.IngressVideoEncodingOptions{ 47 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 48 | FrameRate: 30, 49 | Layers: computeVideoLayers(&livekit.VideoLayer{ 50 | Quality: livekit.VideoQuality_HIGH, 51 | Width: 1920, 52 | Height: 1080, 53 | Bitrate: 3_500_000, 54 | }, 3), 55 | }, nil 56 | case livekit.IngressVideoEncodingPreset_H264_540P_25FPS_2_LAYERS: 57 | return &livekit.IngressVideoEncodingOptions{ 58 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 59 | FrameRate: 25, 60 | Layers: computeVideoLayers(&livekit.VideoLayer{ 61 | Quality: livekit.VideoQuality_HIGH, 62 | Width: 960, 63 | Height: 540, 64 | Bitrate: 1_000_000, 65 | }, 2), 66 | }, nil 67 | case livekit.IngressVideoEncodingPreset_H264_720P_30FPS_1_LAYER: 68 | return &livekit.IngressVideoEncodingOptions{ 69 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 70 | FrameRate: 30, 71 | Layers: computeVideoLayers(&livekit.VideoLayer{ 72 | Quality: livekit.VideoQuality_HIGH, 73 | Width: 1280, 74 | Height: 720, 75 | Bitrate: 1_900_000, 76 | }, 1), 77 | }, nil 78 | case livekit.IngressVideoEncodingPreset_H264_1080P_30FPS_1_LAYER: 79 | return &livekit.IngressVideoEncodingOptions{ 80 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 81 | FrameRate: 30, 82 | Layers: computeVideoLayers(&livekit.VideoLayer{ 83 | Quality: livekit.VideoQuality_HIGH, 84 | Width: 1920, 85 | Height: 1080, 86 | Bitrate: 3_500_000, 87 | }, 1), 88 | }, nil 89 | case livekit.IngressVideoEncodingPreset_H264_720P_30FPS_3_LAYERS_HIGH_MOTION: 90 | return &livekit.IngressVideoEncodingOptions{ 91 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 92 | FrameRate: 30, 93 | Layers: computeVideoLayers(&livekit.VideoLayer{ 94 | Quality: livekit.VideoQuality_HIGH, 95 | Width: 1280, 96 | Height: 720, 97 | Bitrate: 2_500_000, 98 | }, 3), 99 | }, nil 100 | case livekit.IngressVideoEncodingPreset_H264_1080P_30FPS_3_LAYERS_HIGH_MOTION: 101 | return &livekit.IngressVideoEncodingOptions{ 102 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 103 | FrameRate: 30, 104 | Layers: computeVideoLayers(&livekit.VideoLayer{ 105 | Quality: livekit.VideoQuality_HIGH, 106 | Width: 1920, 107 | Height: 1080, 108 | Bitrate: 4_500_000, 109 | }, 3), 110 | }, nil 111 | case livekit.IngressVideoEncodingPreset_H264_540P_25FPS_2_LAYERS_HIGH_MOTION: 112 | return &livekit.IngressVideoEncodingOptions{ 113 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 114 | FrameRate: 25, 115 | Layers: computeVideoLayers(&livekit.VideoLayer{ 116 | Quality: livekit.VideoQuality_HIGH, 117 | Width: 960, 118 | Height: 540, 119 | Bitrate: 1_300_000, 120 | }, 2), 121 | }, nil 122 | case livekit.IngressVideoEncodingPreset_H264_720P_30FPS_1_LAYER_HIGH_MOTION: 123 | return &livekit.IngressVideoEncodingOptions{ 124 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 125 | FrameRate: 30, 126 | Layers: computeVideoLayers(&livekit.VideoLayer{ 127 | Quality: livekit.VideoQuality_HIGH, 128 | Width: 1280, 129 | Height: 720, 130 | Bitrate: 2_500_000, 131 | }, 1), 132 | }, nil 133 | case livekit.IngressVideoEncodingPreset_H264_1080P_30FPS_1_LAYER_HIGH_MOTION: 134 | return &livekit.IngressVideoEncodingOptions{ 135 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 136 | FrameRate: 30, 137 | Layers: computeVideoLayers(&livekit.VideoLayer{ 138 | Quality: livekit.VideoQuality_HIGH, 139 | Width: 1920, 140 | Height: 1080, 141 | Bitrate: 4_500_000, 142 | }, 1), 143 | }, nil 144 | 145 | default: 146 | return nil, errors.ErrInvalidVideoPreset 147 | } 148 | } 149 | 150 | func computeVideoLayers(highLayer *livekit.VideoLayer, layerCount int) []*livekit.VideoLayer { 151 | layerCopy := *highLayer 152 | layerCopy.Quality = livekit.VideoQuality_HIGH 153 | 154 | layers := []*livekit.VideoLayer{ 155 | &layerCopy, 156 | } 157 | 158 | for i := 1; i < layerCount; i++ { 159 | w := layerCopy.Width >> i // each layer has dimentions half of the previous one 160 | h := layerCopy.Height >> i 161 | 162 | rate := getBitrateForParams(layerCopy.Bitrate, layerCopy.Width, layerCopy.Height, 1, w, h, 1) 163 | 164 | layer := &livekit.VideoLayer{ 165 | Width: w, 166 | Height: h, 167 | Bitrate: rate, 168 | Quality: livekit.VideoQuality(layerCount - 1 - i), 169 | } 170 | 171 | layers = append(layers, layer) 172 | } 173 | 174 | return layers 175 | } 176 | 177 | func getBitrateForParams(refBitrate, refWidth, refHeight uint32, refFramerate float64, targetWidth, targetHeight uint32, targetFramerate float64) uint32 { 178 | // bitrate = ref_bitrate * (target framerate / ref framerate) ^ 0.75 * (target pixel area / ref pixel area) ^ 0.75 179 | 180 | ratio := math.Pow(float64(targetFramerate)/float64(refFramerate), 0.75) 181 | ratio = ratio * math.Pow(float64(targetWidth)*float64(targetHeight)/(float64(refWidth)*float64(refHeight)), 0.75) 182 | 183 | return uint32(float64(refBitrate) * ratio) 184 | } 185 | 186 | func getOptionsForAudioPreset(preset livekit.IngressAudioEncodingPreset) (*livekit.IngressAudioEncodingOptions, error) { 187 | switch preset { 188 | case livekit.IngressAudioEncodingPreset_OPUS_STEREO_96KBPS: 189 | return &livekit.IngressAudioEncodingOptions{ 190 | AudioCodec: livekit.AudioCodec_OPUS, 191 | Channels: 2, 192 | Bitrate: 96000, 193 | }, nil 194 | case livekit.IngressAudioEncodingPreset_OPUS_MONO_64KBS: 195 | return &livekit.IngressAudioEncodingOptions{ 196 | AudioCodec: livekit.AudioCodec_OPUS, 197 | Channels: 1, 198 | Bitrate: 64000, 199 | }, nil 200 | default: 201 | return nil, errors.ErrInvalidAudioPreset 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /pkg/params/presets_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package params 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/livekit/protocol/livekit" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | var ( 25 | expectedDefaultLayers = []*livekit.VideoLayer{ 26 | &livekit.VideoLayer{ 27 | Quality: livekit.VideoQuality_HIGH, 28 | Width: 1280, 29 | Height: 720, 30 | Bitrate: 1700000, 31 | }, 32 | &livekit.VideoLayer{ 33 | Quality: livekit.VideoQuality_MEDIUM, 34 | Width: 640, 35 | Height: 360, 36 | Bitrate: 601040, 37 | }, 38 | &livekit.VideoLayer{ 39 | Quality: livekit.VideoQuality_LOW, 40 | Width: 320, 41 | Height: 180, 42 | Bitrate: 212500, 43 | }, 44 | } 45 | ) 46 | 47 | func TestComputeVideoLayers(t *testing.T) { 48 | 49 | l := computeVideoLayers(expectedDefaultLayers[0], 3) 50 | require.Equal(t, expectedDefaultLayers, l) 51 | 52 | expectedDefaultLayers[1].Quality = livekit.VideoQuality_LOW 53 | l = computeVideoLayers(expectedDefaultLayers[0], 2) 54 | require.Equal(t, expectedDefaultLayers[:2], l) 55 | 56 | l = computeVideoLayers(expectedDefaultLayers[0], 1) 57 | require.Equal(t, expectedDefaultLayers[:1], l) 58 | 59 | } 60 | -------------------------------------------------------------------------------- /pkg/rtmp/relay_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rtmp 16 | 17 | import ( 18 | "io" 19 | "net/http" 20 | "strings" 21 | 22 | "github.com/livekit/ingress/pkg/errors" 23 | "github.com/livekit/protocol/logger" 24 | "github.com/livekit/psrpc" 25 | ) 26 | 27 | type RTMPRelayHandler struct { 28 | rtmpServer *RTMPServer 29 | } 30 | 31 | func NewRTMPRelayHandler(rtmpServer *RTMPServer) *RTMPRelayHandler { 32 | return &RTMPRelayHandler{ 33 | rtmpServer: rtmpServer, 34 | } 35 | } 36 | 37 | func (h *RTMPRelayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 38 | var err error 39 | defer func() { 40 | var psrpcErr psrpc.Error 41 | 42 | switch { 43 | case errors.As(err, &psrpcErr): 44 | w.WriteHeader(psrpcErr.ToHttp()) 45 | case err == nil: 46 | // Nothing, we already responded 47 | default: 48 | w.WriteHeader(http.StatusInternalServerError) 49 | } 50 | }() 51 | 52 | resourceId := strings.TrimLeft(r.URL.Path, "/rtmp/") 53 | token := r.URL.Query().Get("token") 54 | 55 | log := logger.Logger(logger.GetLogger().WithValues("resourceID", resourceId)) 56 | log.Infow("relaying ingress") 57 | 58 | pr, pw := io.Pipe() 59 | done := make(chan error) 60 | 61 | go func() { 62 | _, err = io.Copy(w, pr) 63 | done <- err 64 | close(done) 65 | }() 66 | 67 | err = h.rtmpServer.AssociateRelay(resourceId, token, pw) 68 | if err != nil { 69 | return 70 | } 71 | defer func() { 72 | pw.Close() 73 | h.rtmpServer.DissociateRelay(resourceId) 74 | }() 75 | 76 | err = <-done 77 | } 78 | -------------------------------------------------------------------------------- /pkg/service/cmd.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "os/exec" 21 | 22 | "google.golang.org/protobuf/encoding/protojson" 23 | "gopkg.in/yaml.v3" 24 | 25 | "github.com/livekit/ingress/pkg/params" 26 | "github.com/livekit/ingress/pkg/utils" 27 | "github.com/livekit/protocol/logger" 28 | ) 29 | 30 | func NewCmd(ctx context.Context, p *params.Params) (*exec.Cmd, error) { 31 | confString, err := yaml.Marshal(p.Config) 32 | if err != nil { 33 | logger.Errorw("could not marshal config", err) 34 | return nil, err 35 | } 36 | 37 | infoString, err := protojson.Marshal(p.IngressInfo) 38 | if err != nil { 39 | logger.Errorw("could not marshal request", err) 40 | return nil, err 41 | } 42 | 43 | extraParamsString := "" 44 | if p.ExtraParams != nil { 45 | p, err := json.Marshal(p.ExtraParams) 46 | if err != nil { 47 | logger.Errorw("could not marshall extra parameters", err) 48 | return nil, err 49 | } 50 | extraParamsString = string(p) 51 | } 52 | 53 | loggingFields := "" 54 | if len(p.LoggingFields) > 0 { 55 | b, err := json.Marshal(p.LoggingFields) 56 | if err != nil { 57 | return nil, err 58 | } 59 | loggingFields = string(b) 60 | } 61 | 62 | args := []string{ 63 | "run-handler", 64 | "--config-body", string(confString), 65 | "--info", string(infoString), 66 | "--relay-token", p.RelayToken, 67 | } 68 | 69 | if p.WsUrl != "" { 70 | args = append(args, "--ws-url", p.WsUrl) 71 | } 72 | if p.Token != "" { 73 | args = append(args, "--token", p.Token) 74 | } 75 | if extraParamsString != "" { 76 | args = append(args, "--extra-params", extraParamsString) 77 | } 78 | if loggingFields != "" { 79 | args = append(args, "--logging-fields", loggingFields) 80 | } 81 | 82 | cmd := exec.Command("ingress", 83 | args..., 84 | ) 85 | 86 | cmd.Dir = "/" 87 | l := utils.NewHandlerLogger(p.State.ResourceId, p.IngressId) 88 | cmd.Stdout = l 89 | cmd.Stderr = l 90 | 91 | return cmd, nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/service/debug.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "net/http" 21 | "strconv" 22 | "strings" 23 | 24 | "github.com/livekit/ingress/pkg/errors" 25 | "github.com/livekit/protocol/logger" 26 | "github.com/livekit/protocol/pprof" 27 | "github.com/livekit/psrpc" 28 | ) 29 | 30 | const ( 31 | gstPipelineDotFileApp = "gst_pipeline" 32 | pprofApp = "pprof" 33 | ) 34 | 35 | func (s *Service) StartDebugHandlers() { 36 | if s.conf.DebugHandlerPort == 0 { 37 | logger.Debugw("debug handler disabled") 38 | return 39 | } 40 | 41 | mux := http.NewServeMux() 42 | mux.HandleFunc(fmt.Sprintf("/%s/", gstPipelineDotFileApp), s.handleGstPipelineDotFile) 43 | mux.HandleFunc(fmt.Sprintf("/%s/", pprofApp), s.handlePProf) 44 | 45 | go func() { 46 | addr := fmt.Sprintf(":%d", s.conf.DebugHandlerPort) 47 | logger.Debugw(fmt.Sprintf("starting debug handler on address %s", addr)) 48 | _ = http.ListenAndServe(addr, mux) 49 | }() 50 | } 51 | 52 | func (s *Service) GetGstPipelineDotFile(resourceID string) (string, error) { 53 | api, err := s.sm.GetIngressSessionAPI(resourceID) 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | return api.GetPipelineDot(context.Background()) 59 | } 60 | 61 | // URL path format is "///" 62 | func (s *Service) handleGstPipelineDotFile(w http.ResponseWriter, r *http.Request) { 63 | pathElements := strings.Split(r.URL.Path, "/") 64 | if len(pathElements) < 3 { 65 | http.Error(w, "malformed url", http.StatusNotFound) 66 | return 67 | } 68 | 69 | resourceID := pathElements[2] 70 | dotFile, err := s.GetGstPipelineDotFile(resourceID) 71 | if err != nil { 72 | http.Error(w, err.Error(), getErrorCode(err)) 73 | return 74 | } 75 | _, _ = w.Write([]byte(dotFile)) 76 | } 77 | 78 | // URL path format is "///" or "//" to profile the service 79 | func (s *Service) handlePProf(w http.ResponseWriter, r *http.Request) { 80 | var err error 81 | var b []byte 82 | 83 | timeout, _ := strconv.Atoi(r.URL.Query().Get("timeout")) 84 | debug, _ := strconv.Atoi(r.URL.Query().Get("debug")) 85 | 86 | pathElements := strings.Split(r.URL.Path, "/") 87 | switch len(pathElements) { 88 | case 3: 89 | // profile main service 90 | b, err = pprof.GetProfileData(context.Background(), pathElements[2], timeout, debug) 91 | 92 | case 4: 93 | resourceID := pathElements[2] 94 | api, err := s.sm.GetIngressSessionAPI(resourceID) 95 | if err != nil { 96 | http.Error(w, "resource not found", http.StatusNotFound) 97 | return 98 | } 99 | 100 | b, err = api.GetProfileData(context.Background(), pathElements[3], timeout, debug) 101 | if err != nil { 102 | return 103 | } 104 | default: 105 | http.Error(w, "malformed url", http.StatusNotFound) 106 | return 107 | } 108 | 109 | if err == nil { 110 | w.Header().Add("Content-Type", "application/octet-stream") 111 | _, err = w.Write(b) 112 | } 113 | if err != nil { 114 | http.Error(w, err.Error(), getErrorCode(err)) 115 | return 116 | } 117 | } 118 | 119 | func getErrorCode(err error) int { 120 | var e psrpc.Error 121 | 122 | switch { 123 | case errors.As(err, &e): 124 | return e.ToHttp() 125 | case err == nil: 126 | return http.StatusOK 127 | default: 128 | return http.StatusInternalServerError 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/service/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "net" 20 | "time" 21 | 22 | "google.golang.org/grpc" 23 | "google.golang.org/grpc/codes" 24 | "google.golang.org/grpc/status" 25 | google_protobuf2 "google.golang.org/protobuf/types/known/emptypb" 26 | 27 | "github.com/frostbyte73/core" 28 | "github.com/livekit/ingress/pkg/config" 29 | "github.com/livekit/ingress/pkg/errors" 30 | "github.com/livekit/ingress/pkg/ipc" 31 | "github.com/livekit/ingress/pkg/media" 32 | "github.com/livekit/ingress/pkg/params" 33 | "github.com/livekit/ingress/pkg/stats" 34 | "github.com/livekit/protocol/livekit" 35 | "github.com/livekit/protocol/logger" 36 | "github.com/livekit/protocol/pprof" 37 | "github.com/livekit/protocol/rpc" 38 | "github.com/livekit/protocol/tracer" 39 | ) 40 | 41 | type Handler struct { 42 | ipc.UnimplementedIngressHandlerServer 43 | 44 | conf *config.Config 45 | pipeline *media.Pipeline 46 | rpcClient rpc.IOInfoClient 47 | 48 | statsGatherer *stats.LocalMediaStatsGatherer 49 | 50 | grpcServer *grpc.Server 51 | kill core.Fuse 52 | done core.Fuse 53 | } 54 | 55 | func NewHandler(conf *config.Config, rpcClient rpc.IOInfoClient) *Handler { 56 | return &Handler{ 57 | conf: conf, 58 | rpcClient: rpcClient, 59 | statsGatherer: stats.NewLocalMediaStatsGatherer(), 60 | grpcServer: grpc.NewServer(), 61 | } 62 | } 63 | 64 | func (h *Handler) HandleIngress(ctx context.Context, info *livekit.IngressInfo, wsUrl, token, relayToken string, loggingFields map[string]string, extraParams any) error { 65 | ctx, span := tracer.Start(ctx, "Handler.HandleRequest") 66 | defer span.End() 67 | 68 | params.InitLogger(h.conf, info, loggingFields) 69 | 70 | p, err := h.buildPipeline(ctx, info, wsUrl, token, relayToken, loggingFields, extraParams) 71 | if err != nil { 72 | span.RecordError(err) 73 | return err 74 | } 75 | h.pipeline = p 76 | 77 | defer func() { 78 | switch err { 79 | case nil: 80 | if p.Reusable { 81 | p.SetStatus(livekit.IngressState_ENDPOINT_INACTIVE, nil) 82 | } else { 83 | p.SetStatus(livekit.IngressState_ENDPOINT_COMPLETE, nil) 84 | } 85 | default: 86 | span.RecordError(err) 87 | p.SetStatus(livekit.IngressState_ENDPOINT_ERROR, err) 88 | } 89 | 90 | p.SendStateUpdate(ctx) 91 | }() 92 | 93 | listener, err := net.Listen(network, getSocketAddress(p.TmpDir)) 94 | if err != nil { 95 | span.RecordError(err) 96 | logger.Errorw("failed starting grpc listener", err) 97 | return err 98 | } 99 | 100 | ipc.RegisterIngressHandlerServer(h.grpcServer, h) 101 | 102 | go func() { 103 | err := h.grpcServer.Serve(listener) 104 | if err != nil { 105 | span.RecordError(err) 106 | logger.Errorw("failed statrting grpc handler", err) 107 | } 108 | }() 109 | 110 | // start ingress 111 | result := make(chan error, 1) 112 | go func() { 113 | err := p.Run(ctx) 114 | result <- err 115 | h.done.Break() 116 | }() 117 | 118 | kill := h.kill.Watch() 119 | 120 | for { 121 | select { 122 | case <-kill: 123 | // kill signal received 124 | p.SendEOS(ctx) 125 | kill = nil 126 | 127 | case err = <-result: 128 | // ingress finished 129 | return err 130 | } 131 | } 132 | } 133 | 134 | func (h *Handler) killAndReturnState(ctx context.Context) (*livekit.IngressState, error) { 135 | h.Kill() 136 | select { 137 | case <-ctx.Done(): 138 | return nil, ctx.Err() 139 | case <-h.done.Watch(): 140 | return h.pipeline.CopyInfo().State, nil 141 | } 142 | } 143 | 144 | func (h *Handler) UpdateIngress(ctx context.Context, req *livekit.UpdateIngressRequest) (*livekit.IngressState, error) { 145 | _, span := tracer.Start(ctx, "Handler.UpdateIngress") 146 | defer span.End() 147 | return h.killAndReturnState(ctx) 148 | } 149 | 150 | func (h *Handler) DeleteIngress(ctx context.Context, req *livekit.DeleteIngressRequest) (*livekit.IngressState, error) { 151 | _, span := tracer.Start(ctx, "Handler.DeleteIngress") 152 | defer span.End() 153 | return h.killAndReturnState(ctx) 154 | } 155 | 156 | func (h *Handler) DeleteWHIPResource(ctx context.Context, req *rpc.DeleteWHIPResourceRequest) (*google_protobuf2.Empty, error) { 157 | _, span := tracer.Start(ctx, "Handler.DeleteWHIPResource") 158 | defer span.End() 159 | 160 | h.killAndReturnState(ctx) 161 | 162 | return &google_protobuf2.Empty{}, nil 163 | } 164 | 165 | func (h *Handler) ICERestartWHIPResource(ctx context.Context, req *rpc.ICERestartWHIPResourceRequest) (*rpc.ICERestartWHIPResourceResponse, error) { 166 | _, span := tracer.Start(ctx, "Handler.ICERestartWHIPResource") 167 | defer span.End() 168 | 169 | return &rpc.ICERestartWHIPResourceResponse{}, nil 170 | } 171 | 172 | func (h *Handler) GetPProf(ctx context.Context, req *ipc.PProfRequest) (*ipc.PProfResponse, error) { 173 | ctx, span := tracer.Start(ctx, "Handler.GetPProf") 174 | defer span.End() 175 | 176 | if h.pipeline == nil { 177 | return nil, errors.ErrIngressNotFound 178 | } 179 | 180 | b, err := pprof.GetProfileData(ctx, req.ProfileName, int(req.Timeout), int(req.Debug)) 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | return &ipc.PProfResponse{ 186 | PprofFile: b, 187 | }, nil 188 | } 189 | 190 | func (h *Handler) GetPipelineDot(ctx context.Context, in *ipc.GstPipelineDebugDotRequest) (*ipc.GstPipelineDebugDotResponse, error) { 191 | ctx, span := tracer.Start(ctx, "Handler.GetPipelineDot") 192 | defer span.End() 193 | 194 | if h.pipeline == nil { 195 | return nil, errors.ErrIngressNotFound 196 | } 197 | 198 | res := make(chan string, 1) 199 | go func() { 200 | res <- h.pipeline.GetGstPipelineDebugDot() 201 | }() 202 | 203 | select { 204 | case r := <-res: 205 | return &ipc.GstPipelineDebugDotResponse{ 206 | DotFile: r, 207 | }, nil 208 | 209 | case <-time.After(2 * time.Second): 210 | return nil, status.New(codes.DeadlineExceeded, "timed out requesting pipeline debug info").Err() 211 | } 212 | 213 | } 214 | 215 | func (h *Handler) GatherMediaStats(ctx context.Context, in *ipc.GatherMediaStatsRequest) (*ipc.GatherMediaStatsResponse, error) { 216 | st, err := h.statsGatherer.GatherStats(ctx) 217 | if err != nil { 218 | return nil, err 219 | } 220 | 221 | return &ipc.GatherMediaStatsResponse{ 222 | Stats: st, 223 | }, nil 224 | } 225 | 226 | func (h *Handler) UpdateMediaStats(ctx context.Context, in *ipc.UpdateMediaStatsRequest) (*google_protobuf2.Empty, error) { 227 | ctx, span := tracer.Start(ctx, "Handler.UpdateMediaStats") 228 | defer span.End() 229 | 230 | if h.pipeline == nil { 231 | return &google_protobuf2.Empty{}, nil 232 | } 233 | 234 | if in.Stats == nil { 235 | return &google_protobuf2.Empty{}, nil 236 | } 237 | 238 | lsu := &stats.LocalStatsUpdater{ 239 | Params: h.pipeline.Params, 240 | } 241 | 242 | err := lsu.UpdateMediaStats(ctx, in.Stats) 243 | if err != nil { 244 | return nil, err 245 | } 246 | 247 | return &google_protobuf2.Empty{}, nil 248 | } 249 | 250 | func (h *Handler) buildPipeline(ctx context.Context, info *livekit.IngressInfo, wsUrl, token, relayToken string, loggingFields map[string]string, extraParams any) (*media.Pipeline, error) { 251 | ctx, span := tracer.Start(ctx, "Handler.buildPipeline") 252 | defer span.End() 253 | 254 | // build/verify params 255 | var p *media.Pipeline 256 | params, err := params.GetParams(ctx, h.rpcClient, h.conf, info, wsUrl, token, relayToken, loggingFields, extraParams) 257 | if err == nil { 258 | // create the pipeline 259 | p, err = media.New(ctx, h.conf, params, h.statsGatherer) 260 | } 261 | 262 | if err != nil { 263 | if params != nil { 264 | info = params.CopyInfo() 265 | } 266 | 267 | info.State.Error = err.Error() 268 | info.State.Status = livekit.IngressState_ENDPOINT_ERROR 269 | h.sendUpdate(ctx, info) 270 | return nil, err 271 | } 272 | 273 | return p, nil 274 | } 275 | 276 | func (h *Handler) sendUpdate(ctx context.Context, info *livekit.IngressInfo) { 277 | switch info.State.Status { 278 | case livekit.IngressState_ENDPOINT_ERROR: 279 | logger.Warnw("ingress failed", errors.New(info.State.Error), 280 | "ingressID", info.IngressId, 281 | ) 282 | case livekit.IngressState_ENDPOINT_INACTIVE: 283 | logger.Infow("ingress complete", "ingressID", info.IngressId) 284 | default: 285 | logger.Infow("ingress update", "ingressID", info.IngressId) 286 | } 287 | 288 | info.State.UpdatedAt = time.Now().UnixNano() 289 | 290 | _, err := h.rpcClient.UpdateIngressState(ctx, &rpc.UpdateIngressStateRequest{ 291 | IngressId: info.IngressId, 292 | State: info.State, 293 | }) 294 | if err != nil { 295 | logger.Errorw("failed to send update", err) 296 | } 297 | } 298 | 299 | func (h *Handler) Kill() { 300 | h.kill.Break() 301 | } 302 | -------------------------------------------------------------------------------- /pkg/service/process_manager.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "net" 20 | "os" 21 | "os/exec" 22 | "path" 23 | "sync" 24 | "syscall" 25 | "time" 26 | 27 | "google.golang.org/grpc" 28 | "google.golang.org/grpc/credentials/insecure" 29 | 30 | "github.com/frostbyte73/core" 31 | "github.com/livekit/ingress/pkg/errors" 32 | "github.com/livekit/ingress/pkg/ipc" 33 | "github.com/livekit/ingress/pkg/params" 34 | "github.com/livekit/protocol/livekit" 35 | "github.com/livekit/protocol/logger" 36 | "github.com/livekit/protocol/tracer" 37 | ) 38 | 39 | const ( 40 | network = "unix" 41 | maxRetries = 3 42 | ) 43 | 44 | type process struct { 45 | sessionCloser 46 | 47 | info *livekit.IngressInfo 48 | retryCount int 49 | cmd *exec.Cmd 50 | grpcClient ipc.IngressHandlerClient 51 | closed core.Fuse 52 | } 53 | 54 | type ProcessManager struct { 55 | sm *SessionManager 56 | newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error) 57 | 58 | mu sync.RWMutex 59 | activeHandlers map[string]*process 60 | onFatal func(info *livekit.IngressInfo, err error) 61 | } 62 | 63 | func NewProcessManager(sm *SessionManager, newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error)) *ProcessManager { 64 | return &ProcessManager{ 65 | sm: sm, 66 | newCmd: newCmd, 67 | activeHandlers: make(map[string]*process), 68 | } 69 | } 70 | 71 | func (s *ProcessManager) onFatalError(f func(info *livekit.IngressInfo, err error)) { 72 | s.onFatal = f 73 | } 74 | 75 | func (s *ProcessManager) startIngress(ctx context.Context, p *params.Params, closeSession func(ctx context.Context)) error { 76 | // TODO send update on failure 77 | _, span := tracer.Start(ctx, "Service.startIngress") 78 | defer span.End() 79 | 80 | h := &process{ 81 | info: p.IngressInfo, 82 | sessionCloser: func(ctx context.Context) { 83 | if closeSession != nil { 84 | closeSession(ctx) 85 | } 86 | 87 | go func() { 88 | time.Sleep(time.Second) 89 | 90 | s.mu.Lock() 91 | h := s.activeHandlers[p.State.ResourceId] 92 | s.mu.Unlock() 93 | 94 | if h != nil && !h.closed.IsBroken() && h.cmd != nil { 95 | logger.Infow("killing handler process still present after termination was requested", "ingressID", h.info.IngressId, "resourceID", p.State.ResourceId, "startedAt", p.State.StartedAt) 96 | if err := h.cmd.Process.Signal(syscall.SIGKILL); err != nil { 97 | logger.Infow("failed to kill process", "error", err, "ingressID", h.info.IngressId) 98 | } 99 | } 100 | }() 101 | }, 102 | } 103 | 104 | s.sm.IngressStarted(p.IngressInfo, h) 105 | 106 | s.mu.Lock() 107 | s.activeHandlers[p.State.ResourceId] = h 108 | s.mu.Unlock() 109 | 110 | if p.TmpDir != "" { 111 | err := os.MkdirAll(p.TmpDir, 0755) 112 | if err != nil { 113 | logger.Errorw("failed creating halder temp directory", err, "path", p.TmpDir) 114 | return err 115 | } 116 | } 117 | 118 | go s.runHandler(ctx, h, p) 119 | 120 | return nil 121 | } 122 | 123 | func (s *ProcessManager) runHandler(ctx context.Context, h *process, p *params.Params) { 124 | _, span := tracer.Start(ctx, "Service.runHandler") 125 | defer span.End() 126 | 127 | defer func() { 128 | h.closed.Break() 129 | s.sm.IngressEnded(h.info.State.ResourceId) 130 | 131 | if p.TmpDir != "" { 132 | os.RemoveAll(p.TmpDir) 133 | } 134 | 135 | s.mu.Lock() 136 | delete(s.activeHandlers, h.info.State.ResourceId) 137 | s.mu.Unlock() 138 | }() 139 | 140 | for h.retryCount = 0; h.retryCount < maxRetries; h.retryCount++ { 141 | socketAddr := getSocketAddress(p.TmpDir) 142 | os.Remove(socketAddr) 143 | conn, err := grpc.Dial(socketAddr, 144 | grpc.WithTransportCredentials(insecure.NewCredentials()), 145 | grpc.WithContextDialer(func(_ context.Context, addr string) (net.Conn, error) { 146 | return net.Dial(network, addr) 147 | }), 148 | ) 149 | if err != nil { 150 | span.RecordError(err) 151 | logger.Errorw("could not dial grpc handler", err) 152 | } 153 | h.grpcClient = ipc.NewIngressHandlerClient(conn) 154 | 155 | cmd, err := s.newCmd(ctx, p) 156 | if err != nil { 157 | span.RecordError(err) 158 | return 159 | } 160 | h.cmd = cmd 161 | 162 | var exitErr *exec.ExitError 163 | 164 | err = h.cmd.Run() 165 | switch { 166 | case err == nil: 167 | // success 168 | return 169 | case errors.As(err, &exitErr): 170 | if exitErr.ProcessState.ExitCode() == 1 { 171 | logger.Infow("relaunching handler process after retryable failure") 172 | } else if err.Error() == "signal: killed" { 173 | logger.Infow("handler killed") 174 | return 175 | } else { 176 | logger.Errorw("unknown handler exit code", err) 177 | return 178 | } 179 | default: 180 | logger.Errorw("could not launch handler", err) 181 | if s.onFatal != nil { 182 | s.onFatal(h.info, err) 183 | } 184 | return 185 | } 186 | } 187 | 188 | } 189 | 190 | func (s *ProcessManager) killAll() { 191 | s.mu.RLock() 192 | defer s.mu.RUnlock() 193 | 194 | for _, h := range s.activeHandlers { 195 | if !h.closed.IsBroken() { 196 | if err := h.cmd.Process.Signal(syscall.SIGINT); err != nil { 197 | logger.Errorw("failed to kill process", err, "ingressID", h.info.IngressId) 198 | } 199 | } 200 | } 201 | } 202 | 203 | func (p *process) GetProfileData(ctx context.Context, profileName string, timeout int, debug int) (b []byte, err error) { 204 | req := &ipc.PProfRequest{ 205 | ProfileName: profileName, 206 | Timeout: int32(timeout), 207 | Debug: int32(debug), 208 | } 209 | 210 | resp, err := p.grpcClient.GetPProf(ctx, req) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | return resp.PprofFile, nil 216 | } 217 | 218 | func (p *process) GetPipelineDot(ctx context.Context) (string, error) { 219 | req := &ipc.GstPipelineDebugDotRequest{} 220 | 221 | resp, err := p.grpcClient.GetPipelineDot(ctx, req) 222 | if err != nil { 223 | return "", err 224 | } 225 | 226 | return resp.DotFile, nil 227 | } 228 | 229 | func (p *process) UpdateMediaStats(ctx context.Context, s *ipc.MediaStats) error { 230 | req := &ipc.UpdateMediaStatsRequest{ 231 | Stats: s, 232 | } 233 | 234 | _, err := p.grpcClient.UpdateMediaStats(ctx, req) 235 | 236 | return err 237 | } 238 | 239 | func (p *process) GatherStats(ctx context.Context) (*ipc.MediaStats, error) { 240 | req := &ipc.GatherMediaStatsRequest{} 241 | 242 | s, err := p.grpcClient.GatherMediaStats(ctx, req) 243 | if err != nil { 244 | return nil, err 245 | } 246 | 247 | return s.Stats, nil 248 | } 249 | 250 | func getSocketAddress(handlerTmpDir string) string { 251 | return path.Join(handlerTmpDir, "service_rpc.sock") 252 | } 253 | -------------------------------------------------------------------------------- /pkg/service/relay.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | 21 | "github.com/livekit/ingress/pkg/config" 22 | "github.com/livekit/ingress/pkg/rtmp" 23 | "github.com/livekit/ingress/pkg/whip" 24 | "github.com/livekit/protocol/logger" 25 | ) 26 | 27 | type Relay struct { 28 | server *http.Server 29 | rtmpServer *rtmp.RTMPServer 30 | whipServer *whip.WHIPServer 31 | } 32 | 33 | func NewRelay(rtmpServer *rtmp.RTMPServer, whipServer *whip.WHIPServer) *Relay { 34 | return &Relay{ 35 | rtmpServer: rtmpServer, 36 | whipServer: whipServer, 37 | } 38 | } 39 | 40 | func (r *Relay) Start(conf *config.Config) error { 41 | port := conf.HTTPRelayPort 42 | 43 | mux := http.NewServeMux() 44 | 45 | if r.rtmpServer != nil { 46 | h := rtmp.NewRTMPRelayHandler(r.rtmpServer) 47 | mux.Handle("/rtmp/", h) 48 | } 49 | if r.whipServer != nil { 50 | h := whip.NewWHIPRelayHandler(r.whipServer) 51 | mux.Handle("/whip/", h) 52 | } 53 | 54 | r.server = &http.Server{ 55 | Handler: mux, 56 | Addr: fmt.Sprintf("localhost:%d", port), 57 | } 58 | 59 | go func() { 60 | err := r.server.ListenAndServe() 61 | logger.Debugw("Relay stopped", "error", err) 62 | }() 63 | 64 | return nil 65 | } 66 | 67 | func (r *Relay) Stop() error { 68 | return r.server.Close() 69 | } 70 | -------------------------------------------------------------------------------- /pkg/service/session_manager.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "sync" 20 | 21 | "github.com/livekit/ingress/pkg/errors" 22 | "github.com/livekit/ingress/pkg/stats" 23 | "github.com/livekit/ingress/pkg/types" 24 | "github.com/livekit/protocol/livekit" 25 | "github.com/livekit/protocol/logger" 26 | "github.com/livekit/protocol/rpc" 27 | ) 28 | 29 | type sessionRecord struct { 30 | info *livekit.IngressInfo 31 | sessionAPI types.SessionAPI 32 | mediaStats *stats.MediaStatsReporter 33 | localStatsGatherer *stats.LocalMediaStatsGatherer 34 | } 35 | 36 | type SessionManager struct { 37 | monitor *stats.Monitor 38 | rpcSrv rpc.IngressInternalServer 39 | 40 | lock sync.Mutex 41 | sessions map[string]*sessionRecord // resourceId -> sessionRecord 42 | } 43 | 44 | func NewSessionManager(monitor *stats.Monitor, rpcSrv rpc.IngressInternalServer) *SessionManager { 45 | return &SessionManager{ 46 | monitor: monitor, 47 | rpcSrv: rpcSrv, 48 | sessions: make(map[string]*sessionRecord), 49 | } 50 | } 51 | 52 | func (sm *SessionManager) IngressStarted(info *livekit.IngressInfo, sessionAPI types.SessionAPI) { 53 | logger.Infow("ingress started", "ingressID", info.IngressId, "resourceID", info.State.ResourceId) 54 | 55 | sm.lock.Lock() 56 | defer sm.lock.Unlock() 57 | 58 | r := &sessionRecord{ 59 | info: info, 60 | sessionAPI: sessionAPI, 61 | mediaStats: stats.NewMediaStats(sessionAPI), 62 | localStatsGatherer: stats.NewLocalMediaStatsGatherer(), 63 | } 64 | r.mediaStats.RegisterGatherer(r.localStatsGatherer) 65 | // Register remote gatherer, if any 66 | r.mediaStats.RegisterGatherer(sessionAPI) 67 | 68 | sm.sessions[info.State.ResourceId] = r 69 | 70 | sm.registerKillIngressSession(info.IngressId, info.State.ResourceId) 71 | 72 | sm.monitor.IngressStarted(info) 73 | } 74 | 75 | func (sm *SessionManager) IngressEnded(resourceID string) { 76 | sm.lock.Lock() 77 | defer sm.lock.Unlock() 78 | 79 | p := sm.sessions[resourceID] 80 | if p != nil { 81 | logger.Infow("ingress ended", "ingressID", p.info.IngressId, "resourceID", resourceID) 82 | 83 | sm.deregisterKillIngressSession(p.info.IngressId, resourceID) 84 | delete(sm.sessions, p.info.State.ResourceId) 85 | p.sessionAPI.CloseSession(context.Background()) 86 | p.mediaStats.Close() 87 | sm.monitor.IngressEnded(p.info) 88 | } 89 | } 90 | 91 | func (sm *SessionManager) GetIngressSessionAPI(resourceId string) (types.SessionAPI, error) { 92 | sm.lock.Lock() 93 | defer sm.lock.Unlock() 94 | 95 | record, ok := sm.sessions[resourceId] 96 | if !ok { 97 | return nil, errors.ErrIngressNotFound 98 | } 99 | 100 | return record.sessionAPI, nil 101 | } 102 | 103 | func (sm *SessionManager) GetIngressMediaStats(resourceId string) (*stats.LocalMediaStatsGatherer, error) { 104 | sm.lock.Lock() 105 | defer sm.lock.Unlock() 106 | 107 | record, ok := sm.sessions[resourceId] 108 | if !ok { 109 | return nil, errors.ErrIngressNotFound 110 | } 111 | 112 | return record.localStatsGatherer, nil 113 | } 114 | 115 | func (sm *SessionManager) IsIdle() bool { 116 | sm.lock.Lock() 117 | defer sm.lock.Unlock() 118 | 119 | return len(sm.sessions) == 0 120 | } 121 | 122 | func (sm *SessionManager) ListIngress() []*rpc.IngressSession { 123 | sm.lock.Lock() 124 | defer sm.lock.Unlock() 125 | 126 | ingressIDs := make([]*rpc.IngressSession, 0, len(sm.sessions)) 127 | for _, r := range sm.sessions { 128 | var resourceID string 129 | if r.info.State != nil { 130 | resourceID = r.info.State.ResourceId 131 | } 132 | ingressIDs = append(ingressIDs, &rpc.IngressSession{IngressId: r.info.IngressId, ResourceId: resourceID}) 133 | } 134 | return ingressIDs 135 | } 136 | 137 | func (sm *SessionManager) registerKillIngressSession(ingressId string, resourceID string) error { 138 | return sm.rpcSrv.RegisterKillIngressSessionTopic(ingressId, resourceID) 139 | } 140 | 141 | func (sm *SessionManager) deregisterKillIngressSession(ingressId string, resourceID string) { 142 | sm.rpcSrv.DeregisterKillIngressSessionTopic(ingressId, resourceID) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/stats/media.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package stats 16 | 17 | import ( 18 | "context" 19 | "sync" 20 | "time" 21 | 22 | "github.com/frostbyte73/core" 23 | 24 | "github.com/livekit/ingress/pkg/ipc" 25 | "github.com/livekit/ingress/pkg/params" 26 | "github.com/livekit/ingress/pkg/types" 27 | "github.com/livekit/protocol/logger" 28 | ) 29 | 30 | const ( 31 | InputAudio = "input.audio" 32 | InputVideo = "input.video" 33 | OutputAudio = "output.audio" 34 | OutputVideo = "output.video" 35 | ) 36 | 37 | type MediaStatsReporter struct { 38 | lock sync.Mutex 39 | 40 | statsUpdater types.MediaStatsUpdater 41 | statGatherers []types.MediaStatGatherer 42 | 43 | done core.Fuse 44 | } 45 | 46 | type LocalStatsUpdater struct { 47 | Params *params.Params 48 | } 49 | 50 | type LocalMediaStatsGatherer struct { 51 | lock sync.Mutex 52 | stats []*MediaTrackStatGatherer 53 | } 54 | 55 | func NewMediaStats(statsUpdater types.MediaStatsUpdater) *MediaStatsReporter { 56 | m := &MediaStatsReporter{ 57 | statsUpdater: statsUpdater, 58 | } 59 | 60 | go func() { 61 | m.runMediaStatsCollector() 62 | }() 63 | 64 | return m 65 | } 66 | 67 | func (m *MediaStatsReporter) Close() { 68 | m.done.Break() 69 | } 70 | 71 | func (m *MediaStatsReporter) RegisterGatherer(g types.MediaStatGatherer) { 72 | m.lock.Lock() 73 | defer m.lock.Unlock() 74 | 75 | m.statGatherers = append(m.statGatherers, g) 76 | } 77 | 78 | func (m *MediaStatsReporter) UpdateStats(ctx context.Context) { 79 | res := &ipc.MediaStats{ 80 | TrackStats: make(map[string]*ipc.TrackStats), 81 | } 82 | 83 | m.lock.Lock() 84 | for _, l := range m.statGatherers { 85 | ms, err := l.GatherStats(ctx) 86 | if err != nil { 87 | logger.Infow("failed gather media stats", "error", err) 88 | continue 89 | } 90 | 91 | if ms == nil { 92 | continue 93 | } 94 | 95 | // Merge the result. Keys are assumed to be exclusive 96 | for k, v := range ms.TrackStats { 97 | res.TrackStats[k] = v 98 | } 99 | 100 | } 101 | m.lock.Unlock() 102 | 103 | m.statsUpdater.UpdateMediaStats(ctx, res) 104 | } 105 | 106 | func (m *MediaStatsReporter) runMediaStatsCollector() { 107 | ticker := time.NewTicker(60 * time.Second) 108 | defer ticker.Stop() 109 | 110 | for { 111 | select { 112 | case <-ticker.C: 113 | // TODO extend core.Fuse to provide a context? 114 | m.UpdateStats(context.Background()) 115 | case <-m.done.Watch(): 116 | return 117 | } 118 | } 119 | } 120 | 121 | func NewLocalMediaStatsGatherer() *LocalMediaStatsGatherer { 122 | return &LocalMediaStatsGatherer{} 123 | } 124 | 125 | func (l *LocalMediaStatsGatherer) RegisterTrackStats(path string) *MediaTrackStatGatherer { 126 | g := NewMediaTrackStatGatherer(path) 127 | 128 | l.lock.Lock() 129 | defer l.lock.Unlock() 130 | 131 | l.stats = append(l.stats, g) 132 | 133 | return g 134 | } 135 | 136 | func (l *LocalMediaStatsGatherer) GatherStats(ctx context.Context) (*ipc.MediaStats, error) { 137 | ms := &ipc.MediaStats{ 138 | TrackStats: make(map[string]*ipc.TrackStats), 139 | } 140 | 141 | l.lock.Lock() 142 | for _, ts := range l.stats { 143 | s := ts.UpdateStats() 144 | ms.TrackStats[ts.Path()] = s 145 | } 146 | l.lock.Unlock() 147 | 148 | return ms, nil 149 | } 150 | 151 | func (a *LocalStatsUpdater) UpdateMediaStats(ctx context.Context, s *ipc.MediaStats) error { 152 | audioStats, ok := s.TrackStats[InputAudio] 153 | if ok { 154 | a.Params.SetInputAudioStats(audioStats) 155 | } 156 | 157 | videoStats, ok := s.TrackStats[InputVideo] 158 | if ok { 159 | a.Params.SetInputVideoStats(videoStats) 160 | } 161 | 162 | LogMediaStats(s, a.Params.GetLogger()) 163 | 164 | return nil 165 | } 166 | 167 | func LogMediaStats(s *ipc.MediaStats, logger logger.Logger) { 168 | for k, v := range s.TrackStats { 169 | logger.Infow("track stats update", "name", k, "currentBitrate", v.CurrentBitrate, "averageBitrate", v.AverageBitrate, "currentPackets", v.CurrentPackets, "totalPacket", v.TotalPackets, "currentLossRate", v.CurrentLossRate, "totalLossRate", v.TotalLossRate, "currentPLI", v.CurrentPli, "totalPLI", v.TotalPli, "jitter", v.Jitter) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /pkg/stats/media_track_stats.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package stats 16 | 17 | import ( 18 | "math" 19 | "sync" 20 | "time" 21 | 22 | "github.com/livekit/ingress/pkg/ipc" 23 | 24 | morestats "github.com/aclements/go-moremath/stats" 25 | ) 26 | 27 | const ( 28 | maxJitterStatsLen = 100_000 29 | ) 30 | 31 | type MediaTrackStatGatherer struct { 32 | lock sync.Mutex 33 | 34 | path string 35 | 36 | totalBytes int64 37 | totalPackets int64 38 | totalLost int64 39 | totalPLI int64 40 | startTime time.Time 41 | 42 | currentBytes int64 43 | currentPackets int64 44 | currentLost int64 45 | currentPLI int64 46 | lastQueryTime time.Time 47 | 48 | lastPacketTime time.Time 49 | lastPacketInterval time.Duration 50 | jitter morestats.Sample 51 | } 52 | 53 | func NewMediaTrackStatGatherer(path string) *MediaTrackStatGatherer { 54 | return &MediaTrackStatGatherer{ 55 | path: path, 56 | } 57 | } 58 | 59 | func (g *MediaTrackStatGatherer) MediaReceived(size int64) { 60 | g.lock.Lock() 61 | defer g.lock.Unlock() 62 | 63 | now := time.Now() 64 | 65 | if g.startTime.IsZero() { 66 | g.startTime = now 67 | g.lastQueryTime = now 68 | } 69 | 70 | g.totalBytes += size 71 | g.currentBytes += size 72 | 73 | g.totalPackets++ 74 | g.currentPackets++ 75 | 76 | var packetInterval time.Duration 77 | if !g.lastPacketTime.IsZero() { 78 | packetInterval = now.Sub(g.lastPacketTime) 79 | } 80 | if g.lastPacketInterval != 0 { 81 | jitter := packetInterval - g.lastPacketInterval 82 | 83 | if len(g.jitter.Xs) < maxJitterStatsLen { 84 | g.jitter.Xs = append(g.jitter.Xs, math.Abs(float64(jitter)/float64(time.Millisecond))) 85 | } 86 | } 87 | 88 | g.lastPacketInterval = packetInterval 89 | g.lastPacketTime = now 90 | } 91 | 92 | func (g *MediaTrackStatGatherer) PacketLost(count int64) { 93 | g.lock.Lock() 94 | defer g.lock.Unlock() 95 | 96 | g.currentLost += count 97 | g.totalLost += count 98 | } 99 | 100 | func (g *MediaTrackStatGatherer) PLI() { 101 | g.lock.Lock() 102 | defer g.lock.Unlock() 103 | 104 | g.currentPLI++ 105 | g.totalPLI++ 106 | } 107 | 108 | func (g *MediaTrackStatGatherer) UpdateStats() *ipc.TrackStats { 109 | g.lock.Lock() 110 | defer g.lock.Unlock() 111 | 112 | now := time.Now() 113 | 114 | averageBps := uint32(float64(g.totalBytes) * 8 * float64(time.Second) / float64(now.Sub(g.startTime))) 115 | currentBps := uint32(float64(g.currentBytes) * 8 * float64(time.Second) / float64(now.Sub(g.lastQueryTime))) 116 | 117 | currentLossRate := float64(g.currentLost) / float64(g.currentPackets) 118 | 119 | jitter := g.jitter.Sort() // To make quantile computation faster 120 | 121 | jitterStats := &ipc.JitterStats{ 122 | P50: jitter.Quantile(0.5), 123 | P90: jitter.Quantile(0.9), 124 | P99: jitter.Quantile(0.99), 125 | } 126 | 127 | g.jitter.Xs = nil 128 | g.jitter.Sorted = false 129 | 130 | st := &ipc.TrackStats{ 131 | AverageBitrate: averageBps, 132 | CurrentBitrate: currentBps, 133 | TotalPackets: uint64(g.totalPackets), 134 | CurrentPackets: uint64(g.currentPackets), 135 | TotalLossRate: float64(g.totalLost) / float64(g.totalPackets), 136 | CurrentLossRate: currentLossRate, 137 | TotalPli: uint64(g.totalPLI), 138 | CurrentPli: uint64(g.currentPLI), 139 | Jitter: jitterStats, 140 | } 141 | 142 | g.lastQueryTime = now 143 | g.currentBytes = 0 144 | g.currentPackets = 0 145 | g.currentLost = 0 146 | g.currentPLI = 0 147 | 148 | return st 149 | } 150 | 151 | func (g *MediaTrackStatGatherer) Path() string { 152 | return g.path 153 | } 154 | -------------------------------------------------------------------------------- /pkg/stats/monitor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package stats 16 | 17 | import ( 18 | "fmt" 19 | "sort" 20 | "sync" 21 | "time" 22 | 23 | "github.com/frostbyte73/core" 24 | "github.com/prometheus/client_golang/prometheus" 25 | "go.uber.org/atomic" 26 | 27 | "github.com/livekit/ingress/pkg/config" 28 | "github.com/livekit/ingress/pkg/errors" 29 | "github.com/livekit/ingress/pkg/params" 30 | "github.com/livekit/protocol/livekit" 31 | "github.com/livekit/protocol/logger" 32 | "github.com/livekit/protocol/utils/hwstats" 33 | ) 34 | 35 | const ( 36 | defaultMinIdle float64 = 0.3 // Target at least 30% idle CPU 37 | ) 38 | 39 | type Monitor struct { 40 | costConfigLock sync.Mutex 41 | cpuCostConfig config.CPUCostConfig 42 | maxCost float64 43 | 44 | promCPULoad prometheus.Gauge 45 | requestGauge *prometheus.GaugeVec 46 | promNodeAvailable prometheus.GaugeFunc 47 | 48 | cpuStats *hwstats.CPUStats 49 | 50 | pendingCPUs atomic.Float64 51 | 52 | started core.Fuse 53 | shutdown core.Fuse 54 | } 55 | 56 | func NewMonitor() *Monitor { 57 | return &Monitor{} 58 | } 59 | 60 | func (m *Monitor) Start(conf *config.Config) error { 61 | cpuStats, err := hwstats.NewCPUStats(func(idle float64) { 62 | m.promCPULoad.Set(1 - idle/float64(m.cpuStats.NumCPU())) 63 | }) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | m.costConfigLock.Lock() 69 | m.cpuStats = cpuStats 70 | m.cpuCostConfig = conf.CPUCost 71 | 72 | if err := m.checkCPUConfig(); err != nil { 73 | m.costConfigLock.Unlock() 74 | return err 75 | } 76 | m.costConfigLock.Unlock() 77 | 78 | m.promCPULoad = prometheus.NewGauge(prometheus.GaugeOpts{ 79 | Namespace: "livekit", 80 | Subsystem: "node", 81 | Name: "cpu_load", 82 | ConstLabels: prometheus.Labels{"node_id": conf.NodeID, "node_type": "INGRESS"}, 83 | }) 84 | m.promNodeAvailable = prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 85 | Namespace: "livekit", 86 | Subsystem: "ingress", 87 | Name: "available", 88 | ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, 89 | }, func() float64 { 90 | c := m.CanAccept() 91 | if c { 92 | return 1 93 | } 94 | return 0 95 | }) 96 | m.requestGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 97 | Namespace: "livekit", 98 | Subsystem: "ingress", 99 | Name: "requests", 100 | ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, 101 | }, []string{"type", "transcoding"}) 102 | 103 | prometheus.MustRegister(m.promCPULoad, m.promNodeAvailable, m.requestGauge) 104 | 105 | m.started.Break() 106 | 107 | return nil 108 | } 109 | 110 | func (m *Monitor) UpdateCostConfig(cpuCostConfig *config.CPUCostConfig) error { 111 | m.costConfigLock.Lock() 112 | defer m.costConfigLock.Unlock() 113 | 114 | // No change 115 | if m.cpuCostConfig == *cpuCostConfig { 116 | return nil 117 | } 118 | 119 | // Update config, but return an error if validation fails 120 | m.cpuCostConfig = *cpuCostConfig 121 | 122 | return m.checkCPUConfig() 123 | } 124 | 125 | // Server is shutting down, but may stay up for some time for draining 126 | func (m *Monitor) Shutdown() { 127 | m.shutdown.Break() 128 | } 129 | 130 | // Stop the monitor before server termination 131 | func (m *Monitor) Stop() { 132 | if m.cpuStats != nil { 133 | m.cpuStats.Stop() 134 | } 135 | 136 | prometheus.Unregister(m.promCPULoad) 137 | prometheus.Unregister(m.requestGauge) 138 | prometheus.Unregister(m.promNodeAvailable) 139 | } 140 | 141 | func (m *Monitor) checkCPUConfig() error { 142 | // Not started 143 | if m.cpuStats == nil { 144 | return nil 145 | } 146 | 147 | if m.cpuCostConfig.MinIdleRatio <= 0 { 148 | m.cpuCostConfig.MinIdleRatio = defaultMinIdle 149 | } 150 | 151 | if m.cpuCostConfig.RTMPCpuCost < 1 { 152 | logger.Warnw("rtmp input requirement too low", nil, 153 | "config value", m.cpuCostConfig.RTMPCpuCost, 154 | "minimum value", 1, 155 | "recommended value", 2, 156 | ) 157 | } 158 | 159 | if m.cpuCostConfig.WHIPCpuCost < 1 { 160 | logger.Warnw("whip input requirement too low", nil, 161 | "config value", m.cpuCostConfig.WHIPCpuCost, 162 | "minimum value", 1, 163 | "recommended value", 2, 164 | ) 165 | } 166 | 167 | if m.cpuCostConfig.WHIPBypassTranscodingCpuCost < 0.05 { 168 | logger.Warnw("whip input with transcoding bypassed requirement too low", nil, 169 | "config value", m.cpuCostConfig.WHIPCpuCost, 170 | "minimum value", 0.05, 171 | "recommended value", 0.1, 172 | ) 173 | } 174 | 175 | if m.cpuCostConfig.URLCpuCost < 1 { 176 | logger.Warnw("url input requirement too low", nil, 177 | "config value", m.cpuCostConfig.URLCpuCost, 178 | "minimum value", 1, 179 | "recommended value", 2, 180 | ) 181 | } 182 | 183 | requirements := []float64{ 184 | m.cpuCostConfig.RTMPCpuCost, 185 | m.cpuCostConfig.WHIPCpuCost, 186 | m.cpuCostConfig.WHIPBypassTranscodingCpuCost, 187 | m.cpuCostConfig.URLCpuCost, 188 | } 189 | sort.Float64s(requirements) 190 | m.maxCost = requirements[len(requirements)-1] 191 | 192 | recommendedMinimum := m.maxCost 193 | if recommendedMinimum < 3 { 194 | recommendedMinimum = 3 195 | } 196 | 197 | if float64(m.cpuStats.NumCPU()) < requirements[0] { 198 | logger.Errorw("not enough cpu", nil, 199 | "minimum cpu", requirements[0], 200 | "recommended", recommendedMinimum, 201 | "available", float64(m.cpuStats.NumCPU()), 202 | ) 203 | return errors.ErrServerCapacityExceeded 204 | } 205 | 206 | if float64(m.cpuStats.NumCPU()) < m.maxCost { 207 | logger.Errorw("not enough cpu for some ingress types", nil, 208 | "minimum cpu", m.maxCost, 209 | "recommended", recommendedMinimum, 210 | "available", m.cpuStats.NumCPU(), 211 | ) 212 | } 213 | 214 | logger.Infow(fmt.Sprintf("available CPU cores: %f max cost: %f", m.cpuStats.NumCPU(), m.maxCost)) 215 | 216 | return nil 217 | } 218 | 219 | func (m *Monitor) GetAvailableCPU() float64 { 220 | return m.getAvailable(m.cpuCostConfig.MinIdleRatio) 221 | } 222 | 223 | func (m *Monitor) CanAccept() bool { 224 | if !m.started.IsBroken() || m.shutdown.IsBroken() { 225 | return false 226 | } 227 | 228 | m.costConfigLock.Lock() 229 | defer m.costConfigLock.Unlock() 230 | 231 | return m.getAvailable(m.cpuCostConfig.MinIdleRatio) > m.maxCost 232 | } 233 | 234 | func (m *Monitor) canAcceptIngress(info *livekit.IngressInfo, alreadyCommitted bool) (bool, float64, float64) { 235 | if !m.started.IsBroken() || m.shutdown.IsBroken() { 236 | return false, 0, 0 237 | } 238 | 239 | var cpuHold float64 240 | var accept bool 241 | 242 | m.costConfigLock.Lock() 243 | defer m.costConfigLock.Unlock() 244 | 245 | minIdle := m.cpuCostConfig.MinIdleRatio 246 | if alreadyCommitted { 247 | minIdle /= 2 248 | } 249 | 250 | available := m.getAvailable(minIdle) 251 | 252 | switch info.InputType { 253 | case livekit.IngressInput_RTMP_INPUT: 254 | accept = available > m.cpuCostConfig.RTMPCpuCost 255 | cpuHold = m.cpuCostConfig.RTMPCpuCost 256 | case livekit.IngressInput_WHIP_INPUT: 257 | if !*info.EnableTranscoding { 258 | accept = available > m.cpuCostConfig.WHIPBypassTranscodingCpuCost 259 | cpuHold = m.cpuCostConfig.WHIPBypassTranscodingCpuCost 260 | } else { 261 | accept = available > m.cpuCostConfig.WHIPCpuCost 262 | cpuHold = m.cpuCostConfig.WHIPCpuCost 263 | } 264 | case livekit.IngressInput_URL_INPUT: 265 | accept = available > m.cpuCostConfig.URLCpuCost 266 | cpuHold = m.cpuCostConfig.URLCpuCost 267 | 268 | default: 269 | logger.Errorw("unsupported request type", errors.New("invalid parameter")) 270 | } 271 | 272 | logger.Debugw("checking if request can be handled", "inputType", info.InputType, "accept", accept, "available", available, "cpuHold", cpuHold, "idle", m.cpuStats.GetCPUIdle(), "pending", m.pendingCPUs.Load()) 273 | 274 | return accept, cpuHold, available 275 | } 276 | 277 | func (m *Monitor) CanAcceptIngress(info *livekit.IngressInfo) bool { 278 | params.UpdateTranscodingEnabled(info) 279 | 280 | accept, _, _ := m.canAcceptIngress(info, false) 281 | 282 | return accept 283 | } 284 | 285 | func (m *Monitor) AcceptIngress(info *livekit.IngressInfo) bool { 286 | accept, cpuHold, available := m.canAcceptIngress(info, true) 287 | 288 | if accept { 289 | m.pendingCPUs.Add(cpuHold) 290 | time.AfterFunc(time.Second, func() { m.pendingCPUs.Sub(cpuHold) }) 291 | } 292 | 293 | logger.Debugw("cpu request", "accepted", accept, "availableCPUs", available, "numCPUs", m.cpuStats.NumCPU()) 294 | return accept 295 | } 296 | 297 | func (m *Monitor) IngressStarted(info *livekit.IngressInfo) { 298 | switch info.InputType { 299 | case livekit.IngressInput_RTMP_INPUT: 300 | m.requestGauge.With(prometheus.Labels{"type": "rtmp", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Add(1) 301 | case livekit.IngressInput_WHIP_INPUT: 302 | m.requestGauge.With(prometheus.Labels{"type": "whip", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Add(1) 303 | case livekit.IngressInput_URL_INPUT: 304 | m.requestGauge.With(prometheus.Labels{"type": "url", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Add(1) 305 | 306 | } 307 | } 308 | 309 | func (m *Monitor) IngressEnded(info *livekit.IngressInfo) { 310 | switch info.InputType { 311 | case livekit.IngressInput_RTMP_INPUT: 312 | m.requestGauge.With(prometheus.Labels{"type": "rtmp", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Sub(1) 313 | case livekit.IngressInput_WHIP_INPUT: 314 | m.requestGauge.With(prometheus.Labels{"type": "whip", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Sub(1) 315 | case livekit.IngressInput_URL_INPUT: 316 | m.requestGauge.With(prometheus.Labels{"type": "url", "transcoding": fmt.Sprintf("%v", *info.EnableTranscoding)}).Sub(1) 317 | } 318 | } 319 | 320 | func (m *Monitor) getAvailable(minIdleRatio float64) float64 { 321 | return m.cpuStats.GetCPUIdle() - m.pendingCPUs.Load() - minIdleRatio*m.cpuStats.NumCPU() 322 | } 323 | -------------------------------------------------------------------------------- /pkg/types/session_api.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package types 16 | 17 | import ( 18 | "context" 19 | ) 20 | 21 | type SessionAPI interface { 22 | MediaStatsUpdater 23 | MediaStatGatherer 24 | 25 | GetProfileData(ctx context.Context, profileName string, timeout int, debug int) (b []byte, err error) 26 | GetPipelineDot(ctx context.Context) (string, error) 27 | CloseSession(ctx context.Context) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/types/stats.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package types 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/livekit/ingress/pkg/ipc" 21 | ) 22 | 23 | type MediaStatsUpdater interface { 24 | UpdateMediaStats(ctx context.Context, stats *ipc.MediaStats) error 25 | } 26 | 27 | type MediaStatGatherer interface { 28 | GatherStats(ctx context.Context) (*ipc.MediaStats, error) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package types 16 | 17 | type StreamKind string 18 | 19 | const ( 20 | Audio StreamKind = "audio" 21 | Video StreamKind = "video" 22 | Interleaved StreamKind = "interleaved" 23 | Unknown StreamKind = "unknown" 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/utils/blocking_queue.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "sync/atomic" 19 | 20 | "github.com/frostbyte73/core" 21 | "github.com/livekit/ingress/pkg/errors" 22 | ) 23 | 24 | type BlockingQueue[T any] struct { 25 | queue chan T 26 | length atomic.Int32 27 | 28 | closed core.Fuse 29 | } 30 | 31 | func NewBlockingQueue[T any](capacity int) *BlockingQueue[T] { 32 | return &BlockingQueue[T]{ 33 | queue: make(chan T, capacity), 34 | } 35 | } 36 | 37 | func (b *BlockingQueue[T]) PushBack(item T) { 38 | select { 39 | case b.queue <- item: 40 | // Small race here 41 | b.length.Add(1) 42 | case <-b.closed.Watch(): 43 | } 44 | } 45 | 46 | func (b *BlockingQueue[T]) PopFront() (T, error) { 47 | select { 48 | case item := <-b.queue: 49 | // Small race here 50 | b.length.Add(-1) 51 | return item, nil 52 | case <-b.closed.Watch(): 53 | return *new(T), errors.ErrIngressClosing 54 | } 55 | } 56 | 57 | func (b *BlockingQueue[T]) QueueLength() int { 58 | return int(b.length.Load()) 59 | } 60 | 61 | func (b *BlockingQueue[T]) Close() { 62 | b.closed.Break() 63 | } 64 | -------------------------------------------------------------------------------- /pkg/utils/handler_logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/livekit/protocol/logger" 8 | "github.com/livekit/protocol/logger/medialogutils" 9 | ) 10 | 11 | const ( 12 | logError = iota 13 | logWarn 14 | logInfo 15 | ignore 16 | ) 17 | 18 | var actions = map[string]int{ 19 | "0:00:": ignore, 20 | "turnc": logInfo, 21 | "ice E": logInfo, 22 | "SDK 2": logInfo, 23 | } 24 | 25 | func NewHandlerLogger(resourceID, ingressID string) *medialogutils.CmdLogger { 26 | l := logger.GetLogger().WithValues("resourceID", resourceID, "ingressID", ingressID) 27 | return medialogutils.NewCmdLogger(func(s string) { 28 | lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") 29 | for _, line := range lines { 30 | if strings.HasSuffix(line, "}") { 31 | fmt.Println(line) 32 | } else { 33 | action := logError 34 | if len(line) > 5 { 35 | action = actions[line[:5]] 36 | } 37 | switch action { 38 | case ignore: 39 | continue 40 | case logInfo: 41 | l.Infow(line) 42 | case logWarn: 43 | l.Warnw(line, nil) 44 | case logError: 45 | l.Errorw(line, nil) 46 | } 47 | } 48 | } 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/utils/media_serialization.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "bytes" 19 | "encoding/binary" 20 | "io" 21 | "time" 22 | ) 23 | 24 | /* 25 | This package provides utilities to serialize and deserialize whip media packets 26 | over the service -> handler relay. 27 | 28 | The format is 29 | 30 | |----------------------------------------------------------------| 31 | | 0------------63 | 0------------32 | 0 ------------- media size | 32 | | timestamp (BE) | media size (BE) | media payload | 33 | |----------------------------------------------------------------| 34 | 35 | */ 36 | 37 | func SerializeMediaForRelay(w io.Writer, data []byte, ts time.Duration) error { 38 | // Only 1 write for a single parsable unit 39 | 40 | b := &bytes.Buffer{} 41 | 42 | err := binary.Write(b, binary.BigEndian, ts) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | err = binary.Write(b, binary.BigEndian, uint32(len(data))) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | _, err = b.Write(data) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | _, err = w.Write(b.Bytes()) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func DeserializeMediaForRelay(r io.Reader) ([]byte, time.Duration, error) { 66 | var ts time.Duration 67 | 68 | err := binary.Read(r, binary.BigEndian, &ts) 69 | if err != nil { 70 | return nil, 0, err 71 | } 72 | 73 | var size uint32 74 | err = binary.Read(r, binary.BigEndian, &size) 75 | if err != nil { 76 | return nil, 0, err 77 | } 78 | 79 | data := make([]byte, int(size)) 80 | _, err = io.ReadFull(r, data) 81 | if err != nil { 82 | return nil, 0, err 83 | } 84 | 85 | return data, ts, nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/utils/output_synchronizer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "context" 19 | "sync" 20 | "sync/atomic" 21 | "time" 22 | 23 | "github.com/frostbyte73/core" 24 | "github.com/livekit/ingress/pkg/errors" 25 | "github.com/livekit/psrpc" 26 | ) 27 | 28 | const ( 29 | leeway = 100 * time.Millisecond 30 | waitDurationAdjustmentAmount = 10 * time.Millisecond 31 | ) 32 | 33 | type OutputSynchronizer struct { 34 | lock sync.Mutex 35 | 36 | zeroTime time.Time 37 | } 38 | 39 | type TrackOutputSynchronizer struct { 40 | os *OutputSynchronizer 41 | closed core.Fuse 42 | firstSampleSent atomic.Bool 43 | } 44 | 45 | func NewOutputSynchronizer() *OutputSynchronizer { 46 | return &OutputSynchronizer{} 47 | } 48 | 49 | // TODO adjust timer deadline when zeroTime is adjusted 50 | func (os *OutputSynchronizer) ScheduleEvent(ctx context.Context, pts time.Duration, event func()) error { 51 | os.lock.Lock() 52 | 53 | if os.zeroTime.IsZero() { 54 | os.lock.Unlock() 55 | return psrpc.NewErrorf(psrpc.OutOfRange, "trying to schedule an event before output synchtonizer timeline was set") 56 | } 57 | 58 | waitDuration := computeWaitDuration(pts, os.zeroTime, time.Now()) 59 | 60 | os.lock.Unlock() 61 | 62 | go func() { 63 | select { 64 | case <-time.After(waitDuration): 65 | event() 66 | case <-ctx.Done(): 67 | return 68 | } 69 | }() 70 | 71 | return nil 72 | } 73 | 74 | func (os *OutputSynchronizer) AddTrack() *TrackOutputSynchronizer { 75 | t := newTrackOutputSynchronizer(os) 76 | 77 | return t 78 | } 79 | 80 | func (os *OutputSynchronizer) getWaitDuration(pts time.Duration, firstSampleSent bool, zeroTimeAdjustment time.Duration) time.Duration { 81 | os.lock.Lock() 82 | defer os.lock.Unlock() 83 | 84 | if !os.zeroTime.IsZero() && firstSampleSent { 85 | os.zeroTime = os.zeroTime.Add(zeroTimeAdjustment) 86 | } 87 | 88 | now := time.Now() 89 | waitDuration := computeWaitDuration(pts, os.zeroTime, now) 90 | 91 | if os.zeroTime.IsZero() || (waitDuration < -leeway && firstSampleSent) { 92 | // Reset zeroTime if the earliest track is late 93 | os.zeroTime = now.Add(-pts) 94 | waitDuration = 0 95 | } 96 | 97 | return waitDuration 98 | } 99 | 100 | func newTrackOutputSynchronizer(os *OutputSynchronizer) *TrackOutputSynchronizer { 101 | return &TrackOutputSynchronizer{ 102 | os: os, 103 | } 104 | } 105 | 106 | func (ost *TrackOutputSynchronizer) Close() { 107 | ost.closed.Break() 108 | } 109 | 110 | func (ost *TrackOutputSynchronizer) getWaitDurationForMediaTime(pts time.Duration, playingTooSlow bool) (time.Duration, bool) { 111 | zeroTimeAdjustment := time.Duration(0) 112 | 113 | if playingTooSlow { 114 | zeroTimeAdjustment = -waitDurationAdjustmentAmount 115 | } 116 | 117 | waitDuration := ost.os.getWaitDuration(pts, ost.firstSampleSent.Load(), zeroTimeAdjustment) 118 | 119 | if waitDuration < -leeway { 120 | return 0, true 121 | } 122 | 123 | waitDuration = max(waitDuration, 0) 124 | 125 | ost.firstSampleSent.Store(true) 126 | 127 | return waitDuration, false 128 | } 129 | 130 | func (ost *TrackOutputSynchronizer) WaitForMediaTime(pts time.Duration, playingTooSlow bool) (bool, error) { 131 | waitDuration, drop := ost.getWaitDurationForMediaTime(pts, playingTooSlow) 132 | if drop { 133 | return drop, nil 134 | } 135 | 136 | select { 137 | case <-time.After(waitDuration): 138 | return false, nil 139 | case <-ost.closed.Watch(): 140 | return false, errors.ErrIngressClosing 141 | } 142 | } 143 | 144 | func computeWaitDuration(pts time.Duration, zeroTime time.Time, now time.Time) time.Duration { 145 | mediaTime := zeroTime.Add(pts) 146 | 147 | return mediaTime.Sub(now) 148 | } 149 | -------------------------------------------------------------------------------- /pkg/utils/prerollbuffer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "bytes" 19 | "io" 20 | "sync" 21 | 22 | "github.com/livekit/ingress/pkg/errors" 23 | ) 24 | 25 | const ( 26 | maxBufferSize = 10000000 27 | ) 28 | 29 | type PrerollBuffer struct { 30 | lock sync.Mutex 31 | buffer *bytes.Buffer 32 | w io.WriteCloser 33 | 34 | onBufferReset func() error 35 | } 36 | 37 | func NewPrerollBuffer(onBufferReset func() error) *PrerollBuffer { 38 | return &PrerollBuffer{ 39 | buffer: &bytes.Buffer{}, 40 | onBufferReset: onBufferReset, 41 | } 42 | } 43 | 44 | func (pb *PrerollBuffer) SetWriter(w io.WriteCloser) error { 45 | pb.lock.Lock() 46 | defer pb.lock.Unlock() 47 | 48 | pb.w = w 49 | if pb.w == nil { 50 | // Send preroll buffer reset event 51 | pb.buffer.Reset() 52 | if pb.onBufferReset != nil { 53 | if err := pb.onBufferReset(); err != nil { 54 | return err 55 | } 56 | } 57 | } else { 58 | _, err := io.Copy(pb.w, pb.buffer) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (pb *PrerollBuffer) Write(p []byte) (int, error) { 68 | pb.lock.Lock() 69 | defer pb.lock.Unlock() 70 | 71 | if pb.w == nil { 72 | if len(p)+pb.buffer.Len() > maxBufferSize { 73 | // We would overflow the max allowed buffer size. Reset th buffer state 74 | pb.buffer.Reset() 75 | if pb.onBufferReset != nil { 76 | if err := pb.onBufferReset(); err != nil { 77 | return 0, err 78 | } 79 | } 80 | return 0, errors.ErrPrerollBufferReset 81 | } 82 | return pb.buffer.Write(p) 83 | } 84 | 85 | n, err := pb.w.Write(p) 86 | if err == io.ErrClosedPipe { 87 | // Do not return errors caused by a consuming pipe getting closed 88 | err = nil 89 | } 90 | return n, err 91 | } 92 | 93 | func (pb *PrerollBuffer) Close() error { 94 | pb.lock.Lock() 95 | defer pb.lock.Unlock() 96 | 97 | if pb.w != nil { 98 | return pb.w.Close() 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/utils/rtcp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import "github.com/pion/rtcp" 18 | 19 | func ReplaceRTCPPacketSSRC(pkt rtcp.Packet, newSSRC uint32) (rtcp.Packet, error) { 20 | switch cpkt := pkt.(type) { 21 | case *rtcp.SenderReport: 22 | cpkt.SSRC = newSSRC 23 | case *rtcp.ReceiverReport: 24 | cpkt.SSRC = newSSRC 25 | for i, _ := range cpkt.Reports { 26 | cpkt.Reports[i].SSRC = newSSRC 27 | } 28 | case *rtcp.SourceDescription: 29 | for i, _ := range cpkt.Chunks { 30 | cpkt.Chunks[i].Source = newSSRC 31 | } 32 | case *rtcp.Goodbye: 33 | for i, _ := range cpkt.Sources { 34 | cpkt.Sources[i] = newSSRC 35 | } 36 | case *rtcp.TransportLayerNack: 37 | cpkt.SenderSSRC = newSSRC 38 | cpkt.MediaSSRC = newSSRC 39 | case *rtcp.RapidResynchronizationRequest: 40 | cpkt.SenderSSRC = newSSRC 41 | cpkt.MediaSSRC = newSSRC 42 | case *rtcp.TransportLayerCC: 43 | cpkt.SenderSSRC = newSSRC 44 | cpkt.MediaSSRC = newSSRC 45 | case *rtcp.CCFeedbackReport: 46 | cpkt.SenderSSRC = newSSRC 47 | case *rtcp.PictureLossIndication: 48 | cpkt.SenderSSRC = newSSRC 49 | cpkt.MediaSSRC = newSSRC 50 | case *rtcp.SliceLossIndication: 51 | cpkt.SenderSSRC = newSSRC 52 | cpkt.MediaSSRC = newSSRC 53 | case *rtcp.ReceiverEstimatedMaximumBitrate: 54 | cpkt.SenderSSRC = newSSRC 55 | case *rtcp.FullIntraRequest: 56 | cpkt.SenderSSRC = newSSRC 57 | cpkt.MediaSSRC = newSSRC 58 | case *rtcp.ExtendedReport: 59 | cpkt.SenderSSRC = newSSRC 60 | err := handleExtendedReports(cpkt.Reports, newSSRC) 61 | if err != nil { 62 | return nil, err 63 | } 64 | } 65 | 66 | return pkt, nil 67 | } 68 | 69 | func handleExtendedReports(reports []rtcp.ReportBlock, newSSRC uint32) error { 70 | for _, report := range reports { 71 | switch r := report.(type) { 72 | case *rtcp.LossRLEReportBlock: 73 | r.SSRC = newSSRC 74 | case *rtcp.DuplicateRLEReportBlock: 75 | r.SSRC = newSSRC 76 | case *rtcp.PacketReceiptTimesReportBlock: 77 | r.SSRC = newSSRC 78 | case *rtcp.DLRRReportBlock: 79 | for i, _ := range r.Reports { 80 | r.Reports[i].SSRC = newSSRC 81 | } 82 | case *rtcp.StatisticsSummaryReportBlock: 83 | r.SSRC = newSSRC 84 | case *rtcp.VoIPMetricsReportBlock: 85 | r.SSRC = newSSRC 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/utils/rtcp_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/pion/rtcp" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | func TestReplaceRTCPPacketSSRC(t *testing.T) { 25 | t.Run("SenderReport", func(t *testing.T) { 26 | p := &rtcp.SenderReport{} 27 | 28 | r, err := ReplaceRTCPPacketSSRC(p, 1) 29 | 30 | require.NoError(t, err) 31 | require.Equal(t, uint32(1), p.SSRC) 32 | checkDestinationSSRC(t, r) 33 | }) 34 | 35 | t.Run("ReceiverReport", func(t *testing.T) { 36 | p := &rtcp.ReceiverReport{} 37 | 38 | r, err := ReplaceRTCPPacketSSRC(p, 1) 39 | 40 | require.NoError(t, err) 41 | require.Equal(t, uint32(1), p.SSRC) 42 | checkDestinationSSRC(t, r) 43 | }) 44 | 45 | t.Run("SourceDescription", func(t *testing.T) { 46 | p := &rtcp.SourceDescription{ 47 | Chunks: []rtcp.SourceDescriptionChunk{ 48 | rtcp.SourceDescriptionChunk{}, 49 | rtcp.SourceDescriptionChunk{}, 50 | rtcp.SourceDescriptionChunk{}, 51 | }, 52 | } 53 | 54 | r, err := ReplaceRTCPPacketSSRC(p, 1) 55 | 56 | require.NoError(t, err) 57 | require.Equal(t, 3, len(p.Chunks)) 58 | for _, c := range p.Chunks { 59 | require.Equal(t, c.Source, uint32(1)) 60 | } 61 | checkDestinationSSRC(t, r) 62 | }) 63 | 64 | t.Run("Goodbye", func(t *testing.T) { 65 | p := &rtcp.Goodbye{ 66 | Sources: []uint32{ 67 | 0, 68 | 0, 69 | 0, 70 | }, 71 | } 72 | 73 | r, err := ReplaceRTCPPacketSSRC(p, 1) 74 | 75 | require.NoError(t, err) 76 | require.Equal(t, 3, len(p.Sources)) 77 | for _, s := range p.Sources { 78 | require.Equal(t, uint32(1), s) 79 | } 80 | checkDestinationSSRC(t, r) 81 | }) 82 | 83 | t.Run("TransportLayerNack", func(t *testing.T) { 84 | p := &rtcp.TransportLayerNack{} 85 | 86 | r, err := ReplaceRTCPPacketSSRC(p, 1) 87 | 88 | require.NoError(t, err) 89 | require.Equal(t, uint32(1), p.MediaSSRC) 90 | require.Equal(t, uint32(1), p.SenderSSRC) 91 | checkDestinationSSRC(t, r) 92 | }) 93 | 94 | t.Run("RapidResynchronizationRequest", func(t *testing.T) { 95 | p := &rtcp.RapidResynchronizationRequest{} 96 | 97 | r, err := ReplaceRTCPPacketSSRC(p, 1) 98 | 99 | require.NoError(t, err) 100 | require.Equal(t, uint32(1), p.MediaSSRC) 101 | require.Equal(t, uint32(1), p.SenderSSRC) 102 | checkDestinationSSRC(t, r) 103 | }) 104 | 105 | t.Run("TransportLayerCC", func(t *testing.T) { 106 | p := &rtcp.TransportLayerCC{} 107 | 108 | r, err := ReplaceRTCPPacketSSRC(p, 1) 109 | 110 | require.NoError(t, err) 111 | require.Equal(t, uint32(1), p.MediaSSRC) 112 | require.Equal(t, uint32(1), p.SenderSSRC) 113 | checkDestinationSSRC(t, r) 114 | }) 115 | 116 | t.Run("CCFeedbackReport", func(t *testing.T) { 117 | p := &rtcp.CCFeedbackReport{} 118 | 119 | r, err := ReplaceRTCPPacketSSRC(p, 1) 120 | 121 | require.NoError(t, err) 122 | require.Equal(t, uint32(1), p.SenderSSRC) 123 | checkDestinationSSRC(t, r) 124 | }) 125 | 126 | t.Run("PictureLossIndication", func(t *testing.T) { 127 | p := &rtcp.PictureLossIndication{} 128 | 129 | r, err := ReplaceRTCPPacketSSRC(p, 1) 130 | 131 | require.NoError(t, err) 132 | require.Equal(t, uint32(1), p.MediaSSRC) 133 | require.Equal(t, uint32(1), p.SenderSSRC) 134 | checkDestinationSSRC(t, r) 135 | }) 136 | 137 | t.Run("SliceLossIndication", func(t *testing.T) { 138 | p := &rtcp.SliceLossIndication{} 139 | 140 | r, err := ReplaceRTCPPacketSSRC(p, 1) 141 | 142 | require.NoError(t, err) 143 | require.Equal(t, uint32(1), p.MediaSSRC) 144 | require.Equal(t, uint32(1), p.SenderSSRC) 145 | checkDestinationSSRC(t, r) 146 | }) 147 | 148 | t.Run("ReceiverEstimatedMaximumBitrate", func(t *testing.T) { 149 | p := &rtcp.ReceiverEstimatedMaximumBitrate{} 150 | 151 | r, err := ReplaceRTCPPacketSSRC(p, 1) 152 | 153 | require.NoError(t, err) 154 | require.Equal(t, uint32(1), p.SenderSSRC) 155 | checkDestinationSSRC(t, r) 156 | }) 157 | 158 | t.Run("FullIntraRequest", func(t *testing.T) { 159 | p := &rtcp.FullIntraRequest{} 160 | 161 | r, err := ReplaceRTCPPacketSSRC(p, 1) 162 | 163 | require.NoError(t, err) 164 | require.Equal(t, uint32(1), p.MediaSSRC) 165 | require.Equal(t, uint32(1), p.SenderSSRC) 166 | checkDestinationSSRC(t, r) 167 | }) 168 | 169 | t.Run("ExtendedReport", func(t *testing.T) { 170 | p := &rtcp.ExtendedReport{ 171 | Reports: []rtcp.ReportBlock{ 172 | &rtcp.LossRLEReportBlock{}, 173 | &rtcp.DuplicateRLEReportBlock{}, 174 | &rtcp.DLRRReportBlock{ 175 | Reports: []rtcp.DLRRReport{ 176 | rtcp.DLRRReport{}, 177 | }, 178 | }, 179 | &rtcp.StatisticsSummaryReportBlock{}, 180 | &rtcp.VoIPMetricsReportBlock{}, 181 | }, 182 | } 183 | 184 | r, err := ReplaceRTCPPacketSSRC(p, 1) 185 | 186 | require.NoError(t, err) 187 | require.Equal(t, uint32(1), p.SenderSSRC) 188 | 189 | require.Equal(t, 6, len(p.DestinationSSRC())) 190 | checkDestinationSSRC(t, r) 191 | }) 192 | } 193 | 194 | func checkDestinationSSRC(t *testing.T, p rtcp.Packet) { 195 | for _, d := range p.DestinationSSRC() { 196 | require.Equal(t, uint32(1), d) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /pkg/whip/relay_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "errors" 19 | "io" 20 | "net/http" 21 | "strings" 22 | 23 | "github.com/livekit/ingress/pkg/types" 24 | "github.com/livekit/protocol/logger" 25 | "github.com/livekit/psrpc" 26 | ) 27 | 28 | type WHIPRelayHandler struct { 29 | whipServer *WHIPServer 30 | } 31 | 32 | func NewWHIPRelayHandler(whipServer *WHIPServer) *WHIPRelayHandler { 33 | return &WHIPRelayHandler{ 34 | whipServer: whipServer, 35 | } 36 | } 37 | 38 | func (h *WHIPRelayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 39 | var err error 40 | defer func() { 41 | var psrpcErr psrpc.Error 42 | 43 | switch { 44 | case errors.As(err, &psrpcErr): 45 | w.WriteHeader(psrpcErr.ToHttp()) 46 | case err == nil: 47 | // Nothing, we already responded 48 | default: 49 | w.WriteHeader(http.StatusInternalServerError) 50 | } 51 | }() 52 | 53 | path := strings.TrimLeft(r.URL.Path, "/whip/") //nolint 54 | v := strings.Split(path, "/") 55 | if len(v) != 2 { 56 | err = psrpc.NewErrorf(psrpc.NotFound, "invalid path") 57 | return 58 | } 59 | resourceId := v[0] 60 | kind := types.StreamKind(v[1]) 61 | token := r.URL.Query().Get("token") 62 | 63 | log := logger.Logger(logger.GetLogger().WithValues("resourceId", resourceId, "kind", kind)) 64 | log.Infow("relaying whip ingress") 65 | 66 | pr, pw := io.Pipe() 67 | done := make(chan error) 68 | 69 | go func() { 70 | b := make([]byte, 2000) 71 | var err error 72 | var n int 73 | for { 74 | n, err = pr.Read(b) 75 | if err != nil { 76 | break 77 | } 78 | 79 | _, err = w.Write(b[:n]) 80 | if err != nil { 81 | break 82 | } 83 | if f, ok := w.(http.Flusher); ok { 84 | f.Flush() 85 | } 86 | } 87 | 88 | if err == io.EOF { 89 | err = nil 90 | } 91 | 92 | done <- err 93 | close(done) 94 | }() 95 | 96 | defer func() { 97 | pw.Close() 98 | h.whipServer.DissociateRelay(resourceId, kind) 99 | }() 100 | 101 | err = h.whipServer.AssociateRelay(resourceId, kind, token, pw) 102 | if err != nil { 103 | return 104 | } 105 | 106 | err = <-done 107 | } 108 | -------------------------------------------------------------------------------- /pkg/whip/relay_media_sink.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "io" 19 | "time" 20 | 21 | "github.com/pion/webrtc/v4/pkg/media" 22 | 23 | "github.com/livekit/ingress/pkg/utils" 24 | "github.com/livekit/protocol/logger" 25 | ) 26 | 27 | type RelayMediaSink struct { 28 | mediaBuffer *utils.PrerollBuffer 29 | } 30 | 31 | func NewRelayMediaSink(logger logger.Logger) *RelayMediaSink { 32 | mediaBuffer := utils.NewPrerollBuffer(func() error { 33 | logger.Infow("preroll buffer reset event") 34 | 35 | return nil 36 | }) 37 | 38 | return &RelayMediaSink{ 39 | mediaBuffer: mediaBuffer, 40 | } 41 | } 42 | 43 | func (rs *RelayMediaSink) SetWriter(w io.WriteCloser) error { 44 | return rs.mediaBuffer.SetWriter(w) 45 | } 46 | 47 | func (rs *RelayMediaSink) Close() error { 48 | rs.mediaBuffer.Close() 49 | 50 | return nil 51 | } 52 | 53 | func (rs *RelayMediaSink) PushSample(s *media.Sample, ts time.Duration) error { 54 | return utils.SerializeMediaForRelay(rs.mediaBuffer, s.Data, ts) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/whip/relay_whip_track_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io" 21 | "net" 22 | "sync" 23 | "time" 24 | 25 | "github.com/frostbyte73/core" 26 | "github.com/livekit/ingress/pkg/errors" 27 | "github.com/livekit/ingress/pkg/stats" 28 | "github.com/livekit/protocol/livekit" 29 | "github.com/livekit/protocol/logger" 30 | "github.com/livekit/server-sdk-go/v2/pkg/jitter" 31 | "github.com/livekit/server-sdk-go/v2/pkg/synchronizer" 32 | "github.com/pion/rtcp" 33 | "github.com/pion/rtp" 34 | "github.com/pion/webrtc/v4" 35 | "github.com/pion/webrtc/v4/pkg/media" 36 | ) 37 | 38 | const ( 39 | maxVideoLatency = 600 * time.Millisecond 40 | maxAudioLatency = time.Second 41 | ) 42 | 43 | type RelayWhipTrackHandler struct { 44 | logger logger.Logger 45 | remoteTrack *webrtc.TrackRemote 46 | depacketizer rtp.Depacketizer 47 | quality livekit.VideoQuality 48 | receiver *webrtc.RTPReceiver 49 | sync *synchronizer.TrackSynchronizer 50 | writePLI func(ssrc webrtc.SSRC) 51 | onRTCP func(packet rtcp.Packet) 52 | 53 | jb *jitter.Buffer 54 | relaySink *RelayMediaSink 55 | 56 | firstPacket sync.Once 57 | fuse core.Fuse 58 | lastSn uint16 59 | lastSnValid bool 60 | 61 | statsLock sync.Mutex 62 | trackStats *stats.MediaTrackStatGatherer 63 | } 64 | 65 | func NewRelayWhipTrackHandler( 66 | logger logger.Logger, 67 | track *webrtc.TrackRemote, 68 | quality livekit.VideoQuality, 69 | sync *synchronizer.TrackSynchronizer, 70 | receiver *webrtc.RTPReceiver, 71 | writePLI func(ssrc webrtc.SSRC), 72 | onRTCP func(packet rtcp.Packet), 73 | ) (*RelayWhipTrackHandler, error) { 74 | jb, err := createJitterBuffer(track, logger, writePLI) 75 | if err != nil { 76 | return nil, err 77 | } 78 | depacketizer, err := createDepacketizer(track) 79 | if err != nil { 80 | return nil, err 81 | } 82 | relaySink := NewRelayMediaSink(logger) 83 | 84 | return &RelayWhipTrackHandler{ 85 | logger: logger, 86 | remoteTrack: track, 87 | quality: quality, 88 | receiver: receiver, 89 | relaySink: relaySink, 90 | sync: sync, 91 | jb: jb, 92 | onRTCP: onRTCP, 93 | depacketizer: depacketizer, 94 | }, nil 95 | } 96 | 97 | func (t *RelayWhipTrackHandler) Start(onDone func(err error)) (err error) { 98 | t.startRTPReceiver(onDone) 99 | if t.onRTCP != nil { 100 | t.startRTCPReceiver() 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func (t *RelayWhipTrackHandler) SetMediaTrackStatsGatherer(st *stats.LocalMediaStatsGatherer) { 107 | t.statsLock.Lock() 108 | 109 | var path string 110 | 111 | switch t.remoteTrack.Kind() { 112 | case webrtc.RTPCodecTypeAudio: 113 | path = stats.InputAudio 114 | case webrtc.RTPCodecTypeVideo: 115 | path = fmt.Sprintf("%s.%s", stats.InputVideo, t.quality) 116 | default: 117 | path = "input.unknown" 118 | } 119 | 120 | g := st.RegisterTrackStats(path) 121 | t.trackStats = g 122 | 123 | t.statsLock.Unlock() 124 | } 125 | 126 | func (t *RelayWhipTrackHandler) SetWriter(w io.WriteCloser) error { 127 | return t.relaySink.SetWriter(w) 128 | } 129 | 130 | func (t *RelayWhipTrackHandler) Close() { 131 | t.fuse.Break() 132 | } 133 | 134 | func (t *RelayWhipTrackHandler) startRTPReceiver(onDone func(err error)) { 135 | go func() { 136 | var err error 137 | defer func() { 138 | t.relaySink.Close() 139 | if onDone != nil { 140 | onDone(err) 141 | } 142 | }() 143 | 144 | t.logger.Infow("starting rtp receiver") 145 | 146 | if t.remoteTrack.Kind() == webrtc.RTPCodecTypeVideo && t.writePLI != nil { 147 | t.writePLI(t.remoteTrack.SSRC()) 148 | } 149 | 150 | for { 151 | select { 152 | case <-t.fuse.Watch(): 153 | t.logger.Debugw("stopping rtp receiver") 154 | return 155 | default: 156 | err = t.processRTPPacket() 157 | switch err { 158 | case nil, errors.ErrPrerollBufferReset: 159 | // continue 160 | case io.EOF: 161 | err = nil // success 162 | return 163 | default: 164 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 165 | continue 166 | } 167 | 168 | t.logger.Warnw("error forwarding rtp packets", err) 169 | return 170 | } 171 | } 172 | } 173 | }() 174 | } 175 | 176 | func (t *RelayWhipTrackHandler) startRTCPReceiver() { 177 | go func() { 178 | t.logger.Infow("starting app source rtcp receiver") 179 | 180 | for { 181 | select { 182 | case <-t.fuse.Watch(): 183 | t.logger.Debugw("stopping app source rtcp receiver") 184 | return 185 | default: 186 | _ = t.receiver.SetReadDeadline(time.Now().Add(time.Millisecond * 500)) 187 | pkts, _, err := t.receiver.ReadRTCP() 188 | 189 | switch { 190 | case err == nil: 191 | // continue 192 | case err == io.EOF: 193 | return 194 | default: 195 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 196 | continue 197 | } 198 | 199 | t.logger.Warnw("error reading rtcp", err) 200 | return 201 | } 202 | 203 | for _, pkt := range pkts { 204 | t.onRTCP(pkt) 205 | } 206 | } 207 | } 208 | }() 209 | } 210 | func (t *RelayWhipTrackHandler) processRTPPacket() error { 211 | var pkt *rtp.Packet 212 | 213 | _ = t.remoteTrack.SetReadDeadline(time.Now().Add(time.Millisecond * 500)) 214 | 215 | pkt, _, err := t.remoteTrack.ReadRTP() 216 | if err != nil { 217 | return err 218 | } 219 | 220 | return t.pushRTP(pkt) 221 | } 222 | 223 | func (t *RelayWhipTrackHandler) pushRTP(pkt *rtp.Packet) error { 224 | t.firstPacket.Do(func() { 225 | t.logger.Debugw("first packet received") 226 | t.sync.Initialize(pkt) 227 | }) 228 | 229 | t.jb.Push(pkt) 230 | 231 | samples := t.jb.PopSamples(false) 232 | for _, pkts := range samples { 233 | if len(pkts) == 0 { 234 | continue 235 | } 236 | 237 | var ts time.Duration 238 | var err error 239 | var buffer bytes.Buffer // TODO reuse the same buffer across calls, after resetting it if buffer allocation is a performane bottleneck 240 | for _, pkt := range pkts { 241 | ts, err = t.sync.GetPTS(pkt) 242 | switch err { 243 | case nil, synchronizer.ErrBackwardsPTS: 244 | err = nil 245 | default: 246 | return err 247 | } 248 | 249 | t.statsLock.Lock() 250 | stats := t.trackStats 251 | t.statsLock.Unlock() 252 | 253 | if t.lastSnValid && t.lastSn+1 != pkt.SequenceNumber { 254 | gap := pkt.SequenceNumber - t.lastSn 255 | if t.lastSn-pkt.SequenceNumber < gap { 256 | gap = t.lastSn - pkt.SequenceNumber 257 | } 258 | if stats != nil { 259 | stats.PacketLost(int64(gap - 1)) 260 | } 261 | } 262 | 263 | t.lastSnValid = true 264 | t.lastSn = pkt.SequenceNumber 265 | 266 | if len(pkt.Payload) <= 2 { 267 | // Padding 268 | continue 269 | } 270 | 271 | buf, err := t.depacketizer.Unmarshal(pkt.Payload) 272 | if err != nil { 273 | t.logger.Warnw("failed unmarshalling RTP payload", err, "pkt", pkt, "payload", pkt.Payload[:min(len(pkt.Payload), 20)]) 274 | return err 275 | } 276 | 277 | if stats != nil { 278 | stats.MediaReceived(int64(len(buf))) 279 | } 280 | 281 | _, err = buffer.Write(buf) 282 | if err != nil { 283 | return err 284 | } 285 | } 286 | 287 | // This returns the average duration, not the actual duration of the specific sample 288 | // SampleBuilder is using the duration of the previous sample, which is inaccurate as well 289 | sampleDuration := t.sync.GetFrameDuration() 290 | 291 | s := &media.Sample{ 292 | Data: buffer.Bytes(), 293 | Duration: sampleDuration, 294 | } 295 | 296 | err = t.relaySink.PushSample(s, ts) 297 | if err != nil { 298 | return err 299 | } 300 | } 301 | 302 | return nil 303 | } 304 | -------------------------------------------------------------------------------- /pkg/whip/sdk_whip_track_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package whip 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "net" 21 | "sync" 22 | "time" 23 | 24 | "github.com/frostbyte73/core" 25 | "github.com/livekit/ingress/pkg/errors" 26 | "github.com/livekit/ingress/pkg/stats" 27 | "github.com/livekit/ingress/pkg/utils" 28 | "github.com/livekit/protocol/livekit" 29 | "github.com/livekit/protocol/logger" 30 | "github.com/pion/rtcp" 31 | "github.com/pion/rtp" 32 | "github.com/pion/webrtc/v4" 33 | ) 34 | 35 | type SDKWhipTrackHandler struct { 36 | logger logger.Logger 37 | remoteTrack *webrtc.TrackRemote 38 | quality livekit.VideoQuality 39 | receiver *webrtc.RTPReceiver 40 | writePLI func(ssrc webrtc.SSRC) 41 | sendRTCPUpStream func(pkt rtcp.Packet) 42 | 43 | startRTCP sync.Once 44 | fuse core.Fuse 45 | lastSn uint16 46 | lastSnValid bool 47 | 48 | stateLock sync.Mutex 49 | trackMediaSink *SDKMediaSinkTrack 50 | trackStats *stats.MediaTrackStatGatherer 51 | stats *stats.LocalMediaStatsGatherer 52 | } 53 | 54 | func NewSDKWhipTrackHandler( 55 | logger logger.Logger, 56 | track *webrtc.TrackRemote, 57 | quality livekit.VideoQuality, 58 | receiver *webrtc.RTPReceiver, 59 | writePLI func(ssrc webrtc.SSRC), 60 | sendRTCPUpStream func(pkt rtcp.Packet), 61 | ) (*SDKWhipTrackHandler, error) { 62 | 63 | return &SDKWhipTrackHandler{ 64 | logger: logger, 65 | remoteTrack: track, 66 | quality: quality, 67 | receiver: receiver, 68 | writePLI: writePLI, 69 | sendRTCPUpStream: sendRTCPUpStream, 70 | }, nil 71 | } 72 | 73 | func (t *SDKWhipTrackHandler) Start(onDone func(err error)) (err error) { 74 | t.startRTPReceiver(onDone) 75 | t.startRTCP.Do(t.startRTCPReceiver) 76 | 77 | return nil 78 | } 79 | 80 | func (t *SDKWhipTrackHandler) SetMediaTrackStatsGatherer(st *stats.LocalMediaStatsGatherer) { 81 | t.stateLock.Lock() 82 | 83 | t.stats = st 84 | 85 | var path string 86 | 87 | switch t.remoteTrack.Kind() { 88 | case webrtc.RTPCodecTypeAudio: 89 | path = stats.InputAudio 90 | case webrtc.RTPCodecTypeVideo: 91 | path = fmt.Sprintf("%s.%s", stats.InputVideo, t.quality) 92 | default: 93 | path = "input.unknown" 94 | } 95 | 96 | g := st.RegisterTrackStats(path) 97 | t.trackStats = g 98 | 99 | if t.trackMediaSink != nil { 100 | t.trackMediaSink.SetStatsGatherer(st) 101 | } 102 | t.stateLock.Unlock() 103 | } 104 | 105 | func (t *SDKWhipTrackHandler) SetMediaSink(s *SDKMediaSinkTrack) { 106 | t.stateLock.Lock() 107 | t.trackMediaSink = s 108 | 109 | if t.stats != nil { 110 | t.trackMediaSink.SetStatsGatherer(t.stats) 111 | } 112 | 113 | t.trackMediaSink.SetRTCPPacketSink(t.handleRTCPPacket) 114 | 115 | t.stateLock.Unlock() 116 | } 117 | 118 | func (t *SDKWhipTrackHandler) Close() { 119 | t.fuse.Break() 120 | } 121 | 122 | func (t *SDKWhipTrackHandler) handleRTCPPacket(pkt rtcp.Packet) { 123 | // LK SDK -> WHIP RTCP handling 124 | 125 | if t.sendRTCPUpStream != nil { 126 | utils.ReplaceRTCPPacketSSRC(pkt, uint32(t.remoteTrack.SSRC())) 127 | 128 | t.sendRTCPUpStream(pkt) 129 | } 130 | } 131 | 132 | func (t *SDKWhipTrackHandler) startRTPReceiver(onDone func(err error)) { 133 | t.stateLock.Lock() 134 | trackMediaSink := t.trackMediaSink 135 | t.stateLock.Unlock() 136 | 137 | go func() { 138 | var err error 139 | 140 | defer func() { 141 | trackMediaSink.Close() 142 | if onDone != nil { 143 | onDone(err) 144 | } 145 | }() 146 | 147 | t.logger.Infow("starting rtp receiver") 148 | 149 | if t.remoteTrack.Kind() == webrtc.RTPCodecTypeVideo && t.writePLI != nil { 150 | t.writePLI(t.remoteTrack.SSRC()) 151 | } 152 | 153 | for { 154 | select { 155 | case <-t.fuse.Watch(): 156 | t.logger.Debugw("stopping rtp receiver") 157 | return 158 | default: 159 | err = t.processRTPPacket(trackMediaSink) 160 | switch err { 161 | case nil, errors.ErrPrerollBufferReset: 162 | // continue 163 | case io.EOF: 164 | err = nil // success 165 | return 166 | default: 167 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 168 | continue 169 | } 170 | 171 | t.logger.Warnw("error forwarding rtp packets", err) 172 | return 173 | } 174 | } 175 | } 176 | }() 177 | } 178 | 179 | func (t *SDKWhipTrackHandler) startRTCPReceiver() { 180 | go func() { 181 | t.logger.Infow("starting app source rtcp receiver") 182 | 183 | for { 184 | select { 185 | case <-t.fuse.Watch(): 186 | t.logger.Debugw("stopping app source rtcp receiver") 187 | return 188 | default: 189 | _ = t.receiver.SetReadDeadline(time.Now().Add(time.Millisecond * 500)) 190 | pkts, _, err := t.receiver.ReadRTCP() 191 | 192 | switch { 193 | case err == nil: 194 | // continue 195 | case err == io.EOF: 196 | return 197 | default: 198 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 199 | continue 200 | } 201 | 202 | t.logger.Warnw("error reading rtcp", err) 203 | return 204 | } 205 | 206 | t.onUpStreamRTCP(pkts) 207 | } 208 | } 209 | }() 210 | } 211 | func (t *SDKWhipTrackHandler) processRTPPacket(trackMediaSink *SDKMediaSinkTrack) error { 212 | var pkt *rtp.Packet 213 | 214 | _ = t.remoteTrack.SetReadDeadline(time.Now().Add(time.Millisecond * 500)) 215 | 216 | pkt, _, err := t.remoteTrack.ReadRTP() 217 | if err != nil { 218 | return err 219 | } 220 | 221 | return t.pushRTP(pkt, trackMediaSink) 222 | } 223 | 224 | func (t *SDKWhipTrackHandler) pushRTP(pkt *rtp.Packet, trackMediaSink *SDKMediaSinkTrack) error { 225 | t.stateLock.Lock() 226 | stats := t.trackStats 227 | t.stateLock.Unlock() 228 | 229 | if t.lastSnValid && t.lastSn+1 != pkt.SequenceNumber { 230 | gap := pkt.SequenceNumber - t.lastSn 231 | if t.lastSn-pkt.SequenceNumber < gap { 232 | gap = t.lastSn - pkt.SequenceNumber 233 | } 234 | if stats != nil { 235 | stats.PacketLost(int64(gap - 1)) 236 | } 237 | } 238 | 239 | t.lastSnValid = true 240 | t.lastSn = pkt.SequenceNumber 241 | 242 | if stats != nil { 243 | stats.MediaReceived(int64(len(pkt.Payload))) 244 | } 245 | 246 | err := trackMediaSink.PushRTP(pkt) 247 | if err != nil { 248 | return err 249 | } 250 | 251 | return nil 252 | } 253 | 254 | func (t *SDKWhipTrackHandler) onUpStreamRTCP(pkts []rtcp.Packet) { 255 | // WHIP -> LK SDK RTCP handling 256 | 257 | t.stateLock.Lock() 258 | trackMediaSink := t.trackMediaSink 259 | t.stateLock.Unlock() 260 | 261 | if trackMediaSink == nil { 262 | return 263 | } 264 | 265 | err := trackMediaSink.PushRTCP(pkts) 266 | if err != nil { 267 | t.logger.Infow("failed writing upstream WHIP RTCP packets", "error", err) 268 | return 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /pkg/whip/utils.go: -------------------------------------------------------------------------------- 1 | package whip 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/livekit/ingress/pkg/errors" 8 | "github.com/livekit/protocol/logger" 9 | "github.com/livekit/server-sdk-go/v2/pkg/jitter" 10 | "github.com/pion/rtp" 11 | "github.com/pion/rtp/codecs" 12 | "github.com/pion/sdp/v3" 13 | "github.com/pion/webrtc/v4" 14 | ) 15 | 16 | func createDepacketizer(track *webrtc.TrackRemote) (rtp.Depacketizer, error) { 17 | var depacketizer rtp.Depacketizer 18 | 19 | switch strings.ToLower(track.Codec().MimeType) { 20 | case strings.ToLower(webrtc.MimeTypeVP8): 21 | depacketizer = &codecs.VP8Packet{} 22 | 23 | case strings.ToLower(webrtc.MimeTypeH264): 24 | depacketizer = &codecs.H264Packet{} 25 | 26 | case strings.ToLower(webrtc.MimeTypeOpus): 27 | depacketizer = &codecs.OpusPacket{} 28 | 29 | default: 30 | return nil, errors.ErrUnsupportedDecodeMimeType(track.Codec().MimeType) 31 | } 32 | 33 | return depacketizer, nil 34 | } 35 | 36 | func createJitterBuffer(track *webrtc.TrackRemote, logger logger.Logger, writePLI func(ssrc webrtc.SSRC)) (*jitter.Buffer, error) { 37 | var maxLatency time.Duration 38 | options := []jitter.Option{jitter.WithLogger(logger)} 39 | 40 | depacketizer, err := createDepacketizer(track) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | switch strings.ToLower(track.Codec().MimeType) { 46 | case strings.ToLower(webrtc.MimeTypeVP8): 47 | maxLatency = maxVideoLatency 48 | options = append(options, jitter.WithPacketDroppedHandler(func() { writePLI(track.SSRC()) })) 49 | 50 | case strings.ToLower(webrtc.MimeTypeH264): 51 | maxLatency = maxVideoLatency 52 | options = append(options, jitter.WithPacketDroppedHandler(func() { writePLI(track.SSRC()) })) 53 | 54 | case strings.ToLower(webrtc.MimeTypeOpus): 55 | maxLatency = maxAudioLatency 56 | // No PLI for audio 57 | 58 | default: 59 | return nil, errors.ErrUnsupportedDecodeMimeType(track.Codec().MimeType) 60 | } 61 | 62 | clockRate := track.Codec().ClockRate 63 | 64 | jb := jitter.NewBuffer(depacketizer, clockRate, maxLatency, options...) 65 | 66 | return jb, nil 67 | } 68 | 69 | func extractICEDetails(in []byte) (ufrag string, pwd string, err error) { 70 | scanAttributes := func(attributes []sdp.Attribute) { 71 | for _, a := range attributes { 72 | if a.Key == "ice-ufrag" { 73 | ufrag = a.Value 74 | } else if a.Key == "ice-pwd" { 75 | pwd = a.Value 76 | } 77 | } 78 | } 79 | 80 | var parsed sdp.SessionDescription 81 | if err = parsed.Unmarshal(in); err != nil { 82 | return 83 | } 84 | 85 | scanAttributes(parsed.Attributes) 86 | for _, m := range parsed.MediaDescriptions { 87 | scanAttributes(m.Attributes) 88 | } 89 | 90 | return 91 | } 92 | 93 | func replaceICEDetails(in, ufrag, pwd string) (string, error) { 94 | var parsed sdp.SessionDescription 95 | replaceAttributes := func(attributes []sdp.Attribute) { 96 | for i := range attributes { 97 | if attributes[i].Key == "ice-ufrag" { 98 | attributes[i].Value = ufrag 99 | } else if attributes[i].Key == "ice-pwd" { 100 | attributes[i].Value = pwd 101 | } 102 | } 103 | } 104 | 105 | if err := parsed.UnmarshalString(in); err != nil { 106 | return "", err 107 | } 108 | 109 | replaceAttributes(parsed.Attributes) 110 | for _, m := range parsed.MediaDescriptions { 111 | replaceAttributes(m.Attributes) 112 | } 113 | 114 | newRemoteDescription, err := parsed.Marshal() 115 | if err != nil { 116 | return "", err 117 | } 118 | 119 | return string(newRemoteDescription), nil 120 | } 121 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "commitBody": "Generated by renovateBot", 7 | "packageRules": [ 8 | { 9 | "matchManagers": ["github-actions"], 10 | "groupName": "github workflows" 11 | }, 12 | { 13 | "matchManagers": ["dockerfile"], 14 | "groupName": "docker deps" 15 | }, 16 | { 17 | "matchManagers": ["npm"], 18 | "groupName": "npm deps" 19 | }, 20 | { 21 | "matchManagers": ["gomod"], 22 | "groupName": "go deps" 23 | }, 24 | { 25 | "matchManagers": ["gomod"], 26 | "matchPackagePrefixes": ["github.com/livekit"], 27 | "groupName": "livekit deps" 28 | } 29 | ], 30 | "postUpdateOptions": [ 31 | "gomodTidy" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /test/config-sample.yaml: -------------------------------------------------------------------------------- 1 | log_level: debug 2 | redis: 3 | address: localhost:6379 4 | api_key: your-api-key 5 | api_secret: your-api-secret 6 | ws_url: wss://your-livekit-url.com 7 | -------------------------------------------------------------------------------- /test/integration.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | "io/ioutil" 22 | "os" 23 | "os/exec" 24 | "testing" 25 | "time" 26 | 27 | "github.com/stretchr/testify/require" 28 | "google.golang.org/protobuf/types/known/emptypb" 29 | "gopkg.in/yaml.v3" 30 | 31 | "github.com/livekit/protocol/livekit" 32 | "github.com/livekit/protocol/rpc" 33 | "github.com/livekit/psrpc" 34 | 35 | "github.com/livekit/ingress/pkg/config" 36 | "github.com/livekit/ingress/pkg/params" 37 | ) 38 | 39 | type TestConfig struct { 40 | *config.Config `yaml:",inline"` 41 | RoomName string `yaml:"room_name"` 42 | RtmpOnly bool `yaml:"rtmp_only"` 43 | WhipOnly bool `yaml:"whip_only"` 44 | URLOnly bool `yaml:"url_only"` 45 | } 46 | 47 | type ioServer struct { 48 | getIngressInfo func(*rpc.GetIngressInfoRequest) (*rpc.GetIngressInfoResponse, error) 49 | updateIngressState func(*rpc.UpdateIngressStateRequest) error 50 | } 51 | 52 | func (s *ioServer) CreateEgress(ctx context.Context, info *livekit.EgressInfo) (*emptypb.Empty, error) { 53 | return &emptypb.Empty{}, nil 54 | } 55 | 56 | func (s *ioServer) GetEgress(ctx context.Context, req *rpc.GetEgressRequest) (*livekit.EgressInfo, error) { 57 | return nil, nil 58 | } 59 | 60 | func (s *ioServer) ListEgress(ctx context.Context, req *livekit.ListEgressRequest) (*livekit.ListEgressResponse, error) { 61 | return nil, nil 62 | } 63 | 64 | func (s *ioServer) UpdateEgress(ctx context.Context, info *livekit.EgressInfo) (*emptypb.Empty, error) { 65 | return &emptypb.Empty{}, nil 66 | } 67 | 68 | func (s *ioServer) UpdateMetrics(ctx context.Context, req *rpc.UpdateMetricsRequest) (*emptypb.Empty, error) { 69 | return &emptypb.Empty{}, nil 70 | } 71 | 72 | func (s *ioServer) GetIngressInfo(ctx context.Context, req *rpc.GetIngressInfoRequest) (*rpc.GetIngressInfoResponse, error) { 73 | return s.getIngressInfo(req) 74 | } 75 | 76 | func (s *ioServer) CreateIngress(ctx context.Context, req *livekit.IngressInfo) (*emptypb.Empty, error) { 77 | return &emptypb.Empty{}, nil 78 | } 79 | 80 | func (s *ioServer) UpdateIngressState(ctx context.Context, req *rpc.UpdateIngressStateRequest) (*emptypb.Empty, error) { 81 | return &emptypb.Empty{}, s.updateIngressState(req) 82 | } 83 | 84 | func (s *ioServer) EvaluateSIPDispatchRules(context.Context, *rpc.EvaluateSIPDispatchRulesRequest) (*rpc.EvaluateSIPDispatchRulesResponse, error) { 85 | return nil, nil 86 | } 87 | 88 | func (s *ioServer) GetSIPTrunkAuthentication(context.Context, *rpc.GetSIPTrunkAuthenticationRequest) (*rpc.GetSIPTrunkAuthenticationResponse, error) { 89 | return nil, nil 90 | } 91 | 92 | func (s *ioServer) UpdateSIPCallState(context.Context, *rpc.UpdateSIPCallStateRequest) (*emptypb.Empty, error) { 93 | return nil, nil 94 | } 95 | 96 | func GetDefaultConfig(t *testing.T) *TestConfig { 97 | tc := &TestConfig{ 98 | Config: &config.Config{ 99 | ServiceConfig: &config.ServiceConfig{}, 100 | InternalConfig: &config.InternalConfig{}, 101 | }, 102 | } 103 | // Defaults 104 | tc.RTMPPort = 1935 105 | tc.HTTPRelayPort = 9090 106 | tc.WHIPPort = 8080 107 | 108 | tc.NodeID = "INGRESS_TEST" 109 | 110 | return tc 111 | } 112 | 113 | func getConfig(t *testing.T) *TestConfig { 114 | tc := GetDefaultConfig(t) 115 | 116 | confString := os.Getenv("INGRESS_CONFIG_BODY") 117 | if confString == "" { 118 | confFile := os.Getenv("INGRESS_CONFIG_FILE") 119 | require.NotEmpty(t, confFile) 120 | b, err := ioutil.ReadFile(confFile) 121 | require.NoError(t, err) 122 | confString = string(b) 123 | } 124 | 125 | require.NoError(t, yaml.Unmarshal([]byte(confString), tc)) 126 | tc.InitLogger() 127 | 128 | return tc 129 | } 130 | 131 | func RunTestSuite(t *testing.T, conf *TestConfig, bus psrpc.MessageBus, newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error)) { 132 | psrpcClient, err := rpc.NewIOInfoClient(bus) 133 | require.NoError(t, err) 134 | 135 | conf.Config.RTCConfig.Validate(conf.Development) 136 | conf.Config.RTCConfig.EnableLoopbackCandidate = true 137 | 138 | commandPsrpcClient, err := rpc.NewIngressHandlerClient(bus, psrpc.WithClientTimeout(5*time.Second)) 139 | require.NoError(t, err) 140 | 141 | if !conf.WhipOnly && !conf.URLOnly { 142 | t.Run("RTMP", func(t *testing.T) { 143 | RunRTMPTest(t, conf, bus, commandPsrpcClient, psrpcClient, newCmd) 144 | }) 145 | } 146 | if !conf.RtmpOnly && !conf.URLOnly { 147 | t.Run("WHIP", func(t *testing.T) { 148 | RunWHIPTest(t, conf, bus, commandPsrpcClient, psrpcClient, newCmd) 149 | }) 150 | } 151 | if !conf.RtmpOnly && !conf.WhipOnly { 152 | t.Run("URL pul", func(t *testing.T) { 153 | RunURLTest(t, conf, bus, commandPsrpcClient, psrpcClient, newCmd) 154 | }) 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /test/integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package test 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/require" 21 | 22 | "github.com/livekit/ingress/pkg/service" 23 | "github.com/livekit/protocol/redis" 24 | "github.com/livekit/psrpc" 25 | ) 26 | 27 | func TestIngress(t *testing.T) { 28 | conf := getConfig(t) 29 | 30 | rc, err := redis.GetRedisClient(conf.Redis) 31 | require.NoError(t, err) 32 | require.NotNil(t, rc, "redis required") 33 | 34 | bus := psrpc.NewRedisMessageBus(rc) 35 | 36 | RunTestSuite(t, conf, bus, service.NewCmd) 37 | } 38 | -------------------------------------------------------------------------------- /test/rtmp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os/exec" 23 | "strings" 24 | "testing" 25 | "time" 26 | 27 | "github.com/stretchr/testify/require" 28 | 29 | "github.com/livekit/ingress/pkg/params" 30 | "github.com/livekit/ingress/pkg/rtmp" 31 | "github.com/livekit/ingress/pkg/service" 32 | "github.com/livekit/protocol/livekit" 33 | "github.com/livekit/protocol/logger" 34 | "github.com/livekit/protocol/rpc" 35 | "github.com/livekit/psrpc" 36 | ) 37 | 38 | func RunRTMPTest(t *testing.T, conf *TestConfig, bus psrpc.MessageBus, commandPsrpcClient rpc.IngressHandlerClient, psrpcClient rpc.IOInfoClient, newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error)) { 39 | rtmpsrv := rtmp.NewRTMPServer() 40 | relay := service.NewRelay(rtmpsrv, nil) 41 | 42 | svc, err := service.NewService(conf.Config, psrpcClient, bus, rtmpsrv, nil, newCmd, "") 43 | require.NoError(t, err) 44 | go func() { 45 | err := svc.Run() 46 | require.NoError(t, err) 47 | }() 48 | 49 | err = rtmpsrv.Start(conf.Config, svc.HandleRTMPPublishRequest) 50 | require.NoError(t, err) 51 | err = relay.Start(conf.Config) 52 | require.NoError(t, err) 53 | 54 | t.Cleanup(func() { 55 | relay.Stop() 56 | rtmpsrv.Stop() 57 | svc.Stop(true) 58 | }) 59 | 60 | updates := make(chan *rpc.UpdateIngressStateRequest, 10) 61 | ios := &ioServer{} 62 | ios.updateIngressState = func(req *rpc.UpdateIngressStateRequest) error { 63 | updates <- req 64 | return nil 65 | } 66 | 67 | info := &livekit.IngressInfo{ 68 | IngressId: "ingress_id", 69 | InputType: livekit.IngressInput_RTMP_INPUT, 70 | Name: "ingress-test", 71 | RoomName: conf.RoomName, 72 | ParticipantIdentity: "ingress-test", 73 | ParticipantName: "ingress-test", 74 | Reusable: true, 75 | StreamKey: "ingress-test", 76 | Url: "rtmp://localhost:1935/live/ingress-test", 77 | Audio: &livekit.IngressAudioOptions{ 78 | Name: "audio", 79 | Source: 0, 80 | EncodingOptions: &livekit.IngressAudioOptions_Options{ 81 | Options: &livekit.IngressAudioEncodingOptions{ 82 | AudioCodec: livekit.AudioCodec_OPUS, 83 | Bitrate: 64000, 84 | DisableDtx: false, 85 | Channels: 2, 86 | }, 87 | }, 88 | }, 89 | Video: &livekit.IngressVideoOptions{ 90 | Name: "video", 91 | Source: 0, 92 | EncodingOptions: &livekit.IngressVideoOptions_Options{ 93 | Options: &livekit.IngressVideoEncodingOptions{ 94 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 95 | FrameRate: 20, 96 | Layers: []*livekit.VideoLayer{ 97 | { 98 | Quality: livekit.VideoQuality_HIGH, 99 | Width: 1280, 100 | Height: 720, 101 | Bitrate: 3000000, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | } 108 | ios.getIngressInfo = func(req *rpc.GetIngressInfoRequest) (*rpc.GetIngressInfoResponse, error) { 109 | return &rpc.GetIngressInfoResponse{Info: info, WsUrl: conf.WsUrl}, nil 110 | } 111 | 112 | ioPsrpc, err := rpc.NewIOInfoServer(ios, bus) 113 | require.NoError(t, err) 114 | t.Cleanup(func() { 115 | ioPsrpc.Kill() 116 | }) 117 | 118 | time.Sleep(time.Second) 119 | 120 | logger.Infow("rtmp url", "url", info.Url) 121 | 122 | cmdString := strings.Split( 123 | fmt.Sprintf( 124 | "gst-launch-1.0 -v flvmux name=mux ! rtmp2sink location=%s "+ 125 | "audiotestsrc freq=200 ! faac ! mux. "+ 126 | "videotestsrc pattern=ball is-live=true ! video/x-raw,width=1280,height=720 ! x264enc speed-preset=3 tune=zerolatency ! mux.", 127 | info.Url), 128 | " ") 129 | cmd := exec.Command(cmdString[0], cmdString[1:]...) 130 | require.NoError(t, cmd.Start()) 131 | 132 | time.Sleep(time.Second * 45) 133 | 134 | _, err = commandPsrpcClient.DeleteIngress(context.Background(), info.IngressId, &livekit.DeleteIngressRequest{IngressId: info.IngressId}) 135 | require.NoError(t, err) 136 | 137 | time.Sleep(time.Second * 2) 138 | 139 | final := <-updates 140 | require.NotEqual(t, final.State.Status, livekit.IngressState_ENDPOINT_ERROR) 141 | } 142 | -------------------------------------------------------------------------------- /test/url.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | "os/exec" 22 | "testing" 23 | "time" 24 | 25 | "github.com/stretchr/testify/require" 26 | 27 | "github.com/livekit/ingress/pkg/params" 28 | "github.com/livekit/ingress/pkg/service" 29 | "github.com/livekit/protocol/livekit" 30 | "github.com/livekit/protocol/logger" 31 | "github.com/livekit/protocol/rpc" 32 | "github.com/livekit/psrpc" 33 | ) 34 | 35 | func RunURLTest(t *testing.T, conf *TestConfig, bus psrpc.MessageBus, commandPsrpcClient rpc.IngressHandlerClient, psrpcClient rpc.IOInfoClient, newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error)) { 36 | svc, err := service.NewService(conf.Config, psrpcClient, bus, nil, nil, newCmd, "") 37 | require.NoError(t, err) 38 | svc.StartDebugHandlers() 39 | 40 | go func() { 41 | err := svc.Run() 42 | require.NoError(t, err) 43 | }() 44 | 45 | t.Cleanup(func() { 46 | svc.Stop(true) 47 | }) 48 | 49 | _, err = rpc.NewIngressInternalServer(svc, bus) 50 | require.NoError(t, err) 51 | 52 | internalPsrpcClient, err := rpc.NewIngressInternalClient(bus, psrpc.WithClientTimeout(5*time.Second)) 53 | require.NoError(t, err) 54 | 55 | updates := make(chan *rpc.UpdateIngressStateRequest, 10) 56 | ios := &ioServer{} 57 | ios.updateIngressState = func(req *rpc.UpdateIngressStateRequest) error { 58 | updates <- req 59 | return nil 60 | } 61 | 62 | info := &livekit.IngressInfo{ 63 | IngressId: "ingress_id", 64 | InputType: livekit.IngressInput_URL_INPUT, 65 | Name: "ingress-test", 66 | RoomName: conf.RoomName, 67 | ParticipantIdentity: "ingress-test", 68 | ParticipantName: "ingress-test", 69 | Reusable: true, 70 | StreamKey: "ingress-test", 71 | Url: "http://devimages.apple.com/iphone/samples/bipbop/gear4/prog_index.m3u8", 72 | Audio: &livekit.IngressAudioOptions{ 73 | Name: "audio", 74 | Source: 0, 75 | EncodingOptions: &livekit.IngressAudioOptions_Options{ 76 | Options: &livekit.IngressAudioEncodingOptions{ 77 | AudioCodec: livekit.AudioCodec_OPUS, 78 | Bitrate: 64000, 79 | DisableDtx: false, 80 | Channels: 2, 81 | }, 82 | }, 83 | }, 84 | Video: &livekit.IngressVideoOptions{ 85 | Name: "video", 86 | Source: 0, 87 | EncodingOptions: &livekit.IngressVideoOptions_Options{ 88 | Options: &livekit.IngressVideoEncodingOptions{ 89 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 90 | FrameRate: 20, 91 | Layers: []*livekit.VideoLayer{ 92 | { 93 | Quality: livekit.VideoQuality_HIGH, 94 | Width: 1280, 95 | Height: 720, 96 | Bitrate: 3000000, 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | } 103 | ios.getIngressInfo = func(req *rpc.GetIngressInfoRequest) (*rpc.GetIngressInfoResponse, error) { 104 | return nil, psrpc.NewErrorf(psrpc.NotFound, "not found") 105 | } 106 | 107 | ioPsrpc, err := rpc.NewIOInfoServer(ios, bus) 108 | require.NoError(t, err) 109 | t.Cleanup(func() { 110 | ioPsrpc.Kill() 111 | }) 112 | 113 | time.Sleep(time.Second) 114 | 115 | logger.Infow("http pull url", "url", info.Url) 116 | 117 | info2, err := internalPsrpcClient.StartIngress(context.Background(), &rpc.StartIngressRequest{Info: info}) 118 | require.NoError(t, err) 119 | require.Equal(t, info2.State.Status, livekit.IngressState_ENDPOINT_BUFFERING) 120 | 121 | time.Sleep(time.Second * 45) 122 | 123 | _, err = commandPsrpcClient.DeleteIngress(context.Background(), info.IngressId, &livekit.DeleteIngressRequest{IngressId: info.IngressId}) 124 | require.NoError(t, err) 125 | 126 | time.Sleep(time.Second * 2) 127 | 128 | final := <-updates 129 | require.NotEqual(t, final.State.Status, livekit.IngressState_ENDPOINT_ERROR) 130 | } 131 | -------------------------------------------------------------------------------- /test/whip.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os/exec" 23 | "strings" 24 | "syscall" 25 | "testing" 26 | "time" 27 | 28 | "github.com/stretchr/testify/require" 29 | 30 | "github.com/livekit/ingress/pkg/params" 31 | "github.com/livekit/ingress/pkg/service" 32 | "github.com/livekit/ingress/pkg/whip" 33 | "github.com/livekit/protocol/livekit" 34 | "github.com/livekit/protocol/logger" 35 | "github.com/livekit/protocol/rpc" 36 | "github.com/livekit/psrpc" 37 | ) 38 | 39 | const ( 40 | whipClientPath = "livekit-whip-bot/cmd/whip-client/whip-client" 41 | ) 42 | 43 | func RunWHIPTest(t *testing.T, conf *TestConfig, bus psrpc.MessageBus, commandPsrpcClient rpc.IngressHandlerClient, psrpcClient rpc.IOInfoClient, newCmd func(ctx context.Context, p *params.Params) (*exec.Cmd, error)) { 44 | whipsrv := whip.NewWHIPServer(commandPsrpcClient) 45 | relay := service.NewRelay(nil, whipsrv) 46 | 47 | svc, err := service.NewService(conf.Config, psrpcClient, bus, nil, whipsrv, newCmd, "") 48 | require.NoError(t, err) 49 | go func() { 50 | err := svc.Run() 51 | require.NoError(t, err) 52 | }() 53 | 54 | err = whipsrv.Start(conf.Config, svc.HandleWHIPPublishRequest, svc.GetHealthHandlers()) 55 | require.NoError(t, err) 56 | err = relay.Start(conf.Config) 57 | require.NoError(t, err) 58 | 59 | t.Cleanup(func() { 60 | relay.Stop() 61 | whipsrv.Stop() 62 | svc.Stop(true) 63 | }) 64 | 65 | updates := make(chan *rpc.UpdateIngressStateRequest, 10) 66 | ios := &ioServer{} 67 | ios.updateIngressState = func(req *rpc.UpdateIngressStateRequest) error { 68 | updates <- req 69 | return nil 70 | } 71 | 72 | tr := true 73 | 74 | info := &livekit.IngressInfo{ 75 | IngressId: "ingress_id", 76 | InputType: livekit.IngressInput_WHIP_INPUT, 77 | Name: "ingress-test", 78 | RoomName: conf.RoomName, 79 | ParticipantIdentity: "ingress-test", 80 | ParticipantName: "ingress-test", 81 | Reusable: true, 82 | StreamKey: "ingress-test", 83 | Url: "http://localhost:8080/w", 84 | EnableTranscoding: &tr, 85 | Audio: &livekit.IngressAudioOptions{ 86 | Name: "audio", 87 | Source: 0, 88 | EncodingOptions: &livekit.IngressAudioOptions_Options{ 89 | Options: &livekit.IngressAudioEncodingOptions{ 90 | AudioCodec: livekit.AudioCodec_OPUS, 91 | Bitrate: 64000, 92 | DisableDtx: false, 93 | Channels: 2, 94 | }, 95 | }, 96 | }, 97 | Video: &livekit.IngressVideoOptions{ 98 | Name: "video", 99 | Source: 0, 100 | EncodingOptions: &livekit.IngressVideoOptions_Options{ 101 | Options: &livekit.IngressVideoEncodingOptions{ 102 | VideoCodec: livekit.VideoCodec_H264_BASELINE, 103 | FrameRate: 20, 104 | Layers: []*livekit.VideoLayer{ 105 | { 106 | Quality: livekit.VideoQuality_HIGH, 107 | Width: 1280, 108 | Height: 720, 109 | Bitrate: 3000000, 110 | }, 111 | { 112 | Quality: livekit.VideoQuality_LOW, 113 | Width: 640, 114 | Height: 360, 115 | Bitrate: 1000000, 116 | }, 117 | }, 118 | }, 119 | }, 120 | }, 121 | } 122 | ios.getIngressInfo = func(req *rpc.GetIngressInfoRequest) (*rpc.GetIngressInfoResponse, error) { 123 | return &rpc.GetIngressInfoResponse{Info: info, WsUrl: conf.WsUrl}, nil 124 | } 125 | 126 | ioPsrpc, err := rpc.NewIOInfoServer(ios, bus) 127 | require.NoError(t, err) 128 | t.Cleanup(func() { 129 | ioPsrpc.Kill() 130 | }) 131 | 132 | time.Sleep(1 * time.Second) 133 | 134 | logger.Infow("whip url", "url", info.Url, "streamKey", info.StreamKey) 135 | 136 | whipUrl := fmt.Sprintf("%s/%s", info.Url, info.StreamKey) 137 | 138 | cmdString := strings.Split( 139 | fmt.Sprintf("%s -url %s", whipClientPath, whipUrl), 140 | " ") 141 | cmd := exec.Command(cmdString[0], cmdString[1:]...) 142 | require.NoError(t, cmd.Start()) 143 | 144 | t.Cleanup(func() { 145 | syscall.Kill(cmd.Process.Pid, syscall.SIGTERM) 146 | }) 147 | 148 | time.Sleep(time.Second * 45) 149 | 150 | _, err = commandPsrpcClient.DeleteIngress(context.Background(), info.IngressId, &livekit.DeleteIngressRequest{IngressId: info.IngressId}) 151 | require.NoError(t, err) 152 | 153 | time.Sleep(time.Second * 2) 154 | 155 | final := <-updates 156 | require.NotEqual(t, final.State.Status, livekit.IngressState_ENDPOINT_ERROR) 157 | 158 | } 159 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package version 16 | 17 | const Version = "1.4.3" 18 | --------------------------------------------------------------------------------