├── .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 | [](https://godoc.org/github.com/LdDl/video-server)
2 | [](https://sourcegraph.com/github.com/LdDl/video-server?badge)
3 | [](https://goreportcard.com/report/github.com/LdDl/video-server)
4 | [](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 |
2 |
3 |
HLS player
4 |
5 |
6 |
7 |
8 |
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 |
2 |
3 |
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 |
2 |
3 |
MSE player
4 |
5 |
6 |
7 |
8 |
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 |
2 |
3 |
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 |
10 |
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 |
--------------------------------------------------------------------------------