├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── docker-image.yml ├── .gitignore ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── configure.go ├── go.mod ├── go.sum ├── main.go ├── out └── .keep ├── player ├── index.html ├── mediaelementjs.html └── videojs.html └── services ├── allow_cors_handler.go ├── buffered_response_writer.go ├── common.go ├── content_prober.go ├── enrich_playlist_handler.go ├── hls.go ├── touch_map.go ├── transcode_pool.go ├── transcoder.go └── web.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *Dockerfile* -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: pavel_tatarskiy 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - 'master' 6 | tags: 7 | - 'v*' 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | docker: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v4 19 | - 20 | name: Docker meta 21 | id: meta 22 | uses: docker/metadata-action@v5 23 | with: 24 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=pr 28 | type=semver,pattern={{version}} 29 | type=semver,pattern={{major}}.{{minor}} 30 | type=sha 31 | - 32 | name: Login to DockerHub 33 | if: github.event_name != 'pull_request' 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | - 40 | name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | context: . 44 | push: ${{ github.event_name != 'pull_request' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.db 14 | 15 | tmp 16 | out/* 17 | !out/.keep 18 | 19 | content-transcoder 20 | 21 | .DS_Store 22 | .env 23 | .idea 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceRoot}", 13 | "args": ["--input", "https://github.com/Matroska-Org/matroska-test-files/raw/master/test_files/test5.mkv", "--player", "true", "--use-snapshot", "false"], 14 | "envFile": "${userHome}/.env" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # golang image 2 | FROM golang:latest AS build 3 | 4 | # set work dir 5 | WORKDIR /app 6 | 7 | # copy the source files 8 | COPY . . 9 | 10 | # compile linux only 11 | ENV GOOS=linux 12 | ENV CGO_ENABLED=0 13 | 14 | # build the binary with debug information removed 15 | RUN go build -ldflags '-w -s' -a -installsuffix cgo -o server 16 | 17 | FROM jrottenberg/ffmpeg:7-alpine AS ffmpeg 18 | 19 | # set work dir 20 | WORKDIR /app 21 | 22 | # copy our static linked library 23 | COPY --from=build /app/server . 24 | 25 | # copy player 26 | COPY --from=build /app/player ./player 27 | 28 | ENV OUTPUT=/data 29 | 30 | # tell we are exposing our service 31 | EXPOSE 8080 8081 8082 8083 32 | 33 | ENTRYPOINT [] 34 | 35 | # run it! 36 | CMD ["./server"] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 webtor.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # content-transcoder 2 | 3 | Transcodes HTTP-stream to HLS with additional features: 4 | 1. Web-access to transcoded content 5 | 2. On-demand transcoding 6 | 3. Quits after specific period of inactivity 7 | 8 | ## Requirements 9 | 1. FFmpeg 3+ 10 | 11 | ## Basic usage 12 | ``` 13 | % ./server help 14 | NAME: 15 | content-transcoder-server - runs content transcoder 16 | 17 | USAGE: 18 | server [global options] command [command options] [arguments...] 19 | 20 | VERSION: 21 | 0.0.1 22 | 23 | COMMANDS: 24 | help, h Shows a list of commands or help for one command 25 | 26 | GLOBAL OPTIONS: 27 | --host value, -H value listening host 28 | --port value, -P value listening port (default: 8080) 29 | --probe-port value, --pP value probe port (default: 8081) 30 | --input value, -i value, --url value input (url) [$INPUT, $ SOURCE_URL, $ URL] 31 | --output value, -o value output (local path) (default: "out") 32 | --content-prober-host value, --cpH value hostname of the content prober service [$CONTENT_PROBER_SERVICE_HOST] 33 | --content-prober-port value, --cpP value port of the content prober service (default: 50051) [$CONTENT_PROBER_SERVICE_PORT] 34 | --access-grace value, --ag value access grace in seconds (default: 600) [$GRACE] 35 | --preset value transcode preset (default: "ultrafast") [$PRESET] 36 | --transcode-grace value, --tg value transcode grace in seconds (default: 5) [$TRANSCODE_GRACE] 37 | --probe-timeout value, --pt value probe timeout in seconds (default: 600) [$PROBE_TIMEOUT] 38 | --job-id value job id [$JOB_ID] 39 | --info-hash value info hash [$INFO_HASH] 40 | --file-path value file path [$FILE_PATH] 41 | --extra value extra [$EXTRA] 42 | --player player 43 | --help, -h show help 44 | --version, -v print the version 45 | ``` 46 | 47 | ## Example 48 | ``` 49 | cd server && 50 | rm -rf out/* && rm -rf tmp/* && 51 | go build -mod=vendor . && 52 | ./server --input='https://github.com/Matroska-Org/matroska-test-files/raw/master/test_files/test5.mkv' --player=true 53 | ``` 54 | Then you can open your browser http://localhost:8080/player/ and watch movie 55 | -------------------------------------------------------------------------------- /configure.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/urfave/cli" 6 | cs "github.com/webtor-io/common-services" 7 | s "github.com/webtor-io/content-transcoder/services" 8 | ) 9 | 10 | func configure(app *cli.App) { 11 | app.Flags = []cli.Flag{} 12 | app.Flags = s.RegisterCommonFlags(app.Flags) 13 | app.Flags = s.RegisterContentProberFlags(app.Flags) 14 | app.Flags = s.RegisterWebFlags(app.Flags) 15 | app.Flags = cs.RegisterProbeFlags(app.Flags) 16 | app.Flags = cs.RegisterPprofFlags(app.Flags) 17 | app.Flags = s.RegisterHLSFlags(app.Flags) 18 | app.Action = run 19 | } 20 | 21 | func run(c *cli.Context) (err error) { 22 | var servers []cs.Servable 23 | 24 | // Setting ContentProbe 25 | contentProbe := s.NewContentProbe(c) 26 | 27 | // Setting Probe 28 | probe := cs.NewProbe(c) 29 | if probe != nil { 30 | servers = append(servers, probe) 31 | defer probe.Close() 32 | } 33 | 34 | // Setting Pprof 35 | pprof := cs.NewPprof(c) 36 | if pprof != nil { 37 | servers = append(servers, pprof) 38 | defer pprof.Close() 39 | } 40 | 41 | // Setting TranscodePool 42 | transcodePool := s.NewTranscodePool() 43 | 44 | // Setting TouchMap 45 | touchMap := s.NewTouchMap() 46 | 47 | // Setting HLSBuilder 48 | hlsBuilder := s.NewHLSBuilder(c) 49 | 50 | // Setting Web 51 | web := s.NewWeb(c, contentProbe, hlsBuilder, transcodePool, touchMap) 52 | servers = append(servers, web) 53 | defer web.Close() 54 | 55 | // Setting Serve 56 | serve := cs.NewServe(servers...) 57 | 58 | // And SERVE! 59 | err = serve.Serve() 60 | 61 | if err != nil { 62 | log.WithError(err).Error("got server error") 63 | return err 64 | } 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/webtor-io/content-transcoder 2 | 3 | require ( 4 | github.com/aws/aws-sdk-go v1.55.6 // indirect 5 | github.com/pkg/errors v0.9.1 6 | github.com/sirupsen/logrus v1.9.3 7 | github.com/urfave/cli v1.22.16 8 | github.com/webtor-io/common-services v0.0.0-20250112153432-554128b56bd5 9 | github.com/webtor-io/content-prober v0.0.0-20220416113613-facff27ad415 10 | golang.org/x/sys v0.31.0 // indirect 11 | google.golang.org/grpc v1.71.0 12 | ) 13 | 14 | require github.com/webtor-io/lazymap v0.0.0-20250308124910-3a61e0f78108 15 | 16 | require ( 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 19 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 20 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 21 | github.com/go-pg/migrations/v8 v8.1.0 // indirect 22 | github.com/go-pg/pg/v10 v10.14.0 // indirect 23 | github.com/go-pg/zerochecker v0.2.0 // indirect 24 | github.com/golang/protobuf v1.5.4 // indirect 25 | github.com/jinzhu/inflection v1.0.0 // indirect 26 | github.com/jmespath/go-jmespath v0.4.0 // indirect 27 | github.com/klauspost/compress v1.18.0 // indirect 28 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 29 | github.com/prometheus/client_golang v1.21.1 // indirect 30 | github.com/prometheus/client_model v0.6.1 // indirect 31 | github.com/prometheus/common v0.62.0 // indirect 32 | github.com/prometheus/procfs v0.15.1 // indirect 33 | github.com/redis/go-redis/v9 v9.7.1 // indirect 34 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 35 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect 36 | github.com/vmihailenco/bufpool v0.1.11 // indirect 37 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 38 | github.com/vmihailenco/tagparser v0.1.2 // indirect 39 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 40 | golang.org/x/crypto v0.36.0 // indirect 41 | golang.org/x/net v0.37.0 // indirect 42 | golang.org/x/text v0.23.0 // indirect 43 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 44 | google.golang.org/protobuf v1.36.5 // indirect 45 | mellium.im/sasl v0.3.2 // indirect 46 | ) 47 | 48 | go 1.24.1 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 4 | github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= 5 | github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 6 | github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= 7 | github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 8 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 9 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 10 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 11 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 12 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 13 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 14 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 15 | github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= 16 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 17 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 18 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 20 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 21 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 26 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 27 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 28 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 29 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 30 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 31 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 32 | github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= 33 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 34 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 35 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 36 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 37 | github.com/go-pg/migrations/v8 v8.1.0 h1:bc1wQwFoWRKvLdluXCRFRkeaw9xDU4qJ63uCAagh66w= 38 | github.com/go-pg/migrations/v8 v8.1.0/go.mod h1:o+CN1u572XHphEHZyK6tqyg2GDkRvL2bIoLNyGIewus= 39 | github.com/go-pg/pg/v10 v10.4.0/go.mod h1:BfgPoQnD2wXNd986RYEHzikqv9iE875PrFaZ9vXvtNM= 40 | github.com/go-pg/pg/v10 v10.14.0 h1:giXuPsJaWjzwzFJTxy39eBgGE44jpqH1jwv0uI3kBUU= 41 | github.com/go-pg/pg/v10 v10.14.0/go.mod h1:6kizZh54FveJxw9XZdNg07x7DDBWNsQrSiJS04MLwO8= 42 | github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= 43 | github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= 44 | github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 45 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 46 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 47 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 48 | github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= 49 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 50 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 51 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 52 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 53 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 54 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 55 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 56 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 57 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 58 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 59 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 60 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 61 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 62 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 63 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 64 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 65 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 66 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 67 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 68 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 69 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 70 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 71 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 72 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 73 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 74 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 75 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 76 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 77 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 78 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 79 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 80 | github.com/joonix/log v0.0.0-20190213172830-51a6cca1fed3/go.mod h1:9alna084PKap49x3Dl7QTGUXiS37acLi8ryAexT1SJc= 81 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 82 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 83 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 84 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 85 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 86 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 87 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 88 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 89 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 90 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 91 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 92 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 93 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 94 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 95 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 96 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 97 | github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= 98 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 99 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 100 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 101 | github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M= 102 | github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 103 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 104 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 105 | github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= 106 | github.com/onsi/gomega v1.10.4 h1:NiTx7EEvBzu9sFOD1zORteLSt3o8gnlvZZwSE9TnY9U= 107 | github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ= 108 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 109 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 110 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 111 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 112 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 113 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 114 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 115 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 116 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 117 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 118 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 119 | github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= 120 | github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= 121 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 122 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 123 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 124 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 125 | github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= 126 | github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= 127 | github.com/redis/go-redis/v9 v9.7.1 h1:4LhKRCIduqXqtvCUlaq9c8bdHOkICjDMrr1+Zb3osAc= 128 | github.com/redis/go-redis/v9 v9.7.1/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= 129 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 130 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 131 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 132 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 133 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 134 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 135 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 136 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 137 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 138 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 139 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 140 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 141 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 142 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 143 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 144 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 145 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 146 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 147 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 148 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 149 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 150 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 151 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= 152 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= 153 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 154 | github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= 155 | github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= 156 | github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= 157 | github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= 158 | github.com/vmihailenco/msgpack/v4 v4.3.11/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= 159 | github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1/go.mod h1:xlngVLeyQ/Qi05oQxhQ+oTuqa03RjMwMfk/7/TCs+QI= 160 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 161 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 162 | github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 163 | github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= 164 | github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 165 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 166 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 167 | github.com/webtor-io/common-services v0.0.0-20250105173005-fe4606378dff h1:xygV+QbcHRDXywujsn7V70bqDxadU0f6k0VppE1qFt0= 168 | github.com/webtor-io/common-services v0.0.0-20250105173005-fe4606378dff/go.mod h1:6jUeO6R+ytZnEJj7PlcLEQZfWaxw8ovav73BP83MTlI= 169 | github.com/webtor-io/common-services v0.0.0-20250112153432-554128b56bd5 h1:EGVq16o1t8LO2HR2eGh4YEJjLF9Np2bBgJxHkUr+fb4= 170 | github.com/webtor-io/common-services v0.0.0-20250112153432-554128b56bd5/go.mod h1:6jUeO6R+ytZnEJj7PlcLEQZfWaxw8ovav73BP83MTlI= 171 | github.com/webtor-io/content-prober v0.0.0-20220416113613-facff27ad415 h1:8eDD+z0mOqEjBwVcMmhdITnu0utqbx+GgFjMjJ6Scts= 172 | github.com/webtor-io/content-prober v0.0.0-20220416113613-facff27ad415/go.mod h1:FJnGLg2A5mjOE6IhsdVJA3KkG73QE3OPIy3IZbVJBQ0= 173 | github.com/webtor-io/lazymap v0.0.0-20241211155941-e81d935cfa1d h1:Xi9E0LCDgK++QliA7ZNFdSI11Bpg5qe7efN3AMWJ3dY= 174 | github.com/webtor-io/lazymap v0.0.0-20241211155941-e81d935cfa1d/go.mod h1:kioEFK4hk8YfHrhg47tGvMG40xawOJM4gcfRQ4EeX4k= 175 | github.com/webtor-io/lazymap v0.0.0-20250308124910-3a61e0f78108 h1:4rJXuBJFmr4ePOQIIBDOkAzQrjFoMpWns8g1zD95ugM= 176 | github.com/webtor-io/lazymap v0.0.0-20250308124910-3a61e0f78108/go.mod h1:kioEFK4hk8YfHrhg47tGvMG40xawOJM4gcfRQ4EeX4k= 177 | go.opentelemetry.io/otel v0.13.0/go.mod h1:dlSNewoRYikTkotEnxdmuBHgzT+k/idJSfDv/FxEnOY= 178 | go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= 179 | go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= 180 | go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= 181 | go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= 182 | go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= 183 | go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= 184 | go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= 185 | go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= 186 | go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= 187 | go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= 188 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 189 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 190 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 191 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 192 | golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 193 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 194 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 195 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 196 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 197 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 198 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 199 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 200 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 201 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 202 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 203 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 204 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 205 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 206 | golang.org/x/net v0.0.0-20190301231341-16b79f2e4e95/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 207 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 208 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 209 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 210 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 211 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 212 | golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 213 | golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 214 | golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 215 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 216 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 217 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 218 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 219 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 220 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 222 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 223 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 224 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 225 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 226 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 227 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 228 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 229 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 230 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 231 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20201017003518-b09fb700fbb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 237 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 238 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 239 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 240 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 241 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 242 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 243 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 244 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 245 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 246 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 247 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 248 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 249 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 250 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 251 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 252 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 253 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 254 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 255 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 256 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 257 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 258 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 259 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 260 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 261 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 262 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 263 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw= 264 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= 265 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= 266 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= 267 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 268 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 269 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 270 | google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= 271 | google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 272 | google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= 273 | google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 274 | google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= 275 | google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 276 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 277 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 278 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 279 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 280 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 281 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 282 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 283 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 284 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 285 | google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= 286 | google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 287 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 288 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 289 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 290 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 291 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 292 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 293 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 294 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 295 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 296 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 297 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 298 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 299 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 300 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 301 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 302 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 303 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 304 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 305 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 306 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 307 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 308 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 309 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 310 | mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ= 311 | mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= 312 | mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= 313 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/urfave/cli" 8 | ) 9 | 10 | func main() { 11 | log.SetFormatter(&log.TextFormatter{ 12 | FullTimestamp: true, 13 | }) 14 | app := cli.NewApp() 15 | app.Name = "content-transcoder" 16 | app.Usage = "runs content transcoder" 17 | app.Version = "0.0.1" 18 | configure(app) 19 | err := app.Run(os.Args) 20 | if err != nil { 21 | log.WithError(err).Fatal("Failed to serve application") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /out/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webtor-io/content-transcoder/97b0aeaf9bef55bde5cfb2cdecf510923409096a/out/.keep -------------------------------------------------------------------------------- /player/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MediaElement.js
5 | Video.js 6 | 7 | -------------------------------------------------------------------------------- /player/mediaelementjs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | MediaElement.js 14 | 15 | 16 |
17 |

MediaElement.js

18 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /player/videojs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | Video.js 13 | 14 | 15 | 16 |
17 |

Video.js

18 | 21 |
22 | 23 | 24 | 25 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /services/allow_cors_handler.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import "net/http" 4 | 5 | func allowCORSHandler(next http.Handler) http.Handler { 6 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 | w.Header().Set("Access-Control-Allow-Origin", "*") 8 | next.ServeHTTP(w, r) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /services/buffered_response_writer.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type bufferedResponseWriter struct { 13 | http.ResponseWriter 14 | statusCode int 15 | buf bytes.Buffer 16 | } 17 | 18 | func NewBufferedResponseWrtier(w http.ResponseWriter) *bufferedResponseWriter { 19 | return &bufferedResponseWriter{ 20 | statusCode: http.StatusOK, 21 | ResponseWriter: w, 22 | } 23 | } 24 | 25 | func (w *bufferedResponseWriter) WriteHeader(statusCode int) { 26 | w.statusCode = statusCode 27 | } 28 | 29 | func (w *bufferedResponseWriter) Write(p []byte) (int, error) { 30 | return w.buf.Write(p) 31 | } 32 | 33 | func (w *bufferedResponseWriter) GetBufferedBytes() []byte { 34 | return w.buf.Bytes() 35 | } 36 | 37 | func (w *bufferedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 38 | h, ok := w.ResponseWriter.(http.Hijacker) 39 | if !ok { 40 | return nil, nil, errors.New("type assertion failed http.ResponseWriter not a http.Hijacker") 41 | } 42 | return h.Hijack() 43 | } 44 | 45 | func (w *bufferedResponseWriter) Flush() { 46 | f, ok := w.ResponseWriter.(http.Flusher) 47 | if !ok { 48 | return 49 | } 50 | 51 | f.Flush() 52 | } 53 | 54 | // Check interface implementations. 55 | var ( 56 | _ http.ResponseWriter = &bufferedResponseWriter{} 57 | _ http.Hijacker = &bufferedResponseWriter{} 58 | _ http.Flusher = &bufferedResponseWriter{} 59 | ) 60 | -------------------------------------------------------------------------------- /services/common.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "crypto/sha1" 5 | "fmt" 6 | "github.com/pkg/errors" 7 | "github.com/urfave/cli" 8 | "os" 9 | "path" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | OutputFlag = "output" 17 | ) 18 | 19 | func RegisterCommonFlags(f []cli.Flag) []cli.Flag { 20 | return append(f, cli.StringFlag{ 21 | Name: OutputFlag + ", o", 22 | Usage: "output (local path)", 23 | Value: "out", 24 | EnvVar: "OUTPUT", 25 | }) 26 | } 27 | 28 | func DistributeByHash(dirs []string, hash string) (string, error) { 29 | sort.Strings(dirs) 30 | hex := fmt.Sprintf("%x", sha1.Sum([]byte(hash)))[0:5] 31 | num64, err := strconv.ParseInt(hex, 16, 64) 32 | if err != nil { 33 | return "", errors.Wrapf(err, "failed to parse hex from hex=%v infohash=%v", hex, hash) 34 | } 35 | num := int(num64 * 1000) 36 | total := 1048575 * 1000 37 | interval := total / len(dirs) 38 | for i := 0; i < len(dirs); i++ { 39 | if num < (i+1)*interval { 40 | return dirs[i], nil 41 | } 42 | } 43 | return "", errors.Wrapf(err, "failed to distribute infohash=%v", hash) 44 | } 45 | 46 | func GetDir(location string, hash string) (string, error) { 47 | if strings.HasSuffix(location, "*") { 48 | prefix := strings.TrimSuffix(location, "*") 49 | dir, lp := path.Split(prefix) 50 | 51 | files, err := os.ReadDir(dir) 52 | if err != nil { 53 | return "", err 54 | } 55 | dirs := []string{} 56 | for _, f := range files { 57 | if f.IsDir() && strings.HasPrefix(f.Name(), lp) { 58 | dirs = append(dirs, f.Name()) 59 | } 60 | } 61 | if len(dirs) == 0 { 62 | return prefix + string(os.PathSeparator) + hash, nil 63 | } else if len(dirs) == 1 { 64 | return dir + dirs[0] + string(os.PathSeparator) + hash, nil 65 | } else { 66 | d, err := DistributeByHash(dirs, hash) 67 | if err != nil { 68 | return "", err 69 | } 70 | return dir + d + string(os.PathSeparator) + hash, nil 71 | } 72 | } else { 73 | return location + "/" + hash, nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /services/content_prober.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | u "net/url" 9 | "os" 10 | "os/exec" 11 | "syscall" 12 | "time" 13 | 14 | log "github.com/sirupsen/logrus" 15 | 16 | "github.com/pkg/errors" 17 | "github.com/urfave/cli" 18 | cp "github.com/webtor-io/content-prober/content-prober" 19 | "github.com/webtor-io/lazymap" 20 | "google.golang.org/grpc" 21 | ) 22 | 23 | const ( 24 | contentProberHostFlag = "content-prober-host" 25 | contentProberPortFlag = "content-prober-port" 26 | contentProberTimeoutFlag = "content-prober-timeout" 27 | ) 28 | 29 | func RegisterContentProberFlags(f []cli.Flag) []cli.Flag { 30 | return append(f, cli.StringFlag{ 31 | Name: contentProberHostFlag, 32 | Usage: "hostname of the content prober service", 33 | EnvVar: "CONTENT_PROBER_SERVICE_HOST", 34 | }, cli.IntFlag{ 35 | Name: contentProberPortFlag, 36 | Usage: "port of the content prober service", 37 | Value: 50051, 38 | EnvVar: "CONTENT_PROBER_SERVICE_PORT", 39 | }, cli.IntFlag{ 40 | Name: contentProberTimeoutFlag, 41 | Usage: "probe timeout in seconds", 42 | Value: 600, 43 | EnvVar: "CONTENT_PROBER_TIMEOUT", 44 | }) 45 | } 46 | 47 | type ContentProbe struct { 48 | lazymap.LazyMap[*cp.ProbeReply] 49 | host string 50 | port int 51 | timeout int 52 | } 53 | 54 | func NewContentProbe(c *cli.Context) *ContentProbe { 55 | return &ContentProbe{ 56 | host: c.String(contentProberHostFlag), 57 | port: c.Int(contentProberPortFlag), 58 | timeout: c.Int(contentProberTimeoutFlag), 59 | LazyMap: lazymap.New[*cp.ProbeReply](&lazymap.Config{ 60 | Expire: 30 * time.Minute, 61 | ErrorExpire: 10 * time.Second, 62 | }), 63 | } 64 | } 65 | 66 | func (s *ContentProbe) Get(input string, out string) (*cp.ProbeReply, error) { 67 | return s.LazyMap.Get(input+out, func() (*cp.ProbeReply, error) { 68 | return s.get(input, out) 69 | }) 70 | } 71 | 72 | func (s *ContentProbe) get(input string, out string) (pr *cp.ProbeReply, err error) { 73 | probeFilePath := out + "/index.json" 74 | 75 | // Check if the file already exists 76 | if _, err := os.Stat(probeFilePath); err == nil { 77 | // File exists, read its content 78 | fileContent, err := os.ReadFile(probeFilePath) 79 | if err != nil { 80 | return nil, errors.Wrap(err, "failed to read existing probe result") 81 | } 82 | // Unmarshal JSON content into ProbeReply 83 | pr = &cp.ProbeReply{} 84 | err = json.Unmarshal(fileContent, pr) 85 | if err != nil { 86 | return nil, errors.Wrap(err, "failed to unmarshal existing probe result") 87 | } 88 | log.WithField("file", probeFilePath).Info("using existing probe result") 89 | return pr, nil 90 | } 91 | 92 | // File does not exist, proceed with probing 93 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.timeout)*time.Second) 94 | defer cancel() 95 | if s.host == "" { 96 | pr, err = s.localProbe(ctx, input) 97 | } else { 98 | 99 | pr, err = s.remoteProbe(ctx, input) 100 | } 101 | 102 | if err != nil { 103 | return nil, errors.Wrap(err, "failed to probe") 104 | } 105 | 106 | json, err := json.Marshal(pr) 107 | 108 | if err != nil { 109 | return nil, errors.Wrap(err, "failed to convert probe result to json") 110 | } 111 | 112 | err = os.WriteFile(probeFilePath, json, 0644) 113 | 114 | if err != nil { 115 | return nil, errors.Wrap(err, "failed to write probe result") 116 | } 117 | 118 | return 119 | } 120 | 121 | func (s *ContentProbe) remoteProbe(ctx context.Context, input string) (*cp.ProbeReply, error) { 122 | addr := fmt.Sprintf("%s:%d", s.host, s.port) 123 | conn, err := grpc.Dial(addr, grpc.WithInsecure()) 124 | if err != nil { 125 | return nil, errors.Wrap(err, "failed to dial probing service") 126 | } 127 | defer func(conn *grpc.ClientConn) { 128 | _ = conn.Close() 129 | }(conn) 130 | cl := cp.NewContentProberClient(conn) 131 | 132 | req := cp.ProbeRequest{ 133 | Url: input, 134 | } 135 | log := log.WithField("request", req) 136 | log.Info("sending probing request") 137 | 138 | r, err := cl.Probe(ctx, &req) 139 | if err != nil { 140 | return nil, errors.Wrap(err, "probing failed") 141 | } 142 | log.WithField("reply", r).Info("got probing reply") 143 | 144 | return r, nil 145 | 146 | } 147 | 148 | func (s *ContentProbe) localProbe(ctx context.Context, input string) (*cp.ProbeReply, error) { 149 | done := make(chan error) 150 | ffprobe, err := exec.LookPath("ffprobe") 151 | if err != nil { 152 | return nil, errors.Wrap(err, "unable to find ffprobe") 153 | } 154 | parsedURL, err := u.Parse(input) 155 | if err != nil { 156 | return nil, errors.Wrap(err, "unable to parse url") 157 | } 158 | cmdText := fmt.Sprintf("%s -show_format -show_streams -print_format json '%s'", ffprobe, parsedURL.String()) 159 | log.WithField("cmd", cmdText).Info("running ffprobe command") 160 | cmd := exec.Command(ffprobe, "-show_format", "-show_streams", "-print_format", "json", parsedURL.String()) 161 | var bufOut bytes.Buffer 162 | var bufErr bytes.Buffer 163 | cmd.Stdout = &bufOut 164 | cmd.Stderr = &bufErr 165 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 166 | err = cmd.Start() 167 | if err != nil { 168 | return nil, errors.Wrap(err, "unable to start ffprobe") 169 | } 170 | 171 | go func() { done <- cmd.Wait() }() 172 | 173 | select { 174 | case <-ctx.Done(): 175 | syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 176 | return nil, errors.Wrap(ctx.Err(), "context done") 177 | case err := <-done: 178 | output := bufOut.String() 179 | stdErr := bufErr.String() 180 | if err != nil { 181 | return nil, errors.Wrapf(err, "probing failed with err=%v", stdErr) 182 | } 183 | var rep cp.ProbeReply 184 | err = json.Unmarshal([]byte(output), &rep) 185 | if err != nil { 186 | return nil, errors.Wrapf(err, "unable to unmarshal output=%v", output) 187 | } 188 | log.WithField("output", rep).Info("probing finished") 189 | return &rep, nil 190 | } 191 | 192 | } 193 | -------------------------------------------------------------------------------- /services/enrich_playlist_handler.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | re2 = regexp.MustCompile(`[asv][0-9]+(\-[0-9]+)?(\-[0-9]+)?\.[0-9a-z]{2,4}`) 15 | re3 = regexp.MustCompile(`(ts|vtt|\,)$`) 16 | ) 17 | 18 | func validatePlaylist(b []byte) bool { 19 | scanner := bufio.NewScanner(bytes.NewReader(b)) 20 | i := 0 21 | for scanner.Scan() { 22 | text := scanner.Text() 23 | i++ 24 | if text == "#EXT-X-ENDLIST" { 25 | return true 26 | } 27 | if i > 5 && !re3.Match([]byte(text)) { 28 | return false 29 | } 30 | } 31 | if i < 5 { 32 | return false 33 | } 34 | return true 35 | } 36 | 37 | func enrichPlaylist(b []byte, w http.ResponseWriter, r *http.Request) { 38 | var sb strings.Builder 39 | scanner := bufio.NewScanner(bytes.NewReader(b)) 40 | for scanner.Scan() { 41 | text := scanner.Text() 42 | if r.URL.RawQuery != "" { 43 | text = re2.ReplaceAllString(text, "$0?"+r.URL.RawQuery) 44 | } 45 | if text == "#EXT-X-MEDIA-SEQUENCE:0" { 46 | sb.WriteString("#EXT-X-PLAYLIST-TYPE:EVENT") 47 | sb.WriteRune('\n') 48 | } 49 | sb.WriteString(text) 50 | sb.WriteRune('\n') 51 | } 52 | w.Header().Set("Content-Length", fmt.Sprintf("%v", sb.Len())) 53 | w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") 54 | w.Write([]byte(sb.String())) 55 | 56 | if err := scanner.Err(); err != nil { 57 | log.Fatal(err) 58 | } 59 | } 60 | 61 | func enrichPlaylistHandler(next http.Handler) http.Handler { 62 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 | p := r.URL.Path 64 | 65 | if !strings.HasSuffix(r.URL.Path, ".m3u8") { 66 | next.ServeHTTP(w, r) 67 | return 68 | } 69 | 70 | wi := NewBufferedResponseWrtier(w) 71 | 72 | r.Header.Del("Range") 73 | 74 | next.ServeHTTP(wi, r) 75 | 76 | if wi.statusCode != http.StatusOK { 77 | w.WriteHeader(wi.statusCode) 78 | return 79 | } 80 | 81 | b := wi.GetBufferedBytes() 82 | 83 | if p != "/index.m3u8" && !validatePlaylist(b) { 84 | w.WriteHeader(http.StatusInternalServerError) 85 | } else { 86 | enrichPlaylist(b, w, r) 87 | } 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /services/hls.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "github.com/urfave/cli" 7 | cp "github.com/webtor-io/content-prober/content-prober" 8 | u "net/url" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | HLSAACCodecFlag = "hls-aac-codec" 15 | ) 16 | 17 | func RegisterHLSFlags(f []cli.Flag) []cli.Flag { 18 | return append(f, cli.StringFlag{ 19 | Name: HLSAACCodecFlag, 20 | Usage: "specify the hls aac codec", 21 | EnvVar: "HLS_AAC_CODEC", 22 | Value: "libfdk_aac", 23 | }) 24 | } 25 | 26 | type Rendition struct { 27 | Height uint 28 | DefRate uint 29 | Required bool 30 | } 31 | 32 | func (s *Rendition) adaptRate(h uint, hl uint, hh uint, bl uint, bh uint) uint { 33 | if h == hl { 34 | return bl 35 | } 36 | if h == hh { 37 | return bh 38 | } 39 | return uint(float64(h-hl)/float64(hh-hl)*float64(bh-bl)) + bl 40 | 41 | } 42 | 43 | // https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate 44 | var DefaultRenditions = []Rendition{ 45 | { 46 | Height: 240, 47 | DefRate: 500, 48 | Required: true, 49 | }, 50 | { 51 | Height: 360, 52 | DefRate: 1000, 53 | Required: true, 54 | }, 55 | { 56 | Height: 480, 57 | DefRate: 2500, 58 | }, 59 | { 60 | Height: 720, 61 | DefRate: 5000, 62 | }, 63 | { 64 | Height: 1080, 65 | DefRate: 8000, 66 | }, 67 | } 68 | 69 | func (s *Rendition) Rate() uint { 70 | h := s.Height 71 | for ri := range DefaultRenditions { 72 | if h <= DefaultRenditions[ri].Height { 73 | var hl, bl uint 74 | if ri != 0 { 75 | hl, bl = DefaultRenditions[ri-1].Height, DefaultRenditions[ri-1].DefRate 76 | } 77 | return s.adaptRate(h, hl, DefaultRenditions[ri].Height, bl, DefaultRenditions[ri].DefRate) 78 | } 79 | } 80 | return DefaultRenditions[len(DefaultRenditions)-1].DefRate 81 | } 82 | 83 | type StreamMode int 84 | 85 | const ( 86 | Online StreamMode = 0 87 | MultiBitrate StreamMode = 1 88 | ) 89 | 90 | type StreamType string 91 | 92 | const ( 93 | Audio StreamType = "a" 94 | Video StreamType = "v" 95 | Subtitle StreamType = "s" 96 | ) 97 | 98 | type HLS struct { 99 | in string 100 | primary []*HLSStream 101 | video []*HLSStream 102 | audio []*HLSStream 103 | subs []*HLSStream 104 | cfg *HLSConfig 105 | } 106 | 107 | func (h *HLS) GetFFmpegParams(out string) ([]string, error) { 108 | 109 | parsedURL, err := u.Parse(h.in) 110 | if err != nil { 111 | return nil, errors.Wrap(err, "Unable to parse url") 112 | } 113 | if h.primary[0].s.GetCodecType() == "video" { 114 | // if h.primary.s.GetCodecName() == "hevc" { 115 | // return nil, errors.Errorf("hevc codec is not supported") 116 | // } 117 | if h.primary[0].s.GetHeight() > 1080 { 118 | return nil, errors.Errorf("resoulution over 1080p is not supported") 119 | } 120 | } 121 | params := []string{} 122 | // if h.sm == Online { 123 | // params = append(params, "-re") 124 | // } 125 | params = append(params, 126 | "-i", parsedURL.String(), 127 | // "-err_detect", "ignore_err", 128 | // "-reconnect_at_eof", "1", 129 | "-xerror", 130 | "-seekable", "1", 131 | ) 132 | for _, s := range h.primary { 133 | params = append(params, s.GetFFmpegParams(out)...) 134 | } 135 | for _, s := range h.audio { 136 | params = append(params, s.GetFFmpegParams(out)...) 137 | } 138 | for _, s := range h.subs { 139 | params = append(params, s.GetFFmpegParams(out)...) 140 | } 141 | return params, nil 142 | } 143 | 144 | type HLSStream struct { 145 | index int 146 | st StreamType 147 | s *cp.Stream 148 | r *Rendition 149 | force bool 150 | cfg *HLSConfig 151 | } 152 | 153 | func (h *HLSStream) GetPlaylistPath(out string) string { 154 | return fmt.Sprintf("%v/%v", out, h.GetPlaylistName()) 155 | } 156 | 157 | func (h *HLSStream) GetPlaylistName() string { 158 | if h.r != nil { 159 | return fmt.Sprintf("%v%v-%v.m3u8", h.st, h.index, h.r.Height) 160 | } else { 161 | return fmt.Sprintf("%v%v.m3u8", h.st, h.index) 162 | } 163 | } 164 | 165 | func (h *HLSStream) GetSegmentFormat() string { 166 | if h.st == Subtitle { 167 | return "webvtt" 168 | } 169 | return "mpegts" 170 | } 171 | 172 | func (h *HLSStream) GetCodecParams() []string { 173 | params := []string{ 174 | fmt.Sprintf("-c:%v", h.st), 175 | } 176 | if h.st == Video && (h.force || h.s.GetCodecName() != "h264") { 177 | params = append( 178 | params, 179 | "h264", 180 | "-vf", fmt.Sprintf("scale=-2:%v", h.r.Height), 181 | "-profile:v", "high", 182 | "-preset", "veryfast", 183 | "-g", "48", "-keyint_min", "48", 184 | "-crf", "20", 185 | "-sc_threshold", "0", 186 | "-b:v", fmt.Sprintf("%vK", h.r.Rate()), 187 | "-maxrate", fmt.Sprintf("%vK", uint(float64(h.r.Rate())*1.3)), 188 | "-bufsize", fmt.Sprintf("%vK", uint(float64(h.r.Rate())*1.5)), 189 | "-pix_fmt", "yuv420p", 190 | ) 191 | } else if h.st == Audio && (h.s.GetCodecName() != "aac" || h.s.GetChannels() > 2) { 192 | params = append( 193 | params, 194 | h.cfg.aacCodec, 195 | "-ac", "2", 196 | ) 197 | } else if h.st == Subtitle && h.s.GetCodecName() != "webvtt" { 198 | params = append(params, "webvtt") 199 | } else { 200 | params = append(params, "copy") 201 | } 202 | return params 203 | } 204 | 205 | func (h *HLSStream) GetFFmpegParams(out string) []string { 206 | 207 | params := []string{ 208 | "-map", fmt.Sprintf("0:%v:%v", h.st, h.index), 209 | "-f", "segment", 210 | "-segment_time", "4", 211 | "-segment_list_type", "hls", 212 | "-segment_list", h.GetPlaylistPath(out), 213 | "-muxdelay", "0", 214 | "-segment_format", h.GetSegmentFormat(), 215 | } 216 | 217 | params = append(params, h.GetCodecParams()...) 218 | if h.r != nil { 219 | params = append(params, fmt.Sprintf("%v/%v%v-%v-%%d.%v", out, h.st, h.index, h.r.Height, h.GetSegmentExtension())) 220 | } else { 221 | params = append(params, fmt.Sprintf("%v/%v%v-%%d.%v", out, h.st, h.index, h.GetSegmentExtension())) 222 | } 223 | 224 | return params 225 | } 226 | func (h *HLSStream) GetSegmentExtension() string { 227 | if h.st == Subtitle { 228 | return "vtt" 229 | } 230 | return "ts" 231 | } 232 | 233 | func (h *HLSStream) GetName() string { 234 | n := "Track" 235 | if h.st == Subtitle { 236 | n = "Subtitle" 237 | } 238 | name := fmt.Sprintf("%v #%v", n, h.index+1) 239 | if title, ok := h.s.Tags["title"]; ok { 240 | name = title 241 | } 242 | if lang, ok := h.s.Tags["language"]; ok { 243 | name = name + fmt.Sprintf(" (%v)", lang) 244 | } 245 | return name 246 | } 247 | 248 | func (h *HLSStream) GetLanguage() string { 249 | lang := "eng" 250 | if title, ok := h.s.Tags["language"]; ok { 251 | lang = title 252 | } 253 | return lang 254 | } 255 | 256 | func (h *HLSStream) MakeMasterPlaylist() string { 257 | t := "AUDIO" 258 | if h.st == Subtitle { 259 | t = "SUBTITLES" 260 | } 261 | extra := "" 262 | if h.st == Audio && h.index == 0 { 263 | extra = ",AUTOSELECT=YES,DEFAULT=YES" 264 | } 265 | return fmt.Sprintf( 266 | `#EXT-X-MEDIA:TYPE=%v,GROUP-ID="%v",LANGUAGE="%v",NAME="%v"%v,URI="%v"`, 267 | t, strings.ToLower(t), h.GetLanguage(), h.GetName(), extra, h.GetPlaylistName(), 268 | ) 269 | } 270 | 271 | func NewHLSStream(index int, st StreamType, s *cp.Stream, r *Rendition, cfg *HLSConfig, force bool) *HLSStream { 272 | return &HLSStream{ 273 | index: index, 274 | st: st, 275 | s: s, 276 | r: r, 277 | cfg: cfg, 278 | force: force, 279 | } 280 | } 281 | 282 | func (s *HLS) getNextRendition(height uint) *Rendition { 283 | for ri := range DefaultRenditions { 284 | if height < DefaultRenditions[ri].Height { 285 | return &DefaultRenditions[ri] 286 | } 287 | } 288 | return nil 289 | } 290 | 291 | func (s *HLS) getRenditions(height uint) []Rendition { 292 | if height > DefaultRenditions[len(DefaultRenditions)-1].Height { 293 | height = DefaultRenditions[len(DefaultRenditions)-1].Height 294 | } 295 | rs := []Rendition{} 296 | for ri := range DefaultRenditions { 297 | if height >= DefaultRenditions[ri].Height { 298 | rs = append(rs, DefaultRenditions[ri]) 299 | } 300 | } 301 | if rs[len(rs)-1].Height < height { 302 | ex := float64(height-rs[len(rs)-1].Height) / float64(s.getNextRendition(height).Height-rs[len(rs)-1].Height) 303 | if !rs[len(rs)-1].Required && ex < 0.3 { 304 | rs = rs[:len(rs)-1] 305 | } 306 | rs = append(rs, Rendition{Height: height}) 307 | } 308 | return rs 309 | } 310 | 311 | func NewHLS(in string, probe *cp.ProbeReply, cfg *HLSConfig) *HLS { 312 | h := &HLS{ 313 | in: in, 314 | video: []*HLSStream{}, 315 | audio: []*HLSStream{}, 316 | subs: []*HLSStream{}, 317 | cfg: cfg, 318 | } 319 | vi := 0 320 | ai := 0 321 | si := 0 322 | for _, s := range probe.GetStreams() { 323 | if s.GetCodecType() == "video" && s.GetCodecName() != "mjpeg" && s.GetCodecName() != "png" && vi < 1 { 324 | if cfg.sm == Online { 325 | h.video = append(h.video, NewHLSStream(vi, Video, s, &Rendition{Height: uint(s.GetHeight())}, cfg, false)) 326 | } else if cfg.sm == MultiBitrate { 327 | rs := h.getRenditions(uint(s.GetHeight())) 328 | for ri := range rs { 329 | h.video = append(h.video, NewHLSStream(vi, Video, s, &rs[ri], cfg, true)) 330 | } 331 | if len(h.video) == 0 { 332 | h.video = append(h.video, NewHLSStream(vi, Video, s, &Rendition{ 333 | Height: uint(s.GetHeight()), 334 | }, cfg, true)) 335 | } 336 | } 337 | vi++ 338 | } else if s.GetCodecType() == "audio" { 339 | h.audio = append(h.audio, NewHLSStream(ai, Audio, s, nil, cfg, false)) 340 | ai++ 341 | } else if s.GetCodecType() == "subtitle" && s.GetCodecName() != "hdmv_pgs_subtitle" { 342 | h.subs = append(h.subs, NewHLSStream(si, Subtitle, s, nil, cfg, false)) 343 | si++ 344 | } 345 | } 346 | if len(h.video) > 0 { 347 | h.primary = h.video 348 | } else if len(h.audio) > 0 { 349 | h.primary = []*HLSStream{h.audio[0]} 350 | h.audio = []*HLSStream{} 351 | h.subs = []*HLSStream{} 352 | } 353 | return h 354 | } 355 | 356 | func (s *HLS) MakeMasterPlaylist(out string) error { 357 | var res strings.Builder 358 | res.WriteString("#EXTM3U\n") 359 | for _, a := range s.audio { 360 | res.WriteString(fmt.Sprintln(a.MakeMasterPlaylist())) 361 | } 362 | for _, su := range s.subs { 363 | res.WriteString(fmt.Sprintln(su.MakeMasterPlaylist())) 364 | } 365 | for _, p := range s.primary { 366 | var rate uint = 1 367 | if p.r != nil { 368 | rate = p.r.Rate() * 1000 369 | } 370 | res.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=%v,CODECS=\"avc1.42e00a,mp4a.40.2\"", rate)) 371 | if len(s.audio) > 0 { 372 | res.WriteString(`,AUDIO="audio"`) 373 | } 374 | if len(s.subs) > 0 { 375 | res.WriteString(`,SUBTITLES="subtitles"`) 376 | } 377 | res.WriteRune('\n') 378 | res.WriteString(p.GetPlaylistName()) 379 | res.WriteRune('\n') 380 | } 381 | return os.WriteFile(out+"/index.m3u8", []byte(res.String()), 0644) 382 | } 383 | 384 | type HLSBuilder struct { 385 | aacCodec string 386 | } 387 | 388 | type HLSConfig struct { 389 | sm StreamMode 390 | aacCodec string 391 | } 392 | 393 | func NewHLSBuilder(c *cli.Context) *HLSBuilder { 394 | return &HLSBuilder{ 395 | aacCodec: c.String(HLSAACCodecFlag), 396 | } 397 | } 398 | 399 | func (s *HLSBuilder) Build(in string, probe *cp.ProbeReply) *HLS { 400 | return NewHLS(in, probe, &HLSConfig{ 401 | sm: Online, 402 | aacCodec: s.aacCodec, 403 | }) 404 | } 405 | -------------------------------------------------------------------------------- /services/touch_map.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/webtor-io/lazymap" 8 | ) 9 | 10 | type TouchMap struct { 11 | lazymap.LazyMap[bool] 12 | } 13 | 14 | func NewTouchMap() *TouchMap { 15 | return &TouchMap{ 16 | LazyMap: lazymap.New[bool](&lazymap.Config{ 17 | Expire: 30 * time.Second, 18 | }), 19 | } 20 | } 21 | 22 | func (s *TouchMap) touch(path string) (bool, error) { 23 | f := path + ".touch" 24 | _, err := os.Stat(f) 25 | if os.IsNotExist(err) { 26 | file, err := os.Create(f) 27 | if err != nil { 28 | return false, err 29 | } 30 | defer func(file *os.File) { 31 | _ = file.Close() 32 | }(file) 33 | } else { 34 | currentTime := time.Now().Local() 35 | err = os.Chtimes(f, currentTime, currentTime) 36 | if err != nil { 37 | return false, err 38 | } 39 | } 40 | return true, nil 41 | } 42 | 43 | func (s *TouchMap) Touch(path string) (bool, error) { 44 | return s.LazyMap.Get(path, func() (bool, error) { 45 | return s.touch(path) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /services/transcode_pool.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/webtor-io/lazymap" 6 | "os" 7 | "path" 8 | "time" 9 | ) 10 | 11 | type TranscodePool struct { 12 | lazymap.LazyMap[bool] 13 | } 14 | 15 | func NewTranscodePool() *TranscodePool { 16 | return &TranscodePool{ 17 | LazyMap: lazymap.New[bool](&lazymap.Config{ 18 | Expire: 30 * time.Minute, 19 | StoreErrors: false, 20 | }), 21 | } 22 | } 23 | 24 | func (s *TranscodePool) IsDone(out string) bool { 25 | marker := path.Join(out, "done") 26 | log.Infof("checking if done marker %s exists", marker) 27 | if _, err := os.Stat(marker); err == nil { 28 | log.Infof("%s exists", marker) 29 | return true 30 | } else { 31 | log.WithError(err).Errorf("failed to check done marker %s", marker) 32 | } 33 | return false 34 | } 35 | 36 | func (s *TranscodePool) IsTranscoding(out string) bool { 37 | _, st := s.LazyMap.Status(out) 38 | return st 39 | } 40 | 41 | func (s *TranscodePool) Transcode(out string, h *HLS) error { 42 | _, err := s.LazyMap.Get(out, func() (bool, error) { 43 | tr := NewTranscoder(out, h) 44 | err := tr.Run() 45 | if err != nil { 46 | return false, err 47 | } 48 | return true, nil 49 | }) 50 | return err 51 | } 52 | -------------------------------------------------------------------------------- /services/transcoder.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "path/filepath" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/pkg/errors" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | type Transcoder struct { 19 | cmd *exec.Cmd 20 | h *HLS 21 | out string 22 | } 23 | 24 | func NewTranscoder(out string, h *HLS) *Transcoder { 25 | return &Transcoder{ 26 | out: out, 27 | h: h, 28 | } 29 | } 30 | 31 | func (s *Transcoder) Stop() error { 32 | if s.cmd != nil { 33 | log.Infof("killing ffmpeg with pid %d", s.cmd.Process.Pid) 34 | _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL) 35 | } 36 | return nil 37 | } 38 | 39 | func (s *Transcoder) isHLSIndexFinished(str *HLSStream) (bool, error) { 40 | path := filepath.Join(s.out, str.GetPlaylistName()) 41 | 42 | if _, err := os.Stat(path); os.IsNotExist(err) { 43 | return false, nil 44 | } else if err != nil { 45 | return false, err 46 | } 47 | 48 | file, err := os.Open(path) 49 | if err != nil { 50 | return false, err 51 | } 52 | defer func(file *os.File) { 53 | _ = file.Close() 54 | }(file) 55 | 56 | allSegmentsExist := true 57 | foundEndlist := false 58 | 59 | scanner := bufio.NewScanner(file) 60 | for scanner.Scan() { 61 | line := strings.TrimSpace(scanner.Text()) 62 | 63 | if line == "#EXT-X-ENDLIST" { 64 | foundEndlist = true 65 | break 66 | } 67 | 68 | if strings.HasPrefix(line, "#") { 69 | continue 70 | } 71 | 72 | segmentPath := filepath.Join(s.out, line) 73 | if _, err = os.Stat(segmentPath); os.IsNotExist(err) { 74 | allSegmentsExist = false 75 | break 76 | } 77 | } 78 | 79 | if err = scanner.Err(); err != nil { 80 | return false, err 81 | } 82 | 83 | return foundEndlist && allSegmentsExist, nil 84 | } 85 | 86 | func (s *Transcoder) isFinished() (bool, error) { 87 | for _, stream := range s.h.video { 88 | ok, err := s.isHLSIndexFinished(stream) 89 | if err != nil { 90 | return false, err 91 | } 92 | if !ok { 93 | return false, nil 94 | } 95 | } 96 | for _, stream := range s.h.audio { 97 | ok, err := s.isHLSIndexFinished(stream) 98 | if err != nil { 99 | return false, err 100 | } 101 | if !ok { 102 | return false, nil 103 | } 104 | } 105 | for _, stream := range s.h.subs { 106 | ok, err := s.isHLSIndexFinished(stream) 107 | if err != nil { 108 | return false, err 109 | } 110 | if !ok { 111 | return false, nil 112 | } 113 | } 114 | return true, nil 115 | } 116 | 117 | func (s *Transcoder) Run() (err error) { 118 | ffmpeg, err := exec.LookPath("ffmpeg") 119 | if err != nil { 120 | return errors.Wrap(err, "failed to find ffmpeg") 121 | } 122 | 123 | params, err := s.h.GetFFmpegParams(s.out) 124 | if err != nil { 125 | return errors.Wrap(err, "failed to get ffmpeg params") 126 | } 127 | 128 | log.Infof("got ffmpeg params %-v", params) 129 | 130 | s.cmd = exec.Command(ffmpeg, params...) 131 | 132 | outLog, err := os.Create(fmt.Sprintf("%v/%v", s.out, "ffmpeg.out")) 133 | if err != nil { 134 | return errors.Wrapf(err, "failed create %v", fmt.Sprintf("%v/%v", s.out, "ffmpeg.out")) 135 | } 136 | defer func(outLog *os.File) { 137 | _ = outLog.Close() 138 | }(outLog) 139 | 140 | errLog, err := os.Create(fmt.Sprintf("%v/%v", s.out, "ffmpeg.err")) 141 | if err != nil { 142 | return errors.Wrapf(err, "failed create %v", fmt.Sprintf("%v/%v", s.out, "ffmpeg.out")) 143 | } 144 | defer func(errLog *os.File) { 145 | _ = errLog.Close() 146 | }(errLog) 147 | 148 | s.cmd.Stdout = io.MultiWriter(os.Stdout, outLog) 149 | s.cmd.Stderr = io.MultiWriter(os.Stderr, errLog) 150 | s.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 151 | err = s.cmd.Start() 152 | if err != nil { 153 | return errors.Wrap(err, "failed to start ffmpeg") 154 | } 155 | log.Info("starting Transcoder") 156 | sigs := make(chan os.Signal, 1) 157 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 158 | done := make(chan any, 1) 159 | go func() { 160 | select { 161 | case <-sigs: 162 | s.Stop() 163 | return 164 | case <-done: 165 | return 166 | } 167 | }() 168 | 169 | err = s.cmd.Wait() 170 | if err != nil { 171 | log.WithError(err).Warn("got error while transcoding") 172 | } 173 | log.Info("transcoding finished") 174 | close(done) 175 | ok, err := s.isFinished() 176 | if err != nil { 177 | return errors.Wrap(err, "failed to check if ffmpeg finished") 178 | } 179 | if !ok { 180 | return errors.New("ffmpeg not finished") 181 | } 182 | err = os.WriteFile(fmt.Sprintf("%v/%v", s.out, "done"), []byte{}, 0644) 183 | if err != nil { 184 | return errors.Wrap(err, "failed to put done marker") 185 | } 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /services/web.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "fmt" 8 | "html/template" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "strings" 14 | "time" 15 | 16 | "github.com/pkg/errors" 17 | log "github.com/sirupsen/logrus" 18 | "github.com/urfave/cli" 19 | ) 20 | 21 | const ( 22 | webHostFlag = "host" 23 | webPortFlag = "port" 24 | webPlayerFlag = "player" 25 | ) 26 | 27 | func RegisterWebFlags(f []cli.Flag) []cli.Flag { 28 | return append(f, cli.StringFlag{ 29 | Name: webHostFlag + ", H", 30 | Usage: "host", 31 | Value: "", 32 | EnvVar: "WEB_HOST", 33 | }, cli.IntFlag{ 34 | Name: webPortFlag + ", P", 35 | Usage: "port", 36 | Value: 8080, 37 | EnvVar: "WEB_PORT", 38 | }, cli.BoolFlag{ 39 | Name: webPlayerFlag, 40 | Usage: "player", 41 | EnvVar: "PLAYER", 42 | }) 43 | } 44 | 45 | type Web struct { 46 | host string 47 | port int 48 | player bool 49 | output string 50 | handler http.Handler 51 | ln net.Listener 52 | contentProbe *ContentProbe 53 | transcodePool *TranscodePool 54 | touchMap *TouchMap 55 | hlsBuilder *HLSBuilder 56 | } 57 | 58 | func NewWeb(c *cli.Context, contentProbe *ContentProbe, hlsBuilder *HLSBuilder, transcodePool *TranscodePool, touchMap *TouchMap) *Web { 59 | we := &Web{ 60 | host: c.String(webHostFlag), 61 | port: c.Int(webPortFlag), 62 | player: c.Bool(webPlayerFlag), 63 | output: c.String(OutputFlag), 64 | contentProbe: contentProbe, 65 | transcodePool: transcodePool, 66 | touchMap: touchMap, 67 | hlsBuilder: hlsBuilder, 68 | } 69 | we.buildHandler() 70 | return we 71 | } 72 | 73 | func getSourceURL(r *http.Request) string { 74 | if r.Header.Get("X-Source-Url") != "" { 75 | return r.Header.Get("X-Source-Url") 76 | } else if r.URL.Query().Get("source_url") != "" { 77 | return r.URL.Query().Get("source_url") 78 | } 79 | return "" 80 | } 81 | 82 | type PlayerData struct { 83 | SourceURL string 84 | } 85 | 86 | func (s *Web) playerHandler(w http.ResponseWriter, r *http.Request) { 87 | path := r.URL.Path 88 | if path == "/player/" { 89 | path = "/player/index.html" 90 | } 91 | path = strings.TrimPrefix(path, "/") 92 | if _, err := os.Stat(path); os.IsNotExist(err) { 93 | http.Error(w, "template file not found", http.StatusNotFound) 94 | return 95 | } 96 | tmpl, err := template.ParseFiles(path) 97 | if err != nil { 98 | http.Error(w, "unable to load template", http.StatusInternalServerError) 99 | return 100 | } 101 | err = tmpl.Execute(w, &PlayerData{ 102 | SourceURL: getSourceURL(r), 103 | }) 104 | if err != nil { 105 | http.Error(w, "unable to render template", http.StatusInternalServerError) 106 | return 107 | } 108 | } 109 | 110 | func (s *Web) transcode(input string, output string) error { 111 | err := os.MkdirAll(output, 0755) 112 | if err != nil { 113 | return err 114 | } 115 | if s.transcodePool.IsDone(output) || s.transcodePool.IsTranscoding(output) { 116 | return nil 117 | } 118 | pr, err := s.contentProbe.Get(input, output) 119 | if err != nil { 120 | return err 121 | } 122 | hls := s.hlsBuilder.Build(input, pr) 123 | err = hls.MakeMasterPlaylist(output) 124 | if err != nil { 125 | return err 126 | } 127 | go func() { 128 | err = s.transcodePool.Transcode(output, hls) 129 | if err != nil { 130 | log.Error(err) 131 | } 132 | }() 133 | return nil 134 | } 135 | 136 | func (s *Web) transcodeHandler(next http.Handler) http.Handler { 137 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 138 | if r.URL.Path != "/index.m3u8" { 139 | next.ServeHTTP(w, r) 140 | return 141 | } 142 | sourceURL := r.Context().Value(SourceURLContext).(string) 143 | out := r.Context().Value(OutputDirContext).(string) 144 | err := s.transcode(sourceURL, out) 145 | if err != nil { 146 | http.Error(w, "failed to start transcode", http.StatusInternalServerError) 147 | return 148 | } 149 | next.ServeHTTP(w, r) 150 | }) 151 | } 152 | 153 | func (s *Web) buildHandler() { 154 | mux := http.NewServeMux() 155 | if s.player { 156 | mux.HandleFunc("/player/", s.playerHandler) 157 | } 158 | var h http.Handler 159 | h = fileHandler() 160 | h = s.transcodeHandler(h) 161 | h = enrichPlaylistHandler(h) 162 | h = s.waitHandler(h) 163 | h = allowCORSHandler(h) 164 | h = s.touchHandler(h) 165 | h = s.doneHandler(h) 166 | h = setContextHandler(h, s.output) 167 | 168 | mux.Handle("/", h) 169 | s.handler = mux 170 | } 171 | 172 | type contextKey int 173 | 174 | const ( 175 | OutputDirContext contextKey = iota 176 | SourceURLContext 177 | ) 178 | 179 | func setContextHandler(next http.Handler, output string) http.Handler { 180 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 181 | h := sha1.New() 182 | sourceURL := getSourceURL(r) 183 | if sourceURL == "" { 184 | log.Error("empty source url") 185 | http.Error(w, "empty source url", http.StatusBadRequest) 186 | return 187 | } 188 | u, err := url.Parse(sourceURL) 189 | if err != nil { 190 | log.WithError(err).WithField("source_url", sourceURL).Error("unable to parse source url") 191 | http.Error(w, "failed to parse source url", http.StatusInternalServerError) 192 | return 193 | } 194 | h.Write([]byte(u.Path)) 195 | hash := hex.EncodeToString(h.Sum(nil)) 196 | dir, err := GetDir(output, hash) 197 | if err != nil { 198 | log.WithError(err).WithField("hash", hash).Error("unable to get dir") 199 | http.Error(w, "failed to get output dir", http.StatusInternalServerError) 200 | return 201 | } 202 | ctx := context.WithValue(r.Context(), OutputDirContext, dir) 203 | ctx = context.WithValue(ctx, SourceURLContext, sourceURL) 204 | 205 | r = r.WithContext(ctx) 206 | next.ServeHTTP(w, r) 207 | }) 208 | } 209 | 210 | func (s *Web) Serve() error { 211 | addr := fmt.Sprintf("%s:%d", s.host, s.port) 212 | ln, err := net.Listen("tcp", addr) 213 | if err != nil { 214 | return errors.Wrap(err, "failed to bind address") 215 | } 216 | s.ln = ln 217 | log.Infof("serving Web at %v", addr) 218 | if s.player { 219 | log.Info(fmt.Sprintf("player available at http://%v/player/", addr)) 220 | } 221 | return http.Serve(ln, s.handler) 222 | } 223 | 224 | func (s *Web) Close() { 225 | log.Info("closing Web") 226 | defer func() { 227 | log.Info("Web closed") 228 | }() 229 | if s.ln != nil { 230 | _ = s.ln.Close() 231 | } 232 | } 233 | 234 | func (s *Web) waitHandler(next http.Handler) http.Handler { 235 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 236 | if !strings.HasSuffix(r.URL.Path, ".m3u8") || r.URL.Path == "/index.m3u8" { 237 | next.ServeHTTP(w, r) 238 | return 239 | } 240 | 241 | for { 242 | if r.Context().Err() != nil { 243 | return 244 | } 245 | wi := NewBufferedResponseWrtier(w) 246 | r.Header.Del("Range") 247 | next.ServeHTTP(wi, r) 248 | b := wi.GetBufferedBytes() 249 | if wi.statusCode == http.StatusOK || wi.statusCode == http.StatusNotModified { 250 | w.Header().Set("Content-Length", fmt.Sprintf("%v", len(b))) 251 | w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") 252 | _, _ = w.Write(b) 253 | return 254 | } 255 | <-time.After(500 * time.Millisecond) 256 | } 257 | 258 | }) 259 | } 260 | 261 | func (s *Web) touchHandler(next http.Handler) http.Handler { 262 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 263 | out := r.Context().Value(OutputDirContext).(string) 264 | _, _ = s.touchMap.Touch(out) 265 | next.ServeHTTP(w, r) 266 | }) 267 | } 268 | 269 | func (s *Web) doneHandler(next http.Handler) http.Handler { 270 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 271 | if _, ok := r.URL.Query()["done"]; ok { 272 | out := r.Context().Value(OutputDirContext).(string) 273 | if !s.transcodePool.IsDone(out) { 274 | w.WriteHeader(http.StatusNotFound) 275 | } 276 | } else { 277 | next.ServeHTTP(w, r) 278 | } 279 | }) 280 | 281 | } 282 | 283 | func fileHandler() http.Handler { 284 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 285 | out := r.Context().Value(OutputDirContext).(string) 286 | d := http.Dir(out) 287 | fs := http.FileServer(d) 288 | fs.ServeHTTP(w, r) 289 | }) 290 | } 291 | --------------------------------------------------------------------------------