Implemented encode server

This commit is contained in:
DataHoarder 2023-10-21 23:00:19 +02:00
parent f605079083
commit 241ee4164e
Signed by: DataHoarder
SSH key fingerprint: SHA256:OLTRf6Fl87G52SiR7sWLGNzlJt4WOX+tfI2yxo0z7xk
10 changed files with 712 additions and 9 deletions

113
cli/encode-server/encode.go Normal file
View file

@ -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
}
}

127
cli/encode-server/job.go Normal file
View file

@ -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
}
}

381
cli/encode-server/main.go Normal file
View file

@ -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()
}

View file

@ -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"
)

View file

@ -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 {

View file

@ -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()

View file

@ -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 {

8
go.mod
View file

@ -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
)

15
go.sum
View file

@ -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=

View file

@ -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)
}