diff --git a/transcoder/main.go b/transcoder/main.go index 5f10cf8f..4902eb04 100644 --- a/transcoder/main.go +++ b/transcoder/main.go @@ -3,11 +3,7 @@ package main import ( "context" "fmt" - "io" - "mime" "net/http" - "path/filepath" - "strconv" _ "github.com/zoriya/kyoo/transcoder/docs" @@ -40,175 +36,6 @@ func ErrorHandler(err error, c echo.Context) { }{Errors: []string{message}}) } -// Identify -// -// Identify metadata about a file. -// -// Path: /:path/info -func (h *Handler) GetInfo(c echo.Context) error { - path, sha, err := GetPath(c) - if err != nil { - return err - } - - ret, err := h.metadata.GetMetadata(c.Request().Context(), path, sha) - if err != nil { - return err - } - err = ret.SearchExternalSubtitles() - if err != nil { - fmt.Printf("Couldn't find external subtitles: %v", err) - } - return c.JSON(http.StatusOK, ret) -} - -// Get attachments -// -// Get a specific attachment. -// -// Path: /:path/attachment/:name -func (h *Handler) GetAttachment(c echo.Context) (err error) { - _, sha, err := GetPath(c) - if err != nil { - return err - } - name := c.Param("name") - if err := SanitizePath(name); err != nil { - return err - } - - attachementStream, err := h.metadata.GetAttachment(c.Request().Context(), sha, name) - if err != nil { - return err - } - defer utils.CleanupWithErr(&err, attachementStream.Close, "failed to close attachment reader") - - mimeType, err := guessMimeType(name, attachementStream) - if err != nil { - return fmt.Errorf("failed to guess mime type: %w", err) - } - - return c.Stream(200, mimeType, attachementStream) -} - -// Get subtitle -// -// Get a specific subtitle. -// -// Path: /:path/subtitle/:name -func (h *Handler) GetSubtitle(c echo.Context) (err error) { - _, sha, err := GetPath(c) - if err != nil { - return err - } - name := c.Param("name") - if err := SanitizePath(name); err != nil { - return err - } - - subtitleStream, err := h.metadata.GetSubtitle(c.Request().Context(), sha, name) - if err != nil { - return err - } - defer utils.CleanupWithErr(&err, subtitleStream.Close, "failed to close subtitle reader") - - mimeType, err := guessMimeType(name, subtitleStream) - if err != nil { - return fmt.Errorf("failed to guess mime type: %w", err) - } - - // Default the mime type to text/plain if it is not recognized - if mimeType == "" { - mimeType = "text/plain" - } - - return c.Stream(200, mimeType, subtitleStream) -} - -// Get thumbnail sprite -// -// Get a sprite file containing all the thumbnails of the show. -// -// Path: /:path/thumbnails.png -func (h *Handler) GetThumbnails(c echo.Context) (err error) { - path, sha, err := GetPath(c) - if err != nil { - return err - } - - sprite, err := h.metadata.GetThumbSprite(c.Request().Context(), path, sha) - if err != nil { - return err - } - defer utils.CleanupWithErr(&err, sprite.Close, "failed to close thumbnail sprite reader") - - return c.Stream(200, "image/png", sprite) -} - -// Get thumbnail vtt -// -// Get a vtt file containing timing/position of thumbnails inside the sprite file. -// https://developer.bitmovin.com/playback/docs/webvtt-based-thumbnails for more info. -// -// Path: /:path/:resource/:slug/thumbnails.vtt -func (h *Handler) GetThumbnailsVtt(c echo.Context) (err error) { - path, sha, err := GetPath(c) - if err != nil { - return err - } - - vtt, err := h.metadata.GetThumbVtt(c.Request().Context(), path, sha) - if err != nil { - return err - } - defer utils.CleanupWithErr(&err, vtt.Close, "failed to close thumbnail vtt reader") - - return c.Stream(200, "text/vtt", vtt) -} - -type Handler struct { - transcoder *src.Transcoder - metadata *src.MetadataService -} - -// Try to guess the mime type of a file based on its extension. -// If the extension is not recognized, return an empty string. -// If path is provided, it should contain a file extension (i.e. ".mp4"). -// If content is provided, it should be of type io.ReadSeeker. Instances of other types are ignored. -// This implementation is based upon http.ServeContent. -func guessMimeType(path string, content any) (string, error) { - // This does not match a large number of different types that are likely in use. - // TODO add telemetry to see what file extensions are used, then add logic - // to detect the type based on the file extension. - mimeType := "" - - // First check the file extension, if there is one. - ext := filepath.Ext(path) - if ext != "" { - if mimeType = mime.TypeByExtension(ext); mimeType != "" { - return mimeType, nil - } - } - - // Try reading the first few bytes of the file to guess the mime type. - // Only do this if seeking is supported - if reader, ok := content.(io.ReadSeeker); ok { - // 512 bytes is the most that DetectContentType will consider, so no - // need to read more than that. - var buf [512]byte - n, _ := io.ReadFull(reader, buf[:]) - mimeType = http.DetectContentType(buf[:n]) - - // Reset the reader to the beginning of the file - _, err := reader.Seek(0, io.SeekStart) - if err != nil { - return "", fmt.Errorf("mime type guesser failed to seek to beginning of file: %w", err) - } - } - - return mimeType, nil -} - // @title gocoder - Kyoo's transcoder // @version 1.0 // @description Real time transcoder. @@ -248,11 +75,6 @@ func main() { return } - h := Handler{ - transcoder: transcoder, - metadata: metadata, - } - ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -278,13 +100,9 @@ func main() { // return keys.LookupKeyID(kid.(string)) }, })) - g.GET("/:path/info", h.GetInfo) - g.GET("/:path/thumbnails.png", h.GetThumbnails) - g.GET("/:path/thumbnails.vtt", h.GetThumbnailsVtt) - g.GET("/:path/attachment/:name", h.GetAttachment) - g.GET("/:path/subtitle/:name", h.GetSubtitle) - api.RegisterStreamHandlers(g) + api.RegisterStreamHandlers(g, transcoder) + api.RegisterMetadataHandlers(g, metadata) api.RegisterPProfHandlers(e) e.Logger.Fatal(e.Start(":7666")) diff --git a/transcoder/src/api/metadata.go b/transcoder/src/api/metadata.go new file mode 100644 index 00000000..f9d903f5 --- /dev/null +++ b/transcoder/src/api/metadata.go @@ -0,0 +1,213 @@ +package api + +import ( + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + + "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/transcoder/src" + "github.com/zoriya/kyoo/transcoder/src/utils" +) + +type mhandler struct { + metadata *src.MetadataService +} + +func RegisterMetadataHandlers(e *echo.Group, metadata *src.MetadataService) { + h := mhandler{metadata} + + e.GET("/:path/info", h.GetInfo) + e.GET("/:path/subtitle/:name", h.GetSubtitle) + e.GET("/:path/attachment/:name", h.GetAttachment) + e.GET("/:path/thumbnails.png", h.GetThumbnails) + e.GET("/:path/thumbnails.vtt", h.GetThumbnailsVtt) +} + +// @Summary Identify +// +// @Description Identify metadata about a file. +// +// @Tags metadata +// @Param path path string true "Base64 of a video's path" format(base64) example(L3ZpZGVvL2J1YmJsZS5ta3YK) +// +// @Success 200 {object} src.MediaInfo "Metadata info of the video." +// @Router /:path/info [get] +func (h *mhandler) GetInfo(c echo.Context) error { + path, sha, err := getPath(c) + if err != nil { + return err + } + + ret, err := h.metadata.GetMetadata(c.Request().Context(), path, sha) + if err != nil { + return err + } + err = ret.SearchExternalSubtitles() + if err != nil { + fmt.Printf("Couldn't find external subtitles: %v", err) + } + return c.JSON(http.StatusOK, ret) +} + +// @Summary Get subtitle +// +// @Description Get a specific subtitle. +// +// @Tags metadata +// @Param path path string true "Base64 of a video's path" format(base64) example(L3ZpZGVvL2J1YmJsZS5ta3YK) +// @Param name path string true "Name of the subtitle" example(en.srt) +// +// @Success 200 file "Requested subtitle" +// @Router /:path/subtitle/:name [get] +func (h *mhandler) GetSubtitle(c echo.Context) (err error) { + _, sha, err := getPath(c) + if err != nil { + return err + } + name := c.Param("name") + if err := sanitizePath(name); err != nil { + return err + } + + subtitleStream, err := h.metadata.GetSubtitle(c.Request().Context(), sha, name) + if err != nil { + return err + } + defer utils.CleanupWithErr(&err, subtitleStream.Close, "failed to close subtitle reader") + + mimeType, err := guessMimeType(name, subtitleStream) + if err != nil { + return fmt.Errorf("failed to guess mime type: %w", err) + } + + // Default the mime type to text/plain if it is not recognized + if mimeType == "" { + mimeType = "text/plain" + } + + return c.Stream(200, mimeType, subtitleStream) +} + +// @Summary Get attachments +// +// @Description Get a specific attachment. +// +// @Tags metadata +// @Param path path string true "Base64 of a video's path" format(base64) example(L3ZpZGVvL2J1YmJsZS5ta3YK) +// @Param name path string true "Name of the attachment" example(font.ttf) +// +// @Success 200 file "Requested attachment" +// @Router /:path/attachment/:name [get] +func (h *mhandler) GetAttachment(c echo.Context) (err error) { + _, sha, err := getPath(c) + if err != nil { + return err + } + name := c.Param("name") + if err := sanitizePath(name); err != nil { + return err + } + + attachementStream, err := h.metadata.GetAttachment(c.Request().Context(), sha, name) + if err != nil { + return err + } + defer utils.CleanupWithErr(&err, attachementStream.Close, "failed to close attachment reader") + + mimeType, err := guessMimeType(name, attachementStream) + if err != nil { + return fmt.Errorf("failed to guess mime type: %w", err) + } + + return c.Stream(200, mimeType, attachementStream) +} + +// Try to guess the mime type of a file based on its extension. +// If the extension is not recognized, return an empty string. +// If path is provided, it should contain a file extension (i.e. ".mp4"). +// If content is provided, it should be of type io.ReadSeeker. Instances of other types are ignored. +// This implementation is based upon http.ServeContent. +func guessMimeType(path string, content any) (string, error) { + // This does not match a large number of different types that are likely in use. + // TODO add telemetry to see what file extensions are used, then add logic + // to detect the type based on the file extension. + mimeType := "" + + // First check the file extension, if there is one. + ext := filepath.Ext(path) + if ext != "" { + if mimeType = mime.TypeByExtension(ext); mimeType != "" { + return mimeType, nil + } + } + + // Try reading the first few bytes of the file to guess the mime type. + // Only do this if seeking is supported + if reader, ok := content.(io.ReadSeeker); ok { + // 512 bytes is the most that DetectContentType will consider, so no + // need to read more than that. + var buf [512]byte + n, _ := io.ReadFull(reader, buf[:]) + mimeType = http.DetectContentType(buf[:n]) + + // Reset the reader to the beginning of the file + _, err := reader.Seek(0, io.SeekStart) + if err != nil { + return "", fmt.Errorf("mime type guesser failed to seek to beginning of file: %w", err) + } + } + + return mimeType, nil +} + +// @Summary Get thumbnail sprite +// +// @Description Get a sprite file containing all the thumbnails of the show. +// +// @Tags metadata +// @Param path path string true "Base64 of a video's path" format(base64) example(L3ZpZGVvL2J1YmJsZS5ta3YK) +// +// @Success 200 file "sprite" +// @Router /:path/thumbnails.png [get] +func (h *mhandler) GetThumbnails(c echo.Context) (err error) { + path, sha, err := getPath(c) + if err != nil { + return err + } + + sprite, err := h.metadata.GetThumbSprite(c.Request().Context(), path, sha) + if err != nil { + return err + } + defer utils.CleanupWithErr(&err, sprite.Close, "failed to close thumbnail sprite reader") + + return c.Stream(200, "image/png", sprite) +} + +// @Summary Get thumbnail vtt +// +// @Description Get a vtt file containing timing/position of thumbnails inside the sprite file. +// @Description https://developer.bitmovin.com/playback/docs/webvtt-based-thumbnails for more info. +// +// @Tags metadata +// @Param path path string true "Base64 of a video's path" format(base64) example(L3ZpZGVvL2J1YmJsZS5ta3YK) +// +// @Success 200 file "sprite" +// @Router /:path/thumbnails.vtt [get] +func (h *mhandler) GetThumbnailsVtt(c echo.Context) (err error) { + path, sha, err := getPath(c) + if err != nil { + return err + } + + vtt, err := h.metadata.GetThumbVtt(c.Request().Context(), path, sha) + if err != nil { + return err + } + defer utils.CleanupWithErr(&err, vtt.Close, "failed to close thumbnail vtt reader") + + return c.Stream(200, "text/vtt", vtt) +} diff --git a/transcoder/src/api/path.go b/transcoder/src/api/path.go new file mode 100644 index 00000000..35535490 --- /dev/null +++ b/transcoder/src/api/path.go @@ -0,0 +1,57 @@ +package api + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/transcoder/src" +) + +func getPath(c echo.Context) (string, string, error) { + key := c.Param("path") + if key == "" { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Missing resouce path.") + } + pathb, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Invalid path. Should be base64url (without padding) encoded.") + } + path := filepath.Clean(string(pathb)) + if !filepath.IsAbs(path) { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Absolute path required.") + } + if !strings.HasPrefix(path, src.Settings.SafePath) { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Selected path is not marked as safe.") + } + hash, err := getHash(path) + if err != nil { + return "", "", echo.NewHTTPError(http.StatusNotFound, "File does not exist") + } + + return path, hash, nil +} + +func getHash(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + h := sha1.New() + h.Write([]byte(path)) + h.Write([]byte(info.ModTime().String())) + sha := hex.EncodeToString(h.Sum(nil)) + return sha, nil +} + +func sanitizePath(path string) error { + if strings.Contains(path, "/") || strings.Contains(path, "..") { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid parameter. Can't contains path delimiters or ..") + } + return nil +} diff --git a/transcoder/src/api/streams.go b/transcoder/src/api/streams.go index 8bc3f8c7..9b3b8465 100644 --- a/transcoder/src/api/streams.go +++ b/transcoder/src/api/streams.go @@ -1,15 +1,9 @@ package api import ( - "crypto/sha1" - "encoding/base64" - "encoding/hex" "fmt" "net/http" - "os" - "path/filepath" "strconv" - "strings" "github.com/labstack/echo/v4" "github.com/zoriya/kyoo/transcoder/src" @@ -222,49 +216,6 @@ func (h *shandler) GetAudioSegment(c echo.Context) error { return c.File(ret) } -func getPath(c echo.Context) (string, string, error) { - key := c.Param("path") - if key == "" { - return "", "", echo.NewHTTPError(http.StatusBadRequest, "Missing resouce path.") - } - pathb, err := base64.RawURLEncoding.DecodeString(key) - if err != nil { - return "", "", echo.NewHTTPError(http.StatusBadRequest, "Invalid path. Should be base64url (without padding) encoded.") - } - path := filepath.Clean(string(pathb)) - if !filepath.IsAbs(path) { - return "", "", echo.NewHTTPError(http.StatusBadRequest, "Absolute path required.") - } - if !strings.HasPrefix(path, src.Settings.SafePath) { - return "", "", echo.NewHTTPError(http.StatusBadRequest, "Selected path is not marked as safe.") - } - hash, err := getHash(path) - if err != nil { - return "", "", echo.NewHTTPError(http.StatusNotFound, "File does not exist") - } - - return path, hash, nil -} - -func getHash(path string) (string, error) { - info, err := os.Stat(path) - if err != nil { - return "", err - } - h := sha1.New() - h.Write([]byte(path)) - h.Write([]byte(info.ModTime().String())) - sha := hex.EncodeToString(h.Sum(nil)) - return sha, nil -} - -func sanitizePath(path string) error { - if strings.Contains(path, "/") || strings.Contains(path, "..") { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid parameter. Can't contains path delimiters or ..") - } - return nil -} - func getClientId(c echo.Context) (string, error) { key := c.Request().Header.Get("X-CLIENT-ID") if key == "" {