diff --git a/transcoder/go.mod b/transcoder/go.mod index c3500bbf..1daf251d 100644 --- a/transcoder/go.mod +++ b/transcoder/go.mod @@ -3,6 +3,7 @@ module github.com/zoriya/kyoo/transcoder go 1.20 require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/labstack/echo/v4 v4.11.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -13,4 +14,5 @@ require ( golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect ) diff --git a/transcoder/go.sum b/transcoder/go.sum index 453ca6fa..799ef213 100644 --- a/transcoder/go.sum +++ b/transcoder/go.sum @@ -1,3 +1,5 @@ +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -21,3 +23,5 @@ golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/transcoder/main.go b/transcoder/main.go index 7d15058b..6d983d43 100644 --- a/transcoder/main.go +++ b/transcoder/main.go @@ -1,16 +1,19 @@ package main import ( - "fmt" "net/http" + "github.com/zoriya/kyoo/transcoder/transcoder" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) // Direct video // // Retrieve the raw video stream, in the same container as the one on the server. No transcoding or // transmuxing is done. +// // Path: /:resource/:slug/direct func DirectStream(c echo.Context) error { resource := c.Param("resource") @@ -23,23 +26,47 @@ func DirectStream(c echo.Context) error { return c.File(*path) } -func ErrorHandler(err error, c echo.Context) { - code := http.StatusInternalServerError - if he, ok := err.(*echo.HTTPError); ok { - code = he.Code - } else { - c.Logger().Error(err) +// Get master playlist +// +// Get a master playlist containing all possible video qualities and audios available for this resource. +// Note that the direct stream is missing (since the direct is not an hls stream) and +// subtitles/fonts are not included to support more codecs than just webvtt. +// +// Path: /:resource/:slug/master.m3u8 +func (h *Handler) GetMaster(c echo.Context) error { + resource := c.Param("resource") + slug := c.Param("slug") + + client, err := GetClientId(c) + if err != nil { + return err } - c.JSON(code, struct { - Errors []string `json:"errors"` - }{Errors: []string{fmt.Sprint(err)}}) + + path, err := GetPath(resource, slug) + if err != nil { + return err + } + + ret, err := h.transcoder.GetMaster(*path, *client) + if err != nil { + return err + } + return c.String(http.StatusOK, ret) +} + +type Handler struct { + transcoder *transcoder.Transcoder } func main() { e := echo.New() + e.Use(middleware.Logger()) e.HTTPErrorHandler = ErrorHandler + h := Handler{} + e.GET("/:resource/:slug/direct", DirectStream) + e.GET("/:resource/:slug/master.m3u8", h.GetMaster) e.Logger.Fatal(e.Start(":7666")) } diff --git a/transcoder/transcoder/filestream.go b/transcoder/transcoder/filestream.go new file mode 100644 index 00000000..b72cb7f7 --- /dev/null +++ b/transcoder/transcoder/filestream.go @@ -0,0 +1,97 @@ +package transcoder + +import ( + "math" + "os/exec" + "strconv" + "strings" +) + +type FileStream struct { + Path string + Keyframes []float64 + CanTransmux bool + streams map[Quality]TranscodeStream +} + +func NewFileStream(path string) (*FileStream, error) { + keyframes, can_transmux, err := GetKeyframes(path) + if err != nil { + return nil, err + } + + return &FileStream{ + Path: path, + Keyframes: keyframes, + CanTransmux: can_transmux, + streams: make(map[Quality]TranscodeStream), + }, nil +} + +func GetKeyframes(path string) ([]float64, bool, error) { + // run ffprobe to return all IFrames, IFrames are points where we can split the video in segments. + out, err := exec.Command( + "ffprobe", + "-loglevel", "error", + "-select_streams", "v:0", + "-show_entries", "packet=pts_time,flags", + "-of", "csv=print_section=0", + path, + ).Output() + // We ask ffprobe to return the time of each frame and it's flags + // We could ask it to return only i-frames (keyframes) with the -skip_frame nokey but using it is extremly slow + // since ffmpeg parses every frames when this flag is set. + if err != nil { + return nil, false, err + } + + ret := make([]float64, 0, 300) + last := 0. + can_transmux := true + for _, frame := range strings.Split(string(out), "\n") { + x := strings.Split(frame, ",") + pts, flags := x[0], x[1] + + // Only take keyframes + if flags[0] != 'K' { + continue + } + + fpts, err := strconv.ParseFloat(pts, 64) + if err != nil { + return nil, false, err + } + + // Only save keyframes with at least 3s betweens, we dont want a segment of 0.2s + if fpts-last < 3 { + continue + } + + // If we have a segment of more than 20s, create new keyframes during transcode and disable transmuxing + if fpts-last > 20 { + can_transmux = false + + fake_count := math.Ceil(fpts - last/4) + duration := (fpts - last) / fake_count + // let the last one be handled normally, this prevents floating points rounding + for fake_count > 1 { + fake_count-- + last = last + duration + ret = append(ret, last) + } + } + + last = fpts + ret = append(ret, fpts) + } + return ret, can_transmux, nil +} + +func (fs *FileStream) IsDead() bool { + for _, s := range fs.streams { + if len(s.Clients) > 0 { + return true + } + } + return false +} diff --git a/transcoder/transcoder/quality.go b/transcoder/transcoder/quality.go new file mode 100644 index 00000000..09305c6b --- /dev/null +++ b/transcoder/transcoder/quality.go @@ -0,0 +1,39 @@ +package transcoder + +type Quality int8 + +const ( + P240 Quality = iota + P360 + P480 + P720 + P1080 + P1440 + P4k + P8k + Original +) + +func (q Quality) String() string { + switch q { + case P240: + return "240p" + case P360: + return "360p" + case P480: + return "480p" + case P720: + return "720p" + case P1080: + return "1080p" + case P1440: + return "1440p" + case P4k: + return "4k" + case P8k: + return "8k" + case Original: + return "Original" + } + panic("Invalid quality value") +} diff --git a/transcoder/transcoder/stream.go b/transcoder/transcoder/stream.go new file mode 100644 index 00000000..935aab8c --- /dev/null +++ b/transcoder/transcoder/stream.go @@ -0,0 +1,9 @@ +package transcoder + +type TranscodeStream struct { + File FileStream + Clients []string + // true if the segment at given index is completed/transcoded, false otherwise + segments []bool + // TODO: add ffmpeg process +} diff --git a/transcoder/transcoder/transcoder.go b/transcoder/transcoder/transcoder.go new file mode 100644 index 00000000..c6c76bd6 --- /dev/null +++ b/transcoder/transcoder/transcoder.go @@ -0,0 +1,18 @@ +package transcoder + +type Transcoder struct { + // All file streams currently running, index is file path + streams map[string]FileStream +} + +func (t *Transcoder) GetMaster(path string, client string) (*string, error) { + stream, ok := t.streams[path] + if !ok { + stream, err := NewFileStream(path) + if err != nil { + return nil, err + } + t.streams[path] = *stream + } + return &stream.GetMaster() +} diff --git a/transcoder/utils.go b/transcoder/utils.go index ab3cbdfa..49288ef1 100644 --- a/transcoder/utils.go +++ b/transcoder/utils.go @@ -55,3 +55,24 @@ func GetPath(resource string, slug string) (*string, error) { return &ret.Path, nil } + +func GetClientId(c echo.Context) (*string, error) { + key := c.Request().Header.Get("X-CLIENT-ID") + if key == "" { + return nil, errors.New("Missing client id. Please specify the X-CLIENT-ID header to a guid constant for the lifetime of the player (but unique per instance).") + } + return &key, nil +} + +func ErrorHandler(err error, c echo.Context) { + code := http.StatusInternalServerError + if he, ok := err.(*echo.HTTPError); ok { + code = he.Code + } else { + c.Logger().Error(err) + } + c.JSON(code, struct { + Errors []string `json:"errors"` + }{Errors: []string{fmt.Sprint(err)}}) +} +