├── .env ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Makefile ├── README.md ├── assets ├── docs.png └── stream.mp4 ├── cmd ├── discovery.go ├── origin.go ├── proxy.go ├── reporter.go ├── root.go ├── server1.go ├── server2.go └── worker.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── client │ └── client.go ├── config │ └── config.go ├── discovery │ └── discovery.go ├── mimetype │ └── mimetype.go ├── origin │ ├── api.go │ └── origin.go ├── proxy │ └── proxy.go ├── reporter │ ├── api.go │ └── reporter.go ├── server │ └── server.go ├── sources │ └── sources.go └── worker │ ├── command.go │ └── execute.go ├── main.go └── script.sh /.env: -------------------------------------------------------------------------------- 1 | INPUT_STREAM_PATH=assets/stream.mp4 2 | DISCOVERY_RUN_FREQUENCY=5s 3 | LIVE_SIGNAL_NAME=live01 4 | LOCAL_HOST=http://localhost 5 | MAX_AGE_PLAYLIST=10s 6 | OUTPUT_STREAM_PATH=output 7 | SERVER_ONE_PORT=8080 8 | SERVER_TWO_PORT=9090 9 | REPORTER_PORT=2000 10 | ORIGIN_PORT=8001 11 | PROXY_PORT=8002 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /output -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | timeout: 2m 4 | tests: false 5 | output: 6 | formats: 7 | text: 8 | print-linter-name: true 9 | print-issued-lines: true 10 | colors: true 11 | issues: 12 | uniq-by-line: true 13 | linters: 14 | enable: 15 | - bodyclose 16 | - copyloopvar 17 | - dogsled 18 | - dupl 19 | - errcheck 20 | - exhaustive 21 | - goconst 22 | - gocritic 23 | - gocyclo 24 | - gosec 25 | - govet 26 | - ineffassign 27 | - misspell 28 | - nakedret 29 | - nolintlint 30 | - prealloc 31 | - predeclared 32 | - staticcheck 33 | - thelper 34 | - tparallel 35 | - unconvert 36 | - unparam 37 | - unused 38 | - whitespace 39 | settings: 40 | dupl: 41 | threshold: 250 42 | errcheck: 43 | check-type-assertions: true 44 | goconst: 45 | min-len: 2 46 | min-occurrences: 3 47 | gocritic: 48 | disabled-checks: 49 | - hugeParam 50 | - importShadow 51 | enabled-tags: 52 | - diagnostic 53 | - experimental 54 | - opinionated 55 | - performance 56 | - style 57 | nolintlint: 58 | require-explanation: true 59 | require-specific: true 60 | exclusions: 61 | generated: lax 62 | presets: 63 | - comments 64 | - common-false-positives 65 | - legacy 66 | - std-error-handling 67 | paths: 68 | - mocks 69 | - third_party$ 70 | - builtin$ 71 | - examples$ 72 | formatters: 73 | enable: 74 | - gofmt 75 | - goimports 76 | exclusions: 77 | generated: lax 78 | paths: 79 | - mocks 80 | - third_party$ 81 | - builtin$ 82 | - examples$ 83 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS builder 2 | WORKDIR /src 3 | COPY . . 4 | RUN CGO_ENABLED=0 go build -o go-live main.go 5 | 6 | FROM linuxserver/ffmpeg 7 | WORKDIR /app 8 | COPY --from=builder /src/ /app/ 9 | 10 | ENTRYPOINT [ "./go-live" ] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | golangci-lint run -v 3 | @echo "DONE ✅" 4 | 5 | clean: 6 | rm -r output/* 7 | 8 | build: 9 | docker build -t go-live . 10 | 11 | run-worker: 12 | docker run -ti --rm --network host -v OutputVolume:/app go-live worker 13 | 14 | run-server1: 15 | docker run -ti --rm --network host -v OutputVolume:/app go-live server1 16 | 17 | run-server2: 18 | docker run -ti --rm --network host -v OutputVolume:/app go-live server2 19 | 20 | run-discovery: 21 | docker run -ti --rm --network host -v OutputVolume:/app go-live discovery 22 | 23 | run-reporter: 24 | docker run -ti --rm --network host -v OutputVolume:/app go-live reporter 25 | 26 | run-origin: 27 | docker run -ti --rm --network host -v OutputVolume:/app go-live origin 28 | 29 | run-proxy: 30 | docker run -ti --rm --network host -v OutputVolume:/app go-live proxy 31 | 32 | run-local-worker: 33 | go run main.go worker 34 | 35 | run-local-server1: 36 | go run main.go server1 37 | 38 | run-local-server2: 39 | go run main.go server2 40 | 41 | run-local-discovery: 42 | go run main.go discovery 43 | 44 | run-local-reporter: 45 | go run main.go reporter 46 | 47 | run-local-origin: 48 | go run main.go origin 49 | 50 | run-local-proxy: 51 | go run main.go proxy -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Live Streaming Without Downtime (and with Go) 2 | 3 | The true purpose of this project is to set up a zero downtime strategy for a Live Stream event. 4 | 5 | We seek to emulate a live streaming platform architecture, as close to a real one as possible, like in the picture below - all using Golang (and a bit of FFMPEG). 6 | 7 | ![Live Streaming Architecture](assets/docs.png) 8 | 9 | ## Publishing the Live Stream 10 | 11 | First, For this, we use FFMPEG + Golang to produce an HLS playlist and publish it in two separate HTTP servers. This is done by running three different containers: 12 | 13 | - **worker** - Builds and runs the FFMPEG command to generate a live stream output, whose manifest and segments are stored in the folder given by the `OUTPUT_STREAM_PATH` env variable. The input MP4 video used by the `worker` service to generate the FFMPEG is stored in the folder given by the env variable `INPUT_STREAM_PATH`. 14 | - **server1** and **server2** - Each execute a different HTTP server with equal configurations but different ports (`SERVER_ONE_PORT` and `SERVER_TWO_PORT`). 15 | 16 | These services can be run by calling, in different terminal tabs, the commands below - these will use the Go executable `go run` to start the services, but the option to use Docker instead is also available in the Makefile. 17 | 18 | ```sh 19 | make run-local-worker 20 | make run-local-server1 21 | make run-local-server2 22 | ``` 23 | 24 | The output live stream will be an HLS playlist, which can be played in Safari or VLC using the following URLs: 25 | 26 | ```sh 27 | localhost:8080/live01/playlist.m3u8 ## using server1 28 | localhost:9090/live01/playlist.m3u8 ## using server2 29 | ``` 30 | 31 | This way, the **worker** service acts as our mock *encoder*, whose HLS content will be published by our mock *packagers*, represented by **server1** and **server2**. 32 | 33 | ## Reporting the Active Ingest 34 | 35 | The next logical step is building a **discovery** and **reporter** service, which will be read by our **origin** to know which signals are active in which packagers. This information will be important for our **origin** to know from which packager to fetch the content from, and will be especially crucial for when we implement the zero downtime policy. 36 | 37 | Thus, we now have two other containers, which also run separately: 38 | 39 | - **discovery** - Reads the contents from the disk to verify which signals are being ingested (AKA generated by **worker**) and calls a healthcheck on all packagers (AKA **server1** and **server2**) to verify which are active. Then, it assembles all the gathered information and sends it to the **reporter** service using a HTTP POST. *OBS: Execution will fail if the **reporter** service is not active.* 40 | 41 | ```sh 42 | 2024-04-05 11:23:05 INF Registered signal 'live01' as ingest source 43 | ``` 44 | 45 | - **reporter** - Implements an API that receives the ingest information from the **discovery** service and stores it in a local cache. Then, it uses the information from the cache to expose a JSON with the active ingest information on the route `http://localhost:2000/ingests`: 46 | 47 | ```json 48 | [ 49 | { 50 | "signal": "live01", 51 | "packagers": [ 52 | "http://localhost:8080", 53 | "http://localhost:9090" 54 | ], 55 | "last_reported": "Fri, 05 Apr 2024 11:23:45 -03" 56 | } 57 | ] 58 | ``` 59 | 60 | > :warning: Since both servers (**server1** and **server2**) and the **reporter** all implement different HTTP servers which will all be run on the same host `http://localhost`, to differentiate them, each service is executed on a different port: `:8080` for **server1**, `:9090` for **server2** and `:2000` for **reporter**. These values are controlled by the environment variables `SERVER_ONE_PORT`, `SERVER_TWO_PORT` and `REPORTER_PORT`, respectively. 61 | 62 | To run these services, as was done in the previous section, we use the commands available in the Makefile (also running them in separate terminal tabs): 63 | 64 | ```sh 65 | make run-local-reporter 66 | make run-local-discovery 67 | ``` 68 | 69 | ## Serving the Origin 70 | 71 | The **origin** service connects all the services detailed before to the final client, such as a player or a playback/delivery API. The client sends a request for a signal and the **origin** service checks with the **reporter** service which _packager_ is being used to publish it, and then, returns the requested content to the client. 72 | 73 | To run this service, use the Makefile command below: 74 | 75 | ```sh 76 | make run-local-origin 77 | ``` 78 | 79 | The **origin** runs on the `http://localhost:8001` endpoint, and it serves two routes. 80 | 81 | The first `/signals` is used to return a list with the names of all active signals: 82 | 83 | ```sh 84 | curl -v http://localhost:8001/signals 85 | ``` 86 | 87 | ```json 88 | [ 89 | "live01" 90 | ] 91 | ``` 92 | 93 | The second, `/live/`, receives the signal's name as a query parameter, and returns an active ingest source (i.e. _packager_) for this signal: 94 | 95 | ```sh 96 | curl -v http://localhost:8001/live/live01 97 | ``` 98 | 99 | ```json 100 | { 101 | "signal": "live01", 102 | "server": "http://localhost:8080/live01/playlist.m3u8" 103 | } 104 | ``` 105 | 106 | ## Proxy to Active Stream 107 | 108 | The **proxy** service redirects the player request to the active streaming server. If an active stream fails (i.e. the active _packager_ suddenly becomes unavailable), the proxy will automatically redirect the stream to the next available _packager_. This switch works with little to no impact to the playback, avoiding video freezing, buffering or a need to reload the stream - and, thus, *providing the client watching the live stream with practically zero down time*. 109 | 110 | > :warning: Of course, this server only runs correctly if all the services before are already running. 111 | 112 | ```sh 113 | make run-local-proxy 114 | ``` 115 | 116 | ```sh 117 | curl -v localhost:8002/live01/playlist.m3u8 118 | 119 | 120 | ``` 121 | 122 | ## Watching the Live Stream 123 | 124 | To test the entire workflow and play the live stream using a playback client, execute all the services described previously in the following order: 125 | 126 | - Worker 127 | - Server 1 128 | - Server 2 129 | - Reporter 130 | - Discovery 131 | - Origin 132 | - Proxy 133 | 134 | Then, open the Safari Browser or [VLC Player](https://www.videolan.org/vlc/) and play the signal URL `http://localhost:8002/live01/playlist.m3u8`. -------------------------------------------------------------------------------- /assets/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellamariz/gopherplay/f335df94a910ed5a277e4b05fb60a242b59f4d95/assets/docs.png -------------------------------------------------------------------------------- /assets/stream.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellamariz/gopherplay/f335df94a910ed5a277e4b05fb60a242b59f4d95/assets/stream.mp4 -------------------------------------------------------------------------------- /cmd/discovery.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/client" 5 | "github.com/bellamariz/go-live-without-downtime/internal/config" 6 | "github.com/bellamariz/go-live-without-downtime/internal/discovery" 7 | "github.com/bellamariz/go-live-without-downtime/internal/reporter" 8 | "github.com/rs/zerolog/log" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func RunDiscovery(cfg *config.Config) *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "discovery", 15 | Short: "Run discovery service to expose active signals to reporter", 16 | Run: func(*cobra.Command, []string) { 17 | discoveryService := discovery.NewService(cfg) 18 | reporterService := reporter.NewService(cfg, discoveryService) 19 | 20 | httpClient := client.New() 21 | 22 | if !httpClient.Healthcheck(reporterService.Endpoint) { 23 | log.Error().Msg("Reporter service is not running") 24 | return 25 | } 26 | 27 | reporterService.Start(cfg) 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/origin.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/config" 5 | "github.com/bellamariz/go-live-without-downtime/internal/origin" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func RunOrigin(cfg *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "origin", 12 | Short: "Run origin server that awnswer the active signal server", 13 | Run: func(*cobra.Command, []string) { 14 | originAPI := origin.NewServer(cfg) 15 | originAPI.ConfigureRoutes() 16 | err := originAPI.Start() 17 | if err != nil { 18 | panic("failed to start origin service: " + err.Error()) 19 | } 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/proxy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/config" 5 | "github.com/bellamariz/go-live-without-downtime/internal/proxy" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func RunProxy(cfg *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "proxy", 12 | Short: "Run proxy server to player", 13 | Run: func(*cobra.Command, []string) { 14 | proxyAPI := proxy.NewProxyServer(cfg) 15 | proxyAPI.ConfigureRoutes() 16 | err := proxyAPI.Start() 17 | if err != nil { 18 | panic("failed to start proxy service: " + err.Error()) 19 | } 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/reporter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/config" 5 | "github.com/bellamariz/go-live-without-downtime/internal/reporter" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func RunReporter(cfg *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "reporter", 12 | Short: "Run reporter service to expose active ingests", 13 | Run: func(*cobra.Command, []string) { 14 | reporterAPI := reporter.NewServer(cfg) 15 | reporterAPI.ConfigureRoutes() 16 | err := reporterAPI.Start() 17 | if err != nil { 18 | panic("failed to start reporter service: " + err.Error()) 19 | } 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "github.com/bellamariz/go-live-without-downtime/internal/config" 12 | "github.com/joho/godotenv" 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | func NewRootCmd(cfg *config.Config) *cobra.Command { 19 | rootCmd := &cobra.Command{ 20 | Use: "go-live", 21 | Short: "Run full framework for go live application", 22 | SilenceUsage: true, 23 | SilenceErrors: true, 24 | } 25 | 26 | rootCmd.AddCommand(RunServerOne(cfg), RunServerTwo(cfg), RunWorker(cfg), RunDiscovery(cfg), RunReporter(cfg), RunOrigin(cfg), RunProxy(cfg)) 27 | 28 | return rootCmd 29 | } 30 | 31 | func Execute() { 32 | cfg := SetupEnvironment() 33 | 34 | if err := NewRootCmd(cfg).Execute(); err != nil { 35 | fmt.Println(err) 36 | os.Exit(1) 37 | } 38 | } 39 | 40 | func SetupEnvironment() *config.Config { 41 | // Configure global logging 42 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 43 | 44 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.DateTime}) 45 | 46 | // Read env file (for running locally) 47 | if err := godotenv.Load(".env"); err != nil { 48 | log.Info().Err(err).Msg("Could not load .env file") 49 | } 50 | 51 | // Parse environment configuration variables 52 | cfg, err := config.New() 53 | if err != nil { 54 | log.Fatal().Err(err).Msg("Failed to process environment configurations") 55 | } 56 | 57 | // Create local folder for storing the ffmpeg mosaic output 58 | if err := os.MkdirAll(cfg.OutputStreamPath, os.ModePerm); err != nil { 59 | log.Fatal().Err(err).Msg("Failed to create output path directory") 60 | } 61 | 62 | return cfg 63 | } 64 | -------------------------------------------------------------------------------- /cmd/server1.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/config" 5 | "github.com/bellamariz/go-live-without-downtime/internal/server" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func RunServerOne(cfg *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "server1", 12 | Short: "Run HTTP server", 13 | Run: func(*cobra.Command, []string) { 14 | server.Run(cfg.ServerOnePort, cfg.OutputStreamPath) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/server2.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/bellamariz/go-live-without-downtime/internal/config" 5 | "github.com/bellamariz/go-live-without-downtime/internal/server" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func RunServerTwo(cfg *config.Config) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "server2", 12 | Short: "Run HTTP server", 13 | Run: func(*cobra.Command, []string) { 14 | server.Run(cfg.ServerTwoPort, cfg.OutputStreamPath) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/worker.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/bellamariz/go-live-without-downtime/internal/config" 12 | "github.com/bellamariz/go-live-without-downtime/internal/worker" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func RunWorker(cfg *config.Config) *cobra.Command { 17 | return &cobra.Command{ 18 | Use: "worker", 19 | Short: "Run worker to generate ffmpeg live stream", 20 | Run: func(*cobra.Command, []string) { 21 | ctx := context.Background() 22 | 23 | log.Info().Msg("Running worker for ffmpeg...") 24 | 25 | if err := worker.Execute(ctx, cfg); err != nil { 26 | log.Error().Err(err).Msg("Failed to generate playlist") 27 | } 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: 6 | dockerfile: Dockerfile 7 | context: . 8 | volumes: 9 | - ./output:/output 10 | environment: 11 | - VIDEO_PATH=./assets/BigBuckBunny.mp4 12 | ports: 13 | - "8000:8000" -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bellamariz/go-live-without-downtime 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/joho/godotenv v1.5.1 7 | github.com/kelseyhightower/envconfig v1.4.0 8 | github.com/labstack/echo/v4 v4.10.2 9 | github.com/rs/zerolog v1.33.0 10 | github.com/spf13/cobra v1.8.0 11 | ) 12 | 13 | require ( 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/labstack/gommon v0.4.2 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | github.com/valyala/bytebufferpool v1.0.0 // indirect 20 | github.com/valyala/fasttemplate v1.2.2 // indirect 21 | golang.org/x/crypto v0.36.0 // indirect 22 | golang.org/x/net v0.38.0 // indirect 23 | golang.org/x/sys v0.31.0 // indirect 24 | golang.org/x/text v0.23.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 6 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 7 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 8 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 9 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 10 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 11 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 12 | github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= 13 | github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= 14 | github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= 15 | github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= 16 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 17 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 18 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 19 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 20 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 21 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 22 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 23 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 24 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 28 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 29 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 30 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 31 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 32 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 33 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 34 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 35 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 36 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 37 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 38 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 39 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 40 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 41 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 42 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 43 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 44 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 45 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 46 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 47 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 48 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 49 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= 50 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 51 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 52 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 53 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 57 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 59 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 60 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 61 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 62 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 63 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 66 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | -------------------------------------------------------------------------------- /internal/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const ( 11 | httpTimeout = 2 * time.Second 12 | ) 13 | 14 | type HTTPClient struct { 15 | Client *http.Client 16 | } 17 | 18 | func New() *HTTPClient { 19 | return &HTTPClient{ 20 | Client: &http.Client{Timeout: httpTimeout}, 21 | } 22 | } 23 | 24 | func (c *HTTPClient) Healthcheck(endpoint string) bool { 25 | url := fmt.Sprintf("%s/healthcheck", endpoint) 26 | 27 | resp, err := c.Get(url) 28 | 29 | if err != nil { 30 | if resp != nil { 31 | defer resp.Body.Close() 32 | } 33 | 34 | return false 35 | } 36 | 37 | return true 38 | } 39 | 40 | func (c *HTTPClient) Get(endpoint string) (*http.Response, error) { 41 | resp, err := c.Client.Get(endpoint) 42 | 43 | if err != nil { 44 | return nil, fmt.Errorf("get request to %s failed: %w", endpoint, err) 45 | } 46 | 47 | return resp, nil 48 | } 49 | 50 | func (c *HTTPClient) Post(endpoint, contentType string, payload []byte) error { 51 | resp, err := c.Client.Post(endpoint, contentType, bytes.NewBuffer(payload)) /* #nosec G107 */ 52 | if err != nil { 53 | return fmt.Errorf("post request to %s failed: %w", endpoint, err) 54 | } 55 | 56 | defer resp.Body.Close() 57 | 58 | if resp.StatusCode != http.StatusOK { 59 | return fmt.Errorf("post request returned status %d", resp.StatusCode) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (c *HTTPClient) Delete(endpoint string) error { 66 | req, err := http.NewRequest(http.MethodDelete, endpoint, http.NoBody) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | resp, err := c.Client.Do(req) 72 | if err != nil { 73 | return fmt.Errorf("delete request to %s failed: %w", endpoint, err) 74 | } 75 | 76 | defer resp.Body.Close() 77 | 78 | if resp.StatusCode != http.StatusOK { 79 | return fmt.Errorf("delete request returned status %d", resp.StatusCode) 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/kelseyhightower/envconfig" 7 | ) 8 | 9 | type Config struct { 10 | InputStreamPath string `envconfig:"INPUT_STREAM_PATH"` 11 | DiscoveryRunFrequency time.Duration `envconfig:"DISCOVERY_RUN_FREQUENCY"` 12 | LiveSignalName string `envconfig:"LIVE_SIGNAL_NAME"` 13 | LocalHost string `envconfig:"LOCAL_HOST"` 14 | MaxAgePlaylist time.Duration `envconfig:"MAX_AGE_PLAYLIST"` 15 | OutputStreamPath string `envconfig:"OUTPUT_STREAM_PATH"` 16 | OriginPort string `envconfig:"ORIGIN_PORT"` 17 | ProxyPort string `envconfig:"PROXY_PORT"` 18 | ReporterPort string `envconfig:"REPORTER_PORT"` 19 | ServerOnePort string `envconfig:"SERVER_ONE_PORT"` 20 | ServerTwoPort string `envconfig:"SERVER_TWO_PORT"` 21 | } 22 | 23 | // New generate new Config struct from environment variables 24 | func New() (*Config, error) { 25 | var c Config 26 | if err := envconfig.Process("", &c); err != nil { 27 | return nil, err 28 | } 29 | 30 | return &c, nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/discovery/discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/bellamariz/go-live-without-downtime/internal/client" 11 | "github.com/bellamariz/go-live-without-downtime/internal/config" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | type DiscoveryService struct { 16 | httpClient *client.HTTPClient 17 | path string 18 | period time.Duration 19 | } 20 | 21 | func NewService(cfg *config.Config) *DiscoveryService { 22 | return &DiscoveryService{ 23 | httpClient: client.New(), 24 | path: cfg.OutputStreamPath, 25 | period: cfg.MaxAgePlaylist, 26 | } 27 | } 28 | 29 | // FetchActiveSignals returns a list of active signals 30 | // An active signal is a signal that had its manifest recently updated 31 | func (ds *DiscoveryService) FetchActiveSignals() []string { 32 | activeSignals := make([]string, 0) 33 | 34 | err := filepath.Walk(ds.path, func(path string, info os.FileInfo, err error) error { 35 | if err != nil { 36 | log.Error().Err(err).Msgf("Failed to walk path: %s\n", path) 37 | return err 38 | } 39 | 40 | if info.IsDir() { 41 | return nil 42 | } 43 | 44 | if filepath.Ext(path) == ".m3u8" { 45 | if isRecentlyUpdated(info, ds.period) { 46 | activeSignals = append(activeSignals, getSignalName(path, ds.path)) 47 | } 48 | } 49 | 50 | return nil 51 | }) 52 | 53 | if err != nil { 54 | log.Error().Err(err).Msg("Failed to walk stream files") 55 | } 56 | 57 | return activeSignals 58 | } 59 | 60 | // FetchActiveSignals returns a list of active packagers 61 | // Packagers are active when commands 'server1' or 'server2' - our mock local packagers - are running 62 | func (ds *DiscoveryService) FetchActivePackagers(cfg *config.Config) []string { 63 | activePackagers := make([]string, 0) 64 | 65 | packagerPorts := []string{cfg.ServerOnePort, cfg.ServerTwoPort} 66 | 67 | for _, port := range packagerPorts { 68 | packagerEndpoint := cfg.LocalHost + ":" + port 69 | 70 | if ds.httpClient.Healthcheck(packagerEndpoint) { 71 | activePackagers = append(activePackagers, packagerEndpoint) 72 | } 73 | } 74 | 75 | return activePackagers 76 | } 77 | 78 | func isRecentlyUpdated(fi os.FileInfo, period time.Duration) bool { 79 | return time.Since(fi.ModTime()) <= period 80 | } 81 | 82 | func getSignalName(path, prefix string) string { 83 | assetsPathDir := fmt.Sprintf("%s/", prefix) 84 | manifestDir := strings.TrimPrefix(path, assetsPathDir) 85 | signal := strings.TrimSuffix(manifestDir, "/playlist.m3u8") 86 | 87 | return signal 88 | } 89 | -------------------------------------------------------------------------------- /internal/mimetype/mimetype.go: -------------------------------------------------------------------------------- 1 | package mimetype 2 | 3 | import ( 4 | "mime" 5 | ) 6 | 7 | // Indicate the nature and format of the HLS output 8 | func Configure() { 9 | mime.AddExtensionType(".m3u8", "application/vnd.apple.mpegURL") //nolint:errcheck //no reason to check mime errors 10 | mime.AddExtensionType(".ts", "video/MP2T") //nolint:errcheck //no reason to check mime errors 11 | } 12 | -------------------------------------------------------------------------------- /internal/origin/api.go: -------------------------------------------------------------------------------- 1 | package origin 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bellamariz/go-live-without-downtime/internal/config" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | type ( 11 | API struct { 12 | Echo *echo.Echo 13 | Port string 14 | ReporterEndpoint string 15 | } 16 | ) 17 | 18 | func NewServer(cfg *config.Config) *API { 19 | return &API{ 20 | Echo: echo.New(), 21 | Port: cfg.OriginPort, 22 | ReporterEndpoint: cfg.LocalHost + ":" + cfg.ReporterPort, 23 | } 24 | } 25 | 26 | func (api *API) ConfigureRoutes() { 27 | api.Echo.GET("/healthcheck", api.healthcheck) 28 | api.Echo.GET("/live/:name", api.getSignal) 29 | api.Echo.GET("/signals", api.getSignals) 30 | } 31 | 32 | func (api *API) Start() error { 33 | return api.Echo.Start(":" + api.Port) 34 | } 35 | 36 | func (api *API) healthcheck(c echo.Context) error { 37 | return c.String(http.StatusOK, "WORKING") 38 | } 39 | 40 | func (api *API) getSignals(c echo.Context) error { 41 | signals, err := listSignals(api.ReporterEndpoint) 42 | if err != nil { 43 | errorMsg := map[string]string{ 44 | "error": err.Error(), 45 | } 46 | return c.JSON(http.StatusInternalServerError, errorMsg) 47 | } 48 | 49 | return c.JSON(http.StatusOK, signals) 50 | } 51 | 52 | func (api *API) getSignal(c echo.Context) error { 53 | name := c.Param("name") 54 | 55 | signalInfo, err := getSignalIngest(api.ReporterEndpoint, name) 56 | if err != nil { 57 | errorMsg := map[string]string{ 58 | "error": err.Error(), 59 | } 60 | return c.JSON(http.StatusInternalServerError, errorMsg) 61 | } 62 | 63 | activeSignalPath := formatPath(signalInfo.Packagers, signalInfo.Signal) 64 | return c.JSON(http.StatusOK, activeSignalPath) 65 | } 66 | -------------------------------------------------------------------------------- /internal/origin/origin.go: -------------------------------------------------------------------------------- 1 | package origin 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/bellamariz/go-live-without-downtime/internal/client" 11 | "github.com/bellamariz/go-live-without-downtime/internal/sources" 12 | ) 13 | 14 | func listSignals(reporterEndpoint string) ([]string, error) { 15 | httpClient := client.New() 16 | 17 | endpoint := fmt.Sprintf("%s/ingests", reporterEndpoint) 18 | 19 | resp, err := httpClient.Get(endpoint) 20 | if err != nil { 21 | return []string{}, err 22 | } 23 | 24 | if resp.StatusCode != http.StatusOK { 25 | return []string{}, errors.New("no active signals found by origin") 26 | } 27 | 28 | body, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return []string{}, err 31 | } 32 | 33 | defer resp.Body.Close() 34 | 35 | var response []sources.Ingest 36 | if err := json.Unmarshal(body, &response); err != nil { 37 | return []string{}, err 38 | } 39 | 40 | signals := []string{} 41 | for _, v := range response { 42 | signals = append(signals, v.Signal) 43 | } 44 | 45 | return signals, nil 46 | } 47 | 48 | func getSignalIngest(reporterEndpoint, signal string) (*sources.Ingest, error) { 49 | httpClient := client.New() 50 | 51 | endpoint := fmt.Sprintf("%s/ingests/%s", reporterEndpoint, signal) 52 | 53 | resp, err := httpClient.Get(endpoint) 54 | if err != nil { 55 | return &sources.Ingest{}, err 56 | } 57 | 58 | if resp.StatusCode != http.StatusOK { 59 | return &sources.Ingest{}, errors.New("no active signals found by origin") 60 | } 61 | 62 | body, err := io.ReadAll(resp.Body) 63 | if err != nil { 64 | return &sources.Ingest{}, err 65 | } 66 | 67 | defer resp.Body.Close() 68 | 69 | var response sources.Ingest 70 | if err := json.Unmarshal(body, &response); err != nil { 71 | return &sources.Ingest{}, err 72 | } 73 | 74 | return &response, nil 75 | } 76 | 77 | func formatPath(packagers []string, signal string) sources.Source { 78 | path := fmt.Sprintf("%s/%s/playlist.m3u8", packagers[0], signal) 79 | activeSignalPath := sources.Source{ 80 | Signal: signal, 81 | Server: path, 82 | } 83 | 84 | return activeSignalPath 85 | } 86 | -------------------------------------------------------------------------------- /internal/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httputil" 9 | "net/url" 10 | 11 | "github.com/bellamariz/go-live-without-downtime/internal/client" 12 | "github.com/bellamariz/go-live-without-downtime/internal/config" 13 | "github.com/bellamariz/go-live-without-downtime/internal/sources" 14 | "github.com/labstack/echo/v4" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | type ( 19 | API struct { 20 | Echo *echo.Echo 21 | Port string 22 | OriginEndpoint string 23 | } 24 | ) 25 | 26 | func NewProxyServer(cfg *config.Config) *API { 27 | return &API{ 28 | Echo: echo.New(), 29 | Port: cfg.ProxyPort, 30 | OriginEndpoint: fmt.Sprintf("%s:%s", cfg.LocalHost, cfg.OriginPort), 31 | } 32 | } 33 | 34 | func (api *API) ConfigureRoutes() { 35 | api.Echo.GET("/:name/*", api.proxySignalVideo) 36 | } 37 | 38 | func (api *API) Start() error { 39 | return api.Echo.Start(":" + api.Port) 40 | } 41 | 42 | func (api *API) proxySignalVideo(c echo.Context) error { 43 | name := c.Param("name") 44 | if name == "" { 45 | errorMsg := map[string]string{ 46 | "error": "should pass a signal name", 47 | } 48 | return c.JSON(http.StatusBadRequest, errorMsg) 49 | } 50 | 51 | originSignal := fmt.Sprintf("%s/live/%s", api.OriginEndpoint, name) 52 | 53 | httpClient := client.New() 54 | resp, err := httpClient.Get(originSignal) 55 | if err != nil { 56 | errorMsg := map[string]string{ 57 | "error": err.Error(), 58 | } 59 | return c.JSON(http.StatusInternalServerError, errorMsg) 60 | } 61 | 62 | body, err := io.ReadAll(resp.Body) 63 | if err != nil { 64 | errorMsg := map[string]string{ 65 | "error": err.Error(), 66 | } 67 | return c.JSON(http.StatusInternalServerError, errorMsg) 68 | } 69 | 70 | defer resp.Body.Close() 71 | 72 | var response sources.Source 73 | if err := json.Unmarshal(body, &response); err != nil { 74 | errorMsg := map[string]string{ 75 | "error": err.Error(), 76 | } 77 | return c.JSON(http.StatusInternalServerError, errorMsg) 78 | } 79 | 80 | log.Info().Msgf("Proxy live to server %s ", response.Server) 81 | 82 | url, _ := url.Parse(response.Server) 83 | rp := &httputil.ReverseProxy{ 84 | Director: newDirector(url), 85 | } 86 | 87 | rp.ServeHTTP(c.Response(), c.Request()) 88 | return nil 89 | } 90 | 91 | func newDirector(url *url.URL) func(req *http.Request) { 92 | return func(req *http.Request) { 93 | req.URL.Scheme = url.Scheme 94 | req.URL.Host = url.Host 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/reporter/api.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/bellamariz/go-live-without-downtime/internal/config" 8 | "github.com/bellamariz/go-live-without-downtime/internal/sources" 9 | "github.com/labstack/echo/v4" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type ( 14 | API struct { 15 | Echo *echo.Echo 16 | Port string 17 | Cache *sync.Map 18 | } 19 | ) 20 | 21 | func NewServer(cfg *config.Config) *API { 22 | return &API{ 23 | Echo: echo.New(), 24 | Port: cfg.ReporterPort, 25 | Cache: &sync.Map{}, 26 | } 27 | } 28 | 29 | func (api *API) ConfigureRoutes() { 30 | api.Echo.GET("/healthcheck", api.healthcheck) 31 | api.Echo.GET("/ingests", api.getIngests) 32 | api.Echo.GET("/ingests/:name", api.getSignalIngest) 33 | api.Echo.POST("/ingests", api.updateIngests) 34 | } 35 | 36 | func (api *API) Start() error { 37 | return api.Echo.Start(":" + api.Port) 38 | } 39 | 40 | func (api *API) healthcheck(c echo.Context) error { 41 | return c.String(http.StatusOK, "WORKING") 42 | } 43 | 44 | func (api *API) getIngests(c echo.Context) error { 45 | ingests := []sources.Ingest{} 46 | 47 | api.Cache.Range(func(key, value any) bool { 48 | ingest, ok := value.(sources.Ingest) 49 | if !ok { 50 | log.Error().Msg("failed to cast value to sources.Ingest") 51 | return true 52 | } 53 | 54 | if ingest.IsActive() { 55 | ingests = append(ingests, ingest) 56 | } 57 | 58 | return true 59 | }) 60 | 61 | if len(ingests) == 0 { 62 | errMsg := map[string]string{ 63 | "error": "No active ingest info available", 64 | } 65 | 66 | return c.JSON(http.StatusInternalServerError, errMsg) 67 | } 68 | 69 | return c.JSON(http.StatusOK, ingests) 70 | } 71 | 72 | func (api *API) updateIngests(c echo.Context) error { 73 | var ingestSource sources.Ingest 74 | 75 | if err := c.Bind(&ingestSource); err != nil { 76 | errorMsg := map[string]string{ 77 | "error": err.Error(), 78 | } 79 | 80 | return c.JSON(http.StatusBadRequest, errorMsg) 81 | } 82 | 83 | api.Cache.Store(ingestSource.Signal, ingestSource) 84 | 85 | return c.NoContent(http.StatusOK) 86 | } 87 | 88 | func (api *API) getSignalIngest(c echo.Context) error { 89 | signalName := c.Param("name") 90 | 91 | ingest, found := api.Cache.Load(signalName) 92 | if !found { 93 | errorMsg := map[string]string{ 94 | "error": "Active ingests servers not found", 95 | } 96 | 97 | return c.JSON(http.StatusNotFound, errorMsg) 98 | } 99 | 100 | return c.JSON(http.StatusOK, ingest) 101 | } 102 | -------------------------------------------------------------------------------- /internal/reporter/reporter.go: -------------------------------------------------------------------------------- 1 | package reporter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/bellamariz/go-live-without-downtime/internal/client" 9 | "github.com/bellamariz/go-live-without-downtime/internal/config" 10 | "github.com/bellamariz/go-live-without-downtime/internal/discovery" 11 | "github.com/bellamariz/go-live-without-downtime/internal/sources" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | type ReporterService struct { 16 | Client *client.HTTPClient 17 | PackagerService *discovery.DiscoveryService 18 | Endpoint string 19 | } 20 | 21 | func NewService(cfg *config.Config, ds *discovery.DiscoveryService) *ReporterService { 22 | return &ReporterService{ 23 | Client: client.New(), 24 | PackagerService: ds, 25 | Endpoint: cfg.LocalHost + ":" + cfg.ReporterPort, 26 | } 27 | } 28 | 29 | func (rs *ReporterService) Start(cfg *config.Config) { 30 | ticker := time.NewTicker(cfg.DiscoveryRunFrequency) 31 | 32 | for range ticker.C { 33 | rs.SetIngest(cfg) 34 | } 35 | } 36 | 37 | func (rs *ReporterService) SetIngest(cfg *config.Config) { 38 | activePackagers := rs.PackagerService.FetchActivePackagers(cfg) 39 | activeSignals := rs.PackagerService.FetchActiveSignals() 40 | 41 | for _, signal := range activeSignals { 42 | rs.setSignalIngest(signal, activePackagers) 43 | } 44 | } 45 | 46 | func (rs *ReporterService) setSignalIngest(signal string, packagers []string) { 47 | now := time.Now().Format(time.RFC1123) 48 | ingestSource := sources.Ingest{Packagers: packagers, Signal: signal, LastReported: now} 49 | 50 | payload, err := json.Marshal(ingestSource) 51 | if err != nil { 52 | log.Error().Err(err).Msg("Failed to marshal signal ingest data") 53 | return 54 | } 55 | 56 | endpoint := fmt.Sprintf("%s/ingests", rs.Endpoint) 57 | 58 | err = rs.Client.Post(endpoint, "application/json", payload) 59 | if err != nil { 60 | log.Error().Err(err).Msgf("Failed to set ingest for '%s' signal", signal) 61 | return 62 | } 63 | 64 | log.Info().Msgf("Registered signal '%s' as ingest source", signal) 65 | } 66 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "path/filepath" 6 | 7 | "github.com/bellamariz/go-live-without-downtime/internal/mimetype" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | func Run(port, outputStreamPath string) { 12 | mimetype.Configure() 13 | 14 | e := echo.New() 15 | 16 | e.GET("/healthcheck", healthCheck) 17 | e.GET("/*", serveStatic(outputStreamPath)) 18 | 19 | e.Logger.Fatal(e.Start(":" + port)) 20 | } 21 | 22 | func healthCheck(c echo.Context) error { 23 | return c.String(http.StatusOK, "WORKING") 24 | } 25 | 26 | func serveStatic(root string) echo.HandlerFunc { 27 | return func(c echo.Context) error { 28 | file := filepath.Join(root, c.Request().URL.Path) 29 | 30 | return c.File(file) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/sources/sources.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | type Ingest struct { 10 | Signal string `json:"signal"` 11 | Packagers []string `json:"packagers"` 12 | LastReported string `json:"last_reported"` 13 | } 14 | 15 | type Source struct { 16 | Signal string `json:"signal"` 17 | Server string `json:"server"` 18 | } 19 | 20 | func (i Ingest) IsActive() bool { 21 | t, err := time.Parse(time.RFC1123, i.LastReported) 22 | if err != nil { 23 | log.Warn().Err(err).Msg("Error when verifying if ingest is active") 24 | return false 25 | } 26 | 27 | wasRecentlyReported := time.Since(t) <= (15 * time.Second) 28 | hasActiveIngest := len(i.Packagers) > 0 29 | 30 | return wasRecentlyReported && hasActiveIngest 31 | } 32 | -------------------------------------------------------------------------------- /internal/worker/command.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/bellamariz/go-live-without-downtime/internal/config" 7 | ) 8 | 9 | // Build commands to run FFMPEG cli 10 | func BuildCommand(cfg *config.Config) []string { 11 | orderedArgs := []string{"-loglevel", "info"} 12 | 13 | orderedArgs = append(orderedArgs, buildVideoInputArguments(cfg)...) 14 | orderedArgs = append(orderedArgs, buildCodecsConfig()...) 15 | orderedArgs = append(orderedArgs, buildHLSArguments(cfg)...) 16 | 17 | return orderedArgs 18 | } 19 | 20 | func buildVideoInputArguments(cfg *config.Config) []string { 21 | args := []string{ 22 | "-stream_loop", "-1", 23 | "-i", cfg.InputStreamPath, 24 | } 25 | 26 | return args 27 | } 28 | 29 | func buildCodecsConfig() []string { 30 | args := []string{ 31 | "-c:v", "libx264", 32 | "-profile:v", "high", 33 | "-c:a", "copy", 34 | } 35 | 36 | return args 37 | } 38 | 39 | func buildHLSArguments(cfg *config.Config) []string { 40 | outputPath := cfg.OutputStreamPath + "/" + cfg.LiveSignalName 41 | segmentPattern := fmt.Sprintf("%s/seg_%%s.ts", outputPath) 42 | playlistPath := fmt.Sprintf("%s/playlist.m3u8", outputPath) 43 | 44 | args := []string{ 45 | "-f", "hls", 46 | "-hls_time", "5", 47 | "-hls_list_size", "10", 48 | "-hls_flags", "delete_segments", 49 | "-strftime", "1", 50 | "-hls_segment_filename", segmentPattern, 51 | playlistPath, 52 | } 53 | 54 | return args 55 | } 56 | -------------------------------------------------------------------------------- /internal/worker/execute.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/bellamariz/go-live-without-downtime/internal/config" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // Execute FFMPEG command, create HLS playlist from 13 | // mp4 video 14 | func Execute(ctx context.Context, cfg *config.Config) error { 15 | outputPath := cfg.OutputStreamPath + "/" + cfg.LiveSignalName 16 | if err := CreateOutputDir(outputPath); err != nil { 17 | log.Error().Err(err).Msg("Failed to create output directory for generated playlist") 18 | return err 19 | } 20 | 21 | args := BuildCommand(cfg) 22 | cmd := exec.CommandContext(ctx, "ffmpeg", args...) 23 | cmd.Stdout = os.Stdout 24 | cmd.Stderr = os.Stderr 25 | 26 | return cmd.Run() 27 | } 28 | 29 | func CreateOutputDir(path string) error { 30 | return os.MkdirAll(path, os.ModePerm) 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/bellamariz/go-live-without-downtime/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mkdir -p output 3 | 4 | # Generating HLS playlist 5 | ffmpeg \ 6 | -loglevel info \ 7 | -stream_loop -1 -i "assets/stream.mp4" \ 8 | -c:v libx264 -profile:v high \ 9 | -c:a copy \ 10 | -f hls \ 11 | -hls_time 5 \ 12 | -hls_list_size 5 \ 13 | -hls_flags delete_segments \ 14 | -strftime 1 -hls_segment_filename "output/seg_%s.ts" \ 15 | "output/playlist.m3u8" 16 | 17 | echo "Playlist generated successfully!" --------------------------------------------------------------------------------