Move metadata routes to it's own router

This commit is contained in:
Zoe Roux 2025-07-18 23:36:41 +02:00
parent 5d414bea16
commit c340a9b559
4 changed files with 272 additions and 233 deletions

View File

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

View File

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

View File

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

View File

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