├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── adapters ├── download.go ├── download_test.go ├── report.go └── report_test.go ├── core ├── config.go ├── dash.go ├── dash_test.go ├── file.go ├── filter.go ├── filter_test.go ├── handler.go ├── hls.go ├── hls_test.go ├── http.go ├── http_test.go ├── inspector.go ├── monitor.go ├── monitor_test.go ├── report.go ├── report_test.go └── segment.go ├── go.mod ├── go.sum ├── inspectors ├── dash │ ├── adaptation_set.go │ ├── adaptation_set_test.go │ ├── mpd_type.go │ ├── mpd_type_test.go │ ├── presentation_delay.go │ ├── presentation_delay_test.go │ ├── representation.go │ ├── representation_test.go │ ├── speed.go │ └── speed_test.go ├── hls │ ├── playlist_type.go │ ├── playlist_type_test.go │ ├── speed.go │ ├── speed_test.go │ ├── variants_sync.go │ └── variants_sync_test.go └── internal │ └── speed.go ├── internal ├── file │ └── file.go ├── strings │ ├── strings.go │ └── strings_test.go ├── thread │ ├── recover.go │ └── recover_test.go └── url │ ├── url.go │ └── url_test.go ├── main.go └── manager ├── manager.go └── manager_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build_and_test: 4 | docker: 5 | - image: cimg/go:1.24 6 | steps: 7 | - checkout 8 | - run: go vet ./... 9 | - run: go build 10 | - run: go test -coverprofile=coverage.txt -covermode=count ./... 11 | - run: go install github.com/mattn/goveralls@v0.0.9 12 | - run: goveralls -coverprofile=coverage.txt -service=circle-ci 13 | workflows: 14 | version: 2 15 | all: 16 | jobs: 17 | - build_and_test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Binary 9 | antares 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Test coverage 15 | cover.out 16 | cover.html 17 | 18 | # Dependency directories 19 | vendor/ 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 AbemaTV 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | go build 4 | 5 | .PHONY: vet 6 | vet: 7 | go vet ./... 8 | 9 | .PHONY: lint 10 | lint: 11 | golint ./... 12 | 13 | .PHONY: test 14 | test: 15 | go test ./... 16 | 17 | .PHONY: coverage 18 | coverage: 19 | go test -coverprofile=cover.out -covermode=count ./... 20 | go tool cover -html=cover.out -o cover.html 21 | if [ $(shell uname) == Darwin ]; then open cover.html; fi 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # antares 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/abema/antares.svg)](https://pkg.go.dev/github.com/abema/antares) 4 | [![CircleCI](https://circleci.com/gh/abema/antares.svg?style=svg)](https://circleci.com/gh/abema/antares) 5 | [![Coverage Status](https://coveralls.io/repos/github/abema/antares/badge.svg?branch=main)](https://coveralls.io/github/abema/antares?branch=main) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/abema/antares)](https://goreportcard.com/report/github.com/abema/antares) 7 | 8 | Antares is monitoring system for HLS and MPEG-DASH. 9 | This program is written by golang. 10 | 11 | ## Description 12 | 13 | Antares monitors any HLS/MPEG-DASH streams and outputs inspection reports. 14 | You can use prepared command or Go interfaces. 15 | 16 | ## Command 17 | 18 | ### Install 19 | 20 | ```sh 21 | go install github.com/abema/antares/v2@latest 22 | ``` 23 | 24 | ### Example 25 | 26 | ```sh 27 | antares \ 28 | -hls.noEndlist \ 29 | -hls.playlistType omitted \ 30 | -export \ 31 | -export.meta \ 32 | -segment.maxBandwidth 500000 \ 33 | "http://localhost/index.m3u8" 34 | ``` 35 | 36 | ### Help 37 | 38 | ```sh 39 | antares -h 40 | ``` 41 | 42 | ## Integrate with your Go application 43 | 44 | ### Monitor and Manager 45 | 46 | You can use `core.Monitor` to monitor your live stream as follows: 47 | 48 | ```go 49 | config := core.NewConfig("http://localhost/index.m3u8", core.StreamTypeHLS) 50 | config.HLS.Inspectors = []core.HLSInspector{ 51 | hls.NewSpeedInspector(), 52 | hls.NewVariantsSyncInspector(), 53 | } 54 | core.NewMonitor(config) 55 | ``` 56 | 57 | `manager.Manager` manages multiple monitors and provides batch update interface. 58 | 59 | ```go 60 | manager := manager.NewManager(&manager.Config{}) 61 | for range time.Tick(time.Minute) { 62 | configs := make(map[string]*core.Config) 63 | for _, stream := range listMyCurrentStreams() { 64 | config := core.NewConfig(stream.URL, stream.StreamType) 65 | : 66 | configs[stream.ID] = config 67 | } 68 | added, removed := manager.Batch(configs) 69 | log.Println("added", added) 70 | log.Println("removed:", removed) 71 | } 72 | ``` 73 | 74 | ### Inspectors 75 | 76 | Inspector inspects manifest and segment files. 77 | For example, `SpeedInspector` checks whether addition speed of segment is appropriate as compared to real time. 78 | Some inspectors are implemented in `inspectors/hls` package and `inspectors/dash` package for each aims. 79 | Implementing `hls.Inspector` or `dash.Inspector` interface, you can add your any inspectors to Monitor. 80 | 81 | ### Handlers and Adapters 82 | 83 | You can set handlers to handle downloaded files, inspection reports, and etc. 84 | And `adapters` package has some useful handlers. 85 | 86 | ```go 87 | config.OnReport = core.MergeOnReportHandlers( 88 | adapters.ReportLogger(&adapters.ReportLogConfig{JSON: true}, os.Stdout), 89 | adapters.Alarm(&adapters.AlarmConfig{ 90 | OnAlarm : func(reports core.Reports) { /* start alarm */ }, 91 | OnRecover : func(reports core.Reports) { /* stop alarm */ }, 92 | Window : 10, 93 | AlarmIfErrorGreaterThanEqual : 2, 94 | RecoverIfInfoGreaterThanEqual: 10, 95 | }), 96 | func(reports core.Reports) { /* send metrics */ }, 97 | ) 98 | ``` 99 | 100 | ## Manifest format support 101 | 102 | ### HLS 103 | 104 | - [x] Live 105 | - [x] Event 106 | - [x] On-demand 107 | - [ ] Byte range 108 | - [ ] LHLS 109 | - [ ] Decryption 110 | - [ ] I-frame-only playlists 111 | 112 | ### DASH 113 | 114 | - [x] Live 115 | - [x] Static 116 | - [x] SegmentTimeline 117 | - [ ] Open-Ended SegmentTimeline (S@r = -1) 118 | - [ ] SegmentBase 119 | - [ ] SegmentList 120 | - [ ] Only SegmentTemplate (Without SegmentTimeline/SegmentList) 121 | - [x] Multi-Period 122 | - [x] Location 123 | - [ ] Decryption 124 | 125 | Identifiers for URL templates: 126 | 127 | - [x] $$ 128 | - [x] $RepresentationID$ 129 | - [x] $Number$ 130 | - [x] $Bandwidth$ 131 | - [x] $Time$ 132 | - [ ] $SubNumber$ 133 | - [x] IEEE 1003.1 Format Tag 134 | 135 | ## License 136 | 137 | [MIT](https://github.com/abema/antares/blob/main/LICENSE) 138 | -------------------------------------------------------------------------------- /adapters/download.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/url" 7 | "path" 8 | "strings" 9 | 10 | "github.com/abema/antares/v2/core" 11 | "github.com/abema/antares/v2/internal/file" 12 | ) 13 | 14 | func OnDownloadPathSuffixFilter(handler core.OnDownloadHandler, suffixes ...string) core.OnDownloadHandler { 15 | return func(httpFile *core.File) { 16 | u, err := url.Parse(httpFile.URL) 17 | if err != nil { 18 | log.Printf("ERROR: invalid URL: %s: %s", u, err) 19 | return 20 | } 21 | for _, suffix := range suffixes { 22 | if strings.HasSuffix(u.Path, suffix) { 23 | handler(httpFile) 24 | return 25 | } 26 | } 27 | } 28 | } 29 | 30 | func LocalFileExporter(baseDir string, enableMeta bool) core.OnDownloadHandler { 31 | e := &localFileExporter{ 32 | BaseDir: baseDir, 33 | EnableMeta: enableMeta, 34 | } 35 | return func(httpFile *core.File) { 36 | if err := e.onDownload(httpFile); err != nil { 37 | log.Printf("ERROR: failed to export to file: %s: %s", httpFile.URL, err) 38 | } 39 | } 40 | } 41 | 42 | type localFileExporter struct { 43 | BaseDir string 44 | EnableMeta bool 45 | } 46 | 47 | func (e *localFileExporter) onDownload(httpFile *core.File) error { 48 | p, err := e.resolvePath(httpFile) 49 | if err != nil { 50 | return err 51 | } 52 | if err := e.export(p, httpFile); err != nil { 53 | return err 54 | } 55 | if e.EnableMeta { 56 | if err := e.exportMeta(p+"-meta.json", httpFile); err != nil { 57 | return err 58 | } 59 | } 60 | return nil 61 | } 62 | 63 | func (e *localFileExporter) resolvePath(httpFile *core.File) (string, error) { 64 | static := e.isStatic(httpFile) 65 | u, err := url.Parse(httpFile.URL) 66 | if err != nil { 67 | return "", err 68 | } 69 | p := path.Join(e.BaseDir, u.Host, u.Path) 70 | if !static { 71 | ext := path.Ext(p) 72 | p = p[:len(p)-len(ext)] + httpFile.RequestTimestamp.Format("-20060102-150405.000") + ext 73 | } 74 | return p, nil 75 | } 76 | 77 | func (e *localFileExporter) isStatic(httpFile *core.File) bool { 78 | switch httpFile.ResponseHeader.Get("Content-Type") { 79 | case "application/x-mpegURL", "application/dash+xml": 80 | return false 81 | case "video/mp4", "video/MP2T": 82 | return true 83 | } 84 | u, err := url.Parse(httpFile.URL) 85 | if err != nil { 86 | return false 87 | } 88 | return strings.HasSuffix(u.Path, ".mp4") || 89 | strings.HasSuffix(u.Path, ".m4s") || 90 | strings.HasSuffix(u.Path, ".m4v") || 91 | strings.HasSuffix(u.Path, ".m4a") || 92 | strings.HasSuffix(u.Path, ".ts") 93 | } 94 | 95 | func (e *localFileExporter) export(path string, httpFile *core.File) error { 96 | f, err := file.Create(path) 97 | if err != nil { 98 | return err 99 | } 100 | defer f.Close() 101 | if _, err := f.Write(httpFile.Body); err != nil { 102 | return err 103 | } 104 | return nil 105 | } 106 | 107 | func (e *localFileExporter) exportMeta(path string, httpFile *core.File) error { 108 | f, err := file.Create(path) 109 | if err != nil { 110 | return err 111 | } 112 | defer f.Close() 113 | enc := json.NewEncoder(f) 114 | enc.SetIndent("", " ") 115 | if err := enc.Encode(httpFile.Meta); err != nil { 116 | return err 117 | } 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /adapters/download_test.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "testing" 9 | 10 | "github.com/abema/antares/v2/core" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestOnDownloadPathSuffixFilter(t *testing.T) { 16 | testCases := []struct { 17 | url string 18 | expected int 19 | }{ 20 | {url: "http://localhost/foo.ts", expected: 1}, 21 | {url: "http://localhost/foo.mp4", expected: 1}, 22 | {url: "http://localhost/foo.m3u8", expected: 0}, 23 | {url: "http://localhost/foo.mpd", expected: 0}, 24 | } 25 | for _, tc := range testCases { 26 | t.Run(tc.url, func(t *testing.T) { 27 | var called int 28 | OnDownloadPathSuffixFilter(func(file *core.File) { 29 | called++ 30 | }, ".ts", ".mp4")(&core.File{ 31 | Meta: core.Meta{URL: tc.url}, 32 | }) 33 | assert.Equal(t, tc.expected, called) 34 | }) 35 | } 36 | } 37 | 38 | func TestLocalFileExporter(t *testing.T) { 39 | dir, err := ioutil.TempDir("", "antares-test") 40 | require.NoError(t, err) 41 | defer os.RemoveAll(dir) 42 | file := &core.File{ 43 | Meta: core.Meta{ 44 | URL: "https://foo/bar.mp4", 45 | Status: "200 OK", 46 | StatusCode: 200, 47 | Proto: "HTTP/1.0", 48 | }, 49 | Body: []byte("foo"), 50 | } 51 | LocalFileExporter(dir, false)(file) 52 | f, err := os.Open(path.Join(dir, "foo/bar.mp4")) 53 | require.NoError(t, err) 54 | defer f.Close() 55 | b, err := ioutil.ReadAll(f) 56 | require.NoError(t, err) 57 | assert.Equal(t, file.Body, b) 58 | _, err = os.Open(path.Join(dir, "foo/bar.mp4-meta.json")) 59 | require.Error(t, err) 60 | } 61 | 62 | func TestLocalFileExporterWithMeta(t *testing.T) { 63 | dir, err := ioutil.TempDir("", "antares-test") 64 | require.NoError(t, err) 65 | defer os.RemoveAll(dir) 66 | file := &core.File{ 67 | Meta: core.Meta{ 68 | URL: "https://foo/bar.mp4", 69 | Status: "200 OK", 70 | StatusCode: 200, 71 | Proto: "HTTP/1.0", 72 | }, 73 | Body: []byte("foo"), 74 | } 75 | LocalFileExporter(dir, true)(file) 76 | f, err := os.Open(path.Join(dir, "foo/bar.mp4")) 77 | require.NoError(t, err) 78 | defer f.Close() 79 | b, err := ioutil.ReadAll(f) 80 | require.NoError(t, err) 81 | assert.Equal(t, file.Body, b) 82 | m, err := os.Open(path.Join(dir, "foo/bar.mp4-meta.json")) 83 | require.NoError(t, err) 84 | defer m.Close() 85 | var meta core.Meta 86 | require.NoError(t, json.NewDecoder(m).Decode(&meta)) 87 | assert.Equal(t, file.Meta, meta) 88 | } 89 | -------------------------------------------------------------------------------- /adapters/report.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "time" 8 | 9 | "github.com/abema/antares/v2/core" 10 | "github.com/abema/antares/v2/internal/file" 11 | ) 12 | 13 | type AlarmConfig struct { 14 | OnAlarm core.OnReportHandler 15 | OnRecover core.OnReportHandler 16 | Window int 17 | AlarmIfErrorGreaterThanEqual int 18 | RecoverIfInfoGreaterThanEqual int 19 | } 20 | 21 | func Alarm(config *AlarmConfig) core.OnReportHandler { 22 | history := make([]core.Severity, 0) 23 | var alarm bool 24 | return func(reports core.Reports) { 25 | history = append(history, reports.WorstSeverity()) 26 | if len(history) > config.Window { 27 | history = history[1:] 28 | } 29 | var infoCnt int 30 | var errCnt int 31 | for _, hist := range history { 32 | switch hist { 33 | case core.Info: 34 | infoCnt++ 35 | case core.Error: 36 | errCnt++ 37 | } 38 | } 39 | if errCnt >= config.AlarmIfErrorGreaterThanEqual { 40 | if !alarm { 41 | config.OnAlarm(reports) 42 | alarm = true 43 | } 44 | } else if infoCnt >= config.RecoverIfInfoGreaterThanEqual { 45 | if alarm { 46 | config.OnRecover(reports) 47 | alarm = false 48 | } 49 | } 50 | } 51 | } 52 | 53 | type ReportLogConfig struct { 54 | // Flag is log flag defined standard log package. 55 | // When JSON option is true, this option is ignored. 56 | Flag int 57 | // Summary represents whether to output summary line. 58 | // When JSON option is true, this option is ignored. 59 | Summary bool 60 | JSON bool 61 | Severity core.Severity 62 | } 63 | 64 | func ReportLogger(config *ReportLogConfig, w io.Writer) core.OnReportHandler { 65 | return func(reports core.Reports) { 66 | writeReport(config, w, reports) 67 | } 68 | } 69 | 70 | func FileReportLogger(config *ReportLogConfig, name string) core.OnReportHandler { 71 | return func(reports core.Reports) { 72 | file, err := file.Append(name) 73 | if err != nil { 74 | log.Printf("failed to open log file: %s: %s", name, err) 75 | return 76 | } 77 | defer file.Close() 78 | writeReport(config, file, reports) 79 | } 80 | } 81 | 82 | func writeReport(config *ReportLogConfig, w io.Writer, reports core.Reports) { 83 | if config.JSON { 84 | writeReportJSON(config, w, reports) 85 | } else { 86 | writeReportDefault(config, w, reports) 87 | } 88 | } 89 | 90 | func writeReportDefault(config *ReportLogConfig, w io.Writer, reports core.Reports) { 91 | logger := log.New(w, "", config.Flag) 92 | if config.Summary { 93 | severity := reports.WorstSeverity() 94 | if config.Severity <= severity { 95 | logger.Printf("%s: Summary info=%d warn=%d error=%d", severity, len(reports.Infos()), len(reports.Warns()), len(reports.Errors())) 96 | } 97 | } 98 | if config.Severity.BetterThanOrEqual(core.Error) { 99 | for _, err := range reports.Errors() { 100 | logger.Printf("ERROR: %s: %s: %s", err.Name, err.Message, err.Values) 101 | } 102 | } 103 | if config.Severity.BetterThanOrEqual(core.Warn) { 104 | for _, warn := range reports.Warns() { 105 | logger.Printf("WARNING: %s: %s: %s", warn.Name, warn.Message, warn.Values) 106 | } 107 | } 108 | if config.Severity.BetterThanOrEqual(core.Info) { 109 | for _, info := range reports.Infos() { 110 | logger.Printf("INFO: %s: %s: %s", info.Name, info.Message, info.Values) 111 | } 112 | } 113 | } 114 | 115 | func writeReportJSON(config *ReportLogConfig, w io.Writer, reports core.Reports) { 116 | severity := reports.WorstSeverity() 117 | if config.Severity <= severity { 118 | json.NewEncoder(w).Encode(map[string]interface{}{ 119 | "reports": reports, 120 | "severity": severity.String(), 121 | "time": time.Now().Format(time.RFC3339), 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /adapters/report_test.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "testing" 10 | 11 | "github.com/abema/antares/v2/core" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestOnReportFrequencyFilter(t *testing.T) { 17 | var a int 18 | var r int 19 | handler := Alarm(&AlarmConfig{ 20 | OnAlarm: func(reports core.Reports) { 21 | a++ 22 | }, 23 | OnRecover: func(reports core.Reports) { 24 | r++ 25 | }, 26 | Window: 5, 27 | AlarmIfErrorGreaterThanEqual: 3, 28 | RecoverIfInfoGreaterThanEqual: 4, 29 | }) 30 | handler(core.Reports{{Name: "test", Severity: core.Error}}) 31 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 32 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 33 | handler(core.Reports{{Name: "test", Severity: core.Error}}) 34 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 35 | handler(core.Reports{{Name: "test", Severity: core.Error}}) 36 | require.Equal(t, 0, a) 37 | handler(core.Reports{{Name: "test", Severity: core.Error}}) 38 | require.Equal(t, 1, a) 39 | handler(core.Reports{{Name: "test", Severity: core.Warn}}) 40 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 41 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 42 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 43 | require.Equal(t, 0, r) 44 | require.Equal(t, 1, a) 45 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 46 | require.Equal(t, 1, r) 47 | require.Equal(t, 1, a) 48 | handler(core.Reports{{Name: "test", Severity: core.Info}}) 49 | require.Equal(t, 1, r) 50 | require.Equal(t, 1, a) 51 | } 52 | 53 | func TestReportLogger(t *testing.T) { 54 | w := bytes.NewBuffer(nil) 55 | ReportLogger(&ReportLogConfig{ 56 | JSON: true, 57 | }, w)(core.Reports{ 58 | { 59 | Name: "r1", Severity: core.Info, Message: "Report 1", Values: core.Values{ 60 | "int": 1, "string": "foo", 61 | }, 62 | }, { 63 | Name: "r2", Severity: core.Warn, Message: "Report 2", Values: core.Values{ 64 | "int": 2, "string": "bar", 65 | }, 66 | }, { 67 | Name: "r3", Severity: core.Error, Message: "Report 3", Values: core.Values{ 68 | "int": 3, "string": "baz", 69 | }, 70 | }, 71 | }) 72 | var out map[string]interface{} 73 | require.NoError(t, json.Unmarshal(w.Bytes(), &out)) 74 | assert.Len(t, out["reports"], 3) 75 | r1 := out["reports"].([]interface{})[0].(map[string]interface{}) 76 | assert.Equal(t, map[string]interface{}{ 77 | "name": "r1", 78 | "severity": "INFO", 79 | "message": "Report 1", 80 | "values": map[string]interface{}{"int": float64(1), "string": "foo"}, 81 | }, r1) 82 | assert.Equal(t, "ERROR", out["severity"]) 83 | } 84 | 85 | func TestFileReportLogger(t *testing.T) { 86 | dir, err := ioutil.TempDir("", "antares-test") 87 | require.NoError(t, err) 88 | defer os.RemoveAll(dir) 89 | name := path.Join(dir, "test.log") 90 | FileReportLogger(&ReportLogConfig{ 91 | Summary: true, 92 | }, name)(core.Reports{ 93 | { 94 | Name: "r1", Severity: core.Info, Message: "Report 1", Values: core.Values{ 95 | "int": 1, "string": "foo", 96 | }, 97 | }, { 98 | Name: "r2", Severity: core.Warn, Message: "Report 2", Values: core.Values{ 99 | "int": 2, "string": "bar", 100 | }, 101 | }, 102 | }) 103 | f, err := os.Open(name) 104 | require.NoError(t, err) 105 | b, err := ioutil.ReadAll(f) 106 | require.NoError(t, err) 107 | assert.Equal(t, "WARNING: Summary info=1 warn=1 error=0\n"+ 108 | "WARNING: r2: Report 2: int=2 string=bar\n"+ 109 | "INFO: r1: Report 1: int=1 string=foo\n", string(b)) 110 | } 111 | -------------------------------------------------------------------------------- /core/config.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | backoff "github.com/cenkalti/backoff/v4" 8 | ) 9 | 10 | type StreamType int 11 | 12 | const ( 13 | StreamTypeHLS StreamType = iota 14 | StreamTypeDASH 15 | ) 16 | 17 | func (t StreamType) String() string { 18 | switch t { 19 | case StreamTypeHLS: 20 | return "HLS" 21 | case StreamTypeDASH: 22 | return "DASH" 23 | } 24 | return "" 25 | } 26 | 27 | type HLSConfig struct { 28 | Inspectors []HLSInspector 29 | } 30 | 31 | type DASHConfig struct { 32 | Inspectors []DASHInspector 33 | } 34 | 35 | type Config struct { 36 | URL string 37 | DefaultInterval time.Duration 38 | PrioritizeSuggestedInterval bool 39 | HTTPClient *http.Client 40 | RequestHeader http.Header 41 | NoRedirectCache bool 42 | ManifestTimeout time.Duration 43 | ManifestBackoff backoff.BackOff 44 | SegmentTimeout time.Duration 45 | SegmentBackoff backoff.BackOff 46 | SegmentMaxConcurrency int 47 | SegmentFilter SegmentFilter 48 | StreamType StreamType 49 | TerminateIfVOD bool 50 | HLS *HLSConfig 51 | DASH *DASHConfig 52 | // OnDownload will be called when HTTP GET method succeeds. 53 | // This function must be thread-safe. 54 | OnDownload OnDownloadHandler 55 | OnReport OnReportHandler 56 | OnTerminate OnTerminateHandler 57 | } 58 | 59 | func NewConfig(url string, streamType StreamType) *Config { 60 | backoff := backoff.NewExponentialBackOff() 61 | backoff.MaxInterval = 2 * time.Second 62 | backoff.MaxElapsedTime = 10 * time.Second 63 | config := &Config{ 64 | URL: url, 65 | DefaultInterval: 5 * time.Second, 66 | HTTPClient: http.DefaultClient, 67 | ManifestTimeout: 1 * time.Second, 68 | ManifestBackoff: backoff, 69 | SegmentTimeout: 3 * time.Second, 70 | SegmentBackoff: backoff, 71 | SegmentMaxConcurrency: 4, 72 | StreamType: streamType, 73 | } 74 | switch streamType { 75 | case StreamTypeHLS: 76 | config.HLS = &HLSConfig{} 77 | case StreamTypeDASH: 78 | config.DASH = &DASHConfig{} 79 | } 80 | return config 81 | } 82 | -------------------------------------------------------------------------------- /core/dash.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/abema/antares/v2/internal/url" 11 | "github.com/zencoder/go-dash/mpd" 12 | ) 13 | 14 | type Manifest struct { 15 | URL string 16 | Raw []byte 17 | Time time.Time 18 | *mpd.MPD 19 | } 20 | 21 | type DASHSegment struct { 22 | URL string 23 | Initialization bool 24 | Time uint64 25 | Duration uint64 26 | Period *mpd.Period 27 | AdaptationSet *mpd.AdaptationSet 28 | SegmentTemplate *mpd.SegmentTemplate 29 | Representation *mpd.Representation 30 | } 31 | 32 | func (m *Manifest) BaseURL() (string, error) { 33 | if m.MPD.BaseURL != "" { 34 | return url.ResolveReference(m.URL, m.MPD.BaseURL) 35 | } 36 | return m.URL, nil 37 | } 38 | 39 | func (m *Manifest) EachSegments(handle func(*DASHSegment) (cont bool)) error { 40 | baseURL, err := m.BaseURL() 41 | if err != nil { 42 | return err 43 | } 44 | for _, period := range m.Periods { 45 | baseURL := baseURL 46 | if period.BaseURL != "" { 47 | baseURL, err = url.ResolveReference(baseURL, period.BaseURL) 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | var segmentTemplate mpd.SegmentTemplate 53 | if period.SegmentTemplate != nil { 54 | mergeSegmentTemplate(&segmentTemplate, period.SegmentTemplate) 55 | } 56 | for _, as := range period.AdaptationSets { 57 | segmentTemplate := segmentTemplate 58 | if as.SegmentTemplate != nil { 59 | mergeSegmentTemplate(&segmentTemplate, as.SegmentTemplate) 60 | } 61 | for _, rep := range as.Representations { 62 | baseURL := baseURL 63 | if rep.BaseURL != nil { 64 | baseURL, err = url.ResolveReference(baseURL, *rep.BaseURL) 65 | if err != nil { 66 | return err 67 | } 68 | } 69 | segmentTemplate := segmentTemplate 70 | if rep.SegmentTemplate != nil { 71 | mergeSegmentTemplate(&segmentTemplate, rep.SegmentTemplate) 72 | } 73 | cont, err := visitSegmentsBySegmentTimeline(baseURL, &segmentTemplate, period, as, rep, handle) 74 | if err != nil || !cont { 75 | return err 76 | } 77 | } 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | func (m *Manifest) Segments() ([]*DASHSegment, error) { 84 | segments := make([]*DASHSegment, 0) 85 | m.EachSegments(func(segment *DASHSegment) bool { 86 | segments = append(segments, segment) 87 | return true 88 | }) 89 | return segments, nil 90 | } 91 | 92 | func visitSegmentsBySegmentTimeline( 93 | baseURL string, 94 | template *mpd.SegmentTemplate, 95 | period *mpd.Period, 96 | as *mpd.AdaptationSet, 97 | rep *mpd.Representation, 98 | handle func(*DASHSegment) (cont bool), 99 | ) (bool, error) { 100 | var repID string 101 | if rep.ID != nil { 102 | repID = *rep.ID 103 | } 104 | var bandwidth int64 105 | if rep.Bandwidth != nil { 106 | bandwidth = *rep.Bandwidth 107 | } 108 | var number int64 109 | if template.StartNumber != nil { 110 | number = *template.StartNumber 111 | } 112 | 113 | if template.Initialization != nil { 114 | u, err := url.ResolveReference(baseURL, ResolveTemplate(*template.Initialization, TemplateParams{ 115 | RepresentationID: repID, 116 | Bandwidth: bandwidth, 117 | })) 118 | if err != nil { 119 | return false, err 120 | } 121 | if !handle(&DASHSegment{ 122 | URL: u, 123 | Initialization: true, 124 | Period: period, 125 | AdaptationSet: as, 126 | SegmentTemplate: template, 127 | Representation: rep, 128 | }) { 129 | return false, nil 130 | } 131 | } 132 | if template.SegmentTimeline != nil && template.Media != nil { 133 | var t uint64 134 | for _, segment := range template.SegmentTimeline.Segments { 135 | n := 1 136 | if segment.RepeatCount != nil { 137 | n = *segment.RepeatCount + 1 138 | } 139 | if segment.StartTime != nil { 140 | t = *segment.StartTime 141 | } 142 | for i := 0; i < n; i++ { 143 | u, err := url.ResolveReference(baseURL, ResolveTemplate(*template.Media, TemplateParams{ 144 | RepresentationID: repID, 145 | Number: number, 146 | Bandwidth: bandwidth, 147 | Time: t, 148 | })) 149 | if err != nil { 150 | return false, err 151 | } 152 | if !handle(&DASHSegment{ 153 | URL: u, 154 | Time: t, 155 | Duration: segment.Duration, 156 | Period: period, 157 | AdaptationSet: as, 158 | SegmentTemplate: template, 159 | Representation: rep, 160 | }) { 161 | return false, nil 162 | } 163 | t += segment.Duration 164 | number++ 165 | } 166 | } 167 | } 168 | return true, nil 169 | } 170 | 171 | type TemplateParams struct { 172 | RepresentationID string 173 | Number int64 174 | Bandwidth int64 175 | Time uint64 176 | } 177 | 178 | func ResolveTemplate(format string, params TemplateParams) string { 179 | var ret string 180 | ss := strings.Split(format, "$") 181 | for i, s := range ss { 182 | if i%2 == 0 { 183 | ret += s 184 | } else if s == "" { 185 | ret += "$" 186 | } else if s == "RepresentationID" { 187 | ret += params.RepresentationID 188 | } else if s == "Number" { 189 | ret += strconv.FormatInt(params.Number, 10) 190 | } else if s == "Bandwidth" { 191 | ret += strconv.FormatInt(params.Bandwidth, 10) 192 | } else if s == "Time" { 193 | ret += strconv.FormatUint(params.Time, 10) 194 | } else if strings.HasPrefix(s, "Number%") { 195 | ret += fmt.Sprintf(s[6:], params.Number) 196 | } else if strings.HasPrefix(s, "Bandwidth%") { 197 | ret += fmt.Sprintf(s[9:], params.Bandwidth) 198 | } else if strings.HasPrefix(s, "Time%") { 199 | ret += fmt.Sprintf(s[4:], params.Time) 200 | } else { 201 | ret += "$" + s + "$" 202 | } 203 | } 204 | return ret 205 | } 206 | 207 | type dashManifestDownloader struct { 208 | client client 209 | timeout time.Duration 210 | location string 211 | } 212 | 213 | func newDASHManifestDownloader(client client, timeout time.Duration) *dashManifestDownloader { 214 | return &dashManifestDownloader{ 215 | client: client, 216 | timeout: timeout, 217 | } 218 | } 219 | 220 | func (d *dashManifestDownloader) Download(ctx context.Context, u string) (*Manifest, error) { 221 | ctx, cancel := context.WithTimeout(ctx, d.timeout) 222 | defer cancel() 223 | 224 | if d.location != "" { 225 | u = d.location 226 | } 227 | data, loc, err := d.client.Get(ctx, u) 228 | if err != nil { 229 | return nil, fmt.Errorf("failed to download manifest: %s: %w", u, err) 230 | } 231 | m, err := mpd.ReadFromString(string(data)) 232 | if err != nil { 233 | return nil, fmt.Errorf("failed to decode manifest: %s: %w", u, err) 234 | } 235 | if m.Location != "" { 236 | d.location = m.Location 237 | } 238 | return &Manifest{ 239 | URL: loc, 240 | Raw: data, 241 | Time: time.Now(), 242 | MPD: m, 243 | }, nil 244 | } 245 | 246 | func mergeSegmentTemplate(base, child *mpd.SegmentTemplate) { 247 | if child.SegmentTimeline != nil { 248 | base.SegmentTimeline = child.SegmentTimeline 249 | } 250 | if child.PresentationTimeOffset != nil { 251 | base.PresentationTimeOffset = child.PresentationTimeOffset 252 | } 253 | if child.Duration != nil { 254 | base.Duration = child.Duration 255 | } 256 | if child.Initialization != nil { 257 | base.Initialization = child.Initialization 258 | } 259 | if child.Media != nil { 260 | base.Media = child.Media 261 | } 262 | if child.StartNumber != nil { 263 | base.StartNumber = child.StartNumber 264 | } 265 | if child.Timescale != nil { 266 | base.Timescale = child.Timescale 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /core/dash_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/zencoder/go-dash/helpers/ptrs" 13 | "github.com/zencoder/go-dash/mpd" 14 | ) 15 | 16 | func TestSegments(t *testing.T) { 17 | t.Run("shared_SegmentTemplate", func(t *testing.T) { 18 | m := Manifest{ 19 | URL: "https://localhost/foo/manifest.mpd", 20 | MPD: &mpd.MPD{ 21 | BaseURL: "./bar/", 22 | Periods: []*mpd.Period{ 23 | { 24 | AdaptationSets: []*mpd.AdaptationSet{ 25 | { 26 | SegmentTemplate: &mpd.SegmentTemplate{ 27 | Initialization: ptrs.Strptr("$RepresentationID$/init.mp4"), 28 | Media: ptrs.Strptr("$RepresentationID$/$Time$.mp4"), 29 | SegmentTimeline: &mpd.SegmentTimeline{ 30 | Segments: []*mpd.SegmentTimelineSegment{ 31 | {StartTime: ptrs.Uint64ptr(1000000), Duration: 90000}, 32 | {Duration: 80000, RepeatCount: ptrs.Intptr(2)}, 33 | {Duration: 70000, RepeatCount: ptrs.Intptr(1)}, 34 | }, 35 | }, 36 | }, 37 | Representations: []*mpd.Representation{ 38 | {ID: ptrs.Strptr("r0")}, 39 | {ID: ptrs.Strptr("r1")}, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | } 47 | segments, err := m.Segments() 48 | require.NoError(t, err) 49 | require.Len(t, segments, 14) 50 | 51 | assert.Equal(t, "https://localhost/foo/bar/r0/init.mp4", segments[0].URL) 52 | assert.True(t, segments[0].Initialization) 53 | 54 | assert.Equal(t, "https://localhost/foo/bar/r0/1000000.mp4", segments[1].URL) 55 | assert.False(t, segments[1].Initialization) 56 | assert.Equal(t, uint64(1000000), segments[1].Time) 57 | assert.Equal(t, uint64(90000), segments[1].Duration) 58 | 59 | assert.Equal(t, "https://localhost/foo/bar/r0/1090000.mp4", segments[2].URL) 60 | assert.Equal(t, "https://localhost/foo/bar/r0/1170000.mp4", segments[3].URL) 61 | assert.Equal(t, "https://localhost/foo/bar/r0/1250000.mp4", segments[4].URL) 62 | assert.Equal(t, "https://localhost/foo/bar/r0/1330000.mp4", segments[5].URL) 63 | assert.Equal(t, "https://localhost/foo/bar/r0/1400000.mp4", segments[6].URL) 64 | assert.Equal(t, "https://localhost/foo/bar/r1/init.mp4", segments[7].URL) 65 | assert.Equal(t, "https://localhost/foo/bar/r1/1000000.mp4", segments[8].URL) 66 | assert.Equal(t, "https://localhost/foo/bar/r1/1090000.mp4", segments[9].URL) 67 | assert.Equal(t, "https://localhost/foo/bar/r1/1170000.mp4", segments[10].URL) 68 | assert.Equal(t, "https://localhost/foo/bar/r1/1250000.mp4", segments[11].URL) 69 | assert.Equal(t, "https://localhost/foo/bar/r1/1330000.mp4", segments[12].URL) 70 | assert.Equal(t, "https://localhost/foo/bar/r1/1400000.mp4", segments[13].URL) 71 | }) 72 | 73 | t.Run("individual_SegmentTemplate", func(t *testing.T) { 74 | m := Manifest{ 75 | URL: "https://localhost/foo/manifest.mpd", 76 | MPD: &mpd.MPD{ 77 | BaseURL: "./bar/", 78 | Periods: []*mpd.Period{ 79 | { 80 | SegmentTemplate: &mpd.SegmentTemplate{StartNumber: ptrs.Int64ptr(1), Timescale: ptrs.Int64ptr(90000)}, 81 | AdaptationSets: []*mpd.AdaptationSet{ 82 | { 83 | SegmentTemplate: &mpd.SegmentTemplate{StartNumber: ptrs.Int64ptr(1), Timescale: ptrs.Int64ptr(30000)}, 84 | Representations: []*mpd.Representation{ 85 | { 86 | ID: ptrs.Strptr("r0"), 87 | SegmentTemplate: &mpd.SegmentTemplate{ 88 | StartNumber: ptrs.Int64ptr(100), 89 | Initialization: ptrs.Strptr("$RepresentationID$/init.mp4"), 90 | Media: ptrs.Strptr("$RepresentationID$/$Time$.mp4"), 91 | SegmentTimeline: &mpd.SegmentTimeline{ 92 | Segments: []*mpd.SegmentTimelineSegment{ 93 | {StartTime: ptrs.Uint64ptr(1000000), Duration: 90000, RepeatCount: ptrs.Intptr(2)}, 94 | }, 95 | }, 96 | }, 97 | }, 98 | { 99 | ID: ptrs.Strptr("r1"), 100 | SegmentTemplate: &mpd.SegmentTemplate{ 101 | StartNumber: ptrs.Int64ptr(100), 102 | Initialization: ptrs.Strptr("$RepresentationID$/init.mp4"), 103 | Media: ptrs.Strptr("$RepresentationID$/$Time$.mp4"), 104 | SegmentTimeline: &mpd.SegmentTimeline{ 105 | Segments: []*mpd.SegmentTimelineSegment{ 106 | {StartTime: ptrs.Uint64ptr(1000000), Duration: 90000, RepeatCount: ptrs.Intptr(2)}, 107 | }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | } 118 | segments, err := m.Segments() 119 | require.NoError(t, err) 120 | require.Len(t, segments, 8) 121 | 122 | assert.Equal(t, "https://localhost/foo/bar/r0/init.mp4", segments[0].URL) 123 | assert.True(t, segments[0].Initialization) 124 | 125 | assert.Equal(t, "https://localhost/foo/bar/r0/1000000.mp4", segments[1].URL) 126 | assert.False(t, segments[1].Initialization) 127 | assert.Equal(t, int64(100), *segments[1].SegmentTemplate.StartNumber) 128 | assert.Equal(t, int64(30000), *segments[1].SegmentTemplate.Timescale) 129 | assert.Equal(t, uint64(1000000), segments[1].Time) 130 | assert.Equal(t, uint64(90000), segments[1].Duration) 131 | 132 | assert.Equal(t, "https://localhost/foo/bar/r1/init.mp4", segments[4].URL) 133 | assert.True(t, segments[4].Initialization) 134 | 135 | assert.Equal(t, "https://localhost/foo/bar/r1/1000000.mp4", segments[5].URL) 136 | assert.False(t, segments[5].Initialization) 137 | assert.Equal(t, int64(100), *segments[5].SegmentTemplate.StartNumber) 138 | assert.Equal(t, int64(30000), *segments[5].SegmentTemplate.Timescale) 139 | assert.Equal(t, uint64(1000000), segments[5].Time) 140 | assert.Equal(t, uint64(90000), segments[5].Duration) 141 | }) 142 | } 143 | 144 | func TestResolveTemplate(t *testing.T) { 145 | params := TemplateParams{ 146 | RepresentationID: "r0", 147 | Number: 31, 148 | Bandwidth: 1200000, 149 | Time: 3723720, 150 | } 151 | assert.Equal(t, ResolveTemplate("media.mp4", params), "media.mp4") 152 | assert.Equal(t, ResolveTemplate("media/$RepresentationID$/$Number$.mp4", params), "media/r0/31.mp4") 153 | assert.Equal(t, ResolveTemplate("media/$Bandwidth$/$Time$.mp4", params), "media/1200000/3723720.mp4") 154 | assert.Equal(t, ResolveTemplate("media/$RepresentationID$/$Number%05d$.mp4", params), "media/r0/00031.mp4") 155 | assert.Equal(t, ResolveTemplate("media/$Bandwidth%08d$/$Time%09d$.mp4", params), "media/01200000/003723720.mp4") 156 | assert.Equal(t, ResolveTemplate("media/$$/$Time$.mp4", params), "media/$/3723720.mp4") 157 | assert.Equal(t, ResolveTemplate("media/$Unknown$/$Time$.mp4", params), "media/$Unknown$/3723720.mp4") 158 | assert.Equal(t, ResolveTemplate("media/$Time$.$Unknown$", params), "media/3723720.$Unknown$") 159 | } 160 | 161 | func TestDASHManifestDownloader(t *testing.T) { 162 | manifest := []byte(`` + 163 | `` + 164 | `` + 165 | `` + 166 | `` + 167 | `` + 168 | `` + 169 | `` + 170 | `` + 171 | `` + 172 | `` + 173 | `` + 174 | ``) 175 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 176 | switch r.URL.Path { 177 | case "/manifest.mpd": 178 | w.Write(manifest) 179 | default: 180 | w.WriteHeader(http.StatusNotFound) 181 | } 182 | })) 183 | d := newDASHManifestDownloader(newClient(http.DefaultClient, nil, nil), time.Second) 184 | mpd, err := d.Download(context.Background(), server.URL+"/manifest.mpd") 185 | require.NoError(t, err) 186 | require.Equal(t, server.URL+"/manifest.mpd", mpd.URL) 187 | require.Len(t, mpd.Raw, len(manifest)) 188 | require.Equal(t, "dynamic", *mpd.Type) 189 | require.Len(t, mpd.Periods, 1) 190 | } 191 | -------------------------------------------------------------------------------- /core/file.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | type Meta struct { 9 | URL string `json:"url"` 10 | Via string `json:"via"` 11 | RequestHeader http.Header `json:"requestHeader"` 12 | ResponseHeader http.Header `json:"responseHeader"` 13 | Status string `json:"status"` // e.g. "200 OK" 14 | StatusCode int `json:"statusCode"` // e.g. 200 15 | Proto string `json:"proto"` // e.g. "HTTP/1.0" 16 | ProtoMajor int `json:"protoMajor"` // e.g. 1 17 | ProtoMinor int `json:"protoMinor"` // e.g. 0 18 | ContentLength int64 `json:"contentLength"` 19 | TransferEncoding []string `json:"transferEncoding"` 20 | Uncompressed bool `json:"uncompressed"` 21 | RequestTimestamp time.Time `json:"requestTimestamp"` 22 | DownloadTime time.Duration `json:"downloadTime"` 23 | } 24 | 25 | type File struct { 26 | Meta 27 | Body []byte 28 | } 29 | -------------------------------------------------------------------------------- /core/filter.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "hash/crc32" 5 | "math" 6 | ) 7 | 8 | type FilterResult int 9 | 10 | const ( 11 | Pass FilterResult = iota 12 | Reject 13 | ) 14 | 15 | type SegmentFilter interface { 16 | CheckHLS(segment *HLSSegment) FilterResult 17 | CheckDASH(segment *DASHSegment) FilterResult 18 | } 19 | 20 | type segmentFilterAnd struct { 21 | filters []SegmentFilter 22 | } 23 | 24 | func SegmentFilterAnd(filters ...SegmentFilter) SegmentFilter { 25 | return &segmentFilterAnd{ 26 | filters: filters, 27 | } 28 | } 29 | 30 | func (f *segmentFilterAnd) CheckHLS(segment *HLSSegment) FilterResult { 31 | for _, filter := range f.filters { 32 | if filter.CheckHLS(segment) == Reject { 33 | return Reject 34 | } 35 | } 36 | return Pass 37 | } 38 | 39 | func (f *segmentFilterAnd) CheckDASH(segment *DASHSegment) FilterResult { 40 | for _, filter := range f.filters { 41 | if filter.CheckDASH(segment) == Reject { 42 | return Reject 43 | } 44 | } 45 | return Pass 46 | } 47 | 48 | type segmentFilterOr struct { 49 | filters []SegmentFilter 50 | } 51 | 52 | func SegmentFilterOr(filters ...SegmentFilter) SegmentFilter { 53 | return &segmentFilterOr{ 54 | filters: filters, 55 | } 56 | } 57 | 58 | func (f *segmentFilterOr) CheckHLS(segment *HLSSegment) FilterResult { 59 | for _, filter := range f.filters { 60 | if filter.CheckHLS(segment) == Pass { 61 | return Pass 62 | } 63 | } 64 | return Reject 65 | } 66 | 67 | func (f *segmentFilterOr) CheckDASH(segment *DASHSegment) FilterResult { 68 | for _, filter := range f.filters { 69 | if filter.CheckDASH(segment) == Pass { 70 | return Pass 71 | } 72 | } 73 | return Reject 74 | } 75 | 76 | type allSegmentRejectionFilter struct { 77 | } 78 | 79 | func AllSegmentRejectionFilter() SegmentFilter { 80 | return &allSegmentRejectionFilter{} 81 | } 82 | 83 | func (f *allSegmentRejectionFilter) CheckHLS(segment *HLSSegment) FilterResult { 84 | return Reject 85 | } 86 | 87 | func (f *allSegmentRejectionFilter) CheckDASH(segment *DASHSegment) FilterResult { 88 | return Reject 89 | } 90 | 91 | type maxBandwidthSegmentFilter struct { 92 | bandwidth int64 93 | } 94 | 95 | func MaxBandwidthSegmentFilter(bandwidth int64) SegmentFilter { 96 | return &maxBandwidthSegmentFilter{ 97 | bandwidth: bandwidth, 98 | } 99 | } 100 | 101 | func (f *maxBandwidthSegmentFilter) CheckHLS(segment *HLSSegment) FilterResult { 102 | if segment.StreamInfAttrs == nil { 103 | return Reject 104 | } 105 | bandwidth, err := segment.StreamInfAttrs.Bandwidth() 106 | if err != nil || bandwidth > f.bandwidth { 107 | return Reject 108 | } 109 | return Pass 110 | } 111 | 112 | func (f *maxBandwidthSegmentFilter) CheckDASH(segment *DASHSegment) FilterResult { 113 | if segment.Representation != nil && 114 | segment.Representation.Bandwidth != nil && 115 | *segment.Representation.Bandwidth <= f.bandwidth { 116 | return Pass 117 | } else { 118 | return Reject 119 | } 120 | } 121 | 122 | type minBandwidthSegmentFilter struct { 123 | bandwidth int64 124 | } 125 | 126 | func MinBandwidthSegmentFilter(bandwidth int64) SegmentFilter { 127 | return &minBandwidthSegmentFilter{ 128 | bandwidth: bandwidth, 129 | } 130 | } 131 | 132 | func (f *minBandwidthSegmentFilter) CheckHLS(segment *HLSSegment) FilterResult { 133 | if segment.StreamInfAttrs == nil { 134 | return Reject 135 | } 136 | bandwidth, err := segment.StreamInfAttrs.Bandwidth() 137 | if err != nil || bandwidth < f.bandwidth { 138 | return Reject 139 | } 140 | return Pass 141 | } 142 | 143 | func (f *minBandwidthSegmentFilter) CheckDASH(segment *DASHSegment) FilterResult { 144 | if segment.Representation != nil && 145 | segment.Representation.Bandwidth != nil && 146 | *segment.Representation.Bandwidth >= f.bandwidth { 147 | return Pass 148 | } else { 149 | return Reject 150 | } 151 | } 152 | 153 | type hashSamplingSegmentFilter struct { 154 | rate float64 155 | } 156 | 157 | func HashSamplingSegmentFilter(rate float64) SegmentFilter { 158 | return &hashSamplingSegmentFilter{ 159 | rate: rate, 160 | } 161 | } 162 | 163 | func (f *hashSamplingSegmentFilter) CheckHLS(segment *HLSSegment) FilterResult { 164 | return f.check(segment.URL) 165 | } 166 | 167 | func (f *hashSamplingSegmentFilter) CheckDASH(segment *DASHSegment) FilterResult { 168 | return f.check(segment.URL) 169 | } 170 | 171 | func (f *hashSamplingSegmentFilter) check(url string) FilterResult { 172 | hash32 := crc32.ChecksumIEEE([]byte(url)) 173 | val := float64(hash32) / float64(math.MaxUint32) 174 | if val < f.rate { 175 | return Pass 176 | } else { 177 | return Reject 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /core/filter_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "testing" 5 | 6 | m3u8 "github.com/abema/go-simple-m3u8" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/zencoder/go-dash/helpers/ptrs" 9 | "github.com/zencoder/go-dash/mpd" 10 | ) 11 | 12 | func TestSegmentFilter(t *testing.T) { 13 | filter := SegmentFilterOr( 14 | SegmentFilterAnd( 15 | MaxBandwidthSegmentFilter(9000*1e3), 16 | MinBandwidthSegmentFilter(7000*1e3), 17 | ), 18 | SegmentFilterAnd( 19 | MaxBandwidthSegmentFilter(5000*1e3), 20 | MinBandwidthSegmentFilter(3000*1e3), 21 | ), 22 | ) 23 | assert.Equal(t, Reject, filter.CheckHLS(&HLSSegment{ 24 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "10000000"}, 25 | })) 26 | assert.Equal(t, Pass, filter.CheckHLS(&HLSSegment{ 27 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "8000000"}, 28 | })) 29 | assert.Equal(t, Reject, filter.CheckHLS(&HLSSegment{ 30 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "6000000"}, 31 | })) 32 | assert.Equal(t, Pass, filter.CheckHLS(&HLSSegment{ 33 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "4000000"}, 34 | })) 35 | assert.Equal(t, Reject, filter.CheckHLS(&HLSSegment{ 36 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "2000000"}, 37 | })) 38 | assert.Equal(t, Reject, filter.CheckDASH(&DASHSegment{ 39 | Representation: &mpd.Representation{Bandwidth: ptrs.Int64ptr(10000 * 1e3)}, 40 | })) 41 | assert.Equal(t, Pass, filter.CheckDASH(&DASHSegment{ 42 | Representation: &mpd.Representation{Bandwidth: ptrs.Int64ptr(8000 * 1e3)}, 43 | })) 44 | assert.Equal(t, Reject, filter.CheckDASH(&DASHSegment{ 45 | Representation: &mpd.Representation{Bandwidth: ptrs.Int64ptr(6000 * 1e3)}, 46 | })) 47 | assert.Equal(t, Pass, filter.CheckDASH(&DASHSegment{ 48 | Representation: &mpd.Representation{Bandwidth: ptrs.Int64ptr(4000 * 1e3)}, 49 | })) 50 | assert.Equal(t, Reject, filter.CheckDASH(&DASHSegment{ 51 | Representation: &mpd.Representation{Bandwidth: ptrs.Int64ptr(2000 * 1e3)}, 52 | })) 53 | } 54 | -------------------------------------------------------------------------------- /core/handler.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type ( 4 | OnDownloadHandler func(file *File) 5 | OnReportHandler func(reports Reports) 6 | OnTerminateHandler func() 7 | ) 8 | 9 | func MergeOnDownloadHandlers(handlers ...OnDownloadHandler) OnDownloadHandler { 10 | return func(file *File) { 11 | for _, handler := range handlers { 12 | handler(file) 13 | } 14 | } 15 | } 16 | 17 | func MergeOnReportHandlers(handlers ...OnReportHandler) OnReportHandler { 18 | return func(reports Reports) { 19 | for _, handler := range handlers { 20 | handler(reports) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/hls.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/url" 8 | "sync" 9 | "time" 10 | 11 | "github.com/abema/antares/v2/internal/thread" 12 | m3u8 "github.com/abema/go-simple-m3u8" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | type MasterPlaylist struct { 17 | URL string 18 | Raw []byte 19 | Time time.Time 20 | *m3u8.MasterPlaylist 21 | } 22 | 23 | type MediaPlaylist struct { 24 | URL string 25 | Raw []byte 26 | Time time.Time 27 | *m3u8.MediaPlaylist 28 | StreamInfAttrs m3u8.StreamInfAttrs 29 | MediaAttrs m3u8.MediaAttrs 30 | } 31 | 32 | func (p *MediaPlaylist) SegmentURLs() ([]string, error) { 33 | base, err := url.Parse(p.URL) 34 | if err != nil { 35 | return nil, err 36 | } 37 | urls := make([]string, 0) 38 | for _, segment := range p.Segments { 39 | u, err := base.Parse(segment.URI) 40 | if err != nil { 41 | return nil, err 42 | } 43 | urls = append(urls, u.String()) 44 | } 45 | return urls, nil 46 | } 47 | 48 | type Playlists struct { 49 | MasterPlaylist *MasterPlaylist 50 | MediaPlaylists map[string]*MediaPlaylist 51 | } 52 | 53 | type HLSSegment struct { 54 | URL string 55 | // StreamInfAttrs is reference to related StreamInfAttrs object in MasterPlaylist. 56 | // This property is nullable. 57 | StreamInfAttrs m3u8.StreamInfAttrs 58 | // MediaAttrs is reference to related MediaAttrs object in MasterPlaylist. 59 | // This property is nullable. 60 | MediaAttrs m3u8.MediaAttrs 61 | } 62 | 63 | func (p *Playlists) Segments() ([]*HLSSegment, error) { 64 | segments := make([]*HLSSegment, 0) 65 | for _, playlist := range p.MediaPlaylists { 66 | urls, err := playlist.SegmentURLs() 67 | if err != nil { 68 | return nil, err 69 | } 70 | for _, u := range urls { 71 | segments = append(segments, &HLSSegment{ 72 | URL: u, 73 | StreamInfAttrs: playlist.StreamInfAttrs, 74 | MediaAttrs: playlist.MediaAttrs, 75 | }) 76 | } 77 | } 78 | return segments, nil 79 | } 80 | 81 | func (p *Playlists) IsVOD() bool { 82 | for _, playlist := range p.MediaPlaylists { 83 | if !playlist.EndList { 84 | return false 85 | } 86 | } 87 | return true 88 | } 89 | 90 | func (p *Playlists) MaxTargetDuration() float64 { 91 | var dur float64 92 | for _, playlist := range p.MediaPlaylists { 93 | d := float64(playlist.Tags.TargetDuration()) 94 | if d > dur { 95 | dur = d 96 | } 97 | } 98 | return dur 99 | } 100 | 101 | type hlsPlaylistDownloader struct { 102 | client client 103 | timeout time.Duration 104 | masterPlaylist *MasterPlaylist 105 | } 106 | 107 | func newHLSPlaylistDownloader(client client, timeout time.Duration) *hlsPlaylistDownloader { 108 | return &hlsPlaylistDownloader{ 109 | client: client, 110 | timeout: timeout, 111 | } 112 | } 113 | 114 | func (d *hlsPlaylistDownloader) Download(ctx context.Context, u string) (*Playlists, error) { 115 | ctx, cancel := context.WithTimeout(ctx, d.timeout) 116 | defer cancel() 117 | 118 | if d.masterPlaylist == nil { 119 | data, loc, err := d.client.Get(ctx, u) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to download playlist: %s: %w", u, err) 122 | } 123 | dec, err := m3u8.DecodePlaylist(bytes.NewReader(data)) 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to decode playlist: %s: %w", u, err) 126 | } 127 | if dec.Type() == m3u8.PlaylistTypeMedia { 128 | media := dec.Media() 129 | removeNilSegments(media) 130 | return &Playlists{ 131 | MediaPlaylists: map[string]*MediaPlaylist{ 132 | "_": { 133 | URL: loc, 134 | Raw: data, 135 | Time: time.Now(), 136 | MediaPlaylist: media, 137 | }, 138 | }, 139 | }, nil 140 | } 141 | master := dec.Master() 142 | d.masterPlaylist = &MasterPlaylist{ 143 | URL: loc, 144 | Raw: data, 145 | Time: time.Now(), 146 | MasterPlaylist: master, 147 | } 148 | } 149 | 150 | playlists := &Playlists{ 151 | MasterPlaylist: d.masterPlaylist, 152 | MediaPlaylists: make(map[string]*MediaPlaylist), 153 | } 154 | base, err := url.Parse(u) 155 | if err != nil { 156 | return nil, err 157 | } 158 | var mutex sync.Mutex 159 | eg := new(errgroup.Group) 160 | for vi := range d.masterPlaylist.Streams { 161 | variant := d.masterPlaylist.Streams[vi] 162 | eg.Go(thread.NoPanic(func() error { 163 | mediaPlaylist, err := d.downloadMediaPlaylist(ctx, base, variant.URI, variant.Attributes, nil) 164 | if err != nil { 165 | return err 166 | } 167 | mutex.Lock() 168 | defer mutex.Unlock() 169 | playlists.MediaPlaylists[variant.URI] = mediaPlaylist 170 | return nil 171 | })) 172 | } 173 | var alternatives []m3u8.MediaAttrs 174 | for _, alts := range d.masterPlaylist.Alternatives.Video { 175 | for _, alt := range alts { 176 | alternatives = append(alternatives, alt.Attributes) 177 | } 178 | } 179 | for _, alts := range d.masterPlaylist.Alternatives.Audio { 180 | for _, alt := range alts { 181 | alternatives = append(alternatives, alt.Attributes) 182 | } 183 | } 184 | for _, alts := range d.masterPlaylist.Alternatives.Subtitles { 185 | for _, alt := range alts { 186 | alternatives = append(alternatives, alt.Attributes) 187 | } 188 | } 189 | for _, alt := range alternatives { 190 | eg.Go(thread.NoPanic(func() error { 191 | uri := alt.URI() 192 | mediaPlaylist, err := d.downloadMediaPlaylist(ctx, base, uri, nil, alt) 193 | if err != nil { 194 | return err 195 | } 196 | mutex.Lock() 197 | defer mutex.Unlock() 198 | playlists.MediaPlaylists[uri] = mediaPlaylist 199 | return nil 200 | })) 201 | } 202 | if err := eg.Wait(); err != nil { 203 | return nil, err 204 | } 205 | return playlists, nil 206 | } 207 | 208 | func (d *hlsPlaylistDownloader) downloadMediaPlaylist( 209 | ctx context.Context, 210 | base *url.URL, 211 | u string, 212 | variantParams m3u8.StreamInfAttrs, 213 | alt m3u8.MediaAttrs, 214 | ) (*MediaPlaylist, error) { 215 | absolute, err := base.Parse(u) 216 | if err != nil { 217 | return nil, fmt.Errorf("invalid URL format: %s: %w", u, err) 218 | } 219 | data, loc, err := d.client.Get(ctx, absolute.String()) 220 | if err != nil { 221 | return nil, fmt.Errorf("failed to download media playlist: %s: %w", u, err) 222 | } 223 | dec, err := m3u8.DecodePlaylist(bytes.NewReader(data)) 224 | if err != nil { 225 | return nil, fmt.Errorf("failed to decode media playlist: %s: %w", u, err) 226 | } 227 | media := dec.Media() 228 | removeNilSegments(media) 229 | return &MediaPlaylist{ 230 | URL: loc, 231 | Raw: data, 232 | Time: time.Now(), 233 | MediaPlaylist: media, 234 | StreamInfAttrs: variantParams, 235 | MediaAttrs: alt, 236 | }, nil 237 | } 238 | 239 | // removeNilSegments removes nil elements, because abema/go-simple-m3u8 returns nil-filled large slice. 240 | // https://github.com/abema/go-simple-m3u8/issues/97 241 | func removeNilSegments(media *m3u8.MediaPlaylist) { 242 | for i := range media.Segments { 243 | if media.Segments[i] == nil { 244 | media.Segments = media.Segments[:i] 245 | break 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /core/hls_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "sort" 8 | "testing" 9 | "time" 10 | 11 | m3u8 "github.com/abema/go-simple-m3u8" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestSegmentURLs(t *testing.T) { 16 | p := &Playlists{ 17 | MediaPlaylists: map[string]*MediaPlaylist{ 18 | "media_0.m3u8": { 19 | URL: "https://localhost/foo/media_0.m3u8", 20 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: []*m3u8.Segment{ 21 | {URI: "segment_0_0.ts"}, 22 | {URI: "segment_0_1.ts"}, 23 | }}, 24 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "2000000"}, 25 | MediaAttrs: m3u8.MediaAttrs{"NAME": "audio_0"}, 26 | }, 27 | "media_1.m3u8": { 28 | URL: "https://localhost/foo/media_1.m3u8", 29 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: []*m3u8.Segment{ 30 | {URI: "segment_1_0.ts"}, 31 | {URI: "segment_1_1.ts"}, 32 | }}, 33 | StreamInfAttrs: m3u8.StreamInfAttrs{"BANDWIDTH": "1000000"}, 34 | MediaAttrs: m3u8.MediaAttrs{"NAME": "audio_1"}, 35 | }, 36 | }, 37 | } 38 | segments, err := p.Segments() 39 | require.NoError(t, err) 40 | require.Len(t, segments, 4) 41 | sort.Slice(segments, func(i, j int) bool { 42 | return segments[i].URL < segments[j].URL 43 | }) 44 | require.Equal(t, "https://localhost/foo/segment_0_0.ts", segments[0].URL) 45 | require.Equal(t, "https://localhost/foo/segment_0_1.ts", segments[1].URL) 46 | require.Equal(t, "https://localhost/foo/segment_1_0.ts", segments[2].URL) 47 | require.Equal(t, "https://localhost/foo/segment_1_1.ts", segments[3].URL) 48 | require.Equal(t, "2000000", segments[0].StreamInfAttrs["BANDWIDTH"]) 49 | require.Equal(t, "audio_0", segments[0].MediaAttrs.Name()) 50 | require.Equal(t, "1000000", segments[2].StreamInfAttrs["BANDWIDTH"]) 51 | require.Equal(t, "audio_1", segments[2].MediaAttrs.Name()) 52 | } 53 | 54 | func TestIsVOD(t *testing.T) { 55 | t.Run("all_live", func(t *testing.T) { 56 | p := &Playlists{ 57 | MediaPlaylists: map[string]*MediaPlaylist{ 58 | "media_0.m3u8": { 59 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}, 60 | }, 61 | "media_1.m3u8": { 62 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}, 63 | }, 64 | }, 65 | } 66 | require.False(t, p.IsVOD()) 67 | }) 68 | 69 | t.Run("vod_and_live", func(t *testing.T) { 70 | p := &Playlists{ 71 | MediaPlaylists: map[string]*MediaPlaylist{ 72 | "media_0.m3u8": { 73 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}, 74 | }, 75 | "media_1.m3u8": { 76 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: true}, 77 | }, 78 | }, 79 | } 80 | require.False(t, p.IsVOD()) 81 | }) 82 | 83 | t.Run("vod", func(t *testing.T) { 84 | p := &Playlists{ 85 | MediaPlaylists: map[string]*MediaPlaylist{ 86 | "media_0.m3u8": { 87 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: true}, 88 | }, 89 | "media_1.m3u8": { 90 | MediaPlaylist: &m3u8.MediaPlaylist{EndList: true}, 91 | }, 92 | }, 93 | } 94 | require.True(t, p.IsVOD()) 95 | }) 96 | } 97 | 98 | func TestMaxTargetDuration(t *testing.T) { 99 | p := &Playlists{ 100 | MediaPlaylists: map[string]*MediaPlaylist{ 101 | "media_0.m3u8": { 102 | MediaPlaylist: &m3u8.MediaPlaylist{ 103 | Tags: m3u8.MediaPlaylistTags{m3u8.TagExtXTargetDuration: []string{"6"}}, 104 | }, 105 | }, 106 | "media_1.m3u8": { 107 | MediaPlaylist: &m3u8.MediaPlaylist{ 108 | Tags: m3u8.MediaPlaylistTags{m3u8.TagExtXTargetDuration: []string{"5"}}, 109 | }, 110 | }, 111 | }, 112 | } 113 | require.Equal(t, 6.0, p.MaxTargetDuration()) 114 | } 115 | 116 | func TestHLSPlaylistDownloader(t *testing.T) { 117 | master := []byte(`#EXTM3U` + "\n" + 118 | `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",DEFAULT=YES,URI="media_2.m3u8"` + "\n" + 119 | `#EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000,AUDIO="audio"` + "\n" + 120 | `media_0.m3u8` + "\n" + 121 | `#EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000,AUDIO="audio"` + "\n" + 122 | `media_1.m3u8` + "\n") 123 | media0 := []byte(`#EXTM3U` + "\n" + 124 | `#EXT-X-VERSION:3` + "\n" + 125 | `#EXT-X-TARGETDURATION:8` + "\n" + 126 | `#EXT-X-MEDIA-SEQUENCE:2680` + "\n" + 127 | `#EXTINF:7.975,` + "\n" + 128 | `media_0_100.ts` + "\n" + 129 | `#EXTINF:7.941,` + "\n" + 130 | `media_0_101.ts` + "\n") 131 | media1 := []byte(`#EXTM3U` + "\n" + 132 | `#EXT-X-VERSION:3` + "\n" + 133 | `#EXT-X-TARGETDURATION:8` + "\n" + 134 | `#EXT-X-MEDIA-SEQUENCE:2680` + "\n" + 135 | `#EXTINF:7.975,` + "\n" + 136 | `media_1_100.ts` + "\n" + 137 | `#EXTINF:7.941,` + "\n" + 138 | `media_1_101.ts` + "\n") 139 | media2 := []byte(`#EXTM3U` + "\n" + 140 | `#EXT-X-VERSION:3` + "\n" + 141 | `#EXT-X-TARGETDURATION:8` + "\n" + 142 | `#EXT-X-MEDIA-SEQUENCE:2680` + "\n" + 143 | `#EXTINF:7.975,` + "\n" + 144 | `media_2_100.ts` + "\n" + 145 | `#EXTINF:7.941,` + "\n" + 146 | `media_2_101.ts` + "\n") 147 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 | switch r.URL.Path { 149 | case "/master.m3u8": 150 | w.Write(master) 151 | case "/media_0.m3u8": 152 | w.Write(media0) 153 | case "/media_1.m3u8": 154 | w.Write(media1) 155 | case "/media_2.m3u8": 156 | w.Write(media2) 157 | default: 158 | w.WriteHeader(http.StatusNotFound) 159 | } 160 | })) 161 | 162 | t.Run("master playlist", func(t *testing.T) { 163 | d := newHLSPlaylistDownloader(newClient(http.DefaultClient, nil, nil), time.Second) 164 | playlists, err := d.Download(context.Background(), server.URL+"/master.m3u8") 165 | require.NoError(t, err) 166 | require.Equal(t, server.URL+"/master.m3u8", playlists.MasterPlaylist.URL) 167 | require.Equal(t, master, playlists.MasterPlaylist.Raw) 168 | require.Len(t, playlists.MasterPlaylist.Streams, 2) 169 | require.Equal(t, "media_0.m3u8", playlists.MasterPlaylist.Streams[0].URI) 170 | require.Equal(t, "media_1.m3u8", playlists.MasterPlaylist.Streams[1].URI) 171 | require.Len(t, playlists.MediaPlaylists, 3) 172 | 173 | mp0 := playlists.MediaPlaylists["media_0.m3u8"] 174 | require.Equal(t, server.URL+"/media_0.m3u8", mp0.URL) 175 | require.Equal(t, media0, mp0.Raw) 176 | require.Len(t, mp0.Segments, 2) 177 | require.Equal(t, "media_0_100.ts", mp0.Segments[0].URI) 178 | require.Equal(t, "media_0_101.ts", mp0.Segments[1].URI) 179 | require.Equal(t, "1280000", mp0.StreamInfAttrs["BANDWIDTH"]) 180 | require.Equal(t, "1000000", mp0.StreamInfAttrs["AVERAGE-BANDWIDTH"]) 181 | require.Nil(t, mp0.MediaAttrs) 182 | 183 | mp1 := playlists.MediaPlaylists["media_1.m3u8"] 184 | require.Equal(t, server.URL+"/media_1.m3u8", mp1.URL) 185 | require.Equal(t, media1, mp1.Raw) 186 | require.Len(t, mp1.Segments, 2) 187 | require.Equal(t, "media_1_100.ts", mp1.Segments[0].URI) 188 | require.Equal(t, "media_1_101.ts", mp1.Segments[1].URI) 189 | require.Equal(t, "2560000", mp1.StreamInfAttrs["BANDWIDTH"]) 190 | require.Equal(t, "2000000", mp1.StreamInfAttrs["AVERAGE-BANDWIDTH"]) 191 | require.Nil(t, mp1.MediaAttrs) 192 | 193 | mp2 := playlists.MediaPlaylists["media_2.m3u8"] 194 | require.Equal(t, server.URL+"/media_2.m3u8", mp2.URL) 195 | require.Equal(t, media2, mp2.Raw) 196 | require.Len(t, mp2.Segments, 2) 197 | require.Equal(t, "media_2_100.ts", mp2.Segments[0].URI) 198 | require.Equal(t, "media_2_101.ts", mp2.Segments[1].URI) 199 | require.Nil(t, mp2.StreamInfAttrs) 200 | require.Equal(t, "audio", mp2.MediaAttrs.GroupID()) 201 | }) 202 | 203 | t.Run("single media playlist", func(t *testing.T) { 204 | d := newHLSPlaylistDownloader(newClient(http.DefaultClient, nil, nil), time.Second) 205 | playlists, err := d.Download(context.Background(), server.URL+"/media_0.m3u8") 206 | require.NoError(t, err) 207 | require.Nil(t, playlists.MasterPlaylist) 208 | require.Len(t, playlists.MediaPlaylists, 1) 209 | 210 | mp0 := playlists.MediaPlaylists["_"] 211 | require.Equal(t, server.URL+"/media_0.m3u8", mp0.URL) 212 | require.Equal(t, media0, mp0.Raw) 213 | require.Len(t, mp0.Segments, 2) 214 | require.Equal(t, "media_0_100.ts", mp0.Segments[0].URI) 215 | require.Equal(t, "media_0_101.ts", mp0.Segments[1].URI) 216 | require.Nil(t, mp0.StreamInfAttrs) 217 | require.Nil(t, mp0.MediaAttrs) 218 | }) 219 | } 220 | -------------------------------------------------------------------------------- /core/http.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | const maxRedirectCount = 10 14 | 15 | type permanentError struct { 16 | parent error 17 | } 18 | 19 | func newPermanentError(parent error) error { 20 | return permanentError{parent: parent} 21 | } 22 | 23 | func (err permanentError) Error() string { 24 | return err.parent.Error() 25 | } 26 | 27 | func (err permanentError) Unwrap() error { 28 | return err.parent 29 | } 30 | 31 | type client interface { 32 | Get(ctx context.Context, url string) ([]byte, string, error) 33 | } 34 | 35 | type simpleClient struct { 36 | bare *http.Client 37 | header http.Header 38 | handler func(file *File) 39 | } 40 | 41 | func newClient(bareClient *http.Client, header http.Header, handler func(file *File)) client { 42 | return &simpleClient{ 43 | bare: bareClient, 44 | header: header, 45 | handler: handler, 46 | } 47 | } 48 | 49 | func (c *simpleClient) Get(ctx context.Context, url string) ([]byte, string, error) { 50 | via := url 51 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 52 | if err != nil { 53 | return nil, "", newPermanentError(err) 54 | } 55 | req.Header = c.header 56 | 57 | bare := *c.bare 58 | bare.CheckRedirect = func(req *http.Request, via []*http.Request) error { 59 | url = req.URL.String() 60 | if c.bare.CheckRedirect != nil { 61 | return c.bare.CheckRedirect(req, via) 62 | } 63 | if len(via) >= maxRedirectCount { 64 | return errors.New("stopped after 10 redirects") 65 | } 66 | return nil 67 | } 68 | 69 | requestTime := time.Now() 70 | resp, err := bare.Do(req) 71 | if err != nil { 72 | return nil, "", err 73 | } 74 | data, err := io.ReadAll(resp.Body) 75 | if err != nil { 76 | return nil, "", err 77 | } 78 | elapsed := time.Since(requestTime) 79 | if c.handler != nil { 80 | c.handler(&File{ 81 | Meta: Meta{ 82 | URL: url, 83 | Via: via, 84 | RequestHeader: req.Header, 85 | ResponseHeader: resp.Header, 86 | Status: resp.Status, 87 | StatusCode: resp.StatusCode, 88 | Proto: resp.Proto, 89 | ProtoMajor: resp.ProtoMajor, 90 | ProtoMinor: resp.ProtoMinor, 91 | ContentLength: resp.ContentLength, 92 | TransferEncoding: resp.TransferEncoding, 93 | Uncompressed: resp.Uncompressed, 94 | RequestTimestamp: requestTime, 95 | DownloadTime: elapsed, 96 | }, 97 | Body: data, 98 | }) 99 | } 100 | if resp.StatusCode != http.StatusOK { 101 | err := fmt.Errorf("unexpected status code: %d", resp.StatusCode) 102 | if resp.StatusCode >= 400 && resp.StatusCode < 500 { 103 | err = newPermanentError(err) 104 | } 105 | return nil, "", err 106 | } 107 | return data, url, nil 108 | } 109 | 110 | type redirectKeeper struct { 111 | client 112 | redirectMap map[string]string 113 | mutex sync.RWMutex 114 | } 115 | 116 | func newRedirectKeeper(client client) *redirectKeeper { 117 | return &redirectKeeper{ 118 | client: client, 119 | redirectMap: make(map[string]string), 120 | } 121 | } 122 | 123 | func (c *redirectKeeper) Get(ctx context.Context, url string) ([]byte, string, error) { 124 | via := url 125 | if loc := c.loadCache(url); loc != "" { 126 | url = loc 127 | } 128 | data, loc, err := c.client.Get(ctx, url) 129 | if via != loc { 130 | c.storeCache(via, loc) 131 | } 132 | return data, loc, err 133 | } 134 | 135 | func (c *redirectKeeper) loadCache(url string) string { 136 | c.mutex.RLock() 137 | defer c.mutex.RUnlock() 138 | return c.redirectMap[url] 139 | } 140 | 141 | func (c *redirectKeeper) storeCache(via, url string) { 142 | c.mutex.Lock() 143 | defer c.mutex.Unlock() 144 | c.redirectMap[via] = url 145 | } 146 | -------------------------------------------------------------------------------- /core/http_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestPermanentError(t *testing.T) { 15 | parent := errors.New("foo") 16 | assert.False(t, errors.As(parent, &permanentError{})) 17 | err := newPermanentError(parent) 18 | assert.True(t, errors.As(err, &permanentError{})) 19 | assert.True(t, errors.Is(err, parent)) 20 | assert.False(t, errors.Is(err, errors.New("bar"))) 21 | } 22 | 23 | func TestClient(t *testing.T) { 24 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | require.Equal(t, "antares test", r.Header.Get("User-Agent")) 26 | switch r.URL.Path { 27 | case "/redirect": 28 | http.Redirect(w, r, "/hello", http.StatusFound) 29 | case "/hello": 30 | w.Write([]byte("hello antares")) 31 | case "/not_found": 32 | w.WriteHeader(http.StatusNotFound) 33 | w.Write([]byte("not found")) 34 | } 35 | })) 36 | header := http.Header{ 37 | "User-Agent": []string{"antares test"}, 38 | } 39 | 40 | t.Run("200_ok", func(t *testing.T) { 41 | var handle bool 42 | client := newClient(&http.Client{}, header, func(file *File) { 43 | require.Equal(t, 200, file.StatusCode) 44 | require.Equal(t, []byte("hello antares"), file.Body) 45 | handle = true 46 | }) 47 | data, loc, err := client.Get(context.Background(), server.URL+"/hello") 48 | require.NoError(t, err) 49 | assert.Equal(t, server.URL+"/hello", loc) 50 | assert.Equal(t, "hello antares", string(data)) 51 | assert.True(t, handle) 52 | }) 53 | 54 | t.Run("302_found", func(t *testing.T) { 55 | var handle bool 56 | client := newClient(&http.Client{}, header, func(file *File) { 57 | require.Equal(t, 200, file.StatusCode) 58 | require.Equal(t, []byte("hello antares"), file.Body) 59 | handle = true 60 | }) 61 | data, loc, err := client.Get(context.Background(), server.URL+"/redirect") 62 | require.NoError(t, err) 63 | assert.Equal(t, server.URL+"/hello", loc) 64 | assert.Equal(t, "hello antares", string(data)) 65 | assert.True(t, handle) 66 | }) 67 | 68 | t.Run("404_not_found", func(t *testing.T) { 69 | var handle bool 70 | client := newClient(&http.Client{}, header, func(file *File) { 71 | require.Equal(t, 404, file.StatusCode) 72 | require.Equal(t, []byte("not found"), file.Body) 73 | handle = true 74 | }) 75 | _, _, err := client.Get(context.Background(), server.URL+"/not_found") 76 | require.Error(t, err) 77 | assert.True(t, handle) 78 | }) 79 | } 80 | 81 | func TestRedirectKeeper(t *testing.T) { 82 | accessLog := make([]string, 0) 83 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 | require.Equal(t, "antares test", r.Header.Get("User-Agent")) 85 | switch r.URL.Path { 86 | case "/redirect": 87 | http.Redirect(w, r, "/hello", http.StatusFound) 88 | case "/hello": 89 | w.Write([]byte("hello antares")) 90 | } 91 | accessLog = append(accessLog, r.URL.Path) 92 | })) 93 | client := newRedirectKeeper(newClient(&http.Client{}, http.Header{ 94 | "User-Agent": []string{"antares test"}, 95 | }, nil)) 96 | data, loc, err := client.Get(context.Background(), server.URL+"/redirect") 97 | require.NoError(t, err) 98 | assert.Equal(t, server.URL+"/hello", loc) 99 | assert.Equal(t, "hello antares", string(data)) 100 | data, loc, err = client.Get(context.Background(), server.URL+"/redirect") 101 | require.NoError(t, err) 102 | assert.Equal(t, server.URL+"/hello", loc) 103 | assert.Equal(t, "hello antares", string(data)) 104 | assert.Equal(t, []string{"/redirect", "/hello", "/hello"}, accessLog) 105 | } 106 | -------------------------------------------------------------------------------- /core/inspector.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type HLSInspector interface { 4 | Inspect(playlists *Playlists, segments SegmentStore) *Report 5 | } 6 | 7 | type DASHInspector interface { 8 | Inspect(manifest *Manifest, segments SegmentStore) *Report 9 | } 10 | -------------------------------------------------------------------------------- /core/monitor.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "sort" 8 | "time" 9 | 10 | "github.com/abema/antares/v2/internal/thread" 11 | backoff "github.com/cenkalti/backoff/v4" 12 | "github.com/zencoder/go-dash/mpd" 13 | ) 14 | 15 | type Monitor interface { 16 | Terminate() 17 | } 18 | 19 | type monitor struct { 20 | config *Config 21 | httpClient client 22 | hlsDownloader *hlsPlaylistDownloader 23 | dashDownloader *dashManifestDownloader 24 | segmentStore mutableSegmentStore 25 | context context.Context 26 | terminate func() 27 | } 28 | 29 | func NewMonitor(config *Config) Monitor { 30 | httpClient := newClient(config.HTTPClient, config.RequestHeader, config.OnDownload) 31 | m := &monitor{ 32 | config: config, 33 | httpClient: httpClient, 34 | segmentStore: newSegmentStore(httpClient, config.SegmentTimeout, config.SegmentBackoff, config.SegmentMaxConcurrency), 35 | } 36 | manifestClient := httpClient 37 | if !config.NoRedirectCache { 38 | manifestClient = newRedirectKeeper(manifestClient) 39 | } 40 | switch config.StreamType { 41 | case StreamTypeHLS: 42 | m.hlsDownloader = newHLSPlaylistDownloader(manifestClient, config.ManifestTimeout) 43 | case StreamTypeDASH: 44 | m.dashDownloader = newDASHManifestDownloader(manifestClient, config.ManifestTimeout) 45 | } 46 | m.context, m.terminate = context.WithCancel(context.Background()) 47 | go m.run() 48 | return m 49 | } 50 | 51 | func (m *monitor) Terminate() { 52 | m.terminate() 53 | } 54 | 55 | func (m *monitor) run() { 56 | for { 57 | cont, waitDur := m.proc() 58 | if !cont { 59 | break 60 | } 61 | if cont := m.wait(waitDur); !cont { 62 | break 63 | } 64 | } 65 | if m.config.OnTerminate != nil { 66 | m.config.OnTerminate() 67 | } 68 | } 69 | 70 | func (m *monitor) proc() (cont bool, waitDur time.Duration) { 71 | defer func() { 72 | if r := recover(); r != nil { 73 | m.onPanic(r) 74 | cont = true 75 | waitDur = m.config.DefaultInterval 76 | } 77 | }() 78 | 79 | var playlists *Playlists 80 | var manifest *Manifest 81 | err := backoff.RetryNotify(func() error { 82 | var err error 83 | switch m.config.StreamType { 84 | case StreamTypeHLS: 85 | playlists, err = m.hlsDownloader.Download(m.context, m.config.URL) 86 | default: 87 | manifest, err = m.dashDownloader.Download(m.context, m.config.URL) 88 | } 89 | if err != nil && (m.context.Err() != nil || errors.As(err, &permanentError{})) { 90 | return backoff.Permanent(err) 91 | } 92 | return err 93 | }, m.config.ManifestBackoff, func(err error, _ time.Duration) { 94 | log.Printf("WARN: failed to download manifest: %s: %s", m.config.URL, err) 95 | }) 96 | if err != nil { 97 | m.onError("failed to download manifest", err) 98 | return true, m.config.DefaultInterval 99 | } 100 | 101 | switch m.config.StreamType { 102 | case StreamTypeHLS: 103 | if err := m.updateSegmentStoreHLS(playlists); err != nil { 104 | m.onError("failed to download segment", err) 105 | return true, m.config.DefaultInterval 106 | } 107 | default: 108 | if err := m.updateSegmentStoreDASH(manifest); err != nil { 109 | m.onError("failed to download segment", err) 110 | return true, m.config.DefaultInterval 111 | } 112 | } 113 | 114 | rc := make(chan *Report) 115 | var ni int 116 | switch m.config.StreamType { 117 | case StreamTypeHLS: 118 | for _, inspector := range m.config.HLS.Inspectors { 119 | go func(inspector HLSInspector) { 120 | rc <- inspector.Inspect(playlists, m.segmentStore) 121 | }(inspector) 122 | ni++ 123 | } 124 | default: 125 | for _, inspector := range m.config.DASH.Inspectors { 126 | go func(inspector DASHInspector) { 127 | rc <- inspector.Inspect(manifest, m.segmentStore) 128 | }(inspector) 129 | ni++ 130 | } 131 | } 132 | reports := make([]*Report, 0, ni) 133 | for i := 0; i < ni; i++ { 134 | if rep := <-rc; rep != nil { 135 | reports = append(reports, rep) 136 | } 137 | } 138 | sort.Slice(reports, func(i, j int) bool { 139 | return reports[i].Name < reports[j].Name 140 | }) 141 | m.onReport(reports) 142 | 143 | switch m.config.StreamType { 144 | case StreamTypeHLS: 145 | return m.hlsWaitDuration(playlists) 146 | default: 147 | return m.dashWaitDuration(manifest) 148 | } 149 | } 150 | 151 | func (m *monitor) hlsWaitDuration(playlists *Playlists) (bool, time.Duration) { 152 | if playlists.IsVOD() { 153 | if m.config.TerminateIfVOD { 154 | return false, 0 155 | } 156 | return true, m.config.DefaultInterval 157 | } 158 | if !m.config.PrioritizeSuggestedInterval { 159 | return true, m.config.DefaultInterval 160 | } 161 | dur := time.Duration(playlists.MaxTargetDuration()) * time.Second / 2 162 | if dur == 0 { 163 | return true, m.config.DefaultInterval 164 | } else if dur < time.Second { 165 | return true, time.Second 166 | } 167 | return true, dur 168 | } 169 | 170 | func (m *monitor) dashWaitDuration(manifest *Manifest) (bool, time.Duration) { 171 | if manifest.Type == nil || *manifest.Type != "dynamic" { 172 | if m.config.TerminateIfVOD { 173 | return false, 0 174 | } 175 | return true, m.config.DefaultInterval 176 | } 177 | if !m.config.PrioritizeSuggestedInterval || manifest.MinimumUpdatePeriod == nil { 178 | return true, m.config.DefaultInterval 179 | } 180 | dur, err := mpd.ParseDuration(*manifest.MinimumUpdatePeriod) 181 | if err != nil { 182 | log.Printf("ERROR: failed to parse minimumUpdatePeriod: %s: %s", manifest.URL, err) 183 | return true, m.config.DefaultInterval 184 | } else if dur < time.Second { 185 | return true, time.Second 186 | } 187 | return true, dur 188 | } 189 | 190 | func (m *monitor) wait(dur time.Duration) bool { 191 | select { 192 | case <-time.After(dur): 193 | return true 194 | case <-m.context.Done(): 195 | return false 196 | } 197 | } 198 | 199 | func (m *monitor) updateSegmentStoreHLS(playlists *Playlists) error { 200 | segments, err := playlists.Segments() 201 | if err != nil { 202 | return err 203 | } 204 | urls := make([]string, 0, len(segments)) 205 | for _, seg := range segments { 206 | if m.config.SegmentFilter == nil || m.config.SegmentFilter.CheckHLS(seg) == Pass { 207 | urls = append(urls, seg.URL) 208 | } 209 | } 210 | return m.segmentStore.Sync(m.context, urls) 211 | } 212 | 213 | func (m *monitor) updateSegmentStoreDASH(manifest *Manifest) error { 214 | segments, err := manifest.Segments() 215 | if err != nil { 216 | return err 217 | } 218 | urls := make([]string, 0, len(segments)) 219 | for _, seg := range segments { 220 | if m.config.SegmentFilter == nil || m.config.SegmentFilter.CheckDASH(seg) == Pass { 221 | urls = append(urls, seg.URL) 222 | } 223 | } 224 | return m.segmentStore.Sync(m.context, urls) 225 | } 226 | 227 | func (m *monitor) onPanic(r interface{}) { 228 | m.onError("panic is occurred", thread.PanicToError(r, nil)) 229 | } 230 | 231 | func (m *monitor) onError(msg string, err error) { 232 | m.onReport([]*Report{ 233 | { 234 | Name: "Monitor", 235 | Severity: Error, 236 | Message: msg, 237 | Values: Values{ 238 | "error": err, 239 | }, 240 | }, 241 | }) 242 | } 243 | 244 | func (m *monitor) onReport(reports Reports) { 245 | if m.config.OnReport != nil { 246 | m.config.OnReport(reports) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /core/monitor_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | m3u8 "github.com/abema/go-simple-m3u8" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/zencoder/go-dash/helpers/ptrs" 13 | "github.com/zencoder/go-dash/mpd" 14 | ) 15 | 16 | type mockHLSInspector struct { 17 | inspect func(playlists *Playlists, segments SegmentStore) *Report 18 | } 19 | 20 | func (ins *mockHLSInspector) Inspect(playlists *Playlists, segments SegmentStore) *Report { 21 | return ins.inspect(playlists, segments) 22 | } 23 | 24 | type mockDASHInspector struct { 25 | inspect func(manifest *Manifest, segments SegmentStore) *Report 26 | } 27 | 28 | func (ins *mockDASHInspector) Inspect(manifest *Manifest, segments SegmentStore) *Report { 29 | return ins.inspect(manifest, segments) 30 | } 31 | 32 | func TestMonitor(t *testing.T) { 33 | t.Run("HLS Live", func(t *testing.T) { 34 | master := []byte(`#EXTM3U` + "\n" + 35 | `#EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000` + "\n" + 36 | `media_0.m3u8` + "\n" + 37 | `#EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000` + "\n" + 38 | `media_1.m3u8` + "\n") 39 | media0 := []byte(`#EXTM3U` + "\n" + 40 | `#EXT-X-VERSION:3` + "\n" + 41 | `#EXT-X-TARGETDURATION:8` + "\n" + 42 | `#EXT-X-MEDIA-SEQUENCE:2680` + "\n" + 43 | `#EXTINF:7.975,` + "\n" + 44 | `media_0_100.ts` + "\n" + 45 | `#EXTINF:7.941,` + "\n" + 46 | `media_0_101.ts` + "\n") 47 | media1 := []byte(`#EXTM3U` + "\n" + 48 | `#EXT-X-VERSION:3` + "\n" + 49 | `#EXT-X-TARGETDURATION:8` + "\n" + 50 | `#EXT-X-MEDIA-SEQUENCE:2680` + "\n" + 51 | `#EXTINF:7.975,` + "\n" + 52 | `media_1_100.ts` + "\n" + 53 | `#EXTINF:7.941,` + "\n" + 54 | `media_1_101.ts` + "\n") 55 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 | switch r.URL.Path { 57 | case "/master.m3u8": 58 | w.Write(master) 59 | case "/media_0.m3u8": 60 | w.Write(media0) 61 | case "/media_1.m3u8": 62 | w.Write(media1) 63 | case "/media_0_100.ts", "/media_0_101.ts", 64 | "/media_1_100.ts", "/media_1_101.ts": 65 | w.Write([]byte("dummy TS file:" + r.URL.Path)) 66 | default: 67 | w.WriteHeader(http.StatusNotFound) 68 | } 69 | })) 70 | 71 | config := NewConfig(server.URL+"/master.m3u8", StreamTypeHLS) 72 | config.HLS.Inspectors = []HLSInspector{ 73 | &mockHLSInspector{inspect: func(playlists *Playlists, segments SegmentStore) *Report { 74 | ts, ok := segments.Load(server.URL + "/media_0_100.ts") 75 | require.True(t, ok) 76 | assert.Equal(t, "dummy TS file:/media_0_100.ts", string(ts)) 77 | assert.True(t, segments.Exists(server.URL+"/media_0_101.ts")) 78 | assert.False(t, segments.Exists(server.URL+"/media_0_102.ts")) 79 | return &Report{Name: "i1", Severity: Info} 80 | }}, 81 | &mockHLSInspector{inspect: func(playlists *Playlists, segments SegmentStore) *Report { 82 | return &Report{Name: "i2", Severity: Warn} 83 | }}, 84 | } 85 | callCh := make(chan string, 100) 86 | config.OnDownload = func(file *File) { 87 | switch file.URL { 88 | case server.URL + "/master.m3u8": 89 | assert.Equal(t, master, file.Body) 90 | case server.URL + "/media_0.m3u8": 91 | assert.Equal(t, media0, file.Body) 92 | case server.URL + "/media_1.m3u8": 93 | assert.Equal(t, media1, file.Body) 94 | case server.URL + "/media_0_100.ts": 95 | assert.Equal(t, []byte("dummy TS file:/media_0_100.ts"), file.Body) 96 | case server.URL + "/media_0_101.ts": 97 | assert.Equal(t, []byte("dummy TS file:/media_0_101.ts"), file.Body) 98 | case server.URL + "/media_1_100.ts": 99 | assert.Equal(t, []byte("dummy TS file:/media_1_100.ts"), file.Body) 100 | case server.URL + "/media_1_101.ts": 101 | assert.Equal(t, []byte("dummy TS file:/media_1_101.ts"), file.Body) 102 | default: 103 | require.Fail(t, "unexpected URL", file.URL) 104 | } 105 | callCh <- "OnDownload" 106 | } 107 | config.OnReport = func(reports Reports) { 108 | assert.Len(t, reports, 2) 109 | assert.Equal(t, Info, reports[0].Severity) 110 | assert.Equal(t, Warn, reports[1].Severity) 111 | callCh <- "OnReport" 112 | } 113 | config.OnTerminate = func() { 114 | callCh <- "OnTerminate" 115 | } 116 | 117 | m := NewMonitor(config) 118 | time.Sleep(500 * time.Millisecond) 119 | m.Terminate() 120 | time.Sleep(100 * time.Millisecond) 121 | close(callCh) 122 | 123 | calls := make([]string, 0) 124 | for call := range callCh { 125 | calls = append(calls, call) 126 | } 127 | require.Len(t, calls, 9) 128 | for i := 0; i < 7; i++ { 129 | assert.Equal(t, "OnDownload", calls[i]) 130 | } 131 | assert.Equal(t, "OnReport", calls[7]) 132 | assert.Equal(t, "OnTerminate", calls[8]) 133 | }) 134 | 135 | t.Run("DASH Live", func(t *testing.T) { 136 | manifest := []byte(`` + 137 | `` + 138 | `` + 139 | `` + 140 | `` + 141 | `` + 142 | `` + 143 | `` + 144 | `` + 145 | `` + 146 | `` + 147 | `` + 148 | ``) 149 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 | switch r.URL.Path { 151 | case "/manifest.mpd": 152 | w.Write(manifest) 153 | case "/media_4M_init.mp4", "/media_4M_0.mp4", "/media_4M_900000.mp4", 154 | "/media_2M_init.mp4", "/media_2M_0.mp4", "/media_2M_900000.mp4": 155 | w.Write([]byte("dummy MP4 file:" + r.URL.Path)) 156 | default: 157 | w.WriteHeader(http.StatusNotFound) 158 | } 159 | })) 160 | 161 | config := NewConfig(server.URL+"/manifest.mpd", StreamTypeDASH) 162 | config.DASH.Inspectors = []DASHInspector{ 163 | &mockDASHInspector{inspect: func(manifest *Manifest, segments SegmentStore) *Report { 164 | mp4, ok := segments.Load(server.URL + "/media_4M_init.mp4") 165 | require.True(t, ok) 166 | assert.Equal(t, "dummy MP4 file:/media_4M_init.mp4", string(mp4)) 167 | assert.True(t, segments.Exists(server.URL+"/media_4M_0.mp4")) 168 | assert.True(t, segments.Exists(server.URL+"/media_4M_900000.mp4")) 169 | assert.False(t, segments.Exists(server.URL+"/media_4M_1800000.mp4")) 170 | return &Report{Name: "i1", Severity: Info} 171 | }}, 172 | &mockDASHInspector{inspect: func(manifest *Manifest, segments SegmentStore) *Report { 173 | return &Report{Name: "i2", Severity: Warn} 174 | }}, 175 | } 176 | callCh := make(chan string, 100) 177 | config.OnDownload = func(file *File) { 178 | switch file.URL { 179 | case server.URL + "/manifest.mpd": 180 | assert.Equal(t, manifest, file.Body) 181 | case server.URL + "/media_4M_init.mp4": 182 | assert.Equal(t, []byte("dummy MP4 file:/media_4M_init.mp4"), file.Body) 183 | case server.URL + "/media_4M_0.mp4": 184 | assert.Equal(t, []byte("dummy MP4 file:/media_4M_0.mp4"), file.Body) 185 | case server.URL + "/media_4M_900000.mp4": 186 | assert.Equal(t, []byte("dummy MP4 file:/media_4M_900000.mp4"), file.Body) 187 | case server.URL + "/media_2M_init.mp4": 188 | assert.Equal(t, []byte("dummy MP4 file:/media_2M_init.mp4"), file.Body) 189 | case server.URL + "/media_2M_0.mp4": 190 | assert.Equal(t, []byte("dummy MP4 file:/media_2M_0.mp4"), file.Body) 191 | case server.URL + "/media_2M_900000.mp4": 192 | assert.Equal(t, []byte("dummy MP4 file:/media_2M_900000.mp4"), file.Body) 193 | default: 194 | require.Fail(t, "unexpected URL", file.URL) 195 | } 196 | callCh <- "OnDownload" 197 | } 198 | config.OnReport = func(reports Reports) { 199 | assert.Len(t, reports, 2) 200 | assert.Equal(t, Info, reports[0].Severity) 201 | assert.Equal(t, Warn, reports[1].Severity) 202 | callCh <- "OnReport" 203 | } 204 | config.OnTerminate = func() { 205 | callCh <- "OnTerminate" 206 | } 207 | 208 | m := NewMonitor(config) 209 | time.Sleep(500 * time.Millisecond) 210 | m.Terminate() 211 | time.Sleep(100 * time.Millisecond) 212 | close(callCh) 213 | 214 | calls := make([]string, 0) 215 | for call := range callCh { 216 | calls = append(calls, call) 217 | } 218 | require.Len(t, calls, 9) 219 | for i := 0; i < 7; i++ { 220 | assert.Equal(t, "OnDownload", calls[i]) 221 | } 222 | assert.Equal(t, "OnReport", calls[7]) 223 | assert.Equal(t, "OnTerminate", calls[8]) 224 | }) 225 | } 226 | 227 | func TestMonitor_HLSWaitDuration(t *testing.T) { 228 | livePlaylists := &Playlists{MediaPlaylists: map[string]*MediaPlaylist{ 229 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{ 230 | Tags: m3u8.MediaPlaylistTags{m3u8.TagExtXTargetDuration: []string{"8"}}, 231 | EndList: false, 232 | }}, 233 | }} 234 | vodPlaylists := &Playlists{MediaPlaylists: map[string]*MediaPlaylist{ 235 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{ 236 | Tags: m3u8.MediaPlaylistTags{m3u8.TagExtXTargetDuration: []string{"8"}}, 237 | EndList: true, 238 | }}, 239 | }} 240 | 241 | testCases := []struct { 242 | name string 243 | playlists *Playlists 244 | config *Config 245 | cont bool 246 | dur time.Duration 247 | }{ 248 | { 249 | name: "live_default", 250 | playlists: livePlaylists, 251 | config: &Config{ 252 | DefaultInterval: 5 * time.Second, 253 | PrioritizeSuggestedInterval: false, 254 | }, 255 | cont: true, 256 | dur: 5 * time.Second, 257 | }, 258 | { 259 | name: "live_suggested", 260 | playlists: livePlaylists, 261 | config: &Config{ 262 | DefaultInterval: 5 * time.Second, 263 | PrioritizeSuggestedInterval: true, 264 | }, 265 | cont: true, 266 | dur: 4 * time.Second, 267 | }, 268 | { 269 | name: "vod_not_terminate", 270 | playlists: vodPlaylists, 271 | config: &Config{ 272 | DefaultInterval: 5 * time.Second, 273 | PrioritizeSuggestedInterval: true, 274 | }, 275 | cont: true, 276 | dur: 5 * time.Second, 277 | }, 278 | { 279 | name: "vod_terminate", 280 | playlists: vodPlaylists, 281 | config: &Config{ 282 | DefaultInterval: 5 * time.Second, 283 | PrioritizeSuggestedInterval: true, 284 | TerminateIfVOD: true, 285 | }, 286 | cont: false, 287 | }, 288 | } 289 | for _, tc := range testCases { 290 | t.Run(tc.name, func(t *testing.T) { 291 | m := NewMonitor(tc.config).(*monitor) 292 | cont, dur := m.hlsWaitDuration(tc.playlists) 293 | assert.Equal(t, tc.cont, cont) 294 | assert.Equal(t, tc.dur, dur) 295 | }) 296 | } 297 | } 298 | 299 | func TestMonitor_DASHWaitDuration(t *testing.T) { 300 | liveManifest := &Manifest{ 301 | MPD: &mpd.MPD{ 302 | Type: ptrs.Strptr("dynamic"), 303 | MinimumUpdatePeriod: ptrs.Strptr("PT4S"), 304 | }, 305 | } 306 | vodManifest := &Manifest{ 307 | MPD: &mpd.MPD{ 308 | Type: ptrs.Strptr("static"), 309 | }, 310 | } 311 | 312 | testCases := []struct { 313 | name string 314 | manifest *Manifest 315 | config *Config 316 | cont bool 317 | dur time.Duration 318 | }{ 319 | { 320 | name: "live_default", 321 | manifest: liveManifest, 322 | config: &Config{ 323 | DefaultInterval: 5 * time.Second, 324 | PrioritizeSuggestedInterval: false, 325 | }, 326 | cont: true, 327 | dur: 5 * time.Second, 328 | }, 329 | { 330 | name: "live_suggested", 331 | manifest: liveManifest, 332 | config: &Config{ 333 | DefaultInterval: 5 * time.Second, 334 | PrioritizeSuggestedInterval: true, 335 | }, 336 | cont: true, 337 | dur: 4 * time.Second, 338 | }, 339 | { 340 | name: "vod_not_terminate", 341 | manifest: vodManifest, 342 | config: &Config{ 343 | DefaultInterval: 5 * time.Second, 344 | PrioritizeSuggestedInterval: true, 345 | }, 346 | cont: true, 347 | dur: 5 * time.Second, 348 | }, 349 | { 350 | name: "vod_terminate", 351 | manifest: vodManifest, 352 | config: &Config{ 353 | DefaultInterval: 5 * time.Second, 354 | PrioritizeSuggestedInterval: true, 355 | TerminateIfVOD: true, 356 | }, 357 | cont: false, 358 | }, 359 | } 360 | for _, tc := range testCases { 361 | t.Run(tc.name, func(t *testing.T) { 362 | m := NewMonitor(tc.config).(*monitor) 363 | cont, dur := m.dashWaitDuration(tc.manifest) 364 | assert.Equal(t, tc.cont, cont) 365 | assert.Equal(t, tc.dur, dur) 366 | }) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /core/report.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | ) 9 | 10 | type Severity int 11 | 12 | const ( 13 | Info Severity = iota 14 | Warn 15 | Error 16 | ) 17 | 18 | func (s Severity) String() string { 19 | switch s { 20 | case Info: 21 | return "INFO" 22 | case Warn: 23 | return "WARNING" 24 | case Error: 25 | return "ERROR" 26 | } 27 | return "" 28 | } 29 | 30 | func (s *Severity) UnmarshalText(text []byte) error { 31 | switch string(text) { 32 | case Info.String(): 33 | *s = Info 34 | case Warn.String(): 35 | *s = Warn 36 | case Error.String(): 37 | *s = Error 38 | default: 39 | return errors.New("unknown severity") 40 | } 41 | return nil 42 | } 43 | 44 | func (s Severity) MarshalText() ([]byte, error) { 45 | return []byte(s.String()), nil 46 | } 47 | 48 | func (s Severity) WorseThan(o Severity) bool { 49 | return s > o 50 | } 51 | 52 | func (s Severity) BetterThan(o Severity) bool { 53 | return s < o 54 | } 55 | 56 | func (s Severity) WorseThanOrEqual(o Severity) bool { 57 | return s >= o 58 | } 59 | 60 | func (s Severity) BetterThanOrEqual(o Severity) bool { 61 | return s <= o 62 | } 63 | 64 | func WorstSeverity(ss ...Severity) Severity { 65 | worst := Info 66 | for _, s := range ss { 67 | if s.WorseThan(worst) { 68 | worst = s 69 | } 70 | } 71 | return worst 72 | } 73 | 74 | func BestSeverity(ss ...Severity) Severity { 75 | best := Error 76 | for _, s := range ss { 77 | if s.BetterThan(best) { 78 | best = s 79 | } 80 | } 81 | return best 82 | } 83 | 84 | type Values map[string]any 85 | 86 | func (values Values) Keys() []string { 87 | keys := make([]string, 0, len(values)) 88 | for key := range values { 89 | keys = append(keys, key) 90 | } 91 | sort.Strings(keys) 92 | return keys 93 | } 94 | 95 | func (values Values) String() string { 96 | buf := bytes.NewBuffer(nil) 97 | for _, key := range values.Keys() { 98 | if buf.Len() != 0 { 99 | buf.WriteString(" ") 100 | } 101 | fmt.Fprintf(buf, "%s=%v", key, values[key]) 102 | } 103 | return string(buf.Bytes()) 104 | } 105 | 106 | type Report struct { 107 | Name string `json:"name"` 108 | Severity Severity `json:"severity"` 109 | Message string `json:"message"` 110 | Values Values `json:"values"` 111 | } 112 | 113 | type Reports []*Report 114 | 115 | func (reports Reports) WorstSeverity() Severity { 116 | var worst Severity 117 | for _, report := range reports { 118 | worst = WorstSeverity(worst, report.Severity) 119 | } 120 | return worst 121 | } 122 | 123 | func (reports Reports) Infos() Reports { 124 | infos := make([]*Report, 0, len(reports)) 125 | for _, report := range reports { 126 | if report.Severity == Info { 127 | infos = append(infos, report) 128 | } 129 | } 130 | return infos 131 | } 132 | 133 | func (reports Reports) Warns() Reports { 134 | warns := make([]*Report, 0, len(reports)) 135 | for _, report := range reports { 136 | if report.Severity == Warn { 137 | warns = append(warns, report) 138 | } 139 | } 140 | return warns 141 | } 142 | 143 | func (reports Reports) Errors() Reports { 144 | errors := make([]*Report, 0, len(reports)) 145 | for _, report := range reports { 146 | if report.Severity == Error { 147 | errors = append(errors, report) 148 | } 149 | } 150 | return errors 151 | } 152 | -------------------------------------------------------------------------------- /core/report_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSeverity(t *testing.T) { 10 | assert.Equal(t, "INFO", Info.String()) 11 | assert.Equal(t, "WARNING", Warn.String()) 12 | assert.Equal(t, "ERROR", Error.String()) 13 | 14 | assert.False(t, Info.WorseThan(Info)) 15 | assert.False(t, Info.WorseThan(Warn)) 16 | assert.False(t, Info.WorseThan(Error)) 17 | assert.True(t, Warn.WorseThan(Info)) 18 | assert.False(t, Warn.WorseThan(Warn)) 19 | assert.False(t, Warn.WorseThan(Error)) 20 | assert.True(t, Error.WorseThan(Info)) 21 | assert.True(t, Error.WorseThan(Warn)) 22 | assert.False(t, Error.WorseThan(Error)) 23 | 24 | assert.False(t, Info.BetterThan(Info)) 25 | assert.True(t, Info.BetterThan(Warn)) 26 | assert.True(t, Info.BetterThan(Error)) 27 | assert.False(t, Warn.BetterThan(Info)) 28 | assert.False(t, Warn.BetterThan(Warn)) 29 | assert.True(t, Warn.BetterThan(Error)) 30 | assert.False(t, Error.BetterThan(Info)) 31 | assert.False(t, Error.BetterThan(Warn)) 32 | assert.False(t, Error.BetterThan(Error)) 33 | 34 | assert.True(t, Info.WorseThanOrEqual(Info)) 35 | assert.False(t, Info.WorseThanOrEqual(Warn)) 36 | assert.False(t, Info.WorseThanOrEqual(Error)) 37 | assert.True(t, Warn.WorseThanOrEqual(Info)) 38 | assert.True(t, Warn.WorseThanOrEqual(Warn)) 39 | assert.False(t, Warn.WorseThanOrEqual(Error)) 40 | assert.True(t, Error.WorseThanOrEqual(Info)) 41 | assert.True(t, Error.WorseThanOrEqual(Warn)) 42 | assert.True(t, Error.WorseThanOrEqual(Error)) 43 | 44 | assert.True(t, Info.BetterThanOrEqual(Info)) 45 | assert.True(t, Info.BetterThanOrEqual(Warn)) 46 | assert.True(t, Info.BetterThanOrEqual(Error)) 47 | assert.False(t, Warn.BetterThanOrEqual(Info)) 48 | assert.True(t, Warn.BetterThanOrEqual(Warn)) 49 | assert.True(t, Warn.BetterThanOrEqual(Error)) 50 | assert.False(t, Error.BetterThanOrEqual(Info)) 51 | assert.False(t, Error.BetterThanOrEqual(Warn)) 52 | assert.True(t, Error.BetterThanOrEqual(Error)) 53 | 54 | assert.Equal(t, Info, WorstSeverity(Info, Info, Info)) 55 | assert.Equal(t, Warn, WorstSeverity(Info, Warn, Info)) 56 | assert.Equal(t, Error, WorstSeverity(Error, Warn, Info)) 57 | 58 | assert.Equal(t, Info, BestSeverity(Error, Warn, Info)) 59 | assert.Equal(t, Warn, BestSeverity(Error, Warn, Warn)) 60 | assert.Equal(t, Error, BestSeverity(Error, Error, Error)) 61 | } 62 | 63 | func TestValues(t *testing.T) { 64 | values := Values{ 65 | "int": 123, 66 | "string": "abc", 67 | "array": []string{"foo", "bar"}, 68 | } 69 | assert.Equal(t, []string{"array", "int", "string"}, values.Keys()) 70 | assert.Equal(t, "array=[foo bar] int=123 string=abc", values.String()) 71 | } 72 | 73 | func TestReport(t *testing.T) { 74 | reports := Reports{ 75 | {Severity: Error}, 76 | {Severity: Info}, 77 | {Severity: Warn}, 78 | {Severity: Error}, 79 | {Severity: Info}, 80 | {Severity: Error}, 81 | } 82 | assert.Equal(t, Error, reports.WorstSeverity()) 83 | assert.Len(t, reports.Infos(), 2) 84 | assert.Len(t, reports.Warns(), 1) 85 | assert.Len(t, reports.Errors(), 3) 86 | } 87 | -------------------------------------------------------------------------------- /core/segment.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/abema/antares/v2/internal/thread" 11 | backoff "github.com/cenkalti/backoff/v4" 12 | "golang.org/x/sync/errgroup" 13 | ) 14 | 15 | type cache struct { 16 | data []byte 17 | del bool 18 | } 19 | 20 | type SegmentStore interface { 21 | Exists(url string) bool 22 | Load(url string) ([]byte, bool) 23 | } 24 | 25 | type mutableSegmentStore interface { 26 | SegmentStore 27 | Sync(ctx context.Context, urls []string) error 28 | } 29 | 30 | type segmentStore struct { 31 | httpClient client 32 | backoff backoff.BackOff 33 | cacheMap map[string]*cache 34 | timeout time.Duration 35 | maxConc int 36 | } 37 | 38 | func newSegmentStore( 39 | httpClient client, 40 | timeout time.Duration, 41 | backoff backoff.BackOff, 42 | maxConcurrency int, 43 | ) mutableSegmentStore { 44 | return &segmentStore{ 45 | httpClient: httpClient, 46 | backoff: backoff, 47 | cacheMap: make(map[string]*cache), 48 | timeout: timeout, 49 | maxConc: maxConcurrency, 50 | } 51 | } 52 | 53 | func (s *segmentStore) Exists(url string) bool { 54 | _, ok := s.cacheMap[url] 55 | return ok 56 | } 57 | 58 | func (s *segmentStore) Load(url string) ([]byte, bool) { 59 | seg, ok := s.cacheMap[url] 60 | return seg.data, ok 61 | } 62 | 63 | func (s *segmentStore) Sync(ctx context.Context, urls []string) error { 64 | for url := range s.cacheMap { 65 | s.cacheMap[url].del = true 66 | } 67 | 68 | type result struct { 69 | url string 70 | data []byte 71 | } 72 | results := make(chan result, len(urls)) 73 | eg := new(errgroup.Group) 74 | maxConc := s.maxConc 75 | if maxConc == 0 { 76 | maxConc = 1 77 | } 78 | limiter := make(chan struct{}, maxConc) 79 | for i := range urls { 80 | url := urls[i] 81 | if seg, ok := s.cacheMap[url]; ok { 82 | seg.del = false 83 | continue 84 | } 85 | limiter <- struct{}{} 86 | eg.Go(thread.NoPanic(func() error { 87 | defer func() { 88 | <-limiter 89 | }() 90 | return backoff.RetryNotify(func() error { 91 | ctx, cancel := context.WithTimeout(ctx, s.timeout) 92 | defer cancel() 93 | data, _, err := s.httpClient.Get(ctx, url) 94 | if err != nil { 95 | err = fmt.Errorf("failed to download segment: %s: %w", url, err) 96 | if ctx.Err() != nil || errors.As(err, &permanentError{}) { 97 | return backoff.Permanent(err) 98 | } 99 | return err 100 | } 101 | results <- result{url: url, data: data} 102 | return nil 103 | }, s.backoff, func(err error, _ time.Duration) { 104 | log.Printf("WARN: failed to download segment: %s: %s", url, err) 105 | }) 106 | })) 107 | } 108 | if err := eg.Wait(); err != nil { 109 | return err 110 | } 111 | close(results) 112 | for res := range results { 113 | s.cacheMap[res.url] = &cache{data: res.data} 114 | } 115 | for url := range s.cacheMap { 116 | if s.cacheMap[url].del { 117 | delete(s.cacheMap, url) 118 | } 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/abema/antares/v2 2 | 3 | go 1.21 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/abema/go-simple-m3u8 v0.2.0 9 | github.com/cenkalti/backoff/v4 v4.1.1 10 | github.com/stretchr/testify v1.10.0 11 | github.com/zencoder/go-dash v0.0.0-20201006100653-2f93b14912b2 12 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/kr/pretty v0.1.0 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 20 | gopkg.in/yaml.v3 v3.0.1 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/abema/go-simple-m3u8 v0.2.0 h1:xrQxKTisS5oKJnsbroih9kJtFnjKUWTMyCB/EswuuaM= 2 | github.com/abema/go-simple-m3u8 v0.2.0/go.mod h1:401HvZZcW+gQX8Jzz4XUS0Jl4X9bC9QOws5ImdoquEI= 3 | github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= 4 | github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 8 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 9 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 10 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 15 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 16 | github.com/zencoder/go-dash v0.0.0-20201006100653-2f93b14912b2 h1:0iAY2pL6yYhNYpdc1DbFq0p7ocyu5MlgKmkealhz3nk= 17 | github.com/zencoder/go-dash v0.0.0-20201006100653-2f93b14912b2/go.mod h1:c8Gxxfmh0jmZ6G+ISlpa315WBVkzd8mEhu6gN9mn5Qg= 18 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 19 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 22 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /inspectors/dash/adaptation_set.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abema/antares/v2/core" 7 | "github.com/abema/antares/v2/internal/strings" 8 | ) 9 | 10 | type AdaptationSetInspectorConfig struct { 11 | MandatoryMimeTypes []string 12 | ValidMimeTypes []string 13 | } 14 | 15 | // NewAdaptationSetInspector returns AdaptationSetInspector. 16 | // It inspects number of AdaptationSets and those attributes. 17 | func NewAdaptationSetInspector(config *AdaptationSetInspectorConfig) core.DASHInspector { 18 | return &adaptationSetInspector{ 19 | config: config, 20 | } 21 | } 22 | 23 | type adaptationSetInspector struct { 24 | config *AdaptationSetInspectorConfig 25 | } 26 | 27 | func (ins *adaptationSetInspector) Inspect(manifest *core.Manifest, segments core.SegmentStore) *core.Report { 28 | var noMimeType bool 29 | mimeTypeSet := make(map[string]struct{}, 4) 30 | for _, period := range manifest.Periods { 31 | for _, adaptationSet := range period.AdaptationSets { 32 | if adaptationSet.MimeType == nil { 33 | noMimeType = true 34 | } else { 35 | mimeTypeSet[*adaptationSet.MimeType] = struct{}{} 36 | } 37 | } 38 | } 39 | mimeTypes := make([]string, 0, len(mimeTypeSet)) 40 | for mimeType := range mimeTypeSet { 41 | mimeTypes = append(mimeTypes, mimeType) 42 | } 43 | values := core.Values{ 44 | "mimeType": mimeTypes, 45 | } 46 | 47 | if noMimeType { 48 | return &core.Report{ 49 | Name: "AdaptationSetInspector", 50 | Severity: core.Error, 51 | Message: "mimeType attribute is omitted", 52 | Values: values, 53 | } 54 | } 55 | for mimeType := range mimeTypeSet { 56 | if !strings.ContainsIn(mimeType, ins.config.MandatoryMimeTypes) && 57 | !strings.ContainsIn(mimeType, ins.config.ValidMimeTypes) { 58 | return &core.Report{ 59 | Name: "AdaptationSetInspector", 60 | Severity: core.Error, 61 | Message: fmt.Sprintf("invalid mimeType [%s]", mimeType), 62 | Values: values, 63 | } 64 | } 65 | } 66 | 67 | for _, period := range manifest.Periods { 68 | mimeTypeSetInPeriod := make(map[string]struct{}, 4) 69 | for _, adaptationSet := range period.AdaptationSets { 70 | mimeTypeSetInPeriod[*adaptationSet.MimeType] = struct{}{} 71 | } 72 | for _, mimeType := range ins.config.MandatoryMimeTypes { 73 | if _, ok := mimeTypeSetInPeriod[mimeType]; !ok { 74 | return &core.Report{ 75 | Name: "AdaptationSetInspector", 76 | Severity: core.Error, 77 | Message: fmt.Sprintf("mimeType [%s] is mandatory", mimeType), 78 | Values: values, 79 | } 80 | } 81 | } 82 | } 83 | return &core.Report{ 84 | Name: "AdaptationSetInspector", 85 | Severity: core.Info, 86 | Message: "good", 87 | Values: values, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /inspectors/dash/adaptation_set_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abema/antares/v2/core" 7 | "github.com/stretchr/testify/require" 8 | "github.com/zencoder/go-dash/helpers/ptrs" 9 | "github.com/zencoder/go-dash/mpd" 10 | ) 11 | 12 | func Test(t *testing.T) { 13 | ins := NewAdaptationSetInspector(&AdaptationSetInspectorConfig{ 14 | MandatoryMimeTypes: []string{"video/mp4"}, 15 | ValidMimeTypes: []string{"audio/mp4"}, 16 | }) 17 | 18 | t.Run("video_audio/ok", func(t *testing.T) { 19 | report := ins.Inspect(&core.Manifest{ 20 | MPD: &mpd.MPD{ 21 | Periods: []*mpd.Period{{ 22 | AdaptationSets: []*mpd.AdaptationSet{{ 23 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 24 | MimeType: ptrs.Strptr("video/mp4"), 25 | }, 26 | }, { 27 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 28 | MimeType: ptrs.Strptr("audio/mp4"), 29 | }, 30 | }}, 31 | }}, 32 | }, 33 | }, nil) 34 | require.Equal(t, core.Info, report.Severity) 35 | }) 36 | 37 | t.Run("video/ok", func(t *testing.T) { 38 | report := ins.Inspect(&core.Manifest{ 39 | MPD: &mpd.MPD{ 40 | Periods: []*mpd.Period{{ 41 | AdaptationSets: []*mpd.AdaptationSet{{ 42 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 43 | MimeType: ptrs.Strptr("video/mp4"), 44 | }, 45 | }}, 46 | }}, 47 | }, 48 | }, nil) 49 | require.Equal(t, core.Info, report.Severity) 50 | }) 51 | 52 | t.Run("video_text/error", func(t *testing.T) { 53 | report := ins.Inspect(&core.Manifest{ 54 | MPD: &mpd.MPD{ 55 | Periods: []*mpd.Period{{ 56 | AdaptationSets: []*mpd.AdaptationSet{{ 57 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 58 | MimeType: ptrs.Strptr("video/mp4"), 59 | }, 60 | }, { 61 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 62 | MimeType: ptrs.Strptr("text/vtt"), 63 | }, 64 | }}, 65 | }}, 66 | }, 67 | }, nil) 68 | require.Equal(t, core.Error, report.Severity) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /inspectors/dash/mpd_type.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abema/antares/v2/core" 7 | ) 8 | 9 | // NewMPDTypeInspector returns MPDTypeInspector. 10 | // It inspects whether MPD@type equals to mpdType. 11 | func NewMPDTypeInspector(mpdType string) core.DASHInspector { 12 | return &mpdTypeInspector{ 13 | mpdType: mpdType, 14 | } 15 | } 16 | 17 | type mpdTypeInspector struct { 18 | mpdType string 19 | } 20 | 21 | func (ins *mpdTypeInspector) Inspect(manifest *core.Manifest, segments core.SegmentStore) *core.Report { 22 | mpdType := "static" 23 | if manifest.Type != nil { 24 | mpdType = *manifest.Type 25 | } 26 | 27 | values := core.Values{ 28 | "type": mpdType, 29 | } 30 | 31 | if mpdType != ins.mpdType { 32 | return &core.Report{ 33 | Name: "MPDTypeInspector", 34 | Severity: core.Error, 35 | Message: fmt.Sprintf("invalid Type [%s]", mpdType), 36 | Values: values, 37 | } 38 | } 39 | return &core.Report{ 40 | Name: "MPDTypeInspector", 41 | Severity: core.Info, 42 | Message: "good", 43 | Values: values, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /inspectors/dash/mpd_type_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abema/antares/v2/core" 7 | "github.com/stretchr/testify/require" 8 | "github.com/zencoder/go-dash/helpers/ptrs" 9 | "github.com/zencoder/go-dash/mpd" 10 | ) 11 | 12 | func TestMPDTypeInspector(t *testing.T) { 13 | ins := NewMPDTypeInspector("dynamic") 14 | report := ins.Inspect(&core.Manifest{MPD: &mpd.MPD{Type: ptrs.Strptr("dynamic")}}, nil) 15 | require.Equal(t, core.Info, report.Severity) 16 | report = ins.Inspect(&core.Manifest{MPD: &mpd.MPD{Type: ptrs.Strptr("static")}}, nil) 17 | require.Equal(t, core.Error, report.Severity) 18 | } 19 | -------------------------------------------------------------------------------- /inspectors/dash/presentation_delay.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/abema/antares/v2/core" 7 | ) 8 | 9 | type PresentationDelayInspectorConfig struct { 10 | Warn time.Duration 11 | Error time.Duration 12 | IgnoreEarliestSegment bool 13 | IgnoreLatestSegment bool 14 | } 15 | 16 | func DefaultPresentationDelayInspectorConfig() *PresentationDelayInspectorConfig { 17 | return &PresentationDelayInspectorConfig{ 18 | Warn: 2 * time.Second, 19 | Error: 0, 20 | } 21 | } 22 | 23 | func NewPresentationDelayInspector() core.DASHInspector { 24 | return NewPresentationDelayInspectorWithConfig(DefaultPresentationDelayInspectorConfig()) 25 | } 26 | 27 | func NewPresentationDelayInspectorWithConfig(config *PresentationDelayInspectorConfig) core.DASHInspector { 28 | return &PresentationDelayInspector{ 29 | config, 30 | } 31 | } 32 | 33 | type PresentationDelayInspector struct { 34 | config *PresentationDelayInspectorConfig 35 | } 36 | 37 | func (ins *PresentationDelayInspector) Inspect(manifest *core.Manifest, segments core.SegmentStore) *core.Report { 38 | if manifest.Type != nil && *manifest.Type == "static" { 39 | return &core.Report{ 40 | Name: "PresentationDelayInspector", 41 | Severity: core.Info, 42 | Message: "skip VOD manifest", 43 | } 44 | } 45 | 46 | // get wall-clock 47 | wallClock := time.Now() 48 | if manifest.UTCTiming != nil && manifest.UTCTiming.SchemeIDURI != nil && *manifest.UTCTiming.SchemeIDURI == "urn:mpeg:dash:utc:direct:2014" { 49 | tm, err := time.Parse(time.RFC3339Nano, *manifest.UTCTiming.Value) 50 | if err != nil { 51 | return &core.Report{ 52 | Name: "PresentationDelayInspector", 53 | Severity: core.Error, 54 | Message: "invalid UTCTiming@value", 55 | Values: core.Values{"error": err}, 56 | } 57 | } 58 | wallClock = tm 59 | } else if manifest.PublishTime != nil { 60 | tm, err := time.Parse(time.RFC3339Nano, *manifest.PublishTime) 61 | if err != nil { 62 | return &core.Report{ 63 | Name: "PresentationDelayInspector", 64 | Severity: core.Error, 65 | Message: "invalid MPD@publishTime", 66 | Values: core.Values{"error": err}, 67 | } 68 | } 69 | wallClock = tm 70 | } 71 | 72 | // get suggestedPresentationDelay 73 | var suggestedPresentationDelay time.Duration 74 | if manifest.SuggestedPresentationDelay != nil { 75 | suggestedPresentationDelay = time.Duration(*manifest.SuggestedPresentationDelay) 76 | } 77 | 78 | // get availabilityStartTime 79 | var availabilityStartTime time.Time 80 | if manifest.AvailabilityStartTime != nil { 81 | tm, err := time.Parse(time.RFC3339Nano, *manifest.AvailabilityStartTime) 82 | if err != nil { 83 | return &core.Report{ 84 | Name: "PresentationDelayInspector", 85 | Severity: core.Error, 86 | Message: "invalid MPD@availabilityStartTime", 87 | Values: core.Values{"error": err}, 88 | } 89 | } 90 | availabilityStartTime = tm 91 | } 92 | 93 | // get latest time 94 | var earliestVideoTime time.Time 95 | var latestVideoTime time.Time 96 | err := manifest.EachSegments(func(segment *core.DASHSegment) (cont bool) { 97 | if segment.Initialization { 98 | return true 99 | } 100 | var periodStart float64 101 | if segment.Period.Start != nil { 102 | periodStart = time.Duration(*segment.Period.Start).Seconds() 103 | } 104 | var offset uint64 105 | if segment.SegmentTemplate.PresentationTimeOffset != nil { 106 | offset = *segment.SegmentTemplate.PresentationTimeOffset 107 | } 108 | timescale := float64(1) 109 | if segment.SegmentTemplate.Timescale != nil { 110 | timescale = float64(*segment.SegmentTemplate.Timescale) 111 | } 112 | s := int64(segment.Time) - int64(offset) 113 | fs := periodStart + float64(s)/timescale 114 | ts := availabilityStartTime.Add(time.Duration(fs*1e9) * time.Nanosecond) 115 | if earliestVideoTime.IsZero() || ts.Before(earliestVideoTime) { 116 | earliestVideoTime = ts 117 | } 118 | e := s + int64(segment.Duration) 119 | fe := periodStart + float64(e)/timescale 120 | te := availabilityStartTime.Add(time.Duration(fe*1e9) * time.Nanosecond) 121 | if te.After(latestVideoTime) { 122 | latestVideoTime = te 123 | } 124 | return true 125 | }) 126 | if err != nil { 127 | return &core.Report{ 128 | Name: "PresentationDelayInspector", 129 | Severity: core.Error, 130 | Message: "unexpected error", 131 | Values: core.Values{"error": err}, 132 | } 133 | } 134 | 135 | values := core.Values{ 136 | "earliestVideoTime": earliestVideoTime.UTC().Format(time.RFC3339Nano), 137 | "latestVideoTime": latestVideoTime.UTC().Format(time.RFC3339Nano), 138 | "wallClock": wallClock.UTC().Format(time.RFC3339Nano), 139 | "suggestedPresentationDelay": suggestedPresentationDelay, 140 | } 141 | earliestRenderTime := earliestVideoTime.Add(suggestedPresentationDelay) 142 | latestRenderTime := latestVideoTime.Add(suggestedPresentationDelay) 143 | if !ins.config.IgnoreEarliestSegment { 144 | if earliestRenderTime.Add(ins.config.Error).After(wallClock) { 145 | return &core.Report{ 146 | Name: "PresentationDelayInspector", 147 | Severity: core.Error, 148 | Message: "earliest segment is out of suggested time range", 149 | Values: values, 150 | } 151 | } else if earliestRenderTime.Add(ins.config.Warn).After(wallClock) { 152 | return &core.Report{ 153 | Name: "PresentationDelayInspector", 154 | Severity: core.Warn, 155 | Message: "earliest segment is out of suggested time range", 156 | Values: values, 157 | } 158 | } 159 | } 160 | if !ins.config.IgnoreLatestSegment { 161 | if latestRenderTime.Add(-ins.config.Error).Before(wallClock) { 162 | return &core.Report{ 163 | Name: "PresentationDelayInspector", 164 | Severity: core.Error, 165 | Message: "latest segment is out of suggested time range", 166 | Values: values, 167 | } 168 | } else if latestRenderTime.Add(-ins.config.Warn).Before(wallClock) { 169 | return &core.Report{ 170 | Name: "PresentationDelayInspector", 171 | Severity: core.Warn, 172 | Message: "latest segment is out of suggested time range", 173 | Values: values, 174 | } 175 | } 176 | } 177 | return &core.Report{ 178 | Name: "PresentationDelayInspector", 179 | Severity: core.Info, 180 | Message: "good", 181 | Values: values, 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /inspectors/dash/presentation_delay_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/abema/antares/v2/core" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/zencoder/go-dash/helpers/ptrs" 11 | "github.com/zencoder/go-dash/mpd" 12 | ) 13 | 14 | func TestPresentationDelayInspector(t *testing.T) { 15 | ins := NewPresentationDelayInspector() 16 | 17 | buildManifest := func(suggestedPresentationDelay time.Duration) *core.Manifest { 18 | periodStart := 5 * time.Hour 19 | return &core.Manifest{ 20 | MPD: &mpd.MPD{ 21 | Type: ptrs.Strptr("dynamic"), 22 | AvailabilityStartTime: ptrs.Strptr("2023-01-01T00:00:00Z"), 23 | PublishTime: ptrs.Strptr("2023-01-01T06:00:36Z"), // 4 seconds later from latest segment 24 | SuggestedPresentationDelay: (*mpd.Duration)(&suggestedPresentationDelay), 25 | Periods: []*mpd.Period{{ 26 | Start: (*mpd.Duration)(&periodStart), 27 | AdaptationSets: []*mpd.AdaptationSet{{ 28 | SegmentTemplate: &mpd.SegmentTemplate{ 29 | Timescale: ptrs.Int64ptr(90000), 30 | PresentationTimeOffset: ptrs.Uint64ptr(10000 * 90000), 31 | Media: ptrs.Strptr("$Time$.mp4"), 32 | SegmentTimeline: &mpd.SegmentTimeline{ 33 | Segments: []*mpd.SegmentTimelineSegment{ 34 | { 35 | StartTime: ptrs.Uint64ptr(13600 * 90000), 36 | Duration: 4 * 90000, 37 | RepeatCount: ptrs.Intptr(7), 38 | }, 39 | }, 40 | }, 41 | }, 42 | Representations: []*mpd.Representation{{}}, 43 | }}, 44 | }}, 45 | UTCTiming: &mpd.DescriptorType{ 46 | SchemeIDURI: ptrs.Strptr("urn:mpeg:dash:utc:direct:2014"), 47 | Value: ptrs.Strptr("2023-01-01T06:00:36Z"), // 4 seconds later from latest segment 48 | }, 49 | }, 50 | } 51 | } 52 | 53 | t.Run("ok", func(t *testing.T) { 54 | report := ins.Inspect(buildManifest(7*time.Second), nil) 55 | require.Equal(t, core.Info, report.Severity) 56 | assert.Equal(t, "good", report.Message) 57 | }) 58 | 59 | t.Run("ok/without_utc_timing", func(t *testing.T) { 60 | manifest := buildManifest(7 * time.Second) 61 | manifest.UTCTiming = nil 62 | report := ins.Inspect(manifest, nil) 63 | require.Equal(t, core.Info, report.Severity) 64 | assert.Equal(t, "good", report.Message) 65 | }) 66 | 67 | t.Run("warn/presentation_time_is_new", func(t *testing.T) { 68 | report := ins.Inspect(buildManifest(5*time.Second), nil) 69 | require.Equal(t, core.Warn, report.Severity) 70 | assert.Equal(t, "latest segment is out of suggested time range", report.Message) 71 | }) 72 | 73 | t.Run("error/presentation_time_is_new", func(t *testing.T) { 74 | report := ins.Inspect(buildManifest(3*time.Second), nil) 75 | require.Equal(t, core.Error, report.Severity) 76 | assert.Equal(t, "latest segment is out of suggested time range", report.Message) 77 | }) 78 | 79 | t.Run("warn/presentation_time_is_old", func(t *testing.T) { 80 | report := ins.Inspect(buildManifest(35*time.Second), nil) 81 | require.Equal(t, core.Warn, report.Severity) 82 | assert.Equal(t, "earliest segment is out of suggested time range", report.Message) 83 | }) 84 | 85 | t.Run("error/presentation_time_is_old", func(t *testing.T) { 86 | report := ins.Inspect(buildManifest(37*time.Second), nil) 87 | require.Equal(t, core.Error, report.Severity) 88 | assert.Equal(t, "earliest segment is out of suggested time range", report.Message) 89 | }) 90 | 91 | t.Run("skip_vod_manifest", func(t *testing.T) { 92 | report := ins.Inspect(&core.Manifest{MPD: &mpd.MPD{Type: ptrs.Strptr("static")}}, nil) 93 | require.Equal(t, core.Info, report.Severity) 94 | assert.Equal(t, "skip VOD manifest", report.Message) 95 | }) 96 | 97 | t.Run("invalid_utc_timing", func(t *testing.T) { 98 | report := ins.Inspect(&core.Manifest{ 99 | MPD: &mpd.MPD{ 100 | Type: ptrs.Strptr("dynamic"), 101 | UTCTiming: &mpd.DescriptorType{ 102 | SchemeIDURI: ptrs.Strptr("urn:mpeg:dash:utc:direct:2014"), 103 | Value: ptrs.Strptr("9999-99-99T99:99:99Z"), 104 | }, 105 | }, 106 | }, nil) 107 | require.Equal(t, core.Error, report.Severity) 108 | assert.Equal(t, "invalid UTCTiming@value", report.Message) 109 | }) 110 | 111 | t.Run("invalid_publish_time", func(t *testing.T) { 112 | report := ins.Inspect(&core.Manifest{ 113 | MPD: &mpd.MPD{ 114 | Type: ptrs.Strptr("dynamic"), 115 | PublishTime: ptrs.Strptr("9999-99-99T99:99:99Z"), 116 | }, 117 | }, nil) 118 | require.Equal(t, core.Error, report.Severity) 119 | assert.Equal(t, "invalid MPD@publishTime", report.Message) 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /inspectors/dash/representation.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/abema/antares/v2/core" 10 | "github.com/zencoder/go-dash/mpd" 11 | ) 12 | 13 | type AspectRatio struct { 14 | X int64 15 | Y int64 16 | } 17 | 18 | func ParseAspectRatio(ar string) (AspectRatio, error) { 19 | s := strings.SplitN(ar, ":", 2) 20 | if len(s) != 2 { 21 | return AspectRatio{}, fmt.Errorf("invalid aspect ratio format: %s", ar) 22 | } 23 | x, err := strconv.ParseInt(s[0], 10, 64) 24 | if err != nil { 25 | return AspectRatio{}, fmt.Errorf("invalid aspect ratio format: %s", ar) 26 | } 27 | y, err := strconv.ParseInt(s[1], 10, 64) 28 | if err != nil { 29 | return AspectRatio{}, fmt.Errorf("invalid aspect ratio format: %s", ar) 30 | } 31 | return AspectRatio{X: x, Y: y}, nil 32 | } 33 | 34 | type RepresentationInspectorConfig struct { 35 | WarnMaxHeight int64 36 | ErrorMaxHeight int64 37 | WarnMinHeight int64 38 | ErrorMinHeight int64 39 | ValidPARs []AspectRatio 40 | AllowHeightOmittion bool 41 | AllowWidthOmittion bool 42 | WarnMaxVideoBandwidth int64 43 | ErrorMaxVideoBandwidth int64 44 | WarnMinVideoBandwidth int64 45 | ErrorMinVideoBandwidth int64 46 | WarnMaxAudioBandwidth int64 47 | ErrorMaxAudioBandwidth int64 48 | WarnMinAudioBandwidth int64 49 | ErrorMinAudioBandwidth int64 50 | } 51 | 52 | func NewRepresentationInspector(config *RepresentationInspectorConfig) core.DASHInspector { 53 | return &representationInspector{ 54 | config: config, 55 | } 56 | } 57 | 58 | type representationInspector struct { 59 | config *RepresentationInspectorConfig 60 | } 61 | 62 | func (ins *representationInspector) Inspect(manifest *core.Manifest, segments core.SegmentStore) *core.Report { 63 | rsls := make([]*resolution, 0) 64 | var maxVideoBandwidth int64 65 | minVideoBandwidth := int64(math.MaxInt64) 66 | var maxAudioBandwidth int64 67 | minAudioBandwidth := int64(math.MaxInt64) 68 | 69 | for _, period := range manifest.Periods { 70 | for _, adaptationSet := range period.AdaptationSets { 71 | if adaptationSet.MimeType == nil { 72 | return &core.Report{ 73 | Name: "RepresentationInspector", 74 | Severity: core.Error, 75 | Message: "mimeType attribute is omitted", 76 | } 77 | } 78 | if len(adaptationSet.Representations) == 0 { 79 | return &core.Report{ 80 | Name: "RepresentationInspector", 81 | Severity: core.Error, 82 | Message: "no representation tag", 83 | } 84 | } 85 | for _, representation := range adaptationSet.Representations { 86 | if representation.Bandwidth == nil { 87 | return &core.Report{ 88 | Name: "RepresentationInspector", 89 | Severity: core.Error, 90 | Message: "bandwidth attribute is omitted", 91 | } 92 | } 93 | switch *adaptationSet.MimeType { 94 | case "video/mp4": 95 | rsl, err := getResolution(adaptationSet, representation) 96 | if err != nil { 97 | return &core.Report{ 98 | Name: "RepresentationInspector", 99 | Severity: core.Error, 100 | Message: err.Error(), 101 | } 102 | } 103 | rsls = append(rsls, rsl) 104 | if *representation.Bandwidth > maxVideoBandwidth { 105 | maxVideoBandwidth = *representation.Bandwidth 106 | } 107 | if *representation.Bandwidth < minVideoBandwidth { 108 | minVideoBandwidth = *representation.Bandwidth 109 | } 110 | case "audio/mp4": 111 | if *representation.Bandwidth > maxAudioBandwidth { 112 | maxAudioBandwidth = *representation.Bandwidth 113 | } 114 | if *representation.Bandwidth < minAudioBandwidth { 115 | minAudioBandwidth = *representation.Bandwidth 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | values := core.Values{ 123 | "maxVideoBandwidth": maxVideoBandwidth, 124 | "minVideoBandwidth": minVideoBandwidth, 125 | "maxAudioBandwidth": maxAudioBandwidth, 126 | "minAudioBandwidth": minAudioBandwidth, 127 | } 128 | 129 | for _, rsl := range rsls { 130 | if rsl.Height == nil { 131 | if !ins.config.AllowHeightOmittion { 132 | return &core.Report{ 133 | Name: "RepresentationInspector", 134 | Severity: core.Error, 135 | Message: "height attribute is omitted", 136 | Values: values, 137 | } 138 | } 139 | } else if ins.config.ErrorMaxHeight != 0 && *rsl.Height > ins.config.ErrorMaxHeight { 140 | return &core.Report{ 141 | Name: "RepresentationInspector", 142 | Severity: core.Error, 143 | Message: "too large height", 144 | Values: values, 145 | } 146 | } else if ins.config.WarnMaxHeight != 0 && *rsl.Height > ins.config.WarnMaxHeight { 147 | return &core.Report{ 148 | Name: "RepresentationInspector", 149 | Severity: core.Warn, 150 | Message: "too large height", 151 | Values: values, 152 | } 153 | } else if ins.config.ErrorMinHeight != 0 && *rsl.Height < ins.config.ErrorMinHeight { 154 | return &core.Report{ 155 | Name: "RepresentationInspector", 156 | Severity: core.Error, 157 | Message: "too small height", 158 | Values: values, 159 | } 160 | } else if ins.config.WarnMinHeight != 0 && *rsl.Height < ins.config.WarnMinHeight { 161 | return &core.Report{ 162 | Name: "RepresentationInspector", 163 | Severity: core.Warn, 164 | Message: "too small height", 165 | Values: values, 166 | } 167 | } 168 | if rsl.Width == nil && !ins.config.AllowWidthOmittion { 169 | return &core.Report{ 170 | Name: "RepresentationInspector", 171 | Severity: core.Error, 172 | Message: "width attribute is omitted", 173 | Values: values, 174 | } 175 | } 176 | if rsl.Width != nil && rsl.Height != nil && len(ins.config.ValidPARs) != 0 { 177 | if !containsAspectRatio(AspectRatio{ 178 | X: rsl.SAR.X * (*rsl.Width), 179 | Y: rsl.SAR.Y * (*rsl.Height), 180 | }, ins.config.ValidPARs) { 181 | return &core.Report{ 182 | Name: "RepresentationInspector", 183 | Severity: core.Error, 184 | Message: fmt.Sprintf("invalid PAR: width=%d height=%d sar=[%d:%d]", 185 | *rsl.Width, *rsl.Height, rsl.SAR.X, rsl.SAR.Y), 186 | Values: values, 187 | } 188 | } 189 | } 190 | } 191 | if ins.config.ErrorMaxVideoBandwidth != 0 && maxVideoBandwidth > ins.config.ErrorMaxVideoBandwidth { 192 | return &core.Report{ 193 | Name: "RepresentationInspector", 194 | Severity: core.Error, 195 | Message: "high video bandwidth", 196 | Values: values, 197 | } 198 | } 199 | if ins.config.WarnMaxVideoBandwidth != 0 && maxVideoBandwidth > ins.config.WarnMaxVideoBandwidth { 200 | return &core.Report{ 201 | Name: "RepresentationInspector", 202 | Severity: core.Warn, 203 | Message: "high video bandwidth", 204 | Values: values, 205 | } 206 | } 207 | if ins.config.ErrorMinVideoBandwidth != 0 && minVideoBandwidth < ins.config.ErrorMinVideoBandwidth { 208 | return &core.Report{ 209 | Name: "RepresentationInspector", 210 | Severity: core.Error, 211 | Message: "low video bandwidth", 212 | Values: values, 213 | } 214 | } 215 | if ins.config.WarnMinVideoBandwidth != 0 && minVideoBandwidth < ins.config.WarnMinVideoBandwidth { 216 | return &core.Report{ 217 | Name: "RepresentationInspector", 218 | Severity: core.Warn, 219 | Message: "low video bandwidth", 220 | Values: values, 221 | } 222 | } 223 | if ins.config.ErrorMaxAudioBandwidth != 0 && maxAudioBandwidth > ins.config.ErrorMaxAudioBandwidth { 224 | return &core.Report{ 225 | Name: "RepresentationInspector", 226 | Severity: core.Error, 227 | Message: "high audio bandwidth", 228 | Values: values, 229 | } 230 | } 231 | if ins.config.WarnMaxAudioBandwidth != 0 && maxAudioBandwidth > ins.config.WarnMaxAudioBandwidth { 232 | return &core.Report{ 233 | Name: "RepresentationInspector", 234 | Severity: core.Warn, 235 | Message: "high audio bandwidth", 236 | Values: values, 237 | } 238 | } 239 | if ins.config.ErrorMinAudioBandwidth != 0 && minAudioBandwidth < ins.config.ErrorMinAudioBandwidth { 240 | return &core.Report{ 241 | Name: "RepresentationInspector", 242 | Severity: core.Error, 243 | Message: "low audio bandwidth", 244 | Values: values, 245 | } 246 | } 247 | if ins.config.WarnMinAudioBandwidth != 0 && minAudioBandwidth < ins.config.WarnMinAudioBandwidth { 248 | return &core.Report{ 249 | Name: "RepresentationInspector", 250 | Severity: core.Warn, 251 | Message: "low audio bandwidth", 252 | Values: values, 253 | } 254 | } 255 | 256 | return &core.Report{ 257 | Name: "RepresentationInspector", 258 | Severity: core.Info, 259 | Message: "good", 260 | Values: values, 261 | } 262 | } 263 | 264 | type resolution struct { 265 | Height *int64 266 | Width *int64 267 | SAR AspectRatio 268 | } 269 | 270 | func getResolution(adaptationSet *mpd.AdaptationSet, representation *mpd.Representation) (*resolution, error) { 271 | rsl := &resolution{ 272 | SAR: AspectRatio{X: 1, Y: 1}, 273 | } 274 | if representation.Height != nil { 275 | rsl.Height = representation.Height 276 | } else if adaptationSet.Height != nil { 277 | height, err := strconv.ParseInt(*adaptationSet.Height, 10, 64) 278 | if err != nil { 279 | return nil, fmt.Errorf("invalid height: %w", err) 280 | } 281 | rsl.Height = &height 282 | } 283 | if representation.Width != nil { 284 | rsl.Width = representation.Width 285 | } else if adaptationSet.Width != nil { 286 | width, err := strconv.ParseInt(*adaptationSet.Width, 10, 64) 287 | if err != nil { 288 | return nil, fmt.Errorf("invalid width: %w", err) 289 | } 290 | rsl.Width = &width 291 | } 292 | var sar string 293 | if representation.Sar != nil { 294 | sar = *representation.Sar 295 | } else if adaptationSet.Sar != nil { 296 | sar = *adaptationSet.Sar 297 | } 298 | if sar != "" { 299 | var err error 300 | rsl.SAR, err = ParseAspectRatio(sar) 301 | if err != nil { 302 | return nil, err 303 | } 304 | } 305 | return rsl, nil 306 | } 307 | 308 | func containsAspectRatio(aspectRatio AspectRatio, set []AspectRatio) bool { 309 | for _, r := range set { 310 | d := float64(aspectRatio.X)/float64(aspectRatio.Y) - float64(r.X)/float64(r.Y) 311 | if d < 0.01 && d > -0.01 { 312 | return true 313 | } 314 | } 315 | return false 316 | } 317 | -------------------------------------------------------------------------------- /inspectors/dash/representation_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abema/antares/v2/core" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/zencoder/go-dash/helpers/ptrs" 10 | "github.com/zencoder/go-dash/mpd" 11 | ) 12 | 13 | func TestNewRepresentationInspector(t *testing.T) { 14 | ins := NewRepresentationInspector(&RepresentationInspectorConfig{ 15 | ErrorMaxHeight: 720, 16 | WarnMaxHeight: 480, 17 | WarnMinHeight: 240, 18 | ErrorMinHeight: 180, 19 | ValidPARs: []AspectRatio{{X: 16, Y: 9}}, 20 | ErrorMaxVideoBandwidth: 2000 * 1e3, 21 | WarnMaxVideoBandwidth: 1000 * 1e3, 22 | WarnMinVideoBandwidth: 200 * 1e3, 23 | ErrorMinVideoBandwidth: 100 * 1e3, 24 | ErrorMaxAudioBandwidth: 200 * 1e3, 25 | WarnMaxAudioBandwidth: 100 * 1e3, 26 | WarnMinAudioBandwidth: 50 * 1e3, 27 | ErrorMinAudioBandwidth: 10 * 1e3, 28 | }) 29 | 30 | testCases := []struct { 31 | name string 32 | highVideoWidth int64 33 | highVideoHeight int64 34 | lowVideoWidth int64 35 | lowVideoHeight int64 36 | highVideo int64 37 | lowVideo int64 38 | highAudio int64 39 | lowAudio int64 40 | severity core.Severity 41 | message string 42 | }{ 43 | { 44 | name: "ok", 45 | highVideoWidth: 853, highVideoHeight: 480, 46 | lowVideoWidth: 426, lowVideoHeight: 240, 47 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 48 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 49 | severity: core.Info, 50 | message: "good", 51 | }, 52 | { 53 | name: "large-hight/warn", 54 | highVideoWidth: 1280, highVideoHeight: 720, 55 | lowVideoWidth: 426, lowVideoHeight: 240, 56 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 57 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 58 | severity: core.Warn, 59 | message: "too large height", 60 | }, 61 | { 62 | name: "large-hight/error", 63 | highVideoWidth: 1920, highVideoHeight: 1080, 64 | lowVideoWidth: 426, lowVideoHeight: 240, 65 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 66 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 67 | severity: core.Error, 68 | message: "too large height", 69 | }, 70 | { 71 | name: "small-hight/warn", 72 | highVideoWidth: 853, highVideoHeight: 480, 73 | lowVideoWidth: 320, lowVideoHeight: 180, 74 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 75 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 76 | severity: core.Warn, 77 | message: "too small height", 78 | }, 79 | { 80 | name: "small-hight/error", 81 | highVideoWidth: 853, highVideoHeight: 480, 82 | lowVideoWidth: 178, lowVideoHeight: 100, 83 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 84 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 85 | severity: core.Error, 86 | message: "too small height", 87 | }, 88 | { 89 | name: "video-high-bitrate/warn", 90 | highVideoWidth: 853, highVideoHeight: 480, 91 | lowVideoWidth: 426, lowVideoHeight: 240, 92 | highVideo: 2000 * 1e3, lowVideo: 200 * 1e3, 93 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 94 | severity: core.Warn, 95 | message: "high video bandwidth", 96 | }, 97 | { 98 | name: "video-high-bitrate/error", 99 | highVideoWidth: 853, highVideoHeight: 480, 100 | lowVideoWidth: 426, lowVideoHeight: 240, 101 | highVideo: 4000 * 1e3, lowVideo: 200 * 1e3, 102 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 103 | severity: core.Error, 104 | message: "high video bandwidth", 105 | }, 106 | { 107 | name: "video-low-bitrate/warn", 108 | highVideoWidth: 853, highVideoHeight: 480, 109 | lowVideoWidth: 426, lowVideoHeight: 240, 110 | highVideo: 1000 * 1e3, lowVideo: 100 * 1e3, 111 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 112 | severity: core.Warn, 113 | message: "low video bandwidth", 114 | }, 115 | { 116 | name: "video-low-bitrate/error", 117 | highVideoWidth: 853, highVideoHeight: 480, 118 | lowVideoWidth: 426, lowVideoHeight: 240, 119 | highVideo: 1000 * 1e3, lowVideo: 50 * 1e3, 120 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 121 | severity: core.Error, 122 | message: "low video bandwidth", 123 | }, 124 | { 125 | name: "audio-high-bitrate/warn", 126 | highVideoWidth: 853, highVideoHeight: 480, 127 | lowVideoWidth: 426, lowVideoHeight: 240, 128 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 129 | highAudio: 200 * 1e3, lowAudio: 50 * 1e3, 130 | severity: core.Warn, 131 | message: "high audio bandwidth", 132 | }, 133 | { 134 | name: "audio-high-bitrate/error", 135 | highVideoWidth: 853, highVideoHeight: 480, 136 | lowVideoWidth: 426, lowVideoHeight: 240, 137 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 138 | highAudio: 300 * 1e3, lowAudio: 50 * 1e3, 139 | severity: core.Error, 140 | message: "high audio bandwidth", 141 | }, 142 | { 143 | name: "audio-low-bitrate/warn", 144 | highVideoWidth: 853, highVideoHeight: 480, 145 | lowVideoWidth: 426, lowVideoHeight: 240, 146 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 147 | highAudio: 100 * 1e3, lowAudio: 10 * 1e3, 148 | severity: core.Warn, 149 | message: "low audio bandwidth", 150 | }, 151 | { 152 | name: "audio-low-bitrate/error", 153 | highVideoWidth: 853, highVideoHeight: 480, 154 | lowVideoWidth: 426, lowVideoHeight: 240, 155 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 156 | highAudio: 100 * 1e3, lowAudio: 5 * 1e3, 157 | severity: core.Error, 158 | message: "low audio bandwidth", 159 | }, 160 | { 161 | name: "aspect-ratio/error", 162 | highVideoWidth: 640, highVideoHeight: 480, 163 | lowVideoWidth: 426, lowVideoHeight: 240, 164 | highVideo: 1000 * 1e3, lowVideo: 200 * 1e3, 165 | highAudio: 100 * 1e3, lowAudio: 50 * 1e3, 166 | severity: core.Error, 167 | message: "invalid PAR: width=640 height=480 sar=[1:1]", 168 | }, 169 | } 170 | for _, tc := range testCases { 171 | t.Run(tc.name, func(t *testing.T) { 172 | report := ins.Inspect(&core.Manifest{ 173 | MPD: &mpd.MPD{ 174 | Periods: []*mpd.Period{{ 175 | AdaptationSets: []*mpd.AdaptationSet{{ 176 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 177 | MimeType: ptrs.Strptr("video/mp4"), 178 | }, 179 | Representations: []*mpd.Representation{ 180 | {Width: ptrs.Int64ptr(tc.highVideoWidth), Height: ptrs.Int64ptr(tc.highVideoHeight), Bandwidth: ptrs.Int64ptr(tc.highVideo)}, 181 | {Width: ptrs.Int64ptr(tc.lowVideoWidth), Height: ptrs.Int64ptr(tc.lowVideoHeight), Bandwidth: ptrs.Int64ptr(tc.lowVideo)}, 182 | }, 183 | }, { 184 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 185 | MimeType: ptrs.Strptr("audio/mp4"), 186 | }, 187 | Representations: []*mpd.Representation{ 188 | {Bandwidth: ptrs.Int64ptr(tc.highAudio)}, 189 | {Bandwidth: ptrs.Int64ptr(tc.lowAudio)}, 190 | }, 191 | }}, 192 | }}, 193 | }, 194 | }, nil) 195 | require.Equal(t, tc.severity, report.Severity) 196 | require.Equal(t, tc.message, report.Message) 197 | }) 198 | } 199 | } 200 | 201 | func TestNewRepresentationInspector_Omition(t *testing.T) { 202 | testCases := []struct { 203 | name string 204 | allowHeightOmittion bool 205 | allowWidthOmittion bool 206 | mimeType *string 207 | bandwidth *int64 208 | width *int64 209 | height *int64 210 | message string 211 | }{ 212 | { 213 | name: "height-omittion-error", 214 | allowHeightOmittion: false, 215 | allowWidthOmittion: true, 216 | mimeType: ptrs.Strptr("video/mp4"), 217 | bandwidth: ptrs.Int64ptr(100 * 1e3), 218 | message: "height attribute is omitted", 219 | }, 220 | { 221 | name: "width-omittion-error", 222 | allowHeightOmittion: true, 223 | allowWidthOmittion: false, 224 | mimeType: ptrs.Strptr("video/mp4"), 225 | bandwidth: ptrs.Int64ptr(100 * 1e3), 226 | message: "width attribute is omitted", 227 | }, 228 | { 229 | name: "bandwidth-omittion-error", 230 | allowHeightOmittion: true, 231 | allowWidthOmittion: true, 232 | mimeType: ptrs.Strptr("video/mp4"), 233 | message: "bandwidth attribute is omitted", 234 | }, 235 | { 236 | name: "mimeType-omittion-error", 237 | allowHeightOmittion: true, 238 | allowWidthOmittion: true, 239 | bandwidth: ptrs.Int64ptr(100 * 1e3), 240 | message: "mimeType attribute is omitted", 241 | }, 242 | } 243 | for _, tc := range testCases { 244 | t.Run(tc.name, func(t *testing.T) { 245 | ins := NewRepresentationInspector(&RepresentationInspectorConfig{ 246 | AllowHeightOmittion: tc.allowHeightOmittion, 247 | AllowWidthOmittion: tc.allowWidthOmittion, 248 | }) 249 | report := ins.Inspect(&core.Manifest{ 250 | MPD: &mpd.MPD{ 251 | Periods: []*mpd.Period{{ 252 | AdaptationSets: []*mpd.AdaptationSet{{ 253 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 254 | MimeType: tc.mimeType, 255 | }, 256 | Representations: []*mpd.Representation{{Bandwidth: tc.bandwidth}}, 257 | }}, 258 | }}, 259 | }, 260 | }, nil) 261 | require.Equal(t, core.Error, report.Severity) 262 | require.Equal(t, tc.message, report.Message) 263 | }) 264 | } 265 | } 266 | 267 | func TestGetResolution(t *testing.T) { 268 | t.Run("attribute of AdaptationSet", func(t *testing.T) { 269 | rsl, err := getResolution(&mpd.AdaptationSet{ 270 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 271 | Width: ptrs.Strptr("1920"), 272 | Height: ptrs.Strptr("1080"), 273 | Sar: ptrs.Strptr("1:1"), 274 | }, 275 | }, &mpd.Representation{}) 276 | require.NoError(t, err) 277 | assert.Equal(t, int64(1920), *rsl.Width) 278 | assert.Equal(t, int64(1080), *rsl.Height) 279 | assert.Equal(t, AspectRatio{X: 1, Y: 1}, rsl.SAR) 280 | }) 281 | 282 | t.Run("attribute of Representation", func(t *testing.T) { 283 | rsl, err := getResolution(&mpd.AdaptationSet{ 284 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 285 | Width: ptrs.Strptr("1920"), 286 | Height: ptrs.Strptr("1080"), 287 | Sar: ptrs.Strptr("1:1"), 288 | }, 289 | }, &mpd.Representation{ 290 | CommonAttributesAndElements: mpd.CommonAttributesAndElements{ 291 | Sar: ptrs.Strptr("1:1"), 292 | }, 293 | Width: ptrs.Int64ptr(1280), 294 | Height: ptrs.Int64ptr(720), 295 | }) 296 | require.NoError(t, err) 297 | assert.Equal(t, int64(1280), *rsl.Width) 298 | assert.Equal(t, int64(720), *rsl.Height) 299 | assert.Equal(t, AspectRatio{X: 1, Y: 1}, rsl.SAR) 300 | }) 301 | } 302 | -------------------------------------------------------------------------------- /inspectors/dash/speed.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/abema/antares/v2/core" 8 | "github.com/abema/antares/v2/inspectors/internal" 9 | ) 10 | 11 | type SpeedInspectorConfig struct { 12 | Interval time.Duration 13 | Warn time.Duration 14 | Error time.Duration 15 | } 16 | 17 | func DefaultSpeedInspectorConfig() *SpeedInspectorConfig { 18 | return &SpeedInspectorConfig{ 19 | Interval: 10 * time.Minute, 20 | Warn: 15 * time.Second, 21 | Error: 30 * time.Second, 22 | } 23 | } 24 | 25 | // NewSpeedInspector returns SpeedInspector. 26 | // It inspects gap between video time and real time. 27 | func NewSpeedInspector() core.DASHInspector { 28 | return NewSpeedInspectorWithConfig(DefaultSpeedInspectorConfig()) 29 | } 30 | 31 | func NewSpeedInspectorWithConfig(config *SpeedInspectorConfig) core.DASHInspector { 32 | return &speedInspector{ 33 | config: config, 34 | meter: internal.NewSpeedometer(config.Interval.Seconds()), 35 | } 36 | } 37 | 38 | type speedInspector struct { 39 | config *SpeedInspectorConfig 40 | meter *internal.Speedometer 41 | } 42 | 43 | func (ins *speedInspector) Inspect(manifest *core.Manifest, segments core.SegmentStore) *core.Report { 44 | if manifest.Type != nil && *manifest.Type == "static" { 45 | return &core.Report{ 46 | Name: "SpeedInspector", 47 | Severity: core.Info, 48 | Message: "skip VOD manifest", 49 | } 50 | } 51 | var videoTime float64 52 | err := manifest.EachSegments(func(segment *core.DASHSegment) (cont bool) { 53 | if segment.Initialization { 54 | return true 55 | } 56 | var periodStart float64 57 | if segment.Period.Start != nil { 58 | periodStart = time.Duration(*segment.Period.Start).Seconds() 59 | } 60 | var offset uint64 61 | if segment.SegmentTemplate.PresentationTimeOffset != nil { 62 | offset = *segment.SegmentTemplate.PresentationTimeOffset 63 | } 64 | timescale := float64(1) 65 | if segment.SegmentTemplate.Timescale != nil { 66 | timescale = float64(*segment.SegmentTemplate.Timescale) 67 | } 68 | t := int64(segment.Time) - int64(offset) + int64(segment.Duration) 69 | vt := periodStart + float64(t)/timescale 70 | if vt > videoTime { 71 | videoTime = vt 72 | } 73 | return true 74 | }) 75 | if err != nil { 76 | return &core.Report{ 77 | Name: "SpeedInspector", 78 | Severity: core.Error, 79 | Message: "unexpected error", 80 | Values: core.Values{"error": err}, 81 | } 82 | } 83 | ins.meter.AddTimePoint(&internal.TimePoint{ 84 | RealTime: float64(manifest.Time.UnixNano()) / 1e9, 85 | VideoTime: videoTime, 86 | }) 87 | if !ins.meter.Satisfied() { 88 | return &core.Report{ 89 | Name: "SpeedInspector", 90 | Severity: core.Info, 91 | Message: "wait for accumulating history", 92 | } 93 | } 94 | gap := ins.meter.Gap() 95 | values := core.Values{ 96 | "gap": gap, 97 | "realTime": ins.meter.RealTimeElapsed(), 98 | "videoTime": ins.meter.VideoTimeElapsed(), 99 | } 100 | if ins.config.Error != 0 && math.Abs(gap) >= ins.config.Error.Seconds() { 101 | return &core.Report{ 102 | Name: "SpeedInspector", 103 | Severity: core.Error, 104 | Message: "large gap between real time and video time", 105 | Values: values, 106 | } 107 | } else if ins.config.Warn != 0 && math.Abs(gap) >= ins.config.Warn.Seconds() { 108 | return &core.Report{ 109 | Name: "SpeedInspector", 110 | Severity: core.Warn, 111 | Message: "large gap between real time and video time", 112 | Values: values, 113 | } 114 | } 115 | return &core.Report{ 116 | Name: "SpeedInspector", 117 | Severity: core.Info, 118 | Message: "good", 119 | Values: values, 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /inspectors/dash/speed_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/abema/antares/v2/core" 8 | "github.com/stretchr/testify/require" 9 | "github.com/zencoder/go-dash/helpers/ptrs" 10 | "github.com/zencoder/go-dash/mpd" 11 | ) 12 | 13 | func TestSpeedInspectorTest(t *testing.T) { 14 | manifest := func(typ string, t int64, start uint64) *core.Manifest { 15 | return &core.Manifest{ 16 | Time: time.Unix(t, 0), 17 | MPD: &mpd.MPD{ 18 | Type: ptrs.Strptr(typ), 19 | Periods: []*mpd.Period{ 20 | { 21 | Start: (*mpd.Duration)(ptrs.Int64ptr(0)), 22 | AdaptationSets: []*mpd.AdaptationSet{ 23 | { 24 | SegmentTemplate: &mpd.SegmentTemplate{ 25 | Timescale: ptrs.Int64ptr(90000), 26 | PresentationTimeOffset: ptrs.Uint64ptr(1100 * 90000), 27 | Initialization: ptrs.Strptr("init.mp4"), 28 | Media: ptrs.Strptr("$Time$.mp4"), 29 | SegmentTimeline: &mpd.SegmentTimeline{ 30 | Segments: []*mpd.SegmentTimelineSegment{ 31 | { 32 | StartTime: ptrs.Uint64ptr(start * 90000), 33 | Duration: 10 * 90000, 34 | RepeatCount: ptrs.Intptr(10), 35 | }, 36 | }, 37 | }, 38 | }, 39 | Representations: []*mpd.Representation{{}}, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | } 46 | } 47 | ins := NewSpeedInspector() 48 | rep := ins.Inspect(manifest("dynamic", 1000, 1000), nil) 49 | require.Equal(t, core.Info, rep.Severity) 50 | rep = ins.Inspect(manifest("dynamic", 1050, 1050), nil) 51 | require.Equal(t, core.Info, rep.Severity) 52 | rep = ins.Inspect(manifest("dynamic", 1100, 1100), nil) 53 | require.Equal(t, core.Info, rep.Severity) 54 | rep = ins.Inspect(manifest("dynamic", 1150, 1130), nil) 55 | require.Equal(t, core.Warn, rep.Severity) 56 | rep = ins.Inspect(manifest("dynamic", 1200, 1170), nil) 57 | require.Equal(t, core.Error, rep.Severity) 58 | rep = ins.Inspect(manifest("static", 1200, 1170), nil) 59 | require.Equal(t, core.Info, rep.Severity) 60 | } 61 | -------------------------------------------------------------------------------- /inspectors/hls/playlist_type.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "github.com/abema/antares/v2/core" 5 | m3u8 "github.com/abema/go-simple-m3u8" 6 | ) 7 | 8 | type PlaylistTypeCondition int 9 | 10 | const ( 11 | PlaylistTypeAny PlaylistTypeCondition = iota 12 | PlaylistTypeMustOmitted 13 | PlaylistTypeMustEvent 14 | PlaylistTypeMustVOD 15 | ) 16 | 17 | type EndlistCondition int 18 | 19 | const ( 20 | EndlistAny EndlistCondition = iota 21 | EndlistMustExist 22 | EndlistMustNotExist 23 | ) 24 | 25 | type PlaylistTypeInspectorConfig struct { 26 | PlaylistTypeCondition 27 | EndlistCondition 28 | } 29 | 30 | // NewPlaylistTypeInspector returns PlaylistTypeInspector. 31 | // It inspects EXT-X-PLAYLIST-TYPE tag. 32 | func NewPlaylistTypeInspector(config *PlaylistTypeInspectorConfig) core.HLSInspector { 33 | return &playlistTypeInspector{ 34 | config: config, 35 | } 36 | } 37 | 38 | type playlistTypeInspector struct { 39 | config *PlaylistTypeInspectorConfig 40 | } 41 | 42 | func (ins *playlistTypeInspector) Inspect(playlists *core.Playlists, segments core.SegmentStore) *core.Report { 43 | var noType bool 44 | var event bool 45 | var vod bool 46 | var endlist bool 47 | var noEndlist bool 48 | for _, media := range playlists.MediaPlaylists { 49 | switch media.Tags.PlaylistType() { 50 | case "": 51 | noType = true 52 | case m3u8.MediaPlaylistTypeEvent: 53 | event = true 54 | case m3u8.MediaPlaylistTypeVOD: 55 | vod = true 56 | } 57 | if media.EndList { 58 | endlist = true 59 | } else { 60 | noEndlist = true 61 | } 62 | } 63 | 64 | values := make(core.Values, 2) 65 | if (noType && event) || (noType && vod) || (event && vod) { 66 | values["playlistType"] = "mixed" 67 | } else if noType { 68 | values["playlistType"] = "not exists" 69 | } else if event { 70 | values["playlistType"] = "EVENT" 71 | } else if vod { 72 | values["playlistType"] = "VOD" 73 | } else { 74 | values["playlistType"] = "n/a" 75 | } 76 | if endlist && noEndlist { 77 | values["endlist"] = "mixed" 78 | } else if endlist { 79 | values["endlist"] = "exists" 80 | } else if noEndlist { 81 | values["endlist"] = "not exists" 82 | } else { 83 | values["endlist"] = "n/a" 84 | } 85 | 86 | switch ins.config.PlaylistTypeCondition { 87 | case PlaylistTypeMustOmitted: 88 | if !noType || event || vod { 89 | return &core.Report{ 90 | Name: "PlaylistTypeInspector", 91 | Severity: core.Error, 92 | Message: "PLAYLIST-TYPE must be omitted", 93 | Values: values, 94 | } 95 | } 96 | case PlaylistTypeMustEvent: 97 | if noType || !event || vod { 98 | return &core.Report{ 99 | Name: "PlaylistTypeInspector", 100 | Severity: core.Error, 101 | Message: "PLAYLIST-TYPE must be EVENT", 102 | Values: values, 103 | } 104 | } 105 | case PlaylistTypeMustVOD: 106 | if noType || event || !vod { 107 | return &core.Report{ 108 | Name: "PlaylistTypeInspector", 109 | Severity: core.Error, 110 | Message: "PLAYLIST-TYPE must be VOD", 111 | Values: values, 112 | } 113 | } 114 | } 115 | switch ins.config.EndlistCondition { 116 | case EndlistMustExist: 117 | if !endlist || noEndlist { 118 | return &core.Report{ 119 | Name: "PlaylistTypeInspector", 120 | Severity: core.Error, 121 | Message: "ENDLIST must exist", 122 | Values: values, 123 | } 124 | } 125 | case EndlistMustNotExist: 126 | if endlist || !noEndlist { 127 | return &core.Report{ 128 | Name: "PlaylistTypeInspector", 129 | Severity: core.Error, 130 | Message: "ENDLIST must not exist", 131 | Values: values, 132 | } 133 | } 134 | } 135 | return &core.Report{ 136 | Name: "PlaylistTypeInspector", 137 | Severity: core.Info, 138 | Message: "good", 139 | Values: values, 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /inspectors/hls/playlist_type_test.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abema/antares/v2/core" 7 | m3u8 "github.com/abema/go-simple-m3u8" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPlaylistTypeInspector(t *testing.T) { 12 | eventTypeTags := m3u8.MediaPlaylistTags{} 13 | eventTypeTags.SetPlaylistType(m3u8.MediaPlaylistTypeEvent) 14 | vodTypeTags := m3u8.MediaPlaylistTags{} 15 | vodTypeTags.SetPlaylistType(m3u8.MediaPlaylistTypeVOD) 16 | 17 | testCases := []struct { 18 | name string 19 | playlistTypeCondition PlaylistTypeCondition 20 | endlistCondition EndlistCondition 21 | mediaPlaylists map[string]*core.MediaPlaylist 22 | severity core.Severity 23 | }{ 24 | { 25 | name: "PlaylistTypeMustOmitted/EndlistMustNotExist/OK", 26 | playlistTypeCondition: PlaylistTypeMustOmitted, 27 | endlistCondition: EndlistMustNotExist, 28 | mediaPlaylists: map[string]*core.MediaPlaylist{ 29 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}}, 30 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}}, 31 | }, 32 | severity: core.Info, 33 | }, 34 | { 35 | name: "PlaylistTypeMustOmitted/EndlistMustNotExist/EndlistError", 36 | playlistTypeCondition: PlaylistTypeMustOmitted, 37 | endlistCondition: EndlistMustNotExist, 38 | mediaPlaylists: map[string]*core.MediaPlaylist{ 39 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}}, 40 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{EndList: true}}, 41 | }, 42 | severity: core.Error, 43 | }, 44 | { 45 | name: "PlaylistTypeMustOmitted/EndlistMustNotExist/PlaylistTypeError", 46 | playlistTypeCondition: PlaylistTypeMustOmitted, 47 | endlistCondition: EndlistMustNotExist, 48 | mediaPlaylists: map[string]*core.MediaPlaylist{ 49 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: eventTypeTags, EndList: false}}, 50 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{EndList: false}}, 51 | }, 52 | severity: core.Error, 53 | }, 54 | { 55 | name: "PlaylistTypeMustVOD/EndlistMustExist/OK", 56 | playlistTypeCondition: PlaylistTypeMustVOD, 57 | endlistCondition: EndlistMustExist, 58 | mediaPlaylists: map[string]*core.MediaPlaylist{ 59 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: true}}, 60 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: true}}, 61 | }, 62 | severity: core.Info, 63 | }, 64 | { 65 | name: "PlaylistTypeMustVOD/EndlistMustExist/EndlistError", 66 | playlistTypeCondition: PlaylistTypeMustVOD, 67 | endlistCondition: EndlistMustExist, 68 | mediaPlaylists: map[string]*core.MediaPlaylist{ 69 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: true}}, 70 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: false}}, 71 | }, 72 | severity: core.Error, 73 | }, 74 | { 75 | name: "PlaylistTypeMustVOD/EndlistAny/OK", 76 | playlistTypeCondition: PlaylistTypeMustVOD, 77 | endlistCondition: EndlistAny, 78 | mediaPlaylists: map[string]*core.MediaPlaylist{ 79 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: true}}, 80 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Tags: vodTypeTags, EndList: false}}, 81 | }, 82 | severity: core.Info, 83 | }, 84 | } 85 | for _, tc := range testCases { 86 | t.Run(tc.name, func(t *testing.T) { 87 | ins := NewPlaylistTypeInspector(&PlaylistTypeInspectorConfig{ 88 | PlaylistTypeCondition: tc.playlistTypeCondition, 89 | EndlistCondition: tc.endlistCondition, 90 | }) 91 | report := ins.Inspect(&core.Playlists{MediaPlaylists: tc.mediaPlaylists}, nil) 92 | require.Equal(t, tc.severity, report.Severity) 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /inspectors/hls/speed.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/abema/antares/v2/core" 8 | "github.com/abema/antares/v2/inspectors/internal" 9 | m3u8 "github.com/abema/go-simple-m3u8" 10 | ) 11 | 12 | type SpeedInspectorConfig struct { 13 | Interval time.Duration 14 | Warn time.Duration 15 | Error time.Duration 16 | } 17 | 18 | func DefaultSpeedInspectorConfig() *SpeedInspectorConfig { 19 | return &SpeedInspectorConfig{ 20 | Interval: 10 * time.Minute, 21 | Warn: 15 * time.Second, 22 | Error: 30 * time.Second, 23 | } 24 | } 25 | 26 | // NewSpeedInspector returns SpeedInspector. 27 | // It inspects gap between video time and real time. 28 | func NewSpeedInspector() core.HLSInspector { 29 | return NewSpeedInspectorWithConfig(DefaultSpeedInspectorConfig()) 30 | } 31 | 32 | func NewSpeedInspectorWithConfig(config *SpeedInspectorConfig) core.HLSInspector { 33 | return &speedInspector{ 34 | config: config, 35 | meters: make(map[string]*internal.Speedometer, 8), 36 | } 37 | } 38 | 39 | type speedInspector struct { 40 | config *SpeedInspectorConfig 41 | meters map[string]*internal.Speedometer 42 | } 43 | 44 | func (ins *speedInspector) Inspect(playlists *core.Playlists, segments core.SegmentStore) *core.Report { 45 | if playlists.IsVOD() { 46 | return &core.Report{ 47 | Name: "SpeedInspector", 48 | Severity: core.Info, 49 | Message: "skip VOD playlist", 50 | } 51 | } 52 | var maxGap float64 53 | var maxGapURL string 54 | var values core.Values 55 | for _, media := range playlists.MediaPlaylists { 56 | if media.EndList { 57 | continue 58 | } 59 | realTime := float64(media.Time.UnixNano()) / 1e9 60 | if len(media.Segments) == 0 { 61 | return &core.Report{ 62 | Name: "SpeedInspector", 63 | Severity: core.Error, 64 | Message: "no segments", 65 | Values: core.Values{"url": media.URL}, 66 | } 67 | } 68 | latest := media.Segments[len(media.Segments)-1] 69 | meter := ins.meters[media.URL] 70 | if meter == nil { 71 | meter = internal.NewSpeedometer(ins.config.Interval.Seconds()) 72 | ins.meters[media.URL] = meter 73 | } 74 | // get last time point 75 | lastTimePoint := meter.LatestTimePoint() 76 | if lastTimePoint == nil { 77 | meter.AddTimePoint(&internal.TimePoint{ 78 | RealTime: realTime, 79 | SegmentID: latest.Sequence, 80 | }) 81 | continue 82 | } 83 | // calculate accumulated video duration after previous latest segment 84 | lastSeq := lastTimePoint.SegmentID.(int64) 85 | dur := ins.duration(media.Segments, lastSeq+1) 86 | // add current time point 87 | meter.AddTimePoint(&internal.TimePoint{ 88 | RealTime: realTime, 89 | VideoTime: lastTimePoint.VideoTime + dur, 90 | SegmentID: latest.Sequence, 91 | }) 92 | if !meter.Satisfied() { 93 | continue 94 | } 95 | // gap between real time and video time 96 | gap := meter.Gap() 97 | if math.Abs(gap) > math.Abs(maxGap) { 98 | maxGap = gap 99 | maxGapURL = media.URL 100 | values = core.Values{ 101 | "gap": gap, 102 | "realTime": meter.RealTimeElapsed(), 103 | "videoTime": meter.VideoTimeElapsed(), 104 | } 105 | } 106 | } 107 | if maxGapURL != "" { 108 | values["url"] = maxGapURL 109 | } 110 | if ins.config.Error != 0 && math.Abs(maxGap) >= ins.config.Error.Seconds() { 111 | return &core.Report{ 112 | Name: "SpeedInspector", 113 | Severity: core.Error, 114 | Message: "large gap between real time and video time", 115 | Values: values, 116 | } 117 | } else if ins.config.Warn != 0 && math.Abs(maxGap) >= ins.config.Warn.Seconds() { 118 | return &core.Report{ 119 | Name: "SpeedInspector", 120 | Severity: core.Warn, 121 | Message: "large gap between real time and video time", 122 | Values: values, 123 | } 124 | } 125 | return &core.Report{ 126 | Name: "SpeedInspector", 127 | Severity: core.Info, 128 | Message: "good", 129 | Values: values, 130 | } 131 | } 132 | 133 | func (ins *speedInspector) duration(segments []*m3u8.Segment, begin int64) float64 { 134 | var dur float64 135 | for _, seg := range segments { 136 | if seg.Sequence >= begin { 137 | dur += seg.Tags.ExtInfValue() 138 | } 139 | } 140 | return dur 141 | } 142 | -------------------------------------------------------------------------------- /inspectors/hls/speed_test.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/abema/antares/v2/core" 8 | m3u8 "github.com/abema/go-simple-m3u8" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSpeedInspectorTest(t *testing.T) { 13 | segments := func(begin int) []*m3u8.Segment { 14 | segments := make([]*m3u8.Segment, 0) 15 | for i := begin; i < begin+10; i++ { 16 | segment := &m3u8.Segment{ 17 | Tags: m3u8.SegmentTags{}, 18 | Sequence: int64(i), 19 | } 20 | segment.Tags.SetExtInfValue(10.0, 64) 21 | segments = append(segments, segment) 22 | } 23 | return segments 24 | } 25 | 26 | ins := NewSpeedInspectorWithConfig(&SpeedInspectorConfig{ 27 | Interval: time.Minute, 28 | Warn: 15 * time.Second, 29 | Error: 30 * time.Second, 30 | }) 31 | 32 | // first 33 | rep := ins.Inspect(&core.Playlists{ 34 | MediaPlaylists: map[string]*core.MediaPlaylist{ 35 | "0.m3u8": { 36 | URL: "https://foo/0.m3u8", 37 | Time: time.Unix(1000, 0), 38 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10)}, 39 | }, 40 | "1.m3u8": { 41 | URL: "https://foo/1.m3u8", 42 | Time: time.Unix(1000, 0), 43 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10)}, 44 | }, 45 | }, 46 | }, nil) 47 | require.Equal(t, core.Info, rep.Severity) 48 | 49 | // 40 seconds later 50 | rep = ins.Inspect(&core.Playlists{ 51 | MediaPlaylists: map[string]*core.MediaPlaylist{ 52 | "0.m3u8": { 53 | URL: "https://foo/0.m3u8", 54 | Time: time.Unix(1040, 0), 55 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(14)}, 56 | }, 57 | "1.m3u8": { 58 | URL: "https://foo/1.m3u8", 59 | Time: time.Unix(1040, 0), 60 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(14)}, 61 | }, 62 | }, 63 | }, nil) 64 | require.Equal(t, core.Info, rep.Severity) 65 | 66 | // 80 seconds later 67 | rep = ins.Inspect(&core.Playlists{ 68 | MediaPlaylists: map[string]*core.MediaPlaylist{ 69 | "0.m3u8": { 70 | URL: "https://foo/0.m3u8", 71 | Time: time.Unix(1080, 0), 72 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(18)}, 73 | }, 74 | "1.m3u8": { 75 | URL: "https://foo/1.m3u8", 76 | Time: time.Unix(1080, 0), 77 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(18)}, 78 | }, 79 | }, 80 | }, nil) 81 | require.Equal(t, core.Info, rep.Severity) 82 | 83 | // 120 seconds later, low speed warning 84 | rep = ins.Inspect(&core.Playlists{ 85 | MediaPlaylists: map[string]*core.MediaPlaylist{ 86 | "0.m3u8": { 87 | URL: "https://foo/0.m3u8", 88 | Time: time.Unix(1120, 0), 89 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(22)}, 90 | }, 91 | "1.m3u8": { 92 | URL: "https://foo/1.m3u8", 93 | Time: time.Unix(1120, 0), 94 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(20)}, 95 | }, 96 | }, 97 | }, nil) 98 | require.Equal(t, core.Warn, rep.Severity) 99 | 100 | // 160 seconds later, low speed error 101 | rep = ins.Inspect(&core.Playlists{ 102 | MediaPlaylists: map[string]*core.MediaPlaylist{ 103 | "0.m3u8": { 104 | URL: "https://foo/0.m3u8", 105 | Time: time.Unix(1160, 0), 106 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(26)}, 107 | }, 108 | "1.m3u8": { 109 | URL: "https://foo/1.m3u8", 110 | Time: time.Unix(1160, 0), 111 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(21)}, 112 | }, 113 | }, 114 | }, nil) 115 | require.Equal(t, core.Error, rep.Severity) 116 | 117 | // 200 seconds later 118 | rep = ins.Inspect(&core.Playlists{ 119 | MediaPlaylists: map[string]*core.MediaPlaylist{ 120 | "0.m3u8": { 121 | URL: "https://foo/0.m3u8", 122 | Time: time.Unix(1200, 0), 123 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(30)}, 124 | }, 125 | "1.m3u8": { 126 | URL: "https://foo/1.m3u8", 127 | Time: time.Unix(1200, 0), 128 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(28)}, 129 | }, 130 | }, 131 | }, nil) 132 | require.Equal(t, core.Info, rep.Severity) 133 | 134 | // 240 seconds later, high speed warning 135 | rep = ins.Inspect(&core.Playlists{ 136 | MediaPlaylists: map[string]*core.MediaPlaylist{ 137 | "0.m3u8": { 138 | URL: "https://foo/0.m3u8", 139 | Time: time.Unix(1240, 0), 140 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(36)}, 141 | }, 142 | "1.m3u8": { 143 | URL: "https://foo/1.m3u8", 144 | Time: time.Unix(1240, 0), 145 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(30)}, 146 | }, 147 | }, 148 | }, nil) 149 | require.Equal(t, core.Warn, rep.Severity) 150 | 151 | // 280 seconds later, high speed warning 152 | rep = ins.Inspect(&core.Playlists{ 153 | MediaPlaylists: map[string]*core.MediaPlaylist{ 154 | "0.m3u8": { 155 | URL: "https://foo/0.m3u8", 156 | Time: time.Unix(1280, 0), 157 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(42)}, 158 | }, 159 | "1.m3u8": { 160 | URL: "https://foo/1.m3u8", 161 | Time: time.Unix(1280, 0), 162 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(36)}, 163 | }, 164 | }, 165 | }, nil) 166 | require.Equal(t, core.Error, rep.Severity) 167 | 168 | // no segments 169 | rep = ins.Inspect(&core.Playlists{ 170 | MediaPlaylists: map[string]*core.MediaPlaylist{ 171 | "0.m3u8": { 172 | URL: "https://foo/0.m3u8", 173 | Time: time.Unix(1280, 0), 174 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil}, 175 | }, 176 | "1.m3u8": { 177 | URL: "https://foo/1.m3u8", 178 | Time: time.Unix(1280, 0), 179 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil}, 180 | }, 181 | }, 182 | }, nil) 183 | require.Equal(t, core.Error, rep.Severity) 184 | 185 | // skip VOD variant 186 | rep = ins.Inspect(&core.Playlists{ 187 | MediaPlaylists: map[string]*core.MediaPlaylist{ 188 | "0.m3u8": { 189 | URL: "https://foo/0.m3u8", 190 | Time: time.Unix(1280, 0), 191 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil, EndList: true}, 192 | }, 193 | "1.m3u8": { 194 | URL: "https://foo/1.m3u8", 195 | Time: time.Unix(1280, 0), 196 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil}, 197 | }, 198 | }, 199 | }, nil) 200 | require.Equal(t, core.Error, rep.Severity) 201 | 202 | // VOD only 203 | rep = ins.Inspect(&core.Playlists{ 204 | MediaPlaylists: map[string]*core.MediaPlaylist{ 205 | "0.m3u8": { 206 | URL: "https://foo/0.m3u8", 207 | Time: time.Unix(1280, 0), 208 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil, EndList: true}, 209 | }, 210 | "1.m3u8": { 211 | URL: "https://foo/1.m3u8", 212 | Time: time.Unix(1280, 0), 213 | MediaPlaylist: &m3u8.MediaPlaylist{Segments: nil, EndList: true}, 214 | }, 215 | }, 216 | }, nil) 217 | require.Equal(t, core.Info, rep.Severity) 218 | } 219 | -------------------------------------------------------------------------------- /inspectors/hls/variants_sync.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/abema/antares/v2/core" 7 | ) 8 | 9 | type VariantsSyncInspectorConfig struct { 10 | WarnSegmentDurationDiff time.Duration 11 | ErrorSegmentDurationDiff time.Duration 12 | WarnSequeceDiff uint 13 | ErrorSequeceDiff uint 14 | } 15 | 16 | func DefaultVariantsSyncInspectorConfig() *VariantsSyncInspectorConfig { 17 | return &VariantsSyncInspectorConfig{ 18 | WarnSegmentDurationDiff: 500 * time.Millisecond, 19 | ErrorSegmentDurationDiff: 1000 * time.Millisecond, 20 | WarnSequeceDiff: 2, 21 | ErrorSequeceDiff: 4, 22 | } 23 | } 24 | 25 | // NewVariantsSyncInspector returns VariantsSyncInspector. 26 | // It inspects synchronization of variant streams. 27 | func NewVariantsSyncInspector() core.HLSInspector { 28 | return NewVariantsSyncInspectorWithConfig(DefaultVariantsSyncInspectorConfig()) 29 | } 30 | 31 | func NewVariantsSyncInspectorWithConfig(config *VariantsSyncInspectorConfig) core.HLSInspector { 32 | return &variantsSyncInspector{ 33 | config: config, 34 | } 35 | } 36 | 37 | type variantsSyncInspector struct { 38 | config *VariantsSyncInspectorConfig 39 | } 40 | 41 | func (ins *variantsSyncInspector) Inspect(playlists *core.Playlists, _ core.SegmentStore) *core.Report { 42 | type SequenceKey struct { 43 | GroupID string 44 | Sequence int64 45 | } 46 | type SequenceValue struct { 47 | MaxDuration float64 48 | MinDuration float64 49 | } 50 | sequenceMap := make(map[SequenceKey]SequenceValue) 51 | type GroupValue struct { 52 | MaxSequence int64 53 | MinSequence int64 54 | } 55 | groupMap := make(map[string]GroupValue) 56 | for _, media := range playlists.MediaPlaylists { 57 | if len(media.Segments) == 0 { 58 | return &core.Report{ 59 | Name: "VariantsSyncInspector", 60 | Severity: core.Info, 61 | Message: "no segments", 62 | } 63 | } 64 | var groupID string 65 | if media.MediaAttrs != nil { 66 | groupID = media.MediaAttrs.GroupID() 67 | } 68 | for _, segment := range media.Segments { 69 | skey := SequenceKey{ 70 | Sequence: segment.Sequence, 71 | GroupID: groupID, 72 | } 73 | sval := sequenceMap[skey] 74 | if sval.MaxDuration == 0 || segment.Tags.ExtInfValue() > sval.MaxDuration { 75 | sval.MaxDuration = segment.Tags.ExtInfValue() 76 | } 77 | if sval.MinDuration == 0 || segment.Tags.ExtInfValue() < sval.MinDuration { 78 | sval.MinDuration = segment.Tags.ExtInfValue() 79 | } 80 | sequenceMap[skey] = sval 81 | } 82 | latest := media.Segments[len(media.Segments)-1] 83 | gval := groupMap[groupID] 84 | if gval.MaxSequence == 0 || latest.Sequence > gval.MaxSequence { 85 | gval.MaxSequence = latest.Sequence 86 | } 87 | if gval.MinSequence == 0 || latest.Sequence < gval.MinSequence { 88 | gval.MinSequence = latest.Sequence 89 | } 90 | groupMap[groupID] = gval 91 | } 92 | var maxDurDiff float64 93 | for _, sval := range sequenceMap { 94 | durDiff := sval.MaxDuration - sval.MinDuration 95 | if durDiff > maxDurDiff { 96 | maxDurDiff = durDiff 97 | } 98 | } 99 | var maxSeqDiff int64 100 | for _, gval := range groupMap { 101 | seqDiff := gval.MaxSequence - gval.MinSequence 102 | if seqDiff > maxSeqDiff { 103 | maxSeqDiff = seqDiff 104 | } 105 | } 106 | values := core.Values{"durDiff": maxDurDiff, "seqDiff": maxSeqDiff} 107 | if ins.config.ErrorSegmentDurationDiff != 0 && maxDurDiff >= ins.config.ErrorSegmentDurationDiff.Seconds() { 108 | return &core.Report{ 109 | Name: "VariantsSyncInspector", 110 | Severity: core.Error, 111 | Message: "large duration difference", 112 | Values: values, 113 | } 114 | } 115 | if ins.config.ErrorSequeceDiff != 0 && maxSeqDiff >= int64(ins.config.ErrorSequeceDiff) { 116 | return &core.Report{ 117 | Name: "VariantsSyncInspector", 118 | Severity: core.Error, 119 | Message: "large sequence difference", 120 | Values: values, 121 | } 122 | } 123 | if ins.config.WarnSegmentDurationDiff != 0 && maxDurDiff >= ins.config.WarnSegmentDurationDiff.Seconds() { 124 | return &core.Report{ 125 | Name: "VariantsSyncInspector", 126 | Severity: core.Warn, 127 | Message: "large duration difference", 128 | Values: values, 129 | } 130 | } 131 | if ins.config.WarnSequeceDiff != 0 && maxSeqDiff >= int64(ins.config.WarnSequeceDiff) { 132 | return &core.Report{ 133 | Name: "VariantsSyncInspector", 134 | Severity: core.Warn, 135 | Message: "large sequence difference", 136 | Values: values, 137 | } 138 | } 139 | return &core.Report{ 140 | Name: "VariantsSyncInspector", 141 | Severity: core.Info, 142 | Message: "good", 143 | Values: values, 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /inspectors/hls/variants_sync_test.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abema/antares/v2/core" 7 | m3u8 "github.com/abema/go-simple-m3u8" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestVariantsSyncInspector(t *testing.T) { 12 | segments := func(begin, end int, dur float64) []*m3u8.Segment { 13 | segments := make([]*m3u8.Segment, 0) 14 | for i := begin; i < end; i++ { 15 | segment := &m3u8.Segment{ 16 | Tags: m3u8.SegmentTags{}, 17 | Sequence: int64(i), 18 | } 19 | segment.Tags.SetExtInfValue(dur, 64) 20 | segments = append(segments, segment) 21 | } 22 | return segments 23 | } 24 | 25 | t.Run("1-segment-late/ok", func(t *testing.T) { 26 | ins := NewVariantsSyncInspector() 27 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 28 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 29 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 19, 10.0)}}, 30 | }}, nil) 31 | require.Equal(t, core.Info, report.Severity) 32 | }) 33 | 34 | t.Run("2-segments-late/warn", func(t *testing.T) { 35 | ins := NewVariantsSyncInspector() 36 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 37 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 38 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 18, 10.0)}}, 39 | }}, nil) 40 | require.Equal(t, core.Warn, report.Severity) 41 | }) 42 | 43 | t.Run("3-segments-late/warn", func(t *testing.T) { 44 | ins := NewVariantsSyncInspector() 45 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 46 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 47 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 17, 10.0)}}, 48 | }}, nil) 49 | require.Equal(t, core.Warn, report.Severity) 50 | }) 51 | 52 | t.Run("4-segments-late/error", func(t *testing.T) { 53 | ins := NewVariantsSyncInspector() 54 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 55 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 56 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 16, 10.0)}}, 57 | }}, nil) 58 | require.Equal(t, core.Error, report.Severity) 59 | }) 60 | 61 | t.Run("400ms-difference/ok", func(t *testing.T) { 62 | ins := NewVariantsSyncInspector() 63 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 64 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 9.6)}}, 65 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 66 | }}, nil) 67 | require.Equal(t, core.Info, report.Severity) 68 | }) 69 | 70 | t.Run("500ms-difference/warn", func(t *testing.T) { 71 | ins := NewVariantsSyncInspector() 72 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 73 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 9.5)}}, 74 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 75 | }}, nil) 76 | require.Equal(t, core.Warn, report.Severity) 77 | }) 78 | 79 | t.Run("900ms-difference/warn", func(t *testing.T) { 80 | ins := NewVariantsSyncInspector() 81 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 82 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 9.1)}}, 83 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 84 | }}, nil) 85 | require.Equal(t, core.Warn, report.Severity) 86 | }) 87 | 88 | t.Run("1000ms-difference/warn", func(t *testing.T) { 89 | ins := NewVariantsSyncInspector() 90 | report := ins.Inspect(&core.Playlists{MediaPlaylists: map[string]*core.MediaPlaylist{ 91 | "0.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 9.0)}}, 92 | "1.m3u8": {MediaPlaylist: &m3u8.MediaPlaylist{Segments: segments(10, 20, 10.0)}}, 93 | }}, nil) 94 | require.Equal(t, core.Error, report.Severity) 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /inspectors/internal/speed.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type TimePoint struct { 4 | RealTime float64 5 | VideoTime float64 6 | SegmentID interface{} 7 | } 8 | 9 | type Speedometer struct { 10 | timePoints []*TimePoint 11 | interval float64 12 | } 13 | 14 | func NewSpeedometer(interval float64) *Speedometer { 15 | return &Speedometer{ 16 | timePoints: make([]*TimePoint, 0, 8), 17 | interval: interval, 18 | } 19 | } 20 | 21 | func (m *Speedometer) LatestTimePoint() *TimePoint { 22 | if len(m.timePoints) == 0 { 23 | return nil 24 | } 25 | return m.timePoints[len(m.timePoints)-1] 26 | } 27 | 28 | func (m *Speedometer) AddTimePoint(tp *TimePoint) { 29 | m.timePoints = append(m.timePoints, tp) 30 | for i := 1; i < len(m.timePoints); i++ { 31 | if m.timePoints[i].RealTime > tp.RealTime-m.interval { 32 | m.timePoints = m.timePoints[i-1:] 33 | break 34 | } 35 | } 36 | } 37 | 38 | func (m *Speedometer) Satisfied() bool { 39 | return len(m.timePoints) >= 2 40 | } 41 | 42 | func (m *Speedometer) Gap() float64 { 43 | return m.VideoTimeElapsed() - m.RealTimeElapsed() 44 | } 45 | 46 | func (m *Speedometer) RealTimeElapsed() float64 { 47 | if len(m.timePoints) < 2 { 48 | return 0 49 | } 50 | old := m.timePoints[0] 51 | curr := m.timePoints[len(m.timePoints)-1] 52 | return curr.RealTime - old.RealTime 53 | } 54 | 55 | func (m *Speedometer) VideoTimeElapsed() float64 { 56 | if len(m.timePoints) < 2 { 57 | return 0 58 | } 59 | old := m.timePoints[0] 60 | curr := m.timePoints[len(m.timePoints)-1] 61 | return curr.VideoTime - old.VideoTime 62 | } 63 | -------------------------------------------------------------------------------- /internal/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | func Open(name string) (*os.File, error) { 9 | return OpenFile(name, os.O_RDONLY, 0) 10 | } 11 | 12 | func Create(name string) (*os.File, error) { 13 | return OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) 14 | } 15 | 16 | func Append(name string) (*os.File, error) { 17 | return OpenFile(name, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) 18 | } 19 | 20 | func OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { 21 | logDir := path.Dir(name) 22 | if err := os.MkdirAll(logDir, 0755); err != nil { 23 | return nil, err 24 | } 25 | return os.OpenFile(name, flag, perm) 26 | } 27 | -------------------------------------------------------------------------------- /internal/strings/strings.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | func ContainsIn(s string, set []string) bool { 4 | for _, t := range set { 5 | if s == t { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | -------------------------------------------------------------------------------- /internal/strings/strings_test.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestContainsIn(t *testing.T) { 10 | set := []string{"foo", "bar", "baz"} 11 | assert.True(t, ContainsIn("foo", set)) 12 | assert.True(t, ContainsIn("bar", set)) 13 | assert.True(t, ContainsIn("baz", set)) 14 | assert.False(t, ContainsIn("qux", set)) 15 | } 16 | -------------------------------------------------------------------------------- /internal/thread/recover.go: -------------------------------------------------------------------------------- 1 | package thread 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | func NoPanic(f func() error) func() error { 9 | return func() (err error) { 10 | defer func() { 11 | err = PanicToError(recover(), err) 12 | }() 13 | return f() 14 | } 15 | } 16 | 17 | func PanicToError(thrown interface{}, defaultErr error) error { 18 | if thrown == nil { 19 | return defaultErr 20 | } 21 | const size = 64 << 10 22 | trace := make([]byte, size) 23 | trace = trace[:runtime.Stack(trace, false)] 24 | err, ok := thrown.(error) 25 | if !ok { 26 | err = fmt.Errorf("panic: %v\n%s", thrown, string(trace)) 27 | } else { 28 | err = fmt.Errorf("panic: %w\n%s", err, string(trace)) 29 | } 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /internal/thread/recover_test.go: -------------------------------------------------------------------------------- 1 | package thread 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNoPanic(t *testing.T) { 13 | t.Run("panic", func(t *testing.T) { 14 | err := NoPanic(func() error { 15 | panic("foo") 16 | })() 17 | require.Error(t, err) 18 | assert.True(t, strings.HasPrefix(err.Error(), "panic: foo\ngoroutine ")) 19 | }) 20 | 21 | t.Run("panic error", func(t *testing.T) { 22 | parent := errors.New("foo") 23 | err := NoPanic(func() error { 24 | panic(parent) 25 | })() 26 | require.Error(t, err) 27 | assert.True(t, strings.HasPrefix(err.Error(), "panic: foo\ngoroutine ")) 28 | assert.True(t, errors.Is(err, parent)) 29 | }) 30 | 31 | t.Run("error", func(t *testing.T) { 32 | err := NoPanic(func() error { 33 | return errors.New("foo") 34 | })() 35 | require.Error(t, err) 36 | assert.Equal(t, "foo", err.Error()) 37 | }) 38 | 39 | t.Run("no error", func(t *testing.T) { 40 | err := NoPanic(func() error { 41 | return nil 42 | })() 43 | require.NoError(t, err) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/url/url.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | ) 7 | 8 | func ResolveReference(base string, rel string) (string, error) { 9 | b, err := url.Parse(base) 10 | if err != nil { 11 | return "", err 12 | } 13 | u, err := b.Parse(rel) 14 | if err != nil { 15 | return "", err 16 | } 17 | return u.String(), nil 18 | } 19 | 20 | func ExtNoError(u string) string { 21 | ext, _ := Ext(u) 22 | return ext 23 | } 24 | 25 | func Ext(u string) (string, error) { 26 | parsed, err := url.Parse(u) 27 | if err != nil { 28 | return "", err 29 | } 30 | return path.Ext(parsed.Path), nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/url/url_test.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestResolveReference(t *testing.T) { 11 | var u string 12 | var err error 13 | u, err = ResolveReference("https://foo.bar/baz/qux.quux", "corge.grault") 14 | require.NoError(t, err) 15 | assert.Equal(t, "https://foo.bar/baz/corge.grault", u) 16 | u, err = ResolveReference("", "https://foo.bar/baz.qux") 17 | require.NoError(t, err) 18 | assert.Equal(t, "https://foo.bar/baz.qux", u) 19 | _, err = ResolveReference(":invalid URL", "https://foo.bar/baz.qux") 20 | assert.Error(t, err) 21 | _, err = ResolveReference("https://foo.bar/baz.qux", ":invalid URL") 22 | assert.Error(t, err) 23 | } 24 | 25 | func TestExtNoError(t *testing.T) { 26 | assert.Equal(t, ".quux", ExtNoError("https://foo.bar/baz/qux.quux")) 27 | assert.Equal(t, ".quux", ExtNoError("https://foo.bar/baz/qux.quux?corge=grault")) 28 | assert.Equal(t, ".quux", ExtNoError("/baz/qux.quux")) 29 | assert.Equal(t, "", ExtNoError("https://foo.bar/baz/qux")) 30 | assert.Equal(t, "", ExtNoError("://foo.bar/baz/qux.quux")) 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/abema/antares/v2/adapters" 17 | "github.com/abema/antares/v2/core" 18 | "github.com/abema/antares/v2/inspectors/dash" 19 | "github.com/abema/antares/v2/inspectors/hls" 20 | "github.com/abema/antares/v2/internal/url" 21 | ) 22 | 23 | var opts struct { 24 | IntervalMs uint 25 | MaxSeconds float64 26 | IsHLS bool 27 | IsDASH bool 28 | HLS struct { 29 | PlaylistType string 30 | Endlist bool 31 | NoEndlist bool 32 | } 33 | DASH struct { 34 | MandatoryMimeTypes string 35 | ValidMimeTypes string 36 | MPDType string 37 | MaxHeight int64 38 | MinHeight int64 39 | ValidPARs string 40 | MaxVideoBandwidth int64 41 | MinVideoBandwidth int64 42 | MaxAudioBandwidth int64 43 | MinAudioBandwidth int64 44 | } 45 | Export struct { 46 | Enable bool 47 | Meta bool 48 | Dir string 49 | } 50 | Segment struct { 51 | Disable bool 52 | MaxBandwidth uint 53 | MinBandwidth uint 54 | } 55 | Log struct { 56 | JSON bool 57 | Severity string 58 | } 59 | HTTP struct { 60 | Header string 61 | } 62 | } 63 | var flagSet *flag.FlagSet 64 | 65 | func main() { 66 | defaultExportDir := "" + time.Now().Format("export-20060102-150405") 67 | flagSet = flag.NewFlagSet("antares", flag.ExitOnError) 68 | flagSet.UintVar(&opts.IntervalMs, "interval", 0, "fixed manifest polling interval (milliseconds).") 69 | flagSet.Float64Var(&opts.MaxSeconds, "maxSeconds", 0, "maximum seconds to monitor.") 70 | flagSet.BoolVar(&opts.IsHLS, "hls", false, "This flag indicates URL argument is HLS.") 71 | flagSet.BoolVar(&opts.IsDASH, "dash", false, "This flag indicates URL argument is DASH.") 72 | flagSet.BoolVar(&opts.Export.Enable, "export", false, "Export raw data as local files.") 73 | flagSet.StringVar(&opts.HLS.PlaylistType, "hls.playlistType", "", "PLAYLIST-TYPE tag status (omitted|event|vod)") 74 | flagSet.BoolVar(&opts.HLS.Endlist, "hls.endlist", false, "If true, playlist must have ENDLIST tag.") 75 | flagSet.BoolVar(&opts.HLS.NoEndlist, "hls.noEndlist", false, "If true, playlist must have no ENDLIST tag.") 76 | flagSet.StringVar(&opts.DASH.MandatoryMimeTypes, "dash.mandatoryMimeTypes", "", "comma-separated list of mandatory mimeType attribute values.") 77 | flagSet.StringVar(&opts.DASH.ValidMimeTypes, "dash.validMimeTypes", "", "comma-separated list of valid mimeType attribute values.") 78 | flagSet.StringVar(&opts.DASH.MPDType, "dash.mpdType", "", "expected MPD@type attribute value. (static|dynamic)") 79 | flagSet.Int64Var(&opts.DASH.MaxHeight, "dash.maxHeight", 0, "maximum value of height.") 80 | flagSet.Int64Var(&opts.DASH.MinHeight, "dash.minHeight", 0, "minimum value of height.") 81 | flagSet.StringVar(&opts.DASH.ValidPARs, "dash.validPARs", "", "picture aspect ratio which calculated by width, height and sar. (ex: \"16:9,4:3\")") 82 | flagSet.Int64Var(&opts.DASH.MaxVideoBandwidth, "dash.maxVideoBandwidth", 0, "maximum value of video bandwidth.") 83 | flagSet.Int64Var(&opts.DASH.MinVideoBandwidth, "dash.minVideoBandwidth", 0, "minimum value of video bandwidth.") 84 | flagSet.Int64Var(&opts.DASH.MaxAudioBandwidth, "dash.maxAudioBandwidth", 0, "maximum value of audio bandwidth.") 85 | flagSet.Int64Var(&opts.DASH.MinAudioBandwidth, "dash.minAudioBandwidth", 0, "minimum value of audio bandwidth.") 86 | flagSet.BoolVar(&opts.Export.Meta, "export.meta", false, "Export metadatas with raw files.") 87 | flagSet.StringVar(&opts.Export.Dir, "export.dir", defaultExportDir, "an export directory") 88 | flagSet.BoolVar(&opts.Segment.Disable, "segment.disable", false, "Disable segment download.") 89 | flagSet.UintVar(&opts.Segment.MaxBandwidth, "segment.maxBandwidth", 0, "max-bandwidth segment filter") 90 | flagSet.UintVar(&opts.Segment.MinBandwidth, "segment.minBandwidth", 0, "min-bandwidth segment filter") 91 | flagSet.BoolVar(&opts.Log.JSON, "log.json", false, "JSON log format") 92 | flagSet.StringVar(&opts.Log.Severity, "log.severity", "info", "log severity (info|warn|error)") 93 | flagSet.StringVar(&opts.HTTP.Header, "http.head", "", "file name of custom request header.") 94 | flagSet.Parse(os.Args[1:]) 95 | 96 | if len(flagSet.Args()) != 1 { 97 | invalidArguments("URL must be specified") 98 | } 99 | 100 | terminated := make(chan struct{}) 101 | 102 | u := flagSet.Args()[0] 103 | streamType := getStreamType(u) 104 | config := core.NewConfig(u, streamType) 105 | if opts.IntervalMs != 0 { 106 | config.DefaultInterval = time.Millisecond * time.Duration(opts.IntervalMs) 107 | } else { 108 | config.PrioritizeSuggestedInterval = true 109 | } 110 | config.SegmentFilter = segmentFilter() 111 | config.TerminateIfVOD = true 112 | if opts.Export.Enable { 113 | config.OnDownload = adapters.LocalFileExporter(opts.Export.Dir, opts.Export.Meta) 114 | } 115 | config.OnReport = buildOnReportHandler() 116 | config.OnTerminate = func() { 117 | terminated <- struct{}{} 118 | } 119 | switch streamType { 120 | case core.StreamTypeHLS: 121 | config.HLS = hlsConfig() 122 | case core.StreamTypeDASH: 123 | config.DASH = dashConfig() 124 | } 125 | config.RequestHeader = buildRequestHeader() 126 | m := core.NewMonitor(config) 127 | 128 | var timeout <-chan time.Time 129 | if opts.MaxSeconds != 0 { 130 | timeout = time.After(time.Second * time.Duration(opts.MaxSeconds)) 131 | } 132 | 133 | sigCh := make(chan os.Signal, 1) 134 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 135 | select { 136 | case <-timeout: 137 | log.Print("reached maxSeconds") 138 | case <-terminated: 139 | case sig := <-sigCh: 140 | log.Print("SIGNAL:", sig) 141 | m.Terminate() 142 | <-terminated 143 | } 144 | log.Print("terminated") 145 | } 146 | 147 | func getStreamType(u string) core.StreamType { 148 | if opts.IsHLS { 149 | return core.StreamTypeHLS 150 | } else if opts.IsDASH { 151 | return core.StreamTypeDASH 152 | } else { 153 | switch url.ExtNoError(u) { 154 | case ".m3u8": 155 | return core.StreamTypeHLS 156 | case ".mpd": 157 | return core.StreamTypeDASH 158 | default: 159 | invalidArguments("if the extension is neither .m3u8 or .mpd, you must use -hls or -dash option") 160 | } 161 | } 162 | return 0 163 | } 164 | 165 | func segmentFilter() core.SegmentFilter { 166 | filters := make([]core.SegmentFilter, 0) 167 | if opts.Segment.Disable { 168 | filters = append(filters, core.AllSegmentRejectionFilter()) 169 | } else { 170 | if opts.Segment.MaxBandwidth != 0 { 171 | filters = append(filters, core.MaxBandwidthSegmentFilter(int64(opts.Segment.MaxBandwidth))) 172 | } 173 | if opts.Segment.MinBandwidth != 0 { 174 | filters = append(filters, core.MaxBandwidthSegmentFilter(int64(opts.Segment.MinBandwidth))) 175 | } 176 | } 177 | return core.SegmentFilterAnd(filters...) 178 | } 179 | 180 | func hlsConfig() *core.HLSConfig { 181 | inspectors := []core.HLSInspector{ 182 | hls.NewSpeedInspector(), 183 | hls.NewVariantsSyncInspector(), 184 | } 185 | if opts.HLS.PlaylistType != "" || opts.HLS.NoEndlist { 186 | config := new(hls.PlaylistTypeInspectorConfig) 187 | switch opts.HLS.PlaylistType { 188 | case "omitted": 189 | config.PlaylistTypeCondition = hls.PlaylistTypeMustOmitted 190 | case "event": 191 | config.PlaylistTypeCondition = hls.PlaylistTypeMustEvent 192 | case "vod": 193 | config.PlaylistTypeCondition = hls.PlaylistTypeMustVOD 194 | default: 195 | invalidArguments("unknown playlist type: %s", opts.HLS.PlaylistType) 196 | } 197 | if opts.HLS.Endlist && opts.HLS.NoEndlist { 198 | invalidArguments("only one of -hls.endlist or -hls.noEndlist can be true") 199 | } else if opts.HLS.Endlist { 200 | config.EndlistCondition = hls.EndlistMustExist 201 | } else if opts.HLS.NoEndlist { 202 | config.EndlistCondition = hls.EndlistMustNotExist 203 | } 204 | inspectors = append(inspectors, hls.NewPlaylistTypeInspector(config)) 205 | } 206 | return &core.HLSConfig{ 207 | Inspectors: inspectors, 208 | } 209 | } 210 | 211 | func dashConfig() *core.DASHConfig { 212 | inspectors := []core.DASHInspector{ 213 | dash.NewSpeedInspector(), 214 | dash.NewPresentationDelayInspector(), 215 | } 216 | if opts.DASH.MandatoryMimeTypes != "" || opts.DASH.ValidMimeTypes != "" { 217 | inspectors = append(inspectors, dash.NewAdaptationSetInspector(&dash.AdaptationSetInspectorConfig{ 218 | MandatoryMimeTypes: strings.Split(opts.DASH.MandatoryMimeTypes, ","), 219 | ValidMimeTypes: strings.Split(opts.DASH.ValidMimeTypes, ","), 220 | })) 221 | } 222 | if opts.DASH.MPDType != "" { 223 | inspectors = append(inspectors, dash.NewMPDTypeInspector(opts.DASH.MPDType)) 224 | } 225 | if opts.DASH.MaxHeight != 0 || opts.DASH.MinHeight != 0 || opts.DASH.ValidPARs != "" || 226 | opts.DASH.MaxVideoBandwidth != 0 || opts.DASH.MinVideoBandwidth != 0 || 227 | opts.DASH.MaxAudioBandwidth != 0 || opts.DASH.MinAudioBandwidth != 0 { 228 | inspectors = append(inspectors, dash.NewRepresentationInspector(&dash.RepresentationInspectorConfig{ 229 | ErrorMaxHeight: opts.DASH.MaxHeight, 230 | ErrorMinHeight: opts.DASH.MinHeight, 231 | ValidPARs: buildAspectRatios(opts.DASH.ValidPARs), 232 | ErrorMaxVideoBandwidth: opts.DASH.MaxVideoBandwidth, 233 | ErrorMinVideoBandwidth: opts.DASH.MinVideoBandwidth, 234 | ErrorMaxAudioBandwidth: opts.DASH.MaxAudioBandwidth, 235 | ErrorMinAudioBandwidth: opts.DASH.MinAudioBandwidth, 236 | })) 237 | } 238 | return &core.DASHConfig{ 239 | Inspectors: inspectors, 240 | } 241 | } 242 | 243 | func buildAspectRatios(ars string) []dash.AspectRatio { 244 | aspectRatios := make([]dash.AspectRatio, 0) 245 | for _, ar := range strings.Split(ars, ",") { 246 | aspectRatio, err := dash.ParseAspectRatio(ar) 247 | if err != nil { 248 | invalidArguments("invalid aspect ratio format: %s", ars) 249 | } 250 | aspectRatios = append(aspectRatios, aspectRatio) 251 | } 252 | return aspectRatios 253 | } 254 | 255 | func buildOnReportHandler() core.OnReportHandler { 256 | var severity core.Severity 257 | switch opts.Log.Severity { 258 | case "info": 259 | severity = core.Info 260 | case "warn": 261 | severity = core.Warn 262 | case "error": 263 | severity = core.Error 264 | default: 265 | invalidArguments("invalid log severity: %s", opts.Log.Severity) 266 | } 267 | return adapters.ReportLogger(&adapters.ReportLogConfig{ 268 | Flag: log.LstdFlags, 269 | Summary: true, 270 | JSON: opts.Log.JSON, 271 | Severity: severity, 272 | }, os.Stdout) 273 | } 274 | 275 | func buildRequestHeader() http.Header { 276 | if opts.HTTP.Header == "" { 277 | return http.Header{} 278 | } 279 | file, err := os.Open(opts.HTTP.Header) 280 | if err != nil { 281 | invalidArguments("file not found: %s", opts.HTTP.Header) 282 | } 283 | defer file.Close() 284 | r := bufio.NewReader(file) 285 | header := make(http.Header) 286 | for { 287 | line, _, err := r.ReadLine() 288 | if err == io.EOF { 289 | return header 290 | } 291 | if err != nil { 292 | panic(err) 293 | } 294 | s := strings.SplitN(string(line), ":", 2) 295 | if len(s) == 2 { 296 | header.Add(s[0], strings.TrimLeft(s[1], " ")) 297 | } 298 | } 299 | } 300 | 301 | func printUsage() { 302 | println("USAGE: antares monitor [OPTIONS] URL") 303 | println() 304 | println("OPTIONS:") 305 | flagSet.PrintDefaults() 306 | } 307 | 308 | func invalidArguments(format string, args ...interface{}) { 309 | println("ERROR: invalid arguments:", fmt.Sprintf(format, args...)) 310 | println() 311 | println("HELP: antares -h") 312 | os.Exit(1) 313 | } 314 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/abema/antares/v2/core" 7 | ) 8 | 9 | type Config struct { 10 | AutoRemove bool 11 | } 12 | 13 | type Manager interface { 14 | Add(id string, config *core.Config) bool 15 | Remove(id string) bool 16 | RemoveAll() []string 17 | Batch(map[string]*core.Config) (added, removed []string) 18 | Get(id string) core.Monitor 19 | Map() map[string]core.Monitor 20 | } 21 | 22 | func NewManager(config *Config) Manager { 23 | return &manager{ 24 | config: config, 25 | monitors: make(map[string]core.Monitor), 26 | } 27 | } 28 | 29 | type manager struct { 30 | config *Config 31 | monitors map[string]core.Monitor 32 | mutex sync.RWMutex 33 | } 34 | 35 | func (m *manager) Add(id string, config *core.Config) bool { 36 | m.mutex.Lock() 37 | defer m.mutex.Unlock() 38 | return m.add(id, config) 39 | } 40 | 41 | func (m *manager) add(id string, config *core.Config) bool { 42 | if _, exists := m.monitors[id]; exists { 43 | return false 44 | } 45 | if m.config != nil && m.config.AutoRemove { 46 | orgOnTerminate := config.OnTerminate 47 | copied := *config 48 | config = &copied 49 | config.OnTerminate = func() { 50 | m.Remove(id) 51 | if orgOnTerminate != nil { 52 | orgOnTerminate() 53 | } 54 | } 55 | } 56 | m.monitors[id] = core.NewMonitor(config) 57 | return true 58 | } 59 | 60 | func (m *manager) Remove(id string) bool { 61 | m.mutex.Lock() 62 | defer m.mutex.Unlock() 63 | return m.remove(id) 64 | } 65 | 66 | func (m *manager) remove(id string) bool { 67 | monitor, exists := m.monitors[id] 68 | if !exists { 69 | return false 70 | } 71 | delete(m.monitors, id) 72 | go func() { 73 | monitor.Terminate() 74 | }() 75 | return true 76 | } 77 | 78 | func (m *manager) RemoveAll() []string { 79 | m.mutex.Lock() 80 | defer m.mutex.Unlock() 81 | removed := make([]string, 0, 4) 82 | for id := range m.monitors { 83 | m.remove(id) 84 | removed = append(removed, id) 85 | } 86 | return removed 87 | } 88 | 89 | func (m *manager) Batch(configs map[string]*core.Config) (added, removed []string) { 90 | added = make([]string, 0, 4) 91 | removed = make([]string, 0, 4) 92 | m.mutex.Lock() 93 | defer m.mutex.Unlock() 94 | for id := range m.monitors { 95 | if _, ok := configs[id]; !ok { 96 | m.remove(id) 97 | removed = append(removed, id) 98 | } 99 | } 100 | for id, config := range configs { 101 | if m.add(id, config) { 102 | added = append(added, id) 103 | } 104 | } 105 | return 106 | } 107 | 108 | func (m *manager) Get(id string) core.Monitor { 109 | m.mutex.RLock() 110 | defer m.mutex.RUnlock() 111 | return m.monitors[id] 112 | } 113 | 114 | func (m *manager) Map() map[string]core.Monitor { 115 | m.mutex.RLock() 116 | defer m.mutex.RUnlock() 117 | copied := make(map[string]core.Monitor, len(m.monitors)) 118 | for id, monitor := range m.monitors { 119 | copied[id] = monitor 120 | } 121 | return copied 122 | } 123 | -------------------------------------------------------------------------------- /manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "sort" 7 | "testing" 8 | "time" 9 | 10 | "github.com/abema/antares/v2/core" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestManager(t *testing.T) { 15 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.Write([]byte("#EXTM3U\n")) 17 | })) 18 | m := NewManager(&Config{}) 19 | m.Add("a", core.NewConfig(server.URL, core.StreamTypeHLS)) 20 | require.NotNil(t, m.Get("a")) 21 | require.Nil(t, m.Get("b")) 22 | m.Add("b", core.NewConfig(server.URL, core.StreamTypeHLS)) 23 | require.NotNil(t, m.Get("a")) 24 | require.NotNil(t, m.Get("b")) 25 | m.Get("a").Terminate() 26 | require.NotNil(t, m.Get("a")) 27 | added, removed := m.Batch(map[string]*core.Config{ 28 | "b": core.NewConfig(server.URL, core.StreamTypeHLS), 29 | "c": core.NewConfig(server.URL, core.StreamTypeHLS), 30 | "d": core.NewConfig(server.URL, core.StreamTypeHLS), 31 | }) 32 | sort.Strings(added) 33 | sort.Strings(removed) 34 | require.Equal(t, []string{"c", "d"}, added) 35 | require.Equal(t, []string{"a"}, removed) 36 | require.Nil(t, m.Get("a")) 37 | require.NotNil(t, m.Get("b")) 38 | require.NotNil(t, m.Get("c")) 39 | require.NotNil(t, m.Get("d")) 40 | require.Len(t, m.Map(), 3) 41 | m.Remove("b") 42 | require.Nil(t, m.Get("b")) 43 | removed = m.RemoveAll() 44 | sort.Strings(removed) 45 | require.Equal(t, []string{"c", "d"}, removed) 46 | require.Empty(t, m.Map()) 47 | } 48 | 49 | func TestManagerWithAutoRemove(t *testing.T) { 50 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | w.Write([]byte("#EXTM3U\n")) 52 | })) 53 | m := NewManager(&Config{AutoRemove: true}) 54 | m.Add("a", core.NewConfig(server.URL, core.StreamTypeHLS)) 55 | require.NotNil(t, m.Get("a")) 56 | m.Get("a").Terminate() 57 | time.Sleep(10 * time.Millisecond) 58 | require.Nil(t, m.Get("a")) 59 | } 60 | --------------------------------------------------------------------------------