From 241ee4164ed48e9ece03f46a1830792392c97775 Mon Sep 17 00:00:00 2001 From: WeebDataHoarder <57538841+WeebDataHoarder@users.noreply.github.com> Date: Sat, 21 Oct 2023 23:00:19 +0200 Subject: [PATCH] Implemented encode server --- cli/encode-server/encode.go | 113 +++++++++++ cli/encode-server/job.go | 127 ++++++++++++ cli/encode-server/main.go | 381 ++++++++++++++++++++++++++++++++++++ cli/encode-utils/config.go | 17 ++ color/space.go | 27 +++ encoder/encoder.go | 2 +- frame/stream.go | 12 +- go.mod | 8 +- go.sum | 15 ++ utilities/ratio.go | 19 +- 10 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 cli/encode-server/encode.go create mode 100644 cli/encode-server/job.go create mode 100644 cli/encode-server/main.go create mode 100644 cli/encode-utils/config.go diff --git a/cli/encode-server/encode.go b/cli/encode-server/encode.go new file mode 100644 index 0000000..cb359fe --- /dev/null +++ b/cli/encode-server/encode.go @@ -0,0 +1,113 @@ +package main + +import ( + "compress/bzip2" + "compress/flate" + "compress/gzip" + "errors" + "fmt" + "git.gammaspectra.live/S.O.N.G/Ignite/decoder/y4m" + "git.gammaspectra.live/S.O.N.G/Ignite/frame" + "github.com/ulikunitz/xz" + "io" + "log" + "net/http" + "os" + "time" +) + +func handleDecompress(contentEncoding string, reader io.ReadCloser) (io.ReadCloser, error) { + var err error + switch contentEncoding { + case "gzip": + reader, err = gzip.NewReader(reader) + if err != nil { + return nil, err + } + case "bzip2": + reader = io.NopCloser(bzip2.NewReader(reader)) + case "deflate": + reader = flate.NewReader(reader) + case "xz": + r, err := xz.NewReader(reader) + if err != nil { + return nil, err + } + reader = io.NopCloser(r) + case "": + return reader, nil + default: + return nil, errors.New("unsupported encoding") + } + return reader, nil +} + +func encodeFromReader(reader io.ReadCloser, job *Job, w http.ResponseWriter) { + defer reader.Close() + defer func() { + log.Printf("[job %s] finished, took %s", job.Id, time.Now().Sub(time.Unix(int64(job.Status.Start.Load()), 0))) + }() + + job.Status.Start.Store(uint64(time.Now().Unix())) + job.Status.Read.Store(0) + job.Status.Processed.Store(0) + + decoder, err := y4m.NewDecoder(reader, nil) + if err != nil { + w.Header().Set("x-encoder-error", "") + w.Header().Set("x-decoder-error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + //check decoder frame properties match + if decoder.Properties() != job.Config.Properties { + w.Header().Set("x-encoder-error", "") + w.Header().Set("x-decoder-error", "mismatched config properties") + w.WriteHeader(http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Add("Trailer", "x-encoder-error, x-decoder-error") + w.WriteHeader(http.StatusOK) + job.Logger = log.New(os.Stderr, fmt.Sprintf("[job %s] ", job.Id), log.LstdFlags) + err = job.Init(w) + if err != nil { + w.Header().Set("x-encoder-error", err.Error()) + w.Header().Set("x-decoder-error", "") + return + } + defer job.Close() + + var f frame.Frame + for { + f, err = decoder.Decode() + if err != nil { + if errors.Is(err, io.EOF) { + //we are done + break + } + w.Header().Set("x-encoder-error", "") + w.Header().Set("x-decoder-error", err.Error()) + return + } + job.Status.Read.Add(1) + + err = job.Encoder.Encode(f) + if err != nil { + w.Header().Set("x-encoder-error", err.Error()) + w.Header().Set("x-decoder-error", "") + return + } + job.Status.Processed.Add(1) + //log.Printf("[job %s] %d", job.Id, job.Status.Read.Load()) + } + + err = job.Encoder.Flush() + if err != nil { + w.Header().Set("x-encoder-error", err.Error()) + w.Header().Set("x-decoder-error", "") + return + } +} diff --git a/cli/encode-server/job.go b/cli/encode-server/job.go new file mode 100644 index 0000000..68d1db7 --- /dev/null +++ b/cli/encode-server/job.go @@ -0,0 +1,127 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + encode_utils "git.gammaspectra.live/S.O.N.G/Ignite/cli/encode-utils" + "git.gammaspectra.live/S.O.N.G/Ignite/encoder" + "git.gammaspectra.live/S.O.N.G/Ignite/encoder/libaom" + "git.gammaspectra.live/S.O.N.G/Ignite/encoder/libx264" + "git.gammaspectra.live/S.O.N.G/Ignite/utilities" + "io" + "maps" + "slices" + "sync" + "sync/atomic" +) + +var JobsMutex sync.Mutex + +var jobs []*Job + +type JobId string + +type Job struct { + Id JobId `json:"id" yaml:"id"` + Config encode_utils.JobConfig `json:"config" yaml:"config"` + Logger utilities.Logger `json:"-" yaml:"-"` + Encoder encoder.Encoder `json:"-" yaml:"-"` + sync.Mutex `json:"-" yaml:"-"` + Status struct { + Start atomic.Uint64 + Read atomic.Uint64 + Processed atomic.Uint64 + } `json:"-" yaml:"-"` +} + +func (j *Job) Init(w io.Writer) error { + if j.Encoder != nil { + return errors.New("already initialized") + } + + // Do not modify original settings + settings := maps.Clone(j.Config.Encoder.Settings) + + switch j.Config.Encoder.Name { + case encode_utils.EncoderX264: + x264enc, err := libx264.NewEncoder(w, j.Config.Properties, settings, j.Logger) + if err != nil { + return err + } + j.Encoder = x264enc + case encode_utils.EncoderAOM: + aomenc, err := libaom.NewEncoder(w, j.Config.Properties, settings) + if err != nil { + return err + } + j.Encoder = aomenc + default: + return errors.New("encoder not supported") + } + + return nil +} + +func (j *Job) Close() error { + if j.Encoder != nil { + j.Encoder.Close() + j.Encoder = nil + } + + return nil +} + +// CreateJob Creates a Job. JobsMutex must be held +func CreateJob(cfg encode_utils.JobConfig) (*Job, error) { + job := &Job{ + Config: cfg, + Logger: utilities.DefaultLogger(), + } + var idBuf [32]byte + _, err := io.ReadFull(rand.Reader, idBuf[:]) + if err != nil { + return nil, err + } + job.Id = JobId(base64.RawURLEncoding.EncodeToString(idBuf[:])) + + if slices.ContainsFunc(jobs, func(j *Job) bool { + return j.Id == job.Id + }) { + return nil, errors.New("job already exists") + } + + jobs = append(jobs, job) + + return job, nil +} + +// GetJob Removes a Job. +func GetJob(jobId JobId) *Job { + JobsMutex.Lock() + defer JobsMutex.Unlock() + if i := slices.IndexFunc(jobs, func(j *Job) bool { + return j.Id == jobId + }); i != -1 { + return jobs[i] + } + return nil +} + +// RemoveJob Removes a Job +func RemoveJob(jobId JobId) error { + JobsMutex.Lock() + defer JobsMutex.Unlock() + if i := slices.IndexFunc(jobs, func(j *Job) bool { + return j.Id == jobId + }); i == -1 { + return errors.New("job does not exist") + } else { + err := jobs[i].Close() + if err != nil { + return err + } + jobs = slices.Delete(jobs, i, i+1) + return nil + } +} diff --git a/cli/encode-server/main.go b/cli/encode-server/main.go new file mode 100644 index 0000000..c94303d --- /dev/null +++ b/cli/encode-server/main.go @@ -0,0 +1,381 @@ +package main + +import ( + "encoding/json" + "flag" + encode_utils "git.gammaspectra.live/S.O.N.G/Ignite/cli/encode-utils" + "git.gammaspectra.live/S.O.N.G/Ignite/encoder/libaom" + "git.gammaspectra.live/S.O.N.G/Ignite/encoder/libx264" + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/host" + "github.com/shirou/gopsutil/mem" + "gopkg.in/yaml.v3" + "io" + "log" + "net/http" + "net/url" + "path" + "runtime" + "slices" + "strconv" + "strings" + "time" +) + +func main() { + maxJobs := flag.Uint64("max-jobs", 8, "Maximum number of active jobs") + authKeysStr := flag.String("authKeys", "", "Allowed auth keys, comma separated. Leave empty to disable") + listenAddr := flag.String("listen", "0.0.0.0:8383", "Listen address for the server") + flag.Parse() + + serveMux := http.NewServeMux() + + var authKeys []string + if strings.TrimSpace(*authKeysStr) != "" { + authKeys = strings.Split(*authKeysStr, ",") + } + + type encoderData struct { + Name string + Version string + } + + writeHeaders := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-encoder-max-jobs", strconv.FormatUint(*maxJobs, 10)) + w.Header().Set("x-encoder-current-jobs", strconv.FormatUint(uint64(len(jobs)), 10)) + } + + serveMux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + var statusData struct { + Encoders []encoderData `json:"encoders"` + Host *host.InfoStat `json:"host,omitempty"` + CPU []cpu.InfoStat `json:"cpu"` + Memory *mem.VirtualMemoryStat `json:"memory,omitempty"` + } + + statusData.Encoders = append(statusData.Encoders, encoderData{ + Name: encode_utils.EncoderX264, + Version: libx264.Version(), + }) + statusData.Encoders = append(statusData.Encoders, encoderData{ + Name: encode_utils.EncoderAOM, + Version: libaom.Version(), + }) + + cpuInfo, err := cpu.Info() + if err != nil { + //fallback + statusData.CPU = make([]cpu.InfoStat, runtime.NumCPU()) + } else { + statusData.CPU = cpuInfo + } + + hostInfo, err := host.Info() + if err == nil { + statusData.Host = hostInfo + } + + memInfo, err := mem.VirtualMemory() + if err == nil { + statusData.Memory = memInfo + } + + data, err := json.Marshal(statusData) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + JobsMutex.Lock() + defer JobsMutex.Unlock() + writeHeaders(w, r) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.FormatUint(uint64(len(data)), 10)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + }) + + serveMux.HandleFunc("/create", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + buf, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + + var cfg encode_utils.JobConfig + + err = yaml.Unmarshal(buf, &cfg) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + + if job := func() *Job { + JobsMutex.Lock() + defer JobsMutex.Unlock() + if uint64(len(jobs)) >= *maxJobs { + writeHeaders(w, r) + w.WriteHeader(http.StatusTooManyRequests) + return nil + } + + job, err := CreateJob(cfg) + if err != nil { + writeHeaders(w, r) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return nil + } + + writeHeaders(w, r) + return job + }(); job == nil { + return + } else { + job.Lock() + defer job.Unlock() + data, err := json.Marshal(job) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.FormatUint(uint64(len(data)), 10)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + log.Printf("[job %s] created", job.Id) + return + } + }) + + serveMux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + + jobId := query.Get("jobId") + job := GetJob(JobId(jobId)) + if job == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if !job.TryLock() { + w.WriteHeader(http.StatusLocked) + return + } + defer job.Unlock() + + if r.Header.Get("Content-Type") != "video/x-yuv4mpeg2" { + w.WriteHeader(http.StatusBadRequest) + return + } + + reader, err := handleDecompress(r.Header.Get("Content-Encoding"), r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + defer r.Body.Close() + + log.Printf("[job %s] started POST", job.Id) + encodeFromReader(reader, job, w) + }) + + serveMux.HandleFunc("/startURL", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + + jobId := query.Get("jobId") + job := GetJob(JobId(jobId)) + if job == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if !job.TryLock() { + w.WriteHeader(http.StatusLocked) + return + } + defer job.Unlock() + + urlVal, err := url.Parse(query.Get("url")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + + response, err := http.DefaultClient.Do(&http.Request{ + Method: "GET", + URL: urlVal, + Header: http.Header{ + "Accept-Encoding": {"gzip, deflate, bzip2, xz"}, + }, + }) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + defer io.ReadAll(response.Body) + defer response.Body.Close() + + reader := response.Body + if response.Header.Get("Content-Encoding") != "" { + reader, err = handleDecompress(response.Header.Get("Content-Encoding"), reader) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + } + + // Handle filenames + switch strings.ToLower(path.Ext(urlVal.Path)) { + case "gz": + reader, err = handleDecompress("gz", reader) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + case "bz2", "bzip2": + reader, err = handleDecompress("bz2", reader) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + case "xz": + reader, err = handleDecompress("xz", reader) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + } + + log.Printf("[job %s] started URL", job.Id) + encodeFromReader(reader, job, w) + }) + + serveMux.HandleFunc("/job", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + query := r.URL.Query() + + jobId := query.Get("jobId") + job := GetJob(JobId(jobId)) + if job == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + var jobData struct { + Start uint64 `json:"start"` + Read uint64 `json:"in"` + Processed uint64 `json:"out"` + } + jobData.Start = job.Status.Start.Load() + jobData.Read = job.Status.Read.Load() + jobData.Processed = job.Status.Processed.Load() + + data, err := json.Marshal(jobData) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.FormatUint(uint64(len(data)), 10)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + }) + + serveMux.HandleFunc("/remove", func(w http.ResponseWriter, r *http.Request) { + if len(authKeys) > 0 && !slices.Contains(authKeys, r.URL.Query().Get("k")) { + w.WriteHeader(http.StatusForbidden) + return + } + + query := r.URL.Query() + + jobId := query.Get("jobId") + job := GetJob(JobId(jobId)) + if job == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if !job.TryLock() { + w.WriteHeader(http.StatusLocked) + return + } + defer job.Unlock() + + err := job.Close() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + err = RemoveJob(job.Id) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + log.Printf("[job %s] removed", job.Id) + }) + + s := http.Server{ + ReadTimeout: time.Second * 10, + IdleTimeout: time.Second * 60, + WriteTimeout: 0, + Addr: *listenAddr, + Handler: serveMux, + } + s.SetKeepAlivesEnabled(true) + + if err := s.ListenAndServe(); err != nil { + panic(err) + } + defer s.Close() + +} diff --git a/cli/encode-utils/config.go b/cli/encode-utils/config.go new file mode 100644 index 0000000..44dcd8e --- /dev/null +++ b/cli/encode-utils/config.go @@ -0,0 +1,17 @@ +package encode_utils + +import "git.gammaspectra.live/S.O.N.G/Ignite/frame" + +type JobConfig struct { + Encoder struct { + Name string `json:"name" yaml:"name"` + Settings map[string]any `json:"settings" yaml:"settings"` + } `json:"encoder" yaml:"encoder"` + + Properties frame.StreamProperties `json:"properties" yaml:"properties"` +} + +const ( + EncoderX264 = "libx264" + EncoderAOM = "libaom" +) diff --git a/color/space.go b/color/space.go index 2217b71..984dfc3 100644 --- a/color/space.go +++ b/color/space.go @@ -3,6 +3,8 @@ package color import ( "errors" "fmt" + "gopkg.in/yaml.v3" + "io" "strconv" "strings" ) @@ -13,6 +15,31 @@ type Space struct { BitDepth byte } +func (c *Space) UnmarshalJSON(buf []byte) error { + if len(buf) < 2 { + return io.ErrUnexpectedEOF + } + s, err := NewColorFormatFromString(string(buf[1 : len(buf)-1])) + if err != nil { + return err + } + *c = s + return nil +} + +func (c *Space) UnmarshalYAML(node *yaml.Node) error { + s, err := NewColorFormatFromString(node.Value) + if err != nil { + return err + } + *c = s + return nil +} + +func (c Space) MarshalJSON() ([]byte, error) { + return []byte("\"" + c.String() + "\""), nil +} + func (c Space) String() string { if c.ChromaSampling.J == 4 && c.ChromaSampling.A == 2 && c.ChromaSampling.B == 0 && c.BitDepth == 8 { switch c.ChromaSamplePosition { diff --git a/encoder/encoder.go b/encoder/encoder.go index 4b7d9cb..b9f9fac 100644 --- a/encoder/encoder.go +++ b/encoder/encoder.go @@ -3,7 +3,7 @@ package encoder import "git.gammaspectra.live/S.O.N.G/Ignite/frame" type Encoder interface { - Encode(pts int64, f frame.Frame) error + Encode(f frame.Frame) error EncodeStream(stream *frame.Stream) error Flush() error Close() diff --git a/frame/stream.go b/frame/stream.go index 2053fc3..c7e3a80 100644 --- a/frame/stream.go +++ b/frame/stream.go @@ -23,15 +23,15 @@ func NewStream(properties StreamProperties) (*Stream, chan<- Frame) { type StreamProperties struct { // Width could be not populated until the first frame is read. Frame can contain different settings. - Width int + Width int `json:"width" yaml:"width"` // Height could be not populated until the first frame is read. Frame can contain different settings. - Height int + Height int `json:"height" yaml:"height"` // PixelAspectRatio could be not populated until the first frame is read. Frame can contain different settings. - PixelAspectRatio utilities.Ratio + PixelAspectRatio utilities.Ratio `json:"par" yaml:"par"` // ColorSpace could be not populated until the first frame is read. Frame can contain different settings. - ColorSpace color.Space - FrameRate utilities.Ratio - FullColorRange bool + ColorSpace color.Space `json:"colorspace" yaml:"colorspace"` + FrameRate utilities.Ratio `json:"framerate" yaml:"framerate"` + FullColorRange bool `json:"fullrange" yaml:"fullrange"` } func (p StreamProperties) FrameProperties() Properties { diff --git a/go.mod b/go.mod index a61b03a..a3116be 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,19 @@ module git.gammaspectra.live/S.O.N.G/Ignite go 1.21 require ( + github.com/shirou/gopsutil v3.21.11+incompatible github.com/stretchr/testify v1.8.1 github.com/ulikunitz/xz v0.5.11 golang.org/x/exp v0.0.0-20231006140011-7918f672742d + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index 944b5bc..3efb561 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -10,10 +14,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utilities/ratio.go b/utilities/ratio.go index b74c2fe..9501bcd 100644 --- a/utilities/ratio.go +++ b/utilities/ratio.go @@ -1,6 +1,9 @@ package utilities -import "fmt" +import ( + "fmt" + "gopkg.in/yaml.v3" +) type Ratio struct { Numerator int @@ -11,6 +14,20 @@ func (r Ratio) Float64() float64 { return float64(r.Numerator) / float64(r.Denominator) } +func (r *Ratio) UnmarshalJSON(buf []byte) error { + _, err := fmt.Sscanf(string(buf), "\"%d:%d\"", &r.Numerator, &r.Denominator) + return err +} + +func (r *Ratio) UnmarshalYAML(node *yaml.Node) error { + _, err := fmt.Sscanf(node.Value, "%d:%d", &r.Numerator, &r.Denominator) + return err +} + +func (r Ratio) MarshalJSON() ([]byte, error) { + return []byte("\"" + r.String() + "\""), nil +} + func (r Ratio) String() string { return fmt.Sprintf("%d:%d", r.Numerator, r.Denominator) }