├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ └── feature_request.md ├── .gitignore ├── LICENSE.md ├── README.md ├── application.go ├── build.sh ├── cmd └── video_server │ ├── conf.json │ ├── conf.toml │ └── main.go ├── configuration ├── configuration.go ├── postprocess_cfg.go ├── prepare.go ├── prepare_json.go └── prepare_toml.go ├── docker-compose.yaml ├── errors.go ├── example_client ├── hls_example │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ └── src │ │ ├── App.vue │ │ ├── assets │ │ └── logo.png │ │ ├── components │ │ └── HLSPlayer.vue │ │ └── main.js ├── mse_example │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ └── src │ │ ├── App.vue │ │ ├── assets │ │ └── logo.png │ │ ├── components │ │ └── MSEPlayer.vue │ │ └── main.js └── vanila_js │ └── index.html ├── go.mod ├── go.sum ├── hls.go ├── http_server.go ├── logger.go ├── mp4.go ├── scripts └── minio-ansible.yml ├── storage ├── archive_storage.go ├── filesystem.go ├── minio.go └── storage_types.go ├── stream.go ├── stream_archive.go ├── stream_configuration.go ├── stream_hls.go ├── stream_mp4.go ├── stream_types.go ├── streams.go ├── streams_storage.go ├── utils.go ├── verbose.go ├── viewer.go ├── ws_handler.go └── ws_server.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug, help wanted 6 | assignees: LdDl 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Describe the solution you'd like and provide pseudocode examples if you can** 23 | A clear and concise description of what you want to happen. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Question about documentation or suggestion for one. 4 | title: "[DOCUMENTATION]" 5 | labels: help wanted, question 6 | assignees: LdDl 7 | 8 | --- 9 | 10 | **What is your docs question about? Ask it** 11 | A clear and concise description of what the problem is. 12 | 13 | **What do you suggest?* 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: enhancement 6 | assignees: LdDl 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like and provide pseudocode examples if you can** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered and provide pseudocode examples if you can** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *.exe 3 | cmd/video_server/video_server 4 | cmd/video_server/video_server.exe 5 | cmd/video_server/hls/* 6 | cmd/video_server/mp4/* 7 | example_client/mse_example/node_modules 8 | example_client/mse_example/.nuxt 9 | example_client/mse_example/package-lock.json 10 | example_client/hls_example/node_modules 11 | example_client/hls_example/.nuxt 12 | example_client/hls_example/package-lock.json 13 | cmd/video_server/linux-video_server.zip 14 | cmd/video_server/windows-video_server.zip 15 | cmd/video_server/conf_token.json 16 | video-server-ui 17 | ./video_server 18 | linux-amd64-video_server.tar.gz 19 | ./video_server.exe 20 | windows-video_server.zip 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 LdDl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/LdDl/video-server?status.svg)](https://godoc.org/github.com/LdDl/video-server) 2 | [![Sourcegraph](https://sourcegraph.com/github.com/LdDl/video-server/-/badge.svg)](https://sourcegraph.com/github.com/LdDl/video-server?badge) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/LdDl/video-server)](https://goreportcard.com/report/github.com/LdDl/video-server) 4 | [![GitHub tag](https://img.shields.io/github/tag/LdDl/video-server.svg)](https://github.com/LdDl/video-server/releases) 5 | 6 | # Golang-based video-server for re-streaming RTSP to HLS/MSE 7 | 8 | ## Table of Contents 9 | 10 | - [Golang-based video-server for re-streaming RTSP to HLS/MSE](#golang-based-video-server-for-re-streaming-rtsp-to-hlsmse) 11 | - [Table of Contents](#table-of-contents) 12 | - [About](#about) 13 | - [Instalation](#instalation) 14 | - [Binaries](#binaries) 15 | - [From source](#from-source) 16 | - [Usage](#usage) 17 | - [Start server](#start-server) 18 | - [Test Client-Server](#test-client-server) 19 | - [Dependencies](#dependencies) 20 | - [License](#license) 21 | - [Developers](#developers) 22 | 23 | 24 | ## About 25 | Simple WS/HTTP server for re-streaming video (RTSP) to client in MSE/HLS format. 26 | 27 | It is highly inspired by https://github.com/deepch and his projects. So why am I trying to reinvent the wheel? Well, I'm just trying to fit my needs. 28 | 29 | ## Instalation 30 | ### Binaries 31 | Linux - [link](https://github.com/LdDl/video-server/releases/download/v0.4.0/linux-video_server.tar.gz) 32 | 33 | ### From source 34 | ```bash 35 | go get github.com/LdDl/video-server 36 | # or just clone it 37 | # git clone https://github.com/LdDl/video-server.git 38 | ``` 39 | Go to root folder of downloaded repository, move to cmd/video_server folder: 40 | ```bash 41 | cd $CLONED_PATH/cmd/video_server 42 | go build -o video_server main.go 43 | ``` 44 | 45 | ## Usage 46 | ```shell 47 | video_server -h 48 | ``` 49 | ```shell 50 | -conf string 51 | Path to configuration either TOML-file or JSON-file (default "conf.toml") 52 | -cpuprofile file 53 | write cpu profile to file 54 | -memprofile file 55 | write memory profile to file 56 | ``` 57 | 58 | ### Start server 59 | Prepare configuration file (example [here](cmd/video_server/conf.json)). Then run binary: 60 | ```shell 61 | video_server --conf=conf.toml 62 | ``` 63 | ### Test Client-Server 64 | For HLS-based player go to [hls-subdirectory](example_client/hls_example). 65 | 66 | For MSE-based (websockets) player go to [mse-subdirectory](mse_example/hls_example). 67 | 68 | Then follow this set of commands: 69 | ```shell 70 | npm install 71 | export NODE_OPTIONS=--openssl-legacy-provider 72 | npm run dev 73 | ``` 74 | 75 | You will se something like this after succesfull fron-end start: 76 | ```shell 77 | DONE Compiled successfully in 1783ms 12:09:30 PM 78 | App running at: 79 | - Local: http://localhost:8080/ 80 | ``` 81 | Paste link to the browser and check if video loaded successfully. 82 | 83 | ## Archive 84 | 85 | You can configure application to write MP4 chunks of custom duration (but not less than first keyframe duration) to the filesystem or [S3 MinIO](https://min.io/) 86 | 87 | - For storing archive to the filesystem. Point default directory for storing MP4 files and duration: 88 | ```toml 89 | [archive] 90 | enabled = true 91 | directory = "./mp4" 92 | ms_per_file = 30000 93 | ``` 94 | 95 | For each stream configuration you can override default directory and duration. Field "type" should have value "filesystem": 96 | ```toml 97 | [[rtsp_streams]] 98 | # ... 99 | # Some other single stream props 100 | # ... 101 | archive = { enabled = true, ms_per_file = 20000, type = "filesystem", directory = "custom_folder" } 102 | ``` 103 | 104 | - For storing archive to the S3 MinIO: 105 | Modify configuration file to have both filesystem and minio configuration (filesystem will be picked for storing temporary files before moving it to the MinIO), e.g.: 106 | ```toml 107 | [archive] 108 | enabled = true 109 | directory = "./mp4" 110 | ms_per_file = 30000 111 | minio_settings = { host = "localhost", port = 29199, user = "minio_secret_login", password = "minio_secret_password", default_bucket = "archive-bucket", default_path = "/var/archive_data" } 112 | ``` 113 | 114 | For each stream configuration you can override default directory for temporary files, MinIO bucket and path in it and chunk duration. Field "type" should have value "minio": 115 | ```toml 116 | [[rtsp_streams]] 117 | # ... 118 | # Some other single stream props 119 | # ... 120 | archive = { enabled = true, ms_per_file = 20000, type = "minio", "directory": "custom_folder", minio_bucket = "vod-bucket", minio_path = "/var/archive_data_custom" } 121 | ``` 122 | 123 | - If you want disable archive for specified stream, just set value of the field `enabled` to `false` in streams array. For disabling archive at all you can do the same but in the main configuration (where default values are set) 124 | 125 | - To install MinIO (in case if you want to store archive in S3) you can use [./docker-compose.yaml](docker-compose file) or [./scripts/minio-ansible.yml](Ansible script) for example of deployment workflows 126 | 127 | ## Dependencies 128 | GIN web-framework - [https://github.com/gin-gonic/gin](https://github.com/gin-gonic/gin). License is [MIT](https://github.com/gin-gonic/gin/blob/master/LICENSE) 129 | 130 | Media library - [http://github.com/deepch/vdk](https://github.com/deepch/vdk). License is [MIT](https://github.com/deepch/vdk/blob/master/LICENSE). 131 | 132 | UUID generation and parsing - [https://github.com/google/uuid](https://github.com/google/uuid). License is [BSD 3-Clause](https://github.com/google/uuid/blob/master/LICENSE) 133 | 134 | Websockets - [https://github.com/gorilla/websocket](https://github.com/gorilla/websocket). License is [BSD 2-Clause](https://github.com/gorilla/websocket/blob/master/LICENSE) 135 | 136 | m3u8 library - [https://github.com/grafov/m3u8](https://github.com/grafov/m3u8). License is [BSD 3-Clause](https://github.com/grafov/m3u8/blob/master/LICENSE) 137 | 138 | errors wrapping - [https://github.com/pkg/errors](https://github.com/pkg/errors) . License is [BSD 2-Clause](https://github.com/pkg/errors/blob/master/LICENSE) 139 | 140 | ## License 141 | You can check it [here](LICENSE.md) 142 | 143 | ## Developers 144 | Roman - https://github.com/webver 145 | 146 | Pavel - https://github.com/Pavel7824 147 | 148 | Dimitrii Lopanov - https://github.com/LdDl 149 | 150 | Morozka - https://github.com/morozka 151 | -------------------------------------------------------------------------------- /application.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/LdDl/video-server/configuration" 7 | "github.com/LdDl/video-server/storage" 8 | "github.com/gin-contrib/cors" 9 | "github.com/minio/minio-go/v7" 10 | "github.com/minio/minio-go/v7/pkg/credentials" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/google/uuid" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | // Application is a configuration parameters for application 18 | type Application struct { 19 | APICfg APIConfiguration `json:"api"` 20 | VideoServerCfg VideoConfiguration `json:"video"` 21 | Streams StreamsStorage `json:"streams"` 22 | HLS HLSInfo `json:"hls"` 23 | CorsConfig *cors.Config `json:"-"` 24 | minioClient *minio.Client 25 | } 26 | 27 | // APIConfiguration is just copy of configuration.APIConfiguration but with some not exported fields 28 | type APIConfiguration struct { 29 | Enabled bool `json:"-"` 30 | Host string `json:"host"` 31 | Port int32 `json:"port"` 32 | Mode string `json:"-"` 33 | Verbose VerboseLevel `json:"-"` 34 | } 35 | 36 | // VideoConfiguration is just copy of configuration.VideoConfiguration but with some not exported fields 37 | type VideoConfiguration struct { 38 | Host string `json:"host"` 39 | Port int32 `json:"port"` 40 | Mode string `json:"-"` 41 | Verbose VerboseLevel `json:"-"` 42 | } 43 | 44 | // HLSInfo is an information about HLS parameters for server 45 | type HLSInfo struct { 46 | MsPerSegment int64 `json:"hls_ms_per_segment"` 47 | Directory string `json:"-"` 48 | WindowSize uint `json:"hls_window_size"` 49 | Capacity uint `json:"hls_window_capacity"` 50 | } 51 | 52 | // ServerInfo is an information about server 53 | type ServerInfo struct { 54 | HTTPAddr string `json:"http_addr"` 55 | VideoHTTPPort int32 `json:"http_port"` 56 | APIHTTPPort int32 `json:"-"` 57 | } 58 | 59 | // NewApplication Prepare configuration for application 60 | func NewApplication(cfg *configuration.Configuration) (*Application, error) { 61 | tmp := Application{ 62 | APICfg: APIConfiguration{ 63 | Enabled: cfg.APICfg.Enabled, 64 | Host: cfg.APICfg.Host, 65 | Port: cfg.APICfg.Port, 66 | Mode: cfg.APICfg.Mode, 67 | Verbose: NewVerboseLevelFrom(cfg.APICfg.Verbose), 68 | }, 69 | VideoServerCfg: VideoConfiguration{ 70 | Host: cfg.VideoServerCfg.Host, 71 | Port: cfg.VideoServerCfg.Port, 72 | Verbose: NewVerboseLevelFrom(cfg.VideoServerCfg.Verbose), 73 | }, 74 | Streams: NewStreamsStorageDefault(), 75 | HLS: HLSInfo{ 76 | MsPerSegment: cfg.HLSCfg.MsPerSegment, 77 | Directory: cfg.HLSCfg.Directory, 78 | WindowSize: cfg.HLSCfg.WindowSize, 79 | Capacity: cfg.HLSCfg.Capacity, 80 | }, 81 | } 82 | if cfg.CorsConfig.Enabled { 83 | tmp.setCors(cfg.CorsConfig) 84 | } 85 | minioEnabled := false 86 | for rs := range cfg.RTSPStreams { 87 | rtspStream := cfg.RTSPStreams[rs] 88 | validUUID, err := uuid.Parse(rtspStream.GUID) 89 | if err != nil { 90 | log.Error().Err(err).Str("scope", SCOPE_CONFIGURATION).Str("stream_id", rtspStream.GUID).Msg("Not valid UUID") 91 | continue 92 | } 93 | outputTypes := make([]StreamType, 0, len(rtspStream.OutputTypes)) 94 | for _, v := range rtspStream.OutputTypes { 95 | typ, ok := streamTypeExists(v) 96 | if !ok { 97 | return nil, errors.Wrapf(ErrStreamTypeNotExists, "Type: '%s'", v) 98 | } 99 | if _, ok := supportedOutputStreamTypes[typ]; !ok { 100 | return nil, errors.Wrapf(ErrStreamTypeNotSupported, "Type: '%s'", v) 101 | } 102 | outputTypes = append(outputTypes, typ) 103 | } 104 | 105 | tmp.Streams.store[validUUID] = NewStreamConfiguration(rtspStream.URL, outputTypes) 106 | tmp.Streams.store[validUUID].verboseLevel = NewVerboseLevelFrom(rtspStream.Verbose) 107 | if rtspStream.Archive.Enabled && cfg.ArchiveCfg.Enabled { 108 | if rtspStream.Archive.MsPerSegment == 0 { 109 | return nil, fmt.Errorf("bad ms per segment archive stream") 110 | } 111 | storageType := storage.NewStorageTypeFrom(rtspStream.Archive.TypeArchive) 112 | var archiveStorage StreamArchiveWrapper 113 | switch storageType { 114 | case storage.STORAGE_FILESYSTEM: 115 | fsStorage, err := storage.NewFileSystemProvider(rtspStream.Archive.Directory) 116 | if err != nil { 117 | return nil, errors.Wrap(err, "Can't create filesystem provider") 118 | } 119 | archiveStorage = StreamArchiveWrapper{ 120 | store: fsStorage, 121 | filesystemDir: rtspStream.Archive.Directory, 122 | bucket: rtspStream.Archive.Directory, 123 | bucketPath: rtspStream.Archive.Directory, 124 | msPerSegment: rtspStream.Archive.MsPerSegment, 125 | } 126 | case storage.STORAGE_MINIO: 127 | if !minioEnabled { 128 | client, err := minio.New(fmt.Sprintf("%s:%d", cfg.ArchiveCfg.Minio.Host, cfg.ArchiveCfg.Minio.Port), &minio.Options{ 129 | Creds: credentials.NewStaticV4(cfg.ArchiveCfg.Minio.User, cfg.ArchiveCfg.Minio.Password, ""), 130 | Secure: false, 131 | }) 132 | if err != nil { 133 | return nil, errors.Wrap(err, "Can't connect to MinIO instance") 134 | } 135 | tmp.minioClient = client 136 | minioEnabled = true 137 | } 138 | minioStorage, err := storage.NewMinioProvider(tmp.minioClient, rtspStream.Archive.MinioBucket, rtspStream.Archive.MinioPath) 139 | if err != nil { 140 | return nil, errors.Wrap(err, "Can't create MinIO provider") 141 | } 142 | archiveStorage = StreamArchiveWrapper{ 143 | store: minioStorage, 144 | filesystemDir: rtspStream.Archive.Directory, 145 | bucket: rtspStream.Archive.MinioBucket, 146 | bucketPath: rtspStream.Archive.MinioPath, 147 | msPerSegment: rtspStream.Archive.MsPerSegment, 148 | } 149 | default: 150 | return nil, fmt.Errorf("unsupported archive type") 151 | } 152 | err = tmp.Streams.UpdateArchiveStorageForStream(validUUID, &archiveStorage) 153 | if err != nil { 154 | return nil, errors.Wrap(err, "can't set archive for given stream") 155 | } 156 | } 157 | } 158 | return &tmp, nil 159 | } 160 | 161 | func (app *Application) setCors(cfg configuration.CORSConfiguration) { 162 | newCors := cors.DefaultConfig() 163 | app.CorsConfig = &newCors 164 | app.CorsConfig.AllowOrigins = cfg.AllowOrigins 165 | if len(cfg.AllowMethods) != 0 { 166 | app.CorsConfig.AllowMethods = cfg.AllowMethods 167 | } 168 | if len(cfg.AllowHeaders) != 0 { 169 | app.CorsConfig.AllowHeaders = cfg.AllowHeaders 170 | } 171 | app.CorsConfig.ExposeHeaders = cfg.ExposeHeaders 172 | app.CorsConfig.AllowCredentials = cfg.AllowCredentials 173 | // See https://github.com/gofiber/fiber/security/advisories/GHSA-fmg4-x8pw-hjhg 174 | if app.CorsConfig.AllowCredentials { 175 | for _, v := range app.CorsConfig.AllowOrigins { 176 | if v == "*" { 177 | log.Warn().Str("scope", SCOPE_APP).Str("event", EVENT_APP_CORS_CONFIG).Msg("[CORS] Insecure setup, 'AllowCredentials' is set to true, and 'AllowOrigins' is set to a wildcard. Settings 'AllowCredentials' to be 'false'. See https://github.com/gofiber/fiber/security/advisories/GHSA-fmg4-x8pw-hjhg") 178 | app.CorsConfig.AllowCredentials = false 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | export GOOS=linux && export GOARCH=amd64 && export CGO_ENABLED=0 && go build -ldflags "-s -w" -o video_server -gcflags "all=-trimpath=$GOPATH" -trimpath cmd/video_server/main.go && tar -czvf linux-amd64-video_server.tar.gz video_server 2 | export GOOS=windows && export GOARCH=amd64 && export CGO_ENABLED=0 && go build -ldflags "-s -w" -o video_server.exe -gcflags "all=-trimpath=$GOPATH" -trimpath cmd/video_server/main.go && zip windows-video_server.zip video_server.exe 3 | -------------------------------------------------------------------------------- /cmd/video_server/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": { 3 | "enabled": true, 4 | "host": "localhost", 5 | "port": 8091, 6 | "mode": "release", 7 | "verbose": "v" 8 | }, 9 | "video": { 10 | "host": "localhost", 11 | "port": 8090, 12 | "mode": "release", 13 | "verbose": "v" 14 | }, 15 | "hls": { 16 | "ms_per_segment": 10000, 17 | "directory": "./hls", 18 | "window_size": 5, 19 | "window_capacity" : 10 20 | }, 21 | "archive": { 22 | "enabled": true, 23 | "directory": "./mp4", 24 | "ms_per_file": 30000, 25 | "minio_settings": { 26 | "host": "localhost", 27 | "port": 29199, 28 | "user": "minio_secret_login", 29 | "password": "minio_secret_password", 30 | "default_bucket": "archive-bucket", 31 | "default_path": "/var/archive_data" 32 | } 33 | }, 34 | "cors": { 35 | "enabled": true, 36 | "allow_origins": ["*"], 37 | "allow_methods": ["GET", "PUT", "POST", "DELETE"], 38 | "allow_headers": ["Origin", "Authorization", "Content-Type", "Content-Length", "Accept", "Accept-Encoding", "X-HttpRequest"], 39 | "expose_headers": ["Content-Length"] 40 | }, 41 | "rtsp_streams": [ 42 | { 43 | "guid": "0742091c-19cd-4658-9b4f-5320da160f45", 44 | "type": "rtsp", 45 | "url": "rtsp://localhost:45666/live", 46 | "output_types": ["mse"], 47 | "verbose": "v", 48 | "archive": { 49 | "enabled": true, 50 | "ms_per_file": 10000, 51 | "type": "filesystem", 52 | "directory": "custom_folder" 53 | } 54 | }, 55 | { 56 | "guid": "566bfe72-1f85-4e7d-9c0a-424e6c3b29f3", 57 | "type": "rtsp", 58 | "url": "rtsp://localhost:45664/live", 59 | "output_types": ["mse"], 60 | "verbose": "v", 61 | "archive": { 62 | "enabled": true, 63 | "ms_per_file": 15000, 64 | "type": "minio", 65 | "minio_bucket": "vod-bucket", 66 | "minio_path": "/var/archive_data_custom" 67 | } 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /cmd/video_server/conf.toml: -------------------------------------------------------------------------------- 1 | [api] 2 | enabled = true 3 | host = "localhost" 4 | port = 8091 5 | mode = "release" 6 | verbose = "v" 7 | 8 | [video] 9 | host = "localhost" 10 | port = 8090 11 | mode = "release" 12 | verbose = "v" 13 | 14 | [hls] 15 | ms_per_segment = 10000 16 | directory = "./hls" 17 | window_size = 5 18 | window_capacity = 10 19 | 20 | [archive] 21 | enabled = true 22 | directory = "./mp4" 23 | ms_per_file = 30000 24 | minio_settings = { host = "localhost", port = 29199, user = "minio_secret_login", password = "minio_secret_password", default_bucket = "archive-bucket", default_path = "/var/archive_data" } 25 | 26 | [cors] 27 | enabled = true 28 | allow_origins = ["*"] 29 | allow_methods = ["GET", "PUT", "POST", "DELETE"] 30 | allow_headers = [ 31 | "Origin", 32 | "Authorization", 33 | "Content-Type", 34 | "Content-Length", 35 | "Accept", 36 | "Accept-Encoding", 37 | "X-HttpRequest", 38 | ] 39 | expose_headers = ["Content-Length"] 40 | 41 | [[rtsp_streams]] 42 | guid = "0742091c-19cd-4658-9b4f-5320da160f45" 43 | type = "rtsp" 44 | url = "rtsp://localhost:45666/live" 45 | output_types = ["mse"] 46 | verbose = "v" 47 | archive = { enabled = true, ms_per_file = 10000, type = "filesystem", directory = "custom_folder" } 48 | 49 | [[rtsp_streams]] 50 | guid = "566bfe72-1f85-4e7d-9c0a-424e6c3b29f3" 51 | type = "rtsp" 52 | url = "rtsp://localhost:45664/live" 53 | output_types = ["mse"] 54 | verbose = "v" 55 | archive = { enabled = true, ms_per_file = 15000, type = "minio", minio_bucket = "vod-bucket", minio_path = "/var/archive_data_custom" } 56 | -------------------------------------------------------------------------------- /cmd/video_server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "os/signal" 7 | "runtime" 8 | "runtime/pprof" 9 | "strings" 10 | "syscall" 11 | "time" 12 | 13 | videoserver "github.com/LdDl/video-server" 14 | "github.com/LdDl/video-server/configuration" 15 | "github.com/gin-gonic/gin" 16 | "github.com/rs/zerolog" 17 | "github.com/rs/zerolog/log" 18 | ) 19 | 20 | var ( 21 | cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") 22 | memprofile = flag.String("memprofile", "", "write memory profile to `file`") 23 | conf = flag.String("conf", "conf.toml", "Path to configuration either TOML-file or JSON-file") 24 | EVENT_CPU = "cpu_profile" 25 | EVENT_MEMORY = "memory_profile" 26 | EVENT_APP_START = "app_start" 27 | EVENT_APP_STOP = "app_stop" 28 | EVENT_APP_SIGNAL_OS = "app_signal_os" 29 | ) 30 | 31 | func init() { 32 | zerolog.TimeFieldFormat = time.RFC3339Nano 33 | zerolog.DurationFieldUnit = time.Second 34 | } 35 | 36 | func main() { 37 | flag.Parse() 38 | if *cpuprofile != "" { 39 | f, err := os.Create(*cpuprofile) 40 | if err != nil { 41 | log.Error().Err(err).Str("event", EVENT_CPU).Msg("Could not create file for CPU profiling") 42 | return 43 | } 44 | defer f.Close() 45 | if err := pprof.StartCPUProfile(f); err != nil { 46 | log.Error().Err(err).Str("event", EVENT_CPU).Msg("Could not start CPU profiling") 47 | return 48 | } 49 | defer pprof.StopCPUProfile() 50 | } 51 | appCfg, err := configuration.PrepareConfiguration(*conf) 52 | if err != nil { 53 | log.Error().Err(err).Str("scope", videoserver.SCOPE_CONFIGURATION).Msg("Could not prepare application configuration") 54 | return 55 | } 56 | 57 | app, err := videoserver.NewApplication(appCfg) 58 | if err != nil { 59 | log.Error().Err(err).Str("scope", videoserver.SCOPE_CONFIGURATION).Msg("Could not prepare application") 60 | return 61 | } 62 | 63 | if strings.ToLower(app.APICfg.Mode) == "release" { 64 | gin.SetMode(gin.ReleaseMode) 65 | } 66 | 67 | // Run streams 68 | go app.StartStreams() 69 | 70 | // Start "Video" server 71 | go app.StartVideoServer() 72 | 73 | // Start API server 74 | if appCfg.APICfg.Enabled { 75 | go app.StartAPIServer() 76 | } 77 | 78 | sigOUT := make(chan os.Signal, 1) 79 | exit := make(chan bool, 1) 80 | signal.Notify(sigOUT, syscall.SIGINT, syscall.SIGTERM) 81 | go func() { 82 | sig := <-sigOUT 83 | log.Info().Str("event", EVENT_APP_SIGNAL_OS).Any("signal", sig).Msg("Server has captured signal") 84 | exit <- true 85 | }() 86 | log.Info().Str("event", EVENT_APP_START).Msg("Server has been started (awaiting signal to exit)") 87 | <-exit 88 | log.Info().Str("event", EVENT_APP_STOP).Msg("Stopping video server") 89 | 90 | if *memprofile != "" { 91 | f, err := os.Create(*memprofile) 92 | if err != nil { 93 | log.Error().Err(err).Str("event", EVENT_MEMORY).Msg("Could not create file for memory profiling") 94 | return 95 | } 96 | defer f.Close() 97 | // Explicit for garbage collection 98 | runtime.GC() 99 | if err := pprof.WriteHeapProfile(f); err != nil { 100 | log.Error().Err(err).Str("event", EVENT_MEMORY).Msg("Could not write to file for memory profiling") 101 | return 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // @todo: can we switch to TOML? Any benefits? 8 | 9 | // Configuration represents user defined settings for video server 10 | type Configuration struct { 11 | APICfg APIConfiguration `json:"api" toml:"api"` 12 | VideoServerCfg VideoConfiguration `json:"video" toml:"video"` 13 | HLSCfg HLSConfiguration `json:"hls" toml:"hls"` 14 | ArchiveCfg ArchiveConfiguration `json:"archive" toml:"archive"` 15 | CorsConfig CORSConfiguration `json:"cors" toml:"cors"` 16 | RTSPStreams []SingleStreamConfiguration `json:"rtsp_streams" toml:"rtsp_streams"` 17 | } 18 | 19 | // APIConfiguration is needed for configuring REST API part 20 | type APIConfiguration struct { 21 | Enabled bool `json:"enabled" toml:"enabled"` 22 | Host string `json:"host" toml:"host"` 23 | Port int32 `json:"port" toml:"port"` 24 | // 'release' or 'debug' for GIN 25 | Mode string `json:"mode" toml:"mode"` 26 | Verbose string `json:"verbose" toml:"verbose"` 27 | } 28 | 29 | // VideoConfiguration is needed for configuring actual video server part 30 | type VideoConfiguration struct { 31 | Host string `json:"host" toml:"host"` 32 | Port int32 `json:"port" toml:"port"` 33 | // 'release' or 'debug' for GIN 34 | Mode string `json:"mode" toml:"mode"` 35 | Verbose string `json:"verbose" toml:"verbose"` 36 | } 37 | 38 | // HLSConfiguration is a HLS configuration for every stream with provided "hls" type in 'output_types' field of 'rtsp_streams' objects 39 | type HLSConfiguration struct { 40 | MsPerSegment int64 `json:"ms_per_segment" toml:"ms_per_segment"` 41 | Directory string `json:"directory" toml:"directory"` 42 | WindowSize uint `json:"window_size" toml:"window_size"` 43 | Capacity uint `json:"window_capacity" toml:"window_capacity"` 44 | } 45 | 46 | // ArchiveConfiguration is a archive configuration for every stream with enabled archive option 47 | type ArchiveConfiguration struct { 48 | Enabled bool `json:"enabled" toml:"enabled"` 49 | MsPerSegment int64 `json:"ms_per_file" toml:"ms_per_file"` 50 | Directory string `json:"directory" toml:"directory"` 51 | Minio MinioSettings `json:"minio_settings" toml:"minio_settings"` 52 | } 53 | 54 | // MinioSettings 55 | type MinioSettings struct { 56 | Host string `json:"host" toml:"host"` 57 | Port int32 `json:"port" toml:"port"` 58 | User string `json:"user" toml:"user"` 59 | Password string `json:"password" toml:"password"` 60 | DefaultBucket string `json:"default_bucket" toml:"default_bucket"` 61 | DefaultPath string `json:"default_path" toml:"default_path"` 62 | } 63 | 64 | func (ms *MinioSettings) String() string { 65 | return fmt.Sprintf("Host '%s' Port '%d' User '%s' Pass '%s' Bucket '%s' Path '%s'", ms.Host, ms.Port, ms.User, ms.Password, ms.DefaultBucket, ms.DefaultPath) 66 | } 67 | 68 | // CORSConfiguration is settings for CORS 69 | type CORSConfiguration struct { 70 | Enabled bool `json:"enabled" toml:"enabled"` 71 | AllowOrigins []string `json:"allow_origins" toml:"allow_origins"` 72 | AllowMethods []string `json:"allow_methods" toml:"allow_methods"` 73 | AllowHeaders []string `json:"allow_headers" toml:"allow_headers"` 74 | ExposeHeaders []string `json:"expose_headers" toml:"expose_headers"` 75 | AllowCredentials bool `json:"allow_credentials" toml:"allow_credentials"` 76 | } 77 | 78 | // SingleStreamConfiguration is needed for configuring certain RTSP stream 79 | type SingleStreamConfiguration struct { 80 | GUID string `json:"guid" toml:"guid"` 81 | URL string `json:"url" toml:"url"` 82 | Type string `json:"type" toml:"type"` 83 | OutputTypes []string `json:"output_types" toml:"output_types"` 84 | Archive StreamArchiveConfiguration `json:"archive" toml:"archive"` 85 | // Level of verbose. Pick 'v' or 'vvv' (or leave it empty) 86 | Verbose string `json:"verbose" toml:"verbose"` 87 | } 88 | 89 | // StreamArchiveConfiguration is a archive configuration for cpecific stream. I can overwrite parent archive options in needed 90 | type StreamArchiveConfiguration struct { 91 | Enabled bool `json:"enabled" toml:"enabled"` 92 | MsPerSegment int64 `json:"ms_per_file" toml:"ms_per_file"` 93 | Directory string `json:"directory" toml:"directory"` 94 | TypeArchive string `json:"type" toml:"type"` 95 | MinioBucket string `json:"minio_bucket" toml:"minio_bucket"` 96 | MinioPath string `json:"minio_path" toml:"minio_path"` 97 | } 98 | -------------------------------------------------------------------------------- /configuration/postprocess_cfg.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | const ( 4 | defaultHlsDir = "./hls" 5 | defaultHlsMsPerSegment = 10000 6 | defaultHlsCapacity = 10 7 | defaultHlsWindowSize = 5 8 | ) 9 | 10 | func postProcessDefaults(cfg *Configuration) { 11 | if cfg.HLSCfg.Directory == "" { 12 | cfg.HLSCfg.Directory = defaultHlsDir 13 | } 14 | if cfg.HLSCfg.MsPerSegment == 0 { 15 | cfg.HLSCfg.MsPerSegment = defaultHlsMsPerSegment 16 | } 17 | if cfg.HLSCfg.Capacity == 0 { 18 | cfg.HLSCfg.Capacity = defaultHlsCapacity 19 | } 20 | if cfg.HLSCfg.WindowSize == 0 { 21 | cfg.HLSCfg.WindowSize = defaultHlsWindowSize 22 | } 23 | if cfg.HLSCfg.WindowSize > cfg.HLSCfg.Capacity { 24 | cfg.HLSCfg.WindowSize = cfg.HLSCfg.Capacity 25 | } 26 | for i := range cfg.RTSPStreams { 27 | stream := cfg.RTSPStreams[i] 28 | archiveCfg := stream.Archive 29 | if !archiveCfg.Enabled { 30 | continue 31 | } 32 | 33 | // Default common settings for archive 34 | if archiveCfg.MsPerSegment <= 0 { 35 | if cfg.ArchiveCfg.MsPerSegment > 0 { 36 | cfg.RTSPStreams[i].Archive.MsPerSegment = cfg.ArchiveCfg.MsPerSegment 37 | } else { 38 | cfg.RTSPStreams[i].Archive.MsPerSegment = 30 39 | } 40 | } 41 | 42 | // Default filesystem settigs 43 | if archiveCfg.Directory == "" { 44 | if cfg.ArchiveCfg.Directory != "" { 45 | cfg.RTSPStreams[i].Archive.Directory = cfg.ArchiveCfg.Directory 46 | } else { 47 | cfg.RTSPStreams[i].Archive.Directory = "./mp4" 48 | } 49 | } 50 | 51 | // Default minio settings 52 | if archiveCfg.MinioBucket == "" { 53 | cfg.RTSPStreams[i].Archive.MinioBucket = cfg.ArchiveCfg.Minio.DefaultBucket 54 | } 55 | if archiveCfg.MinioPath == "" { 56 | cfg.RTSPStreams[i].Archive.MinioPath = cfg.ArchiveCfg.Minio.DefaultPath 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /configuration/prepare.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func PrepareConfiguration(confName string) (*Configuration, error) { 11 | var err error 12 | if confName == "" { 13 | errReason := "Empty file name" 14 | return nil, errors.Wrap(err, errReason) 15 | } 16 | 17 | fileNames := strings.Split(confName, ".") 18 | if len(fileNames) != 2 { 19 | errReason := fmt.Sprintf("Bad file name '%s'", confName) 20 | return nil, errors.Wrap(err, errReason) 21 | } 22 | fileFormat := fileNames[1] 23 | 24 | switch fileFormat { 25 | case "json": 26 | mainCfg, err := PrepareConfigurationJSON(confName) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return mainCfg, nil 31 | case "toml": 32 | mainCfg, err := PrepareConfigurationTOML(confName) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return mainCfg, nil 37 | default: 38 | errReason := fmt.Sprintf("Not supported file format '%s'", fileFormat) 39 | return nil, errors.Wrap(err, errReason) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /configuration/prepare_json.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | func PrepareConfigurationJSON(fname string) (*Configuration, error) { 9 | configFile, err := os.ReadFile(fname) 10 | if err != nil { 11 | return nil, err 12 | } 13 | cfg := &Configuration{} 14 | err = json.Unmarshal(configFile, &cfg) 15 | if err != nil { 16 | return nil, err 17 | } 18 | postProcessDefaults(cfg) 19 | return cfg, nil 20 | } 21 | -------------------------------------------------------------------------------- /configuration/prepare_toml.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | ) 6 | 7 | func PrepareConfigurationTOML(fname string) (*Configuration, error) { 8 | cfg := &Configuration{} 9 | _, err := toml.DecodeFile(fname, cfg) 10 | if err != nil { 11 | return nil, err 12 | } 13 | postProcessDefaults(cfg) 14 | return cfg, nil 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | minio: 5 | image: minio/minio:latest 6 | command: server --console-address ":29001" --address ":29199" /data/ 7 | ports: 8 | - "29199:29199" 9 | - "29001:29001" 10 | environment: 11 | # Access key length should be at least 3, and secret key length at least 8 characters 12 | MINIO_ROOT_USER: minio_secret_login 13 | MINIO_ROOT_PASSWORD: minio_secret_password 14 | volumes: 15 | - minio-storage:/data 16 | volumes: 17 | minio-storage: 18 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | var ( 8 | ErrStreamNotFound = fmt.Errorf("stream not found for provided ID") 9 | ErrStreamHasNoVideo = fmt.Errorf("stream has no video") 10 | ErrStreamDisconnected = fmt.Errorf("disconnected") 11 | ErrStreamTypeNotExists = fmt.Errorf("stream type does not exists") 12 | ErrStreamTypeNotSupported = fmt.Errorf("stream type is not supported") 13 | ErrNotSupportedStorage = fmt.Errorf("not supported storage") 14 | ErrNullArchive = fmt.Errorf("archive == nil") 15 | ) 16 | -------------------------------------------------------------------------------- /example_client/hls_example/README.md: -------------------------------------------------------------------------------- 1 | # Example of HLS-based player on VueJS 2 | 3 | ## Install dependencies 4 | ```shell 5 | npm install 6 | ``` 7 | 8 | ### Run debugging mode with hot-reload ability 9 | ```shell 10 | npm run dev 11 | ``` 12 | 13 | ### Prepare for build mode and run 14 | ```shell 15 | npm run build 16 | npm run start 17 | ``` -------------------------------------------------------------------------------- /example_client/hls_example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /example_client/hls_example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-ws-player", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "core-js": "^3.6.4", 12 | "hls.js": "^1.5.15", 13 | "vue": "^2.6.11" 14 | }, 15 | "devDependencies": { 16 | "@vue/cli-plugin-babel": "~4.3.0", 17 | "@vue/cli-plugin-eslint": "~4.3.0", 18 | "@vue/cli-service": "~4.3.0", 19 | "babel-eslint": "^10.1.0", 20 | "eslint": "^6.7.2", 21 | "eslint-plugin-vue": "^6.2.2", 22 | "vue-template-compiler": "^2.6.11" 23 | }, 24 | "eslintConfig": { 25 | "root": true, 26 | "env": { 27 | "node": true 28 | }, 29 | "extends": [ 30 | "plugin:vue/essential", 31 | "eslint:recommended" 32 | ], 33 | "parserOptions": { 34 | "parser": "babel-eslint" 35 | }, 36 | "rules": {} 37 | }, 38 | "browserslist": [ 39 | "> 1%", 40 | "last 2 versions", 41 | "not dead" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /example_client/hls_example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LdDl/video-server/92fba1605a7278031e3fe1fa09a58129e5b451ee/example_client/hls_example/public/favicon.ico -------------------------------------------------------------------------------- /example_client/hls_example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example_client/hls_example/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /example_client/hls_example/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LdDl/video-server/92fba1605a7278031e3fe1fa09a58129e5b451ee/example_client/hls_example/src/assets/logo.png -------------------------------------------------------------------------------- /example_client/hls_example/src/components/HLSPlayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 95 | 96 | -------------------------------------------------------------------------------- /example_client/hls_example/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /example_client/mse_example/README.md: -------------------------------------------------------------------------------- 1 | # Example of MSE-based (websockets) player on VueJS 2 | 3 | ## Install dependencies 4 | ```shell 5 | npm install 6 | ``` 7 | 8 | ### Run debugging mode with hot-reload ability 9 | ```shell 10 | npm run dev 11 | ``` 12 | 13 | ### Prepare for build mode and run 14 | ```shell 15 | npm run build 16 | npm run start 17 | ``` -------------------------------------------------------------------------------- /example_client/mse_example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /example_client/mse_example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-ws-player", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "core-js": "^3.6.4", 12 | "vue": "^2.6.11" 13 | }, 14 | "devDependencies": { 15 | "@vue/cli-plugin-babel": "~4.3.0", 16 | "@vue/cli-plugin-eslint": "~4.3.0", 17 | "@vue/cli-service": "~4.3.0", 18 | "babel-eslint": "^10.1.0", 19 | "eslint": "^6.7.2", 20 | "eslint-plugin-vue": "^6.2.2", 21 | "vue-template-compiler": "^2.6.11" 22 | }, 23 | "eslintConfig": { 24 | "root": true, 25 | "env": { 26 | "node": true 27 | }, 28 | "extends": [ 29 | "plugin:vue/essential", 30 | "eslint:recommended" 31 | ], 32 | "parserOptions": { 33 | "parser": "babel-eslint" 34 | }, 35 | "rules": {} 36 | }, 37 | "browserslist": [ 38 | "> 1%", 39 | "last 2 versions", 40 | "not dead" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /example_client/mse_example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LdDl/video-server/92fba1605a7278031e3fe1fa09a58129e5b451ee/example_client/mse_example/public/favicon.ico -------------------------------------------------------------------------------- /example_client/mse_example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example_client/mse_example/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /example_client/mse_example/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LdDl/video-server/92fba1605a7278031e3fe1fa09a58129e5b451ee/example_client/mse_example/src/assets/logo.png -------------------------------------------------------------------------------- /example_client/mse_example/src/components/MSEPlayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 162 | 163 | -------------------------------------------------------------------------------- /example_client/mse_example/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /example_client/vanila_js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MSE workaround vanilla 6 | 7 | 8 |
9 |
11 | 116 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/LdDl/video-server 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.22.5 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.4.0 9 | github.com/deepch/vdk v0.0.27 10 | github.com/gin-contrib/cors v1.7.2 11 | github.com/gin-contrib/pprof v1.5.0 12 | github.com/gin-gonic/gin v1.10.0 13 | github.com/google/uuid v1.6.0 14 | github.com/gorilla/websocket v1.5.3 15 | github.com/grafov/m3u8 v0.11.1 16 | github.com/minio/minio-go/v7 v7.0.76 17 | github.com/pkg/errors v0.9.1 18 | github.com/rs/zerolog v1.33.0 19 | ) 20 | 21 | require ( 22 | github.com/bytedance/sonic v1.12.2 // indirect 23 | github.com/bytedance/sonic/loader v0.2.0 // indirect 24 | github.com/cloudwego/base64x v0.1.4 // indirect 25 | github.com/cloudwego/iasm v0.2.0 // indirect 26 | github.com/dustin/go-humanize v1.0.1 // indirect 27 | github.com/gabriel-vasile/mimetype v1.4.5 // indirect 28 | github.com/gin-contrib/sse v0.1.0 // indirect 29 | github.com/go-ini/ini v1.67.0 // indirect 30 | github.com/go-playground/locales v0.14.1 // indirect 31 | github.com/go-playground/universal-translator v0.18.1 // indirect 32 | github.com/go-playground/validator/v10 v10.22.0 // indirect 33 | github.com/goccy/go-json v0.10.3 // indirect 34 | github.com/google/go-cmp v0.6.0 // indirect 35 | github.com/json-iterator/go v1.1.12 // indirect 36 | github.com/klauspost/compress v1.17.9 // indirect 37 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 38 | github.com/kr/text v0.2.0 // indirect 39 | github.com/leodido/go-urn v1.4.0 // indirect 40 | github.com/mattn/go-colorable v0.1.13 // indirect 41 | github.com/mattn/go-isatty v0.0.20 // indirect 42 | github.com/minio/md5-simd v1.1.2 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.2 // indirect 45 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 46 | github.com/rs/xid v1.6.0 // indirect 47 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 48 | github.com/ugorji/go/codec v1.2.12 // indirect 49 | golang.org/x/arch v0.9.0 // indirect 50 | golang.org/x/crypto v0.26.0 // indirect 51 | golang.org/x/net v0.28.0 // indirect 52 | golang.org/x/sys v0.24.0 // indirect 53 | golang.org/x/text v0.17.0 // indirect 54 | google.golang.org/protobuf v1.34.2 // indirect 55 | gopkg.in/yaml.v3 v3.0.1 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= 4 | github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= 5 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 6 | github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= 7 | github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 8 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 9 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 10 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 11 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 12 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/deepch/vdk v0.0.27 h1:j/SHaTiZhA47wRpaue8NRp7P9xwOOO/lunxrDJBwcao= 18 | github.com/deepch/vdk v0.0.27/go.mod h1:JlgGyR2ld6+xOIHa7XAxJh+stSDBAkdNvIPkUIdIywk= 19 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 20 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 21 | github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= 22 | github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= 23 | github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= 24 | github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= 25 | github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU= 26 | github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs= 27 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 28 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 29 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 30 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 31 | github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 32 | github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 33 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 34 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 35 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 36 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 37 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 38 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 39 | github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= 40 | github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 41 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 42 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 43 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 44 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 45 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 46 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 47 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 48 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 49 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 50 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 51 | github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= 52 | github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= 53 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 54 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 55 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 56 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 57 | github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 58 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 59 | github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= 60 | github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 61 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 62 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 63 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 64 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 65 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 66 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 67 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 68 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 69 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 70 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 71 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 72 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 73 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 74 | github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 75 | github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 76 | github.com/minio/minio-go/v7 v7.0.76 h1:9nxHH2XDai61cT/EFhyIw/wW4vJfpPNvl7lSFpRt+Ng= 77 | github.com/minio/minio-go/v7 v7.0.76/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg= 78 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 79 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 81 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 82 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 83 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 84 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 85 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 86 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 87 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 88 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 89 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= 90 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 91 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 92 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 93 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 94 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 95 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 96 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 97 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 98 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 99 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 100 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 101 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 102 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 103 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 104 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 105 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 106 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 107 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 108 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 109 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 110 | golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= 111 | golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 112 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 113 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 114 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 115 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 116 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 121 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 122 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 123 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 124 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 125 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 126 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 128 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 129 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 131 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 132 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 133 | -------------------------------------------------------------------------------- /hls.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/deepch/vdk/av" 12 | "github.com/deepch/vdk/format/ts" 13 | "github.com/google/uuid" 14 | "github.com/grafov/m3u8" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | // startHls starts routine to create m3u8 playlists 19 | func (app *Application) startHls(streamID uuid.UUID, ch chan av.Packet, stopCast chan StopSignal) error { 20 | err := ensureDir(app.HLS.Directory) 21 | if err != nil { 22 | return errors.Wrap(err, "Can't create directory for HLS temporary files") 23 | } 24 | 25 | // Create playlist for HLS streams 26 | playlistFileName := filepath.Join(app.HLS.Directory, fmt.Sprintf("%s.m3u8", streamID)) 27 | log.Info().Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_PLAYLIST_PREPARE).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Msg("Need to start HLS for the given stream") 28 | playlist, err := m3u8.NewMediaPlaylist(app.HLS.WindowSize, app.HLS.Capacity) 29 | if err != nil { 30 | return errors.Wrap(err, "Can't create new mediaplayer list") 31 | } 32 | 33 | isConnected := true 34 | segmentNumber := 0 35 | lastPacketTime := time.Duration(0) 36 | lastKeyFrame := av.Packet{} 37 | 38 | // time.Sleep(5 * time.Second) // Artificial delay to wait for first key frame 39 | for isConnected { 40 | // Create new segment file 41 | segmentName := fmt.Sprintf("%s%04d.ts", streamID, segmentNumber) 42 | segmentPath := filepath.Join(app.HLS.Directory, segmentName) 43 | outFile, err := os.Create(segmentPath) 44 | if err != nil { 45 | return errors.Wrap(err, fmt.Sprintf("Can't create TS-segment for stream %s", streamID)) 46 | } 47 | tsMuxer := ts.NewMuxer(outFile) 48 | 49 | // Write header 50 | codecData, err := app.Streams.GetCodecsDataForStream(streamID) 51 | if err != nil { 52 | return errors.Wrap(err, streamID.String()) 53 | } 54 | err = tsMuxer.WriteHeader(codecData) 55 | if err != nil { 56 | return errors.Wrap(err, fmt.Sprintf("Can't write header for TS muxer for stream %s", streamID)) 57 | } 58 | 59 | // Write packets 60 | videoStreamIdx := int8(0) 61 | for idx, codec := range codecData { 62 | if codec.Type().IsVideo() { 63 | videoStreamIdx = int8(idx) 64 | break 65 | } 66 | } 67 | 68 | segmentLength := time.Duration(0) 69 | packetLength := time.Duration(0) 70 | segmentCount := 0 71 | start := false 72 | 73 | // Write lastKeyFrame if exist 74 | if lastKeyFrame.IsKeyFrame { 75 | start = true 76 | if err = tsMuxer.WritePacket(lastKeyFrame); err != nil { 77 | return errors.Wrap(err, fmt.Sprintf("Can't write packet for TS muxer for stream %s (1)", streamID)) 78 | } 79 | // Evaluate segment's length 80 | packetLength = lastKeyFrame.Time - lastPacketTime 81 | lastPacketTime = lastKeyFrame.Time 82 | segmentLength += packetLength 83 | segmentCount++ 84 | } 85 | 86 | // @todo Oh, I don't like GOTOs, but it is what it is. 87 | segmentLoop: 88 | for { 89 | select { 90 | case <-stopCast: 91 | isConnected = false 92 | break segmentLoop 93 | case pck := <-ch: 94 | if pck.Idx == videoStreamIdx && pck.IsKeyFrame { 95 | start = true 96 | if segmentLength.Milliseconds() >= app.HLS.MsPerSegment { 97 | lastKeyFrame = pck 98 | break segmentLoop 99 | } 100 | } 101 | if !start { 102 | continue 103 | } 104 | if (pck.Idx == videoStreamIdx && pck.Time > lastPacketTime) || pck.Idx != videoStreamIdx { 105 | if err = tsMuxer.WritePacket(pck); err != nil { 106 | return errors.Wrap(err, fmt.Sprintf("Can't write packet for TS muxer for stream %s (2)", streamID)) 107 | } 108 | if pck.Idx == videoStreamIdx { 109 | // Evaluate segment length 110 | packetLength = pck.Time - lastPacketTime 111 | lastPacketTime = pck.Time 112 | segmentLength += packetLength 113 | } 114 | segmentCount++ 115 | } else { 116 | // fmt.Println("Current packet time < previous ") 117 | } 118 | } 119 | } 120 | 121 | err = tsMuxer.WriteTrailer() 122 | if err != nil { 123 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_WRITE_TRAIL).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("out_filename", outFile.Name()).Msg("Can't write trailing data for TS muxer") 124 | // @todo: handle? 125 | } 126 | 127 | err = outFile.Close() 128 | if err != nil { 129 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_CLOSE_FILE).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("out_filename", outFile.Name()).Msg("Can't close file") 130 | // @todo: handle? 131 | } 132 | 133 | // Update playlist 134 | playlist.Slide(segmentName, segmentLength.Seconds(), "") 135 | playlistFile, err := os.Create(playlistFileName) 136 | if err != nil { 137 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_PLAYLIST_CREATE).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("out_filename", outFile.Name()).Msg("Can't create playlist") 138 | // @todo: handle? 139 | } 140 | playlistFile.Write(playlist.Encode().Bytes()) 141 | playlistFile.Close() 142 | log.Info().Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_PLAYLIST_RESTART).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("out_filename", outFile.Name()).Msg("Playlist restart") 143 | // Cleanup segments 144 | if err := app.removeOutdatedSegments(streamID, playlist); err != nil { 145 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_REMOVE_OUTDATED).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("out_filename", outFile.Name()).Msg("Can't remove outdated segments") 146 | // @todo: handle? 147 | } 148 | 149 | segmentNumber++ 150 | } 151 | 152 | filesToRemove := make([]string, len(playlist.Segments)+1) 153 | 154 | // Collect obsolete files 155 | for _, segment := range playlist.Segments { 156 | if segment != nil { 157 | filesToRemove = append(filesToRemove, segment.URI) 158 | } 159 | } 160 | _, fileName := filepath.Split(playlistFileName) 161 | filesToRemove = append(filesToRemove, fileName) 162 | 163 | // Defered removement 164 | go func(delay time.Duration, filesToRemove []string) { 165 | time.Sleep(delay) 166 | for _, file := range filesToRemove { 167 | if file != "" { 168 | if err := os.Remove(filepath.Join(app.HLS.Directory, file)); err != nil { 169 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_REMOVE_CHUNK).Str("stream_id", streamID.String()).Str("filename", playlistFileName).Str("chunk_name", file).Msg("Can't remove file (defered)") 170 | // @todo: handle? 171 | } 172 | } 173 | } 174 | }(time.Duration(app.HLS.MsPerSegment*int64(playlist.Count()))*time.Millisecond, filesToRemove) 175 | 176 | return nil 177 | } 178 | 179 | // removeOutdatedSegments removes outdated *.ts 180 | func (app *Application) removeOutdatedSegments(streamID uuid.UUID, playlist *m3u8.MediaPlaylist) error { 181 | // Write all playlist segment URIs into map 182 | currentSegments := make(map[string]struct{}, len(playlist.Segments)) 183 | for _, segment := range playlist.Segments { 184 | if segment != nil { 185 | currentSegments[segment.URI] = struct{}{} 186 | } 187 | } 188 | // Find possible segment files in current directory 189 | segmentFiles, err := filepath.Glob(filepath.Join(app.HLS.Directory, fmt.Sprintf("%s*.ts", streamID))) 190 | if err != nil { 191 | return err 192 | } 193 | for _, segmentFile := range segmentFiles { 194 | _, fileName := filepath.Split(segmentFile) 195 | // Check if file belongs to a playlist's segment 196 | if _, ok := currentSegments[fileName]; !ok { 197 | if err := os.Remove(segmentFile); err != nil { 198 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_REMOVE_OUTDATED_SEGMENT).Str("stream_id", streamID.String()).Str("filename", playlist.String()).Str("segment", segmentFile).Msg("Can't remove outdated segment") 199 | // @todo: handle? 200 | } 201 | } 202 | } 203 | return nil 204 | } 205 | -------------------------------------------------------------------------------- /http_server.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gin-contrib/cors" 9 | "github.com/gin-contrib/pprof" 10 | "github.com/gin-gonic/gin" 11 | "github.com/google/uuid" 12 | "github.com/pkg/errors" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | // StartAPIServer starts server with API functionality 17 | func (app *Application) StartAPIServer() error { 18 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_PREPARE).Msg("Preparing to start API Server") 19 | router := gin.New() 20 | 21 | pprof.Register(router) 22 | 23 | if app.CorsConfig != nil { 24 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_CORS_ENABLE). 25 | Bool("cors_allow_all_origins", app.CorsConfig.AllowAllOrigins). 26 | Any("cors_allow_origins", app.CorsConfig.AllowOrigins). 27 | Any("cors_allow_methods", app.CorsConfig.AllowMethods). 28 | Bool("cors_allow_private_network", app.CorsConfig.AllowPrivateNetwork). 29 | Any("cors_allow_headers", app.CorsConfig.AllowHeaders). 30 | Bool("cors_allow_credentials", app.CorsConfig.AllowCredentials). 31 | Any("cors_expose_headers", app.CorsConfig.ExposeHeaders). 32 | Dur("cors_max_age", app.CorsConfig.MaxAge). 33 | Bool("cors_allow_wildcard", app.CorsConfig.AllowWildcard). 34 | Bool("cors_allow_browser_extensions", app.CorsConfig.AllowBrowserExtensions). 35 | Any("cors_custom_schemas", app.CorsConfig.CustomSchemas). 36 | Bool("cors_allow_websockets", app.CorsConfig.AllowWebSockets). 37 | Bool("cors_allow_files", app.CorsConfig.AllowFiles). 38 | Int("cors_allow_option_status_code", app.CorsConfig.OptionsResponseStatusCode). 39 | Msg("CORS are enabled") 40 | router.Use(cors.New(*app.CorsConfig)) 41 | } 42 | router.GET("/list", ListWrapper(app, app.APICfg.Verbose)) 43 | router.GET("/status", StatusWrapper(app, app.APICfg.Verbose)) 44 | router.POST("/enable_camera", EnableCamera(app, app.APICfg.Verbose)) 45 | router.POST("/disable_camera", DisableCamera(app, app.APICfg.Verbose)) 46 | 47 | url := fmt.Sprintf("%s:%d", app.APICfg.Host, app.APICfg.Port) 48 | s := &http.Server{ 49 | Addr: url, 50 | Handler: router, 51 | ReadTimeout: 30 * time.Second, 52 | WriteTimeout: 30 * time.Second, 53 | } 54 | if app.APICfg.Verbose > VERBOSE_NONE { 55 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_START).Str("url", url).Msg("Start microservice for API server") 56 | } 57 | err := s.ListenAndServe() 58 | if err != nil { 59 | if app.APICfg.Verbose > VERBOSE_NONE { 60 | log.Error().Err(err).Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_START).Str("url", url).Msg("Can't start API server routers") 61 | } 62 | return errors.Wrap(err, "Can't start API") 63 | } 64 | return nil 65 | } 66 | 67 | type StreamsInfoShortenList struct { 68 | Data []StreamInfoShorten `json:"data"` 69 | } 70 | 71 | type StreamInfoShorten struct { 72 | StreamID string `json:"stream_id"` 73 | } 74 | 75 | // ListWrapper returns list of streams 76 | func ListWrapper(app *Application, verboseLevel VerboseLevel) func(ctx *gin.Context) { 77 | return func(ctx *gin.Context) { 78 | if verboseLevel > VERBOSE_SIMPLE { 79 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg("Call streams list") 80 | } 81 | allStreamsIDs := app.Streams.GetAllStreamsIDS() 82 | ans := StreamsInfoShortenList{ 83 | Data: make([]StreamInfoShorten, len(allStreamsIDs)), 84 | } 85 | for i, streamID := range allStreamsIDs { 86 | ans.Data[i] = StreamInfoShorten{ 87 | StreamID: streamID.String(), 88 | } 89 | } 90 | ctx.JSON(200, ans) 91 | } 92 | } 93 | 94 | // StatusWrapper returns statuses for list of streams 95 | func StatusWrapper(app *Application, verboseLevel VerboseLevel) func(ctx *gin.Context) { 96 | return func(ctx *gin.Context) { 97 | if verboseLevel > VERBOSE_SIMPLE { 98 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg("Call streams' statuses list") 99 | } 100 | ctx.JSON(200, app) 101 | } 102 | } 103 | 104 | // EnablePostData is a POST-body for API which enables to turn on/off specific streams 105 | type EnablePostData struct { 106 | GUID uuid.UUID `json:"guid"` 107 | URL string `json:"url"` 108 | OutputTypes []string `json:"output_types"` 109 | } 110 | 111 | // EnableCamera adds new stream if does not exist 112 | func EnableCamera(app *Application, verboseLevel VerboseLevel) func(ctx *gin.Context) { 113 | return func(ctx *gin.Context) { 114 | if verboseLevel > VERBOSE_SIMPLE { 115 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg("Try to enable camera") 116 | } 117 | var postData EnablePostData 118 | if err := ctx.ShouldBindJSON(&postData); err != nil { 119 | errReason := "Bad JSON binding" 120 | if verboseLevel > VERBOSE_NONE { 121 | log.Error().Err(err).Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg(errReason) 122 | } 123 | ctx.JSON(http.StatusBadRequest, gin.H{"Error": errReason}) 124 | return 125 | } 126 | if exist := app.Streams.StreamExists(postData.GUID); !exist { 127 | outputTypes := make([]StreamType, 0, len(postData.OutputTypes)) 128 | for _, v := range postData.OutputTypes { 129 | typ, ok := streamTypeExists(v) 130 | if !ok { 131 | errReason := fmt.Sprintf("%s. Type: '%s'", ErrStreamTypeNotExists, v) 132 | if verboseLevel > VERBOSE_NONE { 133 | log.Error().Err(fmt.Errorf(errReason)).Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg(errReason) 134 | } 135 | ctx.JSON(http.StatusBadRequest, gin.H{"Error": errReason}) 136 | return 137 | } 138 | if _, ok := supportedOutputStreamTypes[typ]; !ok { 139 | errReason := fmt.Sprintf("%s. Type: '%s'", ErrStreamTypeNotSupported, v) 140 | if verboseLevel > VERBOSE_NONE { 141 | log.Error().Err(fmt.Errorf(errReason)).Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg(errReason) 142 | } 143 | ctx.JSON(http.StatusBadRequest, gin.H{"Error": errReason}) 144 | return 145 | } 146 | outputTypes = append(outputTypes, typ) 147 | } 148 | app.Streams.Lock() 149 | app.Streams.store[postData.GUID] = NewStreamConfiguration(postData.URL, outputTypes) 150 | app.Streams.Unlock() 151 | app.StartStream(postData.GUID) 152 | } 153 | ctx.JSON(200, app) 154 | } 155 | } 156 | 157 | // DisableCamera turns off stream for specific stream ID 158 | func DisableCamera(app *Application, verboseLevel VerboseLevel) func(ctx *gin.Context) { 159 | return func(ctx *gin.Context) { 160 | if verboseLevel > VERBOSE_SIMPLE { 161 | log.Info().Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg("Try to disable camera") 162 | } 163 | var postData EnablePostData 164 | if err := ctx.ShouldBindJSON(&postData); err != nil { 165 | errReason := "Bad JSON binding" 166 | if verboseLevel > VERBOSE_NONE { 167 | log.Error().Err(err).Str("scope", SCOPE_API_SERVER).Str("event", EVENT_API_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg(errReason) 168 | } 169 | ctx.JSON(http.StatusBadRequest, gin.H{"Error": errReason}) 170 | return 171 | } 172 | if exist := app.Streams.StreamExists(postData.GUID); exist { 173 | app.Streams.Lock() 174 | delete(app.Streams.store, postData.GUID) 175 | app.Streams.Unlock() 176 | } 177 | ctx.JSON(200, app) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | const ( 4 | SCOPE_APP = "app" 5 | SCOPE_CONFIGURATION = "configuration" 6 | SCOPE_STREAM = "stream" 7 | SCOPE_STREAMING = "streaming" 8 | SCOPE_WS_HANDLER = "ws_handler" 9 | SCOPE_API_SERVER = "api_server" 10 | SCOPE_WS_SERVER = "ws_server" 11 | SCOPE_ARCHIVE = "archive" 12 | SCOPE_MP4 = "mp4" 13 | SCOPE_HLS = "hls" 14 | 15 | EVENT_APP_CORS_CONFIG = "app_cors_config" 16 | 17 | EVENT_STREAM_CODEC_ADD = "stream_codec_add" 18 | EVENT_STREAM_STATUS_UPDATE = "stream_status_update" 19 | EVENT_STREAM_CLIENT_ADD = "stream_client_add" 20 | EVENT_STREAM_CLIENT_DELETE = "stream_client_delete" 21 | EVENT_STREAM_CAST_PACKET = "stream_cast" 22 | 23 | EVENT_STREAMING_RUN = "streaming_run" 24 | EVENT_STREAMING_START = "streaming_start" 25 | EVENT_STREAMING_DONE = "streaming_done" 26 | EVENT_STREAMING_RESTART = "streaming_restart" 27 | EVENT_STREAMING_DIAL = "streaming_dial" 28 | EVENT_STREAMING_STATUS_UPDATE = "streaming_status_update" 29 | EVENT_STREAMING_PACKET_SIGNAL = "streaming_packet_signal" 30 | EVENT_STREAMING_STOP_SIGNAL = "streaming_stop_signal" 31 | EVENT_STREAMING_UNKNOWN_SIGNAL = "streaming_unknown_signal" 32 | EVENT_STREAMING_CODEC_UPDATE_SIGNAL = "streaming_codec_update_signal" 33 | EVENT_STREAMING_EXIT_SIGNAL = "streaming_codec_exit_signal" 34 | EVENT_STREAMING_CODEC_MET = "streaming_codec_met" 35 | EVENT_STREAMING_AUDIO_MET = "streaming_audio_met" 36 | EVENT_STREAMING_HLS_CAST = "streaming_hls_cast" 37 | EVENT_STREAMING_MP4_CAST = "streaming_mp4_cast" 38 | 39 | EVENT_API_PREPARE = "api_server_prepare" 40 | EVENT_API_START = "api_server_start" 41 | EVENT_API_CORS_ENABLE = "api_server_cors_enable" 42 | EVENT_API_REQUEST = "api_request" 43 | 44 | EVENT_WS_PREPARE = "ws_server_prepare" 45 | EVENT_WS_START = "ws_server_start" 46 | EVENT_WS_CORS_ENABLE = "ws_server_cors_enable" 47 | EVENT_WS_REQUEST = "ws_request" 48 | EVENT_WS_UPGRADER = "ws_upgrader" 49 | EVENT_WS_PING = "ws_ping" 50 | 51 | EVENT_HLS_START_CAST = "hls_start_cast" 52 | EVENT_HLS_PLAYLIST_PREPARE = "hls_playlist_prepare" 53 | EVENT_HLS_PLAYLIST_CREATE = "hls_playlist_create" 54 | EVENT_HLS_PLAYLIST_RESTART = "hls_playlist_restart" 55 | EVENT_HLS_CLOSE_FILE = "hls_close_file" 56 | EVENT_HLS_WRITE_TRAIL = "hls_write_trail" 57 | EVENT_HLS_REMOVE_OUTDATED = "hls_remove_outdated" 58 | EVENT_HLS_REMOVE_OUTDATED_SEGMENT = "hls_remove_outdated_segment" 59 | EVENT_HLS_REMOVE_CHUNK = "hls_remove_chunk" 60 | 61 | EVENT_ARCHIVE_START_CAST = "archive_start_cast" 62 | EVENT_ARCHIVE_CREATE_FILE = "archive_create_file" 63 | EVENT_ARCHIVE_CLOSE_FILE = "archive_close_file" 64 | EVENT_CHAN_PACKET = "mp4_chan_pck" 65 | EVENT_CHAN_STOP = "mp4_chan_stop" 66 | EVENT_CHAN_KEYFRAME = "mp4_chan_keyframe" 67 | EVENT_SEGMENT_CUT = "mp4_segment_cut" 68 | EVENT_NO_START = "mp4_no_start" 69 | EVENT_MP4_WRITE = "mp4_write" 70 | EVENT_MP4_WRITE_TRAIL = "mp4_write_trail" 71 | EVENT_MP4_SAVE_MINIO = "mp4_save_minio" 72 | EVENT_MP4_CLOSE = "mp4_close" 73 | ) 74 | -------------------------------------------------------------------------------- /mp4.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/LdDl/video-server/storage" 11 | "github.com/deepch/vdk/av" 12 | "github.com/deepch/vdk/format/mp4" 13 | "github.com/google/uuid" 14 | "github.com/pkg/errors" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | const ( 19 | maxFailureDuration = 3 * time.Second 20 | ) 21 | 22 | var ( 23 | ErrTimeFailure = fmt.Errorf("bad packet times") 24 | ) 25 | 26 | func (app *Application) startMP4(archive *StreamArchiveWrapper, streamID uuid.UUID, ch chan av.Packet, stopCast chan StopSignal, streamVerboseLevel VerboseLevel) error { 27 | if archive == nil { 28 | return ErrNullArchive 29 | } 30 | var err error 31 | err = archive.store.MakeBucket(archive.bucket) 32 | if err != nil { 33 | return errors.Wrap(err, "Can't prepare bucket") 34 | } 35 | err = ensureDir(archive.filesystemDir) 36 | if err != nil { 37 | return errors.Wrap(err, "Can't create directory for mp4 temporary files") 38 | } 39 | 40 | isConnected := true 41 | lastSegmentTime := time.Now() 42 | lastPacketTime := time.Duration(0) 43 | lastKeyFrame := av.Packet{} 44 | 45 | // time.Sleep(5 * time.Second) // Artificial delay to wait for first key frame 46 | for isConnected { 47 | // Create new segment file 48 | st := time.Now() 49 | segmentName := fmt.Sprintf("%s_%d.mp4", streamID, lastSegmentTime.Unix()) 50 | segmentPath := filepath.Join(archive.filesystemDir, segmentName) 51 | 52 | outFile, err := os.Create(segmentPath) 53 | if err != nil { 54 | return errors.Wrap(err, fmt.Sprintf("Can't create mp4-segment for stream %s", streamID)) 55 | } 56 | 57 | fileClosed := false 58 | defer func(file *os.File) { 59 | if fileClosed { 60 | return 61 | } 62 | log.Warn().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_MP4_CLOSE).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Msg("File has not been closed in right order") 63 | if err := file.Close(); err != nil { 64 | log.Error().Err(err).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_CLOSE).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Msg("Can't close file") 65 | // @todo: handle? 66 | } 67 | }(outFile) 68 | 69 | tsMuxer := mp4.NewMuxer(outFile) 70 | log.Info().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_CREATE_FILE).Str("stream_id", streamID.String()).Str("segment_path", segmentPath).Msg("Create segment") 71 | codecData, err := app.Streams.GetCodecsDataForStream(streamID) 72 | if err != nil { 73 | return errors.Wrap(err, streamID.String()) 74 | } 75 | log.Info().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_CREATE_FILE).Str("stream_id", streamID.String()).Str("segment_path", segmentPath).Msg("Write header") 76 | 77 | err = tsMuxer.WriteHeader(codecData) 78 | if err != nil { 79 | return errors.Wrap(err, fmt.Sprintf("Can't write header for mp4 muxer for stream %s", streamID)) 80 | } 81 | 82 | // Write packets 83 | videoStreamIdx := int8(0) 84 | for idx, codec := range codecData { 85 | if codec.Type().IsVideo() { 86 | videoStreamIdx = int8(idx) 87 | break 88 | } 89 | } 90 | segmentLength := time.Duration(0) 91 | packetLength := time.Duration(0) 92 | segmentCount := 0 93 | start := false 94 | failureDuration := time.Duration(0) 95 | 96 | // Write lastKeyFrame if exist 97 | if lastKeyFrame.IsKeyFrame { 98 | start = true 99 | if err = tsMuxer.WritePacket(lastKeyFrame); err != nil { 100 | return errors.Wrap(err, fmt.Sprintf("Can't write packet for TS muxer for stream %s (1)", streamID)) 101 | } 102 | // Evaluate segment's length 103 | packetLength = lastKeyFrame.Time - lastPacketTime 104 | lastPacketTime = lastKeyFrame.Time 105 | segmentLength += packetLength 106 | segmentCount++ 107 | } 108 | log.Info().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_CREATE_FILE).Str("stream_id", streamID.String()).Str("segment_path", segmentPath).Msg("Start segment loop") 109 | 110 | var errProccessing error 111 | lastKeyFrame, lastPacketTime, isConnected, failureDuration, errProccessing = processingMP4(streamID, segmentName, isConnected, start, videoStreamIdx, segmentCount, segmentLength, lastKeyFrame, lastPacketTime, packetLength, archive.msPerSegment, tsMuxer, ch, stopCast, failureDuration, streamVerboseLevel) 112 | if errProccessing != nil { 113 | log.Error().Err(errProccessing).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Dur("failure_dur", failureDuration).Msg("Can't process mp4 channel") 114 | } 115 | 116 | if err := tsMuxer.WriteTrailer(); err != nil { 117 | log.Error().Err(err).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE_TRAIL).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Msg("Can't write trailing data for TS muxer") 118 | // @todo: handle? 119 | } 120 | 121 | log.Info().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_CLOSE_FILE).Str("stream_id", streamID.String()).Str("segment_path", segmentPath).Int64("ms", archive.msPerSegment).Msg("Closing segment") 122 | fileClosed = true 123 | if err := outFile.Close(); err != nil { 124 | log.Error().Err(err).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_CLOSE).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Msg("Can't close file") 125 | // @todo: handle? 126 | } 127 | 128 | if archive.store.Type() == storage.STORAGE_MINIO { 129 | if streamVerboseLevel > VERBOSE_ADD { 130 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Msg("Drop segment to minio") 131 | } 132 | go func() { 133 | st := time.Now() 134 | outSegmentName, err := UploadToMinio(archive.store, segmentName, archive.bucket, segmentPath) 135 | if streamVerboseLevel > VERBOSE_ADD { 136 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Msg("Uploaded segment file") 137 | } 138 | elapsed := time.Since(st) 139 | if err != nil { 140 | log.Error().Err(err).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_SAVE_MINIO).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("elapsed", elapsed).Msg("Can't upload segment to MinIO") 141 | } 142 | if segmentName != outSegmentName { 143 | log.Error().Err(err).Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_SAVE_MINIO).Str("stream_id", streamID.String()).Str("out_filename", outFile.Name()).Dur("elapsed", elapsed).Msg("Can't validate segment") 144 | } 145 | if streamVerboseLevel > VERBOSE_ADD { 146 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_SAVE_MINIO).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("elapsed", elapsed).Msg("Saved to MinIO") 147 | } 148 | }() 149 | } 150 | 151 | lastSegmentTime = lastSegmentTime.Add(time.Since(st)) 152 | log.Info().Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_CLOSE_FILE).Str("stream_id", streamID.String()).Str("segment_path", segmentPath).Int64("ms", archive.msPerSegment).Msg("Closed segment") 153 | if failureDuration > maxFailureDuration && errProccessing != nil { 154 | return errors.Wrap(errProccessing, "Max duration failure exceed") 155 | } 156 | } 157 | return nil 158 | } 159 | 160 | func processingMP4( 161 | streamID uuid.UUID, 162 | segmentName string, 163 | isConnected, 164 | start bool, 165 | videoStreamIdx int8, 166 | segmentCount int, 167 | segmentLength time.Duration, 168 | lastKeyFrame av.Packet, 169 | lastPacketTime time.Duration, 170 | packetLength time.Duration, 171 | msPerSegment int64, 172 | tsMuxer *mp4.Muxer, 173 | ch chan av.Packet, 174 | stopCast chan StopSignal, 175 | failureDuration time.Duration, 176 | streamVerboseLevel VerboseLevel, 177 | ) (av.Packet, time.Duration, bool, time.Duration, error) { 178 | for { 179 | select { 180 | case sig := <-stopCast: 181 | isConnected = false 182 | if streamVerboseLevel > VERBOSE_NONE { 183 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_CHAN_STOP).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Any("stop_signal", sig).Dur("prev_pck_time", lastPacketTime).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Stop cast signal") 184 | } 185 | return lastKeyFrame, lastPacketTime, isConnected, failureDuration, nil 186 | case pck := <-ch: 187 | if streamVerboseLevel > VERBOSE_ADD { 188 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_CHAN_PACKET).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Recieved something in archive channel") 189 | } 190 | if pck.Idx == videoStreamIdx && pck.IsKeyFrame { 191 | if streamVerboseLevel > VERBOSE_ADD { 192 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_CHAN_KEYFRAME).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Packet is a keyframe") 193 | } 194 | start = true 195 | if segmentLength.Milliseconds() >= msPerSegment { 196 | if streamVerboseLevel > VERBOSE_NONE { 197 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_SEGMENT_CUT).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Need to cut segment") 198 | } 199 | lastKeyFrame = pck 200 | return lastKeyFrame, lastPacketTime, isConnected, failureDuration, nil 201 | } 202 | } 203 | if !start { 204 | if streamVerboseLevel > VERBOSE_ADD { 205 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_NO_START).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Still no start") 206 | } 207 | continue 208 | } 209 | if (pck.Idx == videoStreamIdx && pck.Time > lastPacketTime) || pck.Idx != videoStreamIdx { 210 | failureDuration = time.Duration(0) 211 | if streamVerboseLevel > VERBOSE_ADD { 212 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Writing to archive segment") 213 | } 214 | err := tsMuxer.WritePacket(pck) 215 | if err != nil { 216 | return lastKeyFrame, lastPacketTime, isConnected, failureDuration, errors.Wrap(err, fmt.Sprintf("Can't write packet for TS muxer for stream %s (2)", streamID)) 217 | } 218 | if pck.Idx == videoStreamIdx { 219 | // Evaluate segment length 220 | packetLength = pck.Time - lastPacketTime 221 | lastPacketTime = pck.Time 222 | if packetLength.Milliseconds() > msPerSegment { // If comment this you get [0; keyframe time] interval for the very first video file 223 | if streamVerboseLevel > VERBOSE_NONE { 224 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Very first interval") 225 | } 226 | continue 227 | } 228 | segmentLength += packetLength 229 | } 230 | segmentCount++ 231 | } else { 232 | if streamVerboseLevel > VERBOSE_NONE { 233 | log.Warn().Str("scope", SCOPE_MP4).Str("event", EVENT_MP4_WRITE).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Dur("failure_dur", failureDuration).Msg("Current packet time < previous") 234 | } 235 | failureDuration += pck.Duration 236 | if failureDuration > maxFailureDuration { 237 | return lastKeyFrame, lastPacketTime, isConnected, failureDuration, ErrTimeFailure 238 | } 239 | } 240 | if streamVerboseLevel > VERBOSE_ADD { 241 | log.Info().Str("scope", SCOPE_MP4).Str("event", EVENT_CHAN_PACKET).Str("stream_id", streamID.String()).Str("segment_name", segmentName).Dur("pck_time", pck.Time).Dur("prev_pck_time", lastPacketTime).Dur("pck_dur", pck.Duration).Int8("pck_idx", pck.Idx).Int8("stream_idx", videoStreamIdx).Int("segment_count", segmentCount).Dur("segment_len", segmentLength).Msg("Wait other in archive channel") 242 | } 243 | } 244 | } 245 | return lastKeyFrame, lastPacketTime, isConnected, failureDuration, nil 246 | } 247 | 248 | func UploadToMinio(minioStorage storage.ArchiveStorage, segmentName, bucket, sourceFileName string) (string, error) { 249 | obj := storage.ArchiveUnit{ 250 | SegmentName: segmentName, 251 | Bucket: bucket, 252 | FileName: sourceFileName, 253 | } 254 | ctx := context.Background() 255 | outSegmentName, err := minioStorage.UploadFile(ctx, obj) 256 | if err != nil { 257 | return "", err 258 | } 259 | err = os.Remove(sourceFileName) 260 | return outSegmentName, err 261 | } 262 | -------------------------------------------------------------------------------- /scripts/minio-ansible.yml: -------------------------------------------------------------------------------- 1 | 2 | # ansible-playbook minio-ansible.yml --ask-become-pass -i hosts 3 | 4 | - hosts: localhost 5 | gather_facts: no 6 | vars_prompt: 7 | - name: target_host 8 | prompt: Please enter the target host name 9 | private: no 10 | tasks: 11 | - add_host: 12 | name: "{{ target_host }}" 13 | groups: dynamically_created_hosts 14 | 15 | # https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-single-node-single-drive.html#minio-snsd 16 | - hosts: dynamically_created_hosts 17 | remote_user: root 18 | become: yes 19 | become_user: root 20 | become_method: sudo 21 | vars: 22 | install_folder: /tmp/minio_install 23 | target_folder: /usr/local/bin 24 | data_folder: /var/its_server/minio_storage 25 | environment_file: /etc/default/minio 26 | systemd_file: /etc/systemd/system/minio.service 27 | linux_user: minio-user 28 | minio_user: minio_secret_login 29 | minio_password: minio_secret_password 30 | minio_port: 29199 31 | minio_console_port: 29001 32 | tasks: 33 | - name: Create temporary directory for Minio installation 34 | ansible.builtin.file: 35 | path: "{{install_folder}}" 36 | state: directory 37 | - name: Download Minio 38 | get_url: 39 | url: https://dl.min.io/server/minio/release/linux-amd64/minio 40 | dest: "{{install_folder}}/minio" 41 | - name: Change Minio binary permissions 42 | command: chmod +x "{{install_folder}}/minio" 43 | - name: Install Minio to /usr/local/bin 44 | command: mv "{{install_folder}}/minio" "{{target_folder}}/minio" 45 | - name: Remove temporary directory after Minio installation 46 | file: 47 | path: "{{install_folder}}" 48 | state: absent 49 | - name: Prepare folder for minio 50 | ansible.builtin.file: 51 | path: "{{data_folder}}" 52 | state: directory 53 | 54 | # https://blog.min.io/configuring-minio-with-systemd/ 55 | - name: Check that the environment file exists 56 | stat: 57 | path: "{{environment_file}}" 58 | register: stat_environment_result 59 | 60 | - name: Check that the service file exists 61 | stat: 62 | path: "{{systemd_file}}" 63 | register: stat_service_result 64 | 65 | - name: Create environment file, if it doesn't exist already 66 | file: 67 | path: "{{environment_file}}" 68 | state: touch 69 | when: not stat_environment_result.stat.exists 70 | 71 | - name: Create service file, if it doesn't exist already 72 | file: 73 | path: "{{systemd_file}}" 74 | state: touch 75 | when: not stat_service_result.stat.exists 76 | 77 | - name: Prepare environment file, if file doesn't exist 78 | copy: 79 | dest: "{{environment_file}}" 80 | content: | 81 | # Volume to be used for MinIO server. 82 | MINIO_VOLUMES="{{ data_folder }}" 83 | 84 | # Use if you want to run MinIO on a custom port. 85 | MINIO_OPTS="--address :{{ minio_port }} --console-address :{{ minio_console_port }}" 86 | 87 | # Root user for the server. 88 | MINIO_ROOT_USER={{ minio_user }} 89 | 90 | # Root secret for the server. 91 | MINIO_ROOT_PASSWORD={{ minio_password }} 92 | 93 | # set this for MinIO to reload entries with 'mc admin service restart' 94 | MINIO_CONFIG_ENV_FILE=/etc/default/minio 95 | when: not stat_environment_result.stat.exists 96 | 97 | - name: Prepare service file, if file doesn't exist 98 | copy: 99 | dest: "{{systemd_file}}" 100 | content: | 101 | [Unit] 102 | Description=MinIO 103 | Documentation=https://docs.min.io 104 | Wants=network-online.target 105 | After=network-online.target 106 | AssertFileIsExecutable=/usr/local/bin/minio 107 | AssertFileNotEmpty=/etc/default/minio 108 | 109 | [Service] 110 | Type=notify 111 | 112 | WorkingDirectory=/usr/local/ 113 | 114 | User={{ linux_user }} 115 | Group={{ linux_user }} 116 | ProtectProc=invisible 117 | 118 | EnvironmentFile=/etc/default/minio 119 | ExecStartPre=/bin/bash -c "if [ -z \"${MINIO_VOLUMES}\" ]; then echo \"Variable MINIO_VOLUMES not set in /etc/default/minio\"; exit 1; fi" 120 | ExecStart=/usr/local/bin/minio server $MINIO_OPTS $MINIO_VOLUMES 121 | 122 | # Let systemd restart this service always 123 | Restart=always 124 | 125 | # Specifies the maximum file descriptor number that can be opened by this process 126 | LimitNOFILE=65536 127 | 128 | # Specifies the maximum number of threads this process can create 129 | TasksMax=infinity 130 | 131 | # Disable timeout logic and wait until process is stopped 132 | TimeoutSec=infinity 133 | 134 | SendSIGKILL=no 135 | 136 | [Install] 137 | WantedBy=multi-user.target 138 | when: not stat_service_result.stat.exists 139 | 140 | - name: Create Linux system group for Minio 141 | group: 142 | name: "{{ linux_user }}" 143 | state: present 144 | 145 | - name: Create Linux system user for Minio 146 | user: 147 | name: "{{ linux_user }}" 148 | group: "{{ linux_user }}" 149 | create_home: no 150 | state: present 151 | 152 | - name: Change ownership of data folder recursively for Minio user 153 | file: 154 | path: "{{ data_folder }}" 155 | state: directory 156 | owner: "{{ linux_user }}" 157 | group: "{{ linux_user }}" 158 | recurse: yes 159 | 160 | - name: Start running Minio systemd 161 | become: true 162 | service: 163 | name: minio.service 164 | state: started 165 | enabled: true 166 | -------------------------------------------------------------------------------- /storage/archive_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ArchiveUnit struct { 8 | Bucket string 9 | SegmentName string 10 | FileName string 11 | } 12 | 13 | type ArchiveStorage interface { 14 | Type() StorageType 15 | MakeBucket(string) error 16 | UploadFile(context.Context, ArchiveUnit) (string, error) 17 | } 18 | -------------------------------------------------------------------------------- /storage/filesystem.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | var ErrNotImplementedYet = fmt.Errorf("not implemented yet") 10 | 11 | type FileSystemProvider struct { 12 | Path string 13 | } 14 | 15 | func NewFileSystemProvider(path string) (ArchiveStorage, error) { 16 | return &FileSystemProvider{ 17 | Path: path, 18 | }, nil 19 | } 20 | 21 | func (storage *FileSystemProvider) Type() StorageType { 22 | return STORAGE_FILESYSTEM 23 | } 24 | 25 | func (storage *FileSystemProvider) MakeBucket(bucket string) error { 26 | return os.MkdirAll(bucket, os.ModePerm) 27 | } 28 | 29 | func (storage *FileSystemProvider) UploadFile(ctx context.Context, object ArchiveUnit) (string, error) { 30 | return "", ErrNotImplementedYet 31 | } 32 | -------------------------------------------------------------------------------- /storage/minio.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/minio/minio-go/v7" 8 | "github.com/minio/minio-go/v7/pkg/lifecycle" 9 | ) 10 | 11 | type MinioProvider struct { 12 | client *minio.Client 13 | 14 | DefaultBucket string 15 | Path string 16 | } 17 | 18 | func NewMinioProvider(client *minio.Client, bucket, path string) (ArchiveStorage, error) { 19 | return &MinioProvider{ 20 | client: client, 21 | DefaultBucket: bucket, 22 | Path: path, 23 | }, nil 24 | } 25 | 26 | func (m *MinioProvider) Type() StorageType { 27 | return STORAGE_MINIO 28 | } 29 | 30 | func (m *MinioProvider) MakeBucket(bucket string) error { 31 | _ = m.client.MakeBucket(context.Background(), 32 | bucket, 33 | minio.MakeBucketOptions{ 34 | ObjectLocking: true, 35 | }) 36 | config := lifecycle.NewConfiguration() 37 | config.Rules = []lifecycle.Rule{ 38 | { 39 | ID: "expire-bucket", 40 | Status: "Enabled", 41 | Expiration: lifecycle.Expiration{ 42 | Days: 2, 43 | }, 44 | }, 45 | } 46 | 47 | _ = m.client.SetBucketLifecycle(context.Background(), bucket, config) 48 | return nil 49 | } 50 | 51 | // UploadFile loads file to MinIO. Do not provide FileName field in ArchiveUnit object if you want to use Payload bytes; otherwise file will be loaded from filesystem by FileName field 52 | func (m *MinioProvider) UploadFile(ctx context.Context, object ArchiveUnit) (string, error) { 53 | fname := fmt.Sprintf("%s/%s", m.Path, object.SegmentName) 54 | bucket := m.DefaultBucket 55 | if object.Bucket != "" { 56 | bucket = object.Bucket 57 | } 58 | _, err := m.client.FPutObject( 59 | ctx, 60 | bucket, 61 | fname, 62 | object.FileName, 63 | minio.PutObjectOptions{ 64 | ContentType: "application/octet-stream", 65 | }, 66 | ) 67 | return object.SegmentName, err 68 | } 69 | -------------------------------------------------------------------------------- /storage/storage_types.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "strings" 4 | 5 | type StorageType uint16 6 | 7 | const ( 8 | STORAGE_UNDEFINED_TYPE = iota 9 | STORAGE_FILESYSTEM 10 | STORAGE_MINIO 11 | ) 12 | 13 | var storageTypes = map[string]StorageType{ 14 | "filesystem": STORAGE_FILESYSTEM, 15 | "minio": STORAGE_MINIO, 16 | } 17 | 18 | // String returns string representation of the storage type 19 | func (iotaIdx StorageType) String() string { 20 | return [...]string{"undefined", "filesystem", "minio"}[iotaIdx] 21 | } 22 | 23 | func NewStorageTypeFrom(str string) StorageType { 24 | if found, ok := storageTypes[strings.ToLower(str)]; ok { 25 | return found 26 | } 27 | return STORAGE_UNDEFINED_TYPE 28 | } 29 | -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/deepch/vdk/format/rtspv2" 7 | "github.com/google/uuid" 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | const ( 13 | pingDuration = 15 * time.Second 14 | pingDurationRestart = pingDuration + 1*time.Second 15 | dialTimeoutDuration = 33 * time.Second 16 | readTimeoutDuration = 33 * time.Second 17 | ) 18 | 19 | type StopSignal uint8 20 | 21 | const ( 22 | STOP_SIGNAL_ERR = StopSignal(iota) 23 | STOP_SIGNAL_NO_VIDEO 24 | STOP_SIGNAL_DISCONNECT 25 | STOP_SIGNAL_STOP_DIAL 26 | ) 27 | 28 | // runStream runs RTSP grabbing process 29 | func (app *Application) runStream(streamID uuid.UUID, url string, hlsEnabled, archiveEnabled bool, streamVerboseLevel VerboseLevel) error { 30 | var stopHlsCast, stopMP4Cast chan StopSignal 31 | 32 | if hlsEnabled { 33 | stopHlsCast = make(chan StopSignal, 1) 34 | } 35 | if archiveEnabled { 36 | stopMP4Cast = make(chan StopSignal, 1) 37 | } 38 | 39 | errorSignal := make(chan error, 1) 40 | 41 | if streamVerboseLevel > VERBOSE_NONE { 42 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_DIAL).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Msg("Trying to dial") 43 | } 44 | session, err := rtspv2.Dial(rtspv2.RTSPClientOptions{ 45 | URL: url, 46 | DisableAudio: true, 47 | DialTimeout: dialTimeoutDuration, 48 | ReadWriteTimeout: readTimeoutDuration, 49 | Debug: false, 50 | }) 51 | if err != nil { 52 | return errors.Wrapf(err, "Can't connect to stream '%s'", url) 53 | } 54 | defer func() { 55 | if streamVerboseLevel > VERBOSE_NONE { 56 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_DIAL).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Closing connection") 57 | } 58 | if hlsEnabled { 59 | stopHlsCast <- STOP_SIGNAL_STOP_DIAL 60 | } 61 | if archiveEnabled { 62 | stopMP4Cast <- STOP_SIGNAL_STOP_DIAL 63 | } 64 | session.Close() 65 | }() 66 | 67 | if len(session.CodecData) != 0 { 68 | if streamVerboseLevel > VERBOSE_NONE { 69 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_CODEC_MET).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Any("codec_data", session.CodecData).Msg("Found codec. Adding this one") 70 | } 71 | err = app.Streams.AddCodecForStream(streamID, session.CodecData) 72 | if err != nil { 73 | return errors.Wrapf(err, "Can't update codec data for stream %s on empty codecs", streamID) 74 | } 75 | if streamVerboseLevel > VERBOSE_NONE { 76 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_STATUS_UPDATE).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Msg("Update stream status") 77 | } 78 | err = app.Streams.UpdateStreamStatus(streamID, true) 79 | if err != nil { 80 | return errors.Wrapf(err, "Can't update status for stream %s on empty codecs", streamID) 81 | } 82 | } 83 | 84 | isAudioOnly := false 85 | if len(session.CodecData) == 1 { 86 | if session.CodecData[0].Type().IsAudio() { 87 | if streamVerboseLevel > VERBOSE_NONE { 88 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_AUDIO_MET).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Msg("Only audio") 89 | } 90 | isAudioOnly = true 91 | } 92 | } 93 | 94 | if hlsEnabled { 95 | if streamVerboseLevel > VERBOSE_NONE { 96 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_HLS_CAST).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Need to start casting for HLS") 97 | } 98 | err = app.startHlsCast(streamID, stopHlsCast) 99 | if err != nil { 100 | if streamVerboseLevel > VERBOSE_NONE { 101 | log.Warn().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_HLS_CAST).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Can't start HLS casting") 102 | } 103 | } 104 | } 105 | 106 | if archiveEnabled { 107 | if streamVerboseLevel > VERBOSE_NONE { 108 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_MP4_CAST).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Need to start casting to MP4 archive") 109 | } 110 | archive := app.Streams.GetStreamArchiveStorage(streamID) 111 | if archive == nil { 112 | log.Warn().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_MP4_CAST).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Empty archive configuration for the given stream") 113 | } else { 114 | err = app.startMP4Cast(archive, streamID, stopMP4Cast, errorSignal, streamVerboseLevel) 115 | if err != nil { 116 | if streamVerboseLevel > VERBOSE_NONE { 117 | log.Warn().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_MP4_CAST).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Can't start MP4 archive process") 118 | } 119 | } 120 | } 121 | } 122 | 123 | pingStream := time.NewTimer(pingDuration) 124 | for { 125 | select { 126 | case <-pingStream.C: 127 | log.Error().Err(ErrStreamHasNoVideo).Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_EXIT_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Stream has no video") 128 | if hlsEnabled { 129 | stopHlsCast <- STOP_SIGNAL_NO_VIDEO 130 | } 131 | if archiveEnabled { 132 | stopMP4Cast <- STOP_SIGNAL_NO_VIDEO 133 | } 134 | return errors.Wrapf(ErrStreamHasNoVideo, "URL is '%s'", url) 135 | case signals := <-session.Signals: 136 | switch signals { 137 | case rtspv2.SignalCodecUpdate: 138 | if streamVerboseLevel > VERBOSE_NONE { 139 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_CODEC_UPDATE_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Any("codec_data", session.CodecData).Msg("Recieved update codec signal") 140 | } 141 | err = app.Streams.AddCodecForStream(streamID, session.CodecData) 142 | if err != nil { 143 | return errors.Wrapf(err, "Can't update codec data for stream %s on codecs update signal", streamID) 144 | } 145 | err = app.Streams.UpdateStreamStatus(streamID, true) 146 | if err != nil { 147 | return errors.Wrapf(err, "Can't update status for stream %s after codecs update", streamID) 148 | } 149 | case rtspv2.SignalStreamRTPStop: 150 | if streamVerboseLevel > VERBOSE_NONE { 151 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_STOP_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Recieved stop signal") 152 | } 153 | if hlsEnabled { 154 | stopHlsCast <- STOP_SIGNAL_DISCONNECT 155 | } 156 | if archiveEnabled { 157 | stopMP4Cast <- STOP_SIGNAL_DISCONNECT 158 | } 159 | err = app.Streams.UpdateStreamStatus(streamID, false) 160 | if err != nil { 161 | return errors.Wrapf(err, "Can't update status for stream %s after RTP stops", streamID) 162 | } 163 | return errors.Wrapf(ErrStreamDisconnected, "URL is '%s'", url) 164 | default: 165 | log.Info().Str("warn", SCOPE_STREAMING).Str("event", EVENT_STREAMING_UNKNOWN_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Int("signal", signals).Msg("Other signal") 166 | } 167 | case packetAV := <-session.OutgoingPacketQueue: 168 | if streamVerboseLevel > VERBOSE_ADD { 169 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_PACKET_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Msg("Recieved outgoing packet from queue") 170 | } 171 | if isAudioOnly || packetAV.IsKeyFrame { 172 | if streamVerboseLevel > VERBOSE_ADD { 173 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_PACKET_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("only_audio", isAudioOnly).Bool("is_keyframe", packetAV.IsKeyFrame).Msg("Need to reset ping for stream") 174 | } 175 | pingStream.Reset(pingDurationRestart) 176 | } 177 | if streamVerboseLevel > VERBOSE_ADD { 178 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_PACKET_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("only_audio", isAudioOnly).Bool("is_keyframe", packetAV.IsKeyFrame).Msg("Casting packet") 179 | } 180 | err = app.Streams.CastPacket(streamID, *packetAV, hlsEnabled, archiveEnabled) 181 | if err != nil { 182 | if hlsEnabled { 183 | if streamVerboseLevel > VERBOSE_NONE { 184 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_PACKET_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("only_audio", isAudioOnly).Bool("is_keyframe", packetAV.IsKeyFrame).Msg("Need to stop HLS cast") 185 | } 186 | stopHlsCast <- STOP_SIGNAL_ERR 187 | } 188 | if archiveEnabled { 189 | if streamVerboseLevel > VERBOSE_NONE { 190 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_PACKET_SIGNAL).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("only_audio", isAudioOnly).Bool("is_keyframe", packetAV.IsKeyFrame).Msg("Need to stop MP4 cast") 191 | } 192 | stopMP4Cast <- STOP_SIGNAL_ERR 193 | } 194 | errStatus := app.Streams.UpdateStreamStatus(streamID, false) 195 | if errStatus != nil { 196 | return errors.Wrapf(err, "Can't update status for stream %s after casting", streamID) 197 | } 198 | return errors.Wrapf(err, "Can't cast packet %s (%s)", streamID, url) 199 | } 200 | case errS := <-errorSignal: 201 | return errors.Wrapf(errS, "Recieved error signal from MP4 casting") 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /stream_archive.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import "github.com/LdDl/video-server/storage" 4 | 5 | type StreamArchiveWrapper struct { 6 | store storage.ArchiveStorage 7 | filesystemDir string 8 | bucket string 9 | bucketPath string 10 | msPerSegment int64 11 | } 12 | -------------------------------------------------------------------------------- /stream_configuration.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "github.com/deepch/vdk/av" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | // StreamConfiguration is a configuration parameters for specific stream 9 | type StreamConfiguration struct { 10 | URL string `json:"url"` 11 | Status bool `json:"status"` 12 | SupportedOutputTypes []StreamType `json:"supported_output_types"` 13 | Codecs []av.CodecData `json:"codecs"` 14 | Clients map[uuid.UUID]viewer `json:"-"` 15 | hlsChanel chan av.Packet 16 | mp4Chanel chan av.Packet 17 | verboseLevel VerboseLevel 18 | archive *StreamArchiveWrapper 19 | } 20 | 21 | // NewStreamConfiguration returns default configuration 22 | func NewStreamConfiguration(streamURL string, supportedTypes []StreamType) *StreamConfiguration { 23 | return &StreamConfiguration{ 24 | URL: streamURL, 25 | Clients: make(map[uuid.UUID]viewer), 26 | hlsChanel: make(chan av.Packet, 100), 27 | mp4Chanel: make(chan av.Packet, 100), 28 | SupportedOutputTypes: supportedTypes, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /stream_hls.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "github.com/deepch/vdk/av" 5 | "github.com/google/uuid" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func (app *Application) startHlsCast(streamID uuid.UUID, stopCast chan StopSignal) error { 10 | app.Streams.Lock() 11 | defer app.Streams.Unlock() 12 | stream, ok := app.Streams.store[streamID] 13 | if !ok { 14 | return ErrStreamNotFound 15 | } 16 | go func(id uuid.UUID, hlsChanel chan av.Packet, stop chan StopSignal) { 17 | err := app.startHls(id, hlsChanel, stop) 18 | if err != nil { 19 | log.Error().Err(err).Str("scope", SCOPE_HLS).Str("event", EVENT_HLS_START_CAST).Str("stream_id", id.String()).Msg("Error on HLS cast start") 20 | } 21 | }(streamID, stream.hlsChanel, stopCast) 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /stream_mp4.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "github.com/deepch/vdk/av" 5 | "github.com/google/uuid" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func (app *Application) startMP4Cast(archive *StreamArchiveWrapper, streamID uuid.UUID, stopCast chan StopSignal, errorSignal chan error, streamVerboseLevel VerboseLevel) error { 10 | if archive == nil { 11 | return ErrNullArchive 12 | } 13 | app.Streams.Lock() 14 | defer app.Streams.Unlock() 15 | stream, ok := app.Streams.store[streamID] 16 | if !ok { 17 | return ErrStreamNotFound 18 | } 19 | channel := stream.mp4Chanel 20 | go func(arch *StreamArchiveWrapper, id uuid.UUID, mp4Chanel chan av.Packet, stop chan StopSignal, verbose VerboseLevel) { 21 | err := app.startMP4(arch, id, mp4Chanel, stop, verbose) 22 | if err != nil { 23 | log.Error().Err(err).Str("scope", SCOPE_ARCHIVE).Str("event", EVENT_ARCHIVE_START_CAST).Str("stream_id", id.String()).Msg("Error on MP4 cast start") 24 | } 25 | errorSignal <- err 26 | }(archive, streamID, channel, stopCast, streamVerboseLevel) 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /stream_types.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import "strings" 4 | 5 | type StreamType uint16 6 | 7 | const ( 8 | STREAM_TYPE_UNDEFINED = StreamType(iota) 9 | STREAM_TYPE_RTSP 10 | STREAM_TYPE_HLS 11 | STREAM_TYPE_MSE 12 | ) 13 | 14 | func (iotaIdx StreamType) String() string { 15 | return [...]string{"undefined", "rtsp", "hls", "mse"}[iotaIdx] 16 | } 17 | 18 | var ( 19 | supportedOutputStreamTypes = map[StreamType]struct{}{ 20 | STREAM_TYPE_HLS: {}, 21 | STREAM_TYPE_MSE: {}, 22 | } 23 | supportedStreamTypes = map[string]StreamType{ 24 | "rtsp": STREAM_TYPE_RTSP, 25 | "hls": STREAM_TYPE_HLS, 26 | "mse": STREAM_TYPE_MSE, 27 | } 28 | ) 29 | 30 | func streamTypeExists(typeName string) (StreamType, bool) { 31 | v, ok := supportedStreamTypes[strings.ToLower(typeName)] 32 | return v, ok 33 | } 34 | -------------------------------------------------------------------------------- /streams.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | const ( 13 | restartStreamDuration = 5 * time.Second 14 | ) 15 | 16 | // StartStreams starts all video streams 17 | func (app *Application) StartStreams() { 18 | streamsIDs := app.Streams.GetAllStreamsIDS() 19 | for i := range streamsIDs { 20 | app.StartStream(streamsIDs[i]) 21 | } 22 | } 23 | 24 | // StartStream starts single video stream 25 | func (app *Application) StartStream(streamID uuid.UUID) { 26 | go func(id uuid.UUID) { 27 | err := app.RunStream(context.Background(), id) 28 | if err != nil { 29 | log.Error().Err(err).Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_RUN).Str("stream_id", id.String()).Msg("Error on stream runner") 30 | } 31 | }(streamID) 32 | } 33 | 34 | func (app *Application) RunStream(ctx context.Context, streamID uuid.UUID) error { 35 | url, supportedTypes := app.Streams.GetStreamInfo(streamID) 36 | if url == "" { 37 | return ErrStreamNotFound 38 | } 39 | hlsEnabled := typeExists(STREAM_TYPE_HLS, supportedTypes) 40 | archiveEnabled, err := app.Streams.IsArchiveEnabledForStream(streamID) 41 | if err != nil { 42 | return errors.Wrap(err, "Can't enable archive") 43 | } 44 | streamVerboseLevel := app.Streams.GetVerboseLevelForStream(streamID) 45 | app.startLoop(ctx, streamID, url, hlsEnabled, archiveEnabled, streamVerboseLevel) 46 | return nil 47 | } 48 | 49 | // startLoop starts stream loop with dialing to certain RTSP 50 | func (app *Application) startLoop(ctx context.Context, streamID uuid.UUID, url string, hlsEnabled, archiveEnabled bool, streamVerboseLevel VerboseLevel) { 51 | for { 52 | select { 53 | case <-ctx.Done(): 54 | if streamVerboseLevel > VERBOSE_NONE { 55 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_DONE).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", archiveEnabled).Msg("Stream is done") 56 | } 57 | return 58 | default: 59 | if streamVerboseLevel > VERBOSE_NONE { 60 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_START).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", archiveEnabled).Msg("Stream must be establishment") 61 | } 62 | err := app.runStream(streamID, url, hlsEnabled, archiveEnabled, streamVerboseLevel) 63 | if err != nil { 64 | log.Error().Err(err).Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_RESTART).Str("stream_id", streamID.String()).Str("stream_url", url).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", archiveEnabled).Msg("Can't start stream") 65 | } 66 | if streamVerboseLevel > VERBOSE_NONE { 67 | log.Info().Str("scope", SCOPE_STREAMING).Str("event", EVENT_STREAMING_RESTART).Str("stream_id", streamID.String()).Str("stream_url", url).Dur("restart_duration", restartStreamDuration).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", archiveEnabled).Msg("Stream must be re-establishment") 68 | } 69 | } 70 | time.Sleep(restartStreamDuration) 71 | } 72 | } 73 | 74 | // typeExists checks if a type exists in a types list 75 | func typeExists(typ StreamType, types []StreamType) bool { 76 | for i := range types { 77 | if types[i] == typ { 78 | return true 79 | } 80 | } 81 | return false 82 | } 83 | -------------------------------------------------------------------------------- /streams_storage.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/deepch/vdk/av" 8 | "github.com/deepch/vdk/codec/aacparser" 9 | "github.com/deepch/vdk/codec/h264parser" 10 | "github.com/google/uuid" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // StreamsStorage Map wrapper for map[uuid.UUID]*StreamConfiguration with mutex for concurrent usage 15 | type StreamsStorage struct { 16 | sync.RWMutex 17 | store map[uuid.UUID]*StreamConfiguration 18 | } 19 | 20 | // NewStreamsStorageDefault prepares new allocated storage 21 | func NewStreamsStorageDefault() StreamsStorage { 22 | return StreamsStorage{store: make(map[uuid.UUID]*StreamConfiguration)} 23 | } 24 | 25 | // GetStreamInfo returns stream URL and its supported output types 26 | func (streams *StreamsStorage) GetStreamInfo(streamID uuid.UUID) (string, []StreamType) { 27 | streams.Lock() 28 | defer streams.Unlock() 29 | stream, ok := streams.store[streamID] 30 | if !ok { 31 | return "", []StreamType{} 32 | } 33 | return stream.URL, stream.SupportedOutputTypes 34 | } 35 | 36 | // GetAllStreamsIDS returns all storage streams' keys as slice 37 | func (streams *StreamsStorage) GetAllStreamsIDS() []uuid.UUID { 38 | streams.Lock() 39 | defer streams.Unlock() 40 | keys := make([]uuid.UUID, 0, len(streams.store)) 41 | for k := range streams.store { 42 | keys = append(keys, k) 43 | } 44 | return keys 45 | } 46 | 47 | // StreamExists checks whenever given stream ID exists in storage 48 | func (streams *StreamsStorage) StreamExists(streamID uuid.UUID) bool { 49 | streams.RLock() 50 | defer streams.RUnlock() 51 | _, ok := streams.store[streamID] 52 | return ok 53 | } 54 | 55 | // TypeExistsForStream checks whenever specific stream ID supports then given output stream type 56 | func (streams *StreamsStorage) TypeExistsForStream(streamID uuid.UUID, streamType StreamType) bool { 57 | streams.Lock() 58 | defer streams.Unlock() 59 | stream, ok := streams.store[streamID] 60 | if !ok { 61 | return false 62 | } 63 | supportedTypes := stream.SupportedOutputTypes 64 | typeEnabled := typeExists(streamType, supportedTypes) 65 | return ok && typeEnabled 66 | } 67 | 68 | // AddCodecForStream appends new codecs data for the given stream 69 | func (streams *StreamsStorage) AddCodecForStream(streamID uuid.UUID, codecs []av.CodecData) error { 70 | streams.Lock() 71 | defer streams.Unlock() 72 | stream, ok := streams.store[streamID] 73 | if !ok { 74 | return ErrStreamNotFound 75 | } 76 | stream.Codecs = codecs 77 | if stream.verboseLevel > VERBOSE_SIMPLE { 78 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CODEC_ADD).Str("stream_id", streamID.String()).Any("codec_data", codecs).Msg("Add codec") 79 | } 80 | return nil 81 | } 82 | 83 | // GetCodecsDataForStream returns COPY of codecs data for the given stream 84 | func (streams *StreamsStorage) GetCodecsDataForStream(streamID uuid.UUID) ([]av.CodecData, error) { 85 | streams.Lock() 86 | defer streams.Unlock() 87 | stream, ok := streams.store[streamID] 88 | if !ok { 89 | return nil, ErrStreamNotFound 90 | } 91 | codecs := make([]av.CodecData, len(stream.Codecs)) 92 | for i, iface := range stream.Codecs { 93 | switch codecType := iface.(type) { 94 | case aacparser.CodecData, h264parser.CodecData: 95 | codecs[i] = codecType 96 | default: 97 | return nil, fmt.Errorf("unknown codec type: %T", iface) 98 | } 99 | } 100 | return codecs, nil 101 | } 102 | 103 | // UpdateStreamStatus sets new status value for the given stream 104 | func (streams *StreamsStorage) UpdateStreamStatus(streamID uuid.UUID, status bool) error { 105 | streams.Lock() 106 | defer streams.Unlock() 107 | stream, ok := streams.store[streamID] 108 | if !ok { 109 | return ErrStreamNotFound 110 | } 111 | stream.Status = status 112 | if stream.verboseLevel > VERBOSE_SIMPLE { 113 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_STATUS_UPDATE).Str("stream_id", streamID.String()).Bool("status", status).Msg("Status update") 114 | } 115 | return nil 116 | } 117 | 118 | // AddViewer adds client to the given stream. Return newly client ID, buffered channel for stream on success 119 | func (streams *StreamsStorage) AddViewer(streamID uuid.UUID) (uuid.UUID, chan av.Packet, error) { 120 | streams.Lock() 121 | defer streams.Unlock() 122 | stream, ok := streams.store[streamID] 123 | if !ok { 124 | return uuid.UUID{}, nil, ErrStreamNotFound 125 | } 126 | clientID, err := uuid.NewUUID() 127 | if err != nil { 128 | return uuid.UUID{}, nil, err 129 | } 130 | if stream.verboseLevel > VERBOSE_SIMPLE { 131 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CLIENT_ADD).Str("stream_id", streamID.String()).Str("client_id", clientID.String()).Msg("Add client") 132 | } 133 | ch := make(chan av.Packet, 100) 134 | stream.Clients[clientID] = viewer{c: ch} 135 | return clientID, ch, nil 136 | } 137 | 138 | // DeleteViewer removes given client from the stream 139 | func (streams *StreamsStorage) DeleteViewer(streamID, clientID uuid.UUID) { 140 | streams.Lock() 141 | defer streams.Unlock() 142 | stream, ok := streams.store[streamID] 143 | if !ok { 144 | return 145 | } 146 | if stream.verboseLevel > VERBOSE_SIMPLE { 147 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CLIENT_DELETE).Str("stream_id", streamID.String()).Str("client_id", clientID.String()).Msg("Delete client") 148 | } 149 | delete(stream.Clients, clientID) 150 | } 151 | 152 | // CastPacket cast AV Packet to viewers and possible to HLS/MP4 channels 153 | func (streams *StreamsStorage) CastPacket(streamID uuid.UUID, pck av.Packet, hlsEnabled, archiveEnabled bool) error { 154 | streams.Lock() 155 | stream, ok := streams.store[streamID] 156 | if !ok { 157 | streams.Unlock() 158 | return ErrStreamNotFound 159 | } 160 | streams.Unlock() 161 | if stream.verboseLevel > VERBOSE_ADD { 162 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CAST_PACKET).Str("stream_id", streamID.String()).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", stream.archive != nil).Int("clients_num", len(stream.Clients)).Msg("Cast packet") 163 | } 164 | if hlsEnabled { 165 | if stream.verboseLevel > VERBOSE_ADD { 166 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CAST_PACKET).Str("stream_id", streamID.String()).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", stream.archive != nil).Int("clients_num", len(stream.Clients)).Msg("Cast packet to HLS") 167 | } 168 | stream.hlsChanel <- pck 169 | } 170 | if archiveEnabled { 171 | if stream.verboseLevel > VERBOSE_ADD { 172 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CAST_PACKET).Str("stream_id", streamID.String()).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", stream.archive != nil).Int("clients_num", len(stream.Clients)).Msg("Cast packet to MP4") 173 | } 174 | stream.mp4Chanel <- pck 175 | } 176 | if stream.verboseLevel > VERBOSE_ADD { 177 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CAST_PACKET).Str("stream_id", streamID.String()).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", stream.archive != nil).Int("clients_num", len(stream.Clients)).Msg("Cast packet to viewers") 178 | } 179 | for _, v := range stream.Clients { 180 | if len(v.c) < cap(v.c) { 181 | v.c <- pck 182 | } 183 | } 184 | if stream.verboseLevel > VERBOSE_ADD { 185 | log.Info().Str("scope", SCOPE_STREAM).Str("event", EVENT_STREAM_CAST_PACKET).Str("stream_id", streamID.String()).Bool("hls_enabled", hlsEnabled).Bool("archive_enabled", stream.archive != nil).Int("clients_num", len(stream.Clients)).Msg("Done casting") 186 | } 187 | return nil 188 | } 189 | 190 | // GetVerboseLevelForStream returst verbose level for the given stream 191 | func (streams *StreamsStorage) GetVerboseLevelForStream(streamID uuid.UUID) VerboseLevel { 192 | streams.RLock() 193 | defer streams.RUnlock() 194 | stream, ok := streams.store[streamID] 195 | if !ok { 196 | return VERBOSE_NONE 197 | } 198 | return stream.verboseLevel 199 | } 200 | 201 | // IsArchiveEnabledForStream returns whenever archive has been enabled for stream 202 | func (streams *StreamsStorage) IsArchiveEnabledForStream(streamID uuid.UUID) (bool, error) { 203 | streams.RLock() 204 | defer streams.RUnlock() 205 | stream, ok := streams.store[streamID] 206 | if !ok { 207 | return false, ErrStreamNotFound 208 | } 209 | return stream.archive != nil, nil 210 | } 211 | 212 | // UpdateArchiveStorageForStream updates archive storage configuration (it override existing one!) 213 | func (streams *StreamsStorage) UpdateArchiveStorageForStream(streamID uuid.UUID, archiveStorage *StreamArchiveWrapper) error { 214 | streams.Lock() 215 | defer streams.Unlock() 216 | stream, ok := streams.store[streamID] 217 | if !ok { 218 | return ErrStreamNotFound 219 | } 220 | stream.archive = archiveStorage 221 | return nil 222 | } 223 | 224 | // GetStreamArchiveStorage returns pointer to the archive storage for the given stream 225 | func (streams *StreamsStorage) GetStreamArchiveStorage(streamID uuid.UUID) *StreamArchiveWrapper { 226 | streams.Lock() 227 | defer streams.Unlock() 228 | stream, ok := streams.store[streamID] 229 | if !ok { 230 | return nil 231 | } 232 | return stream.archive 233 | } 234 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "sync" 7 | "sync/atomic" 8 | ) 9 | 10 | // https://github.com/trailofbits/go-mutexasserts/blob/master/mutex.go#L15 11 | const mutexLocked = 1 12 | 13 | func RWMutexLocked(rw *sync.RWMutex) bool { 14 | // RWMutex has a "w" sync.Mutex field for write lock 15 | state := reflect.ValueOf(rw).Elem().FieldByName("w").FieldByName("state") 16 | return state.Int()&mutexLocked == mutexLocked 17 | } 18 | 19 | func MutexLocked(m *sync.Mutex) bool { 20 | state := reflect.ValueOf(m).Elem().FieldByName("state") 21 | return state.Int()&mutexLocked == mutexLocked 22 | } 23 | 24 | func RWMutexRLocked(rw *sync.RWMutex) bool { 25 | return readerCount(rw) > 0 26 | } 27 | 28 | // Starting in go1.20, readerCount is an atomic int32 value. 29 | // See: https://go-review.googlesource.com/c/go/+/429767 30 | func readerCount(rw *sync.RWMutex) int64 { 31 | // Look up the address of the readerCount field and use it to create a pointer to an atomic.Int32, 32 | // then load the value to return. 33 | rc := (*atomic.Int32)(reflect.ValueOf(rw).Elem().FieldByName("readerCount").Addr().UnsafePointer()) 34 | return int64(rc.Load()) 35 | } 36 | 37 | // Prior to go1.20, readerCount was an int value. 38 | // func readerCount(rw *sync.RWMutex) int64 { 39 | // return reflect.ValueOf(rw).Elem().FieldByName("readerCount").Int() 40 | // } 41 | 42 | // ensureDir alias to 'mkdir -p' 43 | func ensureDir(dirName string) error { 44 | err := os.MkdirAll(dirName, 0777) 45 | if err == nil || os.IsExist(err) { 46 | return nil 47 | } 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /verbose.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type VerboseLevel uint16 8 | 9 | const ( 10 | VERBOSE_NONE = VerboseLevel(iota) 11 | VERBOSE_SIMPLE 12 | VERBOSE_ADD 13 | VERBOSE_ALL 14 | ) 15 | 16 | var verboseMap = map[string]VerboseLevel{ 17 | "v": VERBOSE_SIMPLE, 18 | "vv": VERBOSE_ADD, 19 | "vvv": VERBOSE_ALL, 20 | } 21 | 22 | func NewVerboseLevelFrom(str string) VerboseLevel { 23 | if verboseLevel, ok := verboseMap[strings.ToLower(str)]; ok { 24 | return verboseLevel 25 | } 26 | return VERBOSE_NONE 27 | } 28 | -------------------------------------------------------------------------------- /viewer.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "github.com/deepch/vdk/av" 5 | ) 6 | 7 | // viewer is just an wrapping alias to chan for av.Packet 8 | type viewer struct { 9 | c chan av.Packet 10 | } 11 | -------------------------------------------------------------------------------- /ws_handler.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/deepch/vdk/format/mp4f" 9 | "github.com/google/uuid" 10 | "github.com/gorilla/websocket" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | var ( 15 | keyFramesTimeout = 10 * time.Second 16 | deadlineTimeout = 10 * time.Second 17 | controlTimeout = 10 * time.Second 18 | ) 19 | 20 | // wshandler is a websocket handler for user connection 21 | func wshandler(wsUpgrader *websocket.Upgrader, w http.ResponseWriter, r *http.Request, streamsStorage *StreamsStorage, verboseLevel VerboseLevel) { 22 | var streamID, clientID uuid.UUID 23 | var mseExists, clientAdded bool 24 | 25 | streamIDSTR := r.FormValue("stream_id") 26 | if verboseLevel > VERBOSE_SIMPLE { 27 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Msg("MSE Connected") 28 | } 29 | conn, err := wsUpgrader.Upgrade(w, r, nil) 30 | if err != nil { 31 | errReason := "Can't call websocket upgrader" 32 | if verboseLevel > VERBOSE_NONE { 33 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Msg(errReason) 34 | } 35 | closeWSwithError(conn, 1011, errReason) 36 | return 37 | } 38 | defer func() { 39 | if verboseLevel > VERBOSE_SIMPLE { 40 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Msg("Connection has been closed") 41 | } 42 | if mseExists && clientAdded { 43 | streamsStorage.DeleteViewer(streamID, clientID) 44 | if verboseLevel > VERBOSE_SIMPLE { 45 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Client has been removed") 46 | } 47 | } 48 | conn.Close() 49 | }() 50 | 51 | streamID, err = uuid.Parse(streamIDSTR) 52 | if err != nil { 53 | errReason := fmt.Sprintf("Not valid UUID: '%s'", streamIDSTR) 54 | if verboseLevel > VERBOSE_NONE { 55 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Msg(errReason) 56 | } 57 | closeWSwithError(conn, 1011, errReason) 58 | return 59 | } 60 | mseExists = streamsStorage.TypeExistsForStream(streamID, STREAM_TYPE_MSE) 61 | if verboseLevel > VERBOSE_SIMPLE { 62 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Bool("mse_exists", mseExists).Msg("Validate stream type") 63 | } 64 | if mseExists { 65 | err = conn.SetWriteDeadline(time.Now().Add(deadlineTimeout)) 66 | if err != nil { 67 | errReason := "Can't set deadline" 68 | if verboseLevel > VERBOSE_NONE { 69 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("event", EVENT_WS_PING).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Msg(errReason) 70 | } 71 | closeWSwithError(conn, 1011, errReason) 72 | return 73 | } 74 | clientID, ch, err := streamsStorage.AddViewer(streamID) 75 | if err != nil { 76 | errReason := "Can't add client to the queue" 77 | if verboseLevel > VERBOSE_NONE { 78 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Msg(errReason) 79 | } 80 | closeWSwithError(conn, 1011, errReason) 81 | return 82 | } 83 | clientAdded = true 84 | if verboseLevel > VERBOSE_SIMPLE { 85 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Client has been added") 86 | } 87 | 88 | codecData, err := streamsStorage.GetCodecsDataForStream(streamID) 89 | if err != nil { 90 | errReason := "Can't extract codec for stream" 91 | if verboseLevel > VERBOSE_NONE { 92 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg(errReason) 93 | } 94 | closeWSwithError(conn, 1011, errReason) 95 | return 96 | } 97 | if verboseLevel > VERBOSE_SIMPLE { 98 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Msg("Validate codecs") 99 | } 100 | 101 | if len(codecData) == 0 { 102 | errReason := "No codec information" 103 | if verboseLevel > VERBOSE_NONE { 104 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg(errReason) 105 | } 106 | closeWSwithError(conn, 1011, errReason) 107 | return 108 | } 109 | muxer := mp4f.NewMuxer(nil) 110 | err = muxer.WriteHeader(codecData) 111 | if err != nil { 112 | errReason := "Can't write codec information to the header" 113 | if verboseLevel > VERBOSE_NONE { 114 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Msg(errReason) 115 | } 116 | closeWSwithError(conn, 1011, errReason) 117 | return 118 | } 119 | if verboseLevel > VERBOSE_SIMPLE { 120 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Msg("Write header to muxer") 121 | } 122 | 123 | meta, init := muxer.GetInit(codecData) 124 | if verboseLevel > VERBOSE_SIMPLE { 125 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg("Get meta information") 126 | } 127 | 128 | err = conn.WriteMessage(websocket.BinaryMessage, append([]byte{9}, meta...)) 129 | if err != nil { 130 | errReason := "Can't write meta information" 131 | if verboseLevel > VERBOSE_NONE { 132 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Msg(errReason) 133 | } 134 | closeWSwithError(conn, 1011, errReason) 135 | return 136 | } 137 | if verboseLevel > VERBOSE_SIMPLE { 138 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg("Send meta information") 139 | } 140 | err = conn.WriteMessage(websocket.BinaryMessage, init) 141 | if err != nil { 142 | errReason := "Can't write initialization information" 143 | if verboseLevel > VERBOSE_NONE { 144 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg(errReason) 145 | } 146 | closeWSwithError(conn, 1011, errReason) 147 | return 148 | } 149 | if verboseLevel > VERBOSE_SIMPLE { 150 | log.Info().Str("remote_addr", r.RemoteAddr).Str("event", EVENT_WS_UPGRADER).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg("Send initialization message") 151 | } 152 | 153 | var start bool 154 | quitCh := make(chan bool) 155 | rxPingCh := make(chan bool) 156 | 157 | go func(quit, ping chan bool) { 158 | if verboseLevel > VERBOSE_SIMPLE { 159 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Start loop in goroutine") 160 | } 161 | for { 162 | msgType, data, err := conn.ReadMessage() 163 | if err != nil { 164 | quit <- true 165 | errReason := "Can't read message" 166 | if verboseLevel > VERBOSE_NONE { 167 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg(errReason) 168 | } 169 | closeWSwithError(conn, 1011, errReason) 170 | return 171 | } 172 | if verboseLevel > VERBOSE_SIMPLE { 173 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Int("message_type", msgType).Int("data_len", len(data)).Msg("Read message in a loop") 174 | } 175 | if msgType == websocket.TextMessage && len(data) > 0 && string(data) == "ping" { 176 | select { 177 | case ping <- true: 178 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Int("message_type", msgType).Int("data_len", len(data)).Msg("Message has been sent") 179 | // message sent 180 | default: 181 | // message dropped 182 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Int("message_type", msgType).Int("data_len", len(data)).Msg("Message has been dropped") 183 | } 184 | } 185 | } 186 | }(quitCh, rxPingCh) 187 | 188 | noKeyFrames := time.NewTimer(keyFramesTimeout) 189 | 190 | if verboseLevel > VERBOSE_SIMPLE { 191 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Start loop") 192 | } 193 | for { 194 | select { 195 | case <-noKeyFrames.C: 196 | if verboseLevel > VERBOSE_SIMPLE { 197 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("No keyframes has been met") 198 | } 199 | return 200 | case <-quitCh: 201 | if verboseLevel > VERBOSE_SIMPLE { 202 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Quit") 203 | } 204 | return 205 | case <-rxPingCh: 206 | if verboseLevel > VERBOSE_SIMPLE { 207 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("'Ping' has been recieved") 208 | } 209 | err := conn.WriteMessage(websocket.TextMessage, []byte("pong")) 210 | if err != nil { 211 | errReason := "Can't write PONG message" 212 | if verboseLevel > VERBOSE_NONE { 213 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("event", EVENT_WS_PING).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("codecs", codecData).Str("meta", meta).Any("init", init).Msg(errReason) 214 | } 215 | closeWSwithError(conn, 1011, errReason) 216 | return 217 | } 218 | case pck := <-ch: 219 | if verboseLevel > VERBOSE_ADD { 220 | log.Info().Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Packet has been recieved from stream source") 221 | } 222 | if pck.IsKeyFrame { 223 | if verboseLevel > VERBOSE_SIMPLE { 224 | log.Info().Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Packet is a keyframe") 225 | } 226 | noKeyFrames.Reset(keyFramesTimeout) 227 | start = true 228 | } 229 | if !start { 230 | if verboseLevel > VERBOSE_ADD { 231 | log.Info().Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Msg("Stream has not been started") 232 | } 233 | continue 234 | } 235 | ready, buf, err := muxer.WritePacket(pck, false) 236 | if err != nil { 237 | errReason := "Can't write packet to the muxer" 238 | if verboseLevel > VERBOSE_NONE { 239 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("event", EVENT_WS_PING).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("packet_len", len(pck.Data)).Msg(errReason) 240 | } 241 | closeWSwithError(conn, 1011, errReason) 242 | return 243 | } 244 | if verboseLevel > VERBOSE_ADD { 245 | log.Info().Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Bool("ready", ready).Int("buf_len", len(buf)).Msg("Write packet to the muxer") 246 | } 247 | if ready { 248 | if verboseLevel > VERBOSE_ADD { 249 | log.Info().Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Bool("ready", ready).Int("buf_len", len(buf)).Msg("Muxer is ready to write another packet") 250 | } 251 | err = conn.SetWriteDeadline(time.Now().Add(deadlineTimeout)) 252 | if err != nil { 253 | errReason := "Can't set new deadline" 254 | if verboseLevel > VERBOSE_NONE { 255 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("event", EVENT_WS_PING).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("packet_len", len(pck.Data)).Bool("ready", ready).Int("buf_len", len(buf)).Msg(errReason) 256 | } 257 | closeWSwithError(conn, 1011, errReason) 258 | return 259 | } 260 | err := conn.WriteMessage(websocket.BinaryMessage, buf) 261 | if err != nil { 262 | errReason := "Can't write buffered message" 263 | if verboseLevel > VERBOSE_NONE { 264 | log.Error().Err(err).Str("scope", SCOPE_WS_HANDLER).Str("event", EVENT_WS_UPGRADER).Str("event", EVENT_WS_PING).Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Any("packet_len", len(pck.Data)).Bool("ready", ready).Int("buf_len", len(buf)).Msg(errReason) 265 | } 266 | closeWSwithError(conn, 1011, errReason) 267 | return 268 | } 269 | if verboseLevel > VERBOSE_ADD { 270 | log.Info().Str("remote_addr", r.RemoteAddr).Str("stream_id", streamIDSTR).Str("client_id", clientID.String()).Bool("ready", ready).Int("buf_len", len(buf)).Msg("Write buffer to the client") 271 | } 272 | } 273 | } 274 | } 275 | } 276 | } 277 | 278 | func prepareError(code int16, message string) []byte { 279 | buf := make([]byte, 0, 2+len(message)) 280 | h, l := uint8(code>>8), uint8(code&0xff) 281 | buf = append(buf, h, l) 282 | buf = append(buf, []byte(message)...) 283 | return buf 284 | } 285 | 286 | func closeWSwithError(conn *websocket.Conn, code int16, message string) { 287 | conn.WriteControl(8, prepareError(code, message), time.Now().Add(controlTimeout)) 288 | } 289 | -------------------------------------------------------------------------------- /ws_server.go: -------------------------------------------------------------------------------- 1 | package videoserver 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/gin-contrib/cors" 10 | "github.com/gin-contrib/pprof" 11 | "github.com/gin-gonic/gin" 12 | "github.com/google/uuid" 13 | "github.com/gorilla/websocket" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | // @todo: eliminate this regexp and use the third party 18 | var uuidRegExp = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}") 19 | 20 | // StartVideoServer initializes "video" server and run it (MSE-websockets and HLS-static files) 21 | func (app *Application) StartVideoServer() { 22 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_PREPARE).Msg("Preparing to start WS Server") 23 | 24 | router := gin.New() 25 | 26 | pprof.Register(router) 27 | 28 | wsUpgrader := websocket.Upgrader{ 29 | CheckOrigin: func(r *http.Request) bool { 30 | return true 31 | }, 32 | } 33 | if app.CorsConfig != nil { 34 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_CORS_ENABLE). 35 | Bool("cors_allow_all_origins", app.CorsConfig.AllowAllOrigins). 36 | Any("cors_allow_origins", app.CorsConfig.AllowOrigins). 37 | Any("cors_allow_methods", app.CorsConfig.AllowMethods). 38 | Bool("cors_allow_private_network", app.CorsConfig.AllowPrivateNetwork). 39 | Any("cors_allow_headers", app.CorsConfig.AllowHeaders). 40 | Bool("cors_allow_credentials", app.CorsConfig.AllowCredentials). 41 | Any("cors_expose_headers", app.CorsConfig.ExposeHeaders). 42 | Dur("cors_max_age", app.CorsConfig.MaxAge). 43 | Bool("cors_allow_wildcard", app.CorsConfig.AllowWildcard). 44 | Bool("cors_allow_browser_extensions", app.CorsConfig.AllowBrowserExtensions). 45 | Any("cors_custom_schemas", app.CorsConfig.CustomSchemas). 46 | Bool("cors_allow_websockets", app.CorsConfig.AllowWebSockets). 47 | Bool("cors_allow_files", app.CorsConfig.AllowFiles). 48 | Int("cors_allow_option_status_code", app.CorsConfig.OptionsResponseStatusCode). 49 | Msg("CORS are enabled") 50 | router.Use(cors.New(*app.CorsConfig)) 51 | } 52 | router.GET("/ws/:stream_id", WebSocketWrapper(&app.Streams, &wsUpgrader, app.VideoServerCfg.Verbose)) 53 | router.GET("/hls/:file", HLSWrapper(&app.HLS, app.VideoServerCfg.Verbose)) 54 | 55 | url := fmt.Sprintf("%s:%d", app.VideoServerCfg.Host, app.VideoServerCfg.Port) 56 | s := &http.Server{ 57 | Addr: url, 58 | Handler: router, 59 | ReadTimeout: 30 * time.Second, 60 | WriteTimeout: 30 * time.Second, 61 | } 62 | if app.VideoServerCfg.Verbose > VERBOSE_NONE { 63 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_START).Str("url", url).Msg("Start microservice for WS server") 64 | } 65 | err := s.ListenAndServe() 66 | if err != nil { 67 | log.Error().Err(err).Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_START).Str("url", url).Msg("Can't start video server routers") 68 | return 69 | } 70 | } 71 | 72 | // WebSocketWrapper returns WS handler 73 | func WebSocketWrapper(streamsStorage *StreamsStorage, wsUpgrader *websocket.Upgrader, verboseLevel VerboseLevel) func(ctx *gin.Context) { 74 | return func(ctx *gin.Context) { 75 | if verboseLevel > VERBOSE_SIMPLE { 76 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Msg("Try to call ws upgrader") 77 | } 78 | wshandler(wsUpgrader, ctx.Writer, ctx.Request, streamsStorage, verboseLevel) 79 | } 80 | } 81 | 82 | // HLSWrapper returns HLS handler (static files) 83 | func HLSWrapper(hlsConf *HLSInfo, verboseLevel VerboseLevel) func(ctx *gin.Context) { 84 | return func(ctx *gin.Context) { 85 | if verboseLevel > VERBOSE_SIMPLE { 86 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Str("hls_dir", hlsConf.Directory).Msg("Call HLS") 87 | } 88 | file := ctx.Param("file") 89 | _, err := uuid.Parse(uuidRegExp.FindString(file)) 90 | if err != nil { 91 | errReason := "Not valid UUId" 92 | if verboseLevel > VERBOSE_NONE { 93 | log.Error().Err(err).Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Str("hls_dir", hlsConf.Directory).Msg(errReason) 94 | } 95 | ctx.JSON(http.StatusBadRequest, gin.H{"Error": err.Error()}) 96 | return 97 | } 98 | ctx.Header("Cache-Control", "no-cache") 99 | if verboseLevel > VERBOSE_SIMPLE { 100 | log.Info().Str("scope", SCOPE_WS_SERVER).Str("event", EVENT_WS_REQUEST).Str("method", ctx.Request.Method).Str("route", ctx.Request.URL.Path).Str("remote", ctx.Request.RemoteAddr).Str("hls_dir", hlsConf.Directory).Msg("Send file") 101 | } 102 | ctx.FileFromFS(file, http.Dir(hlsConf.Directory)) 103 | } 104 | } 105 | --------------------------------------------------------------------------------