mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-10-27 00:32:34 -04:00
277 lines
7.0 KiB
Go
277 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
_ "github.com/zoriya/kyoo/transcoder/docs"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
echoSwagger "github.com/swaggo/echo-swagger"
|
|
"github.com/zoriya/kyoo/transcoder/src"
|
|
"github.com/zoriya/kyoo/transcoder/src/api"
|
|
"github.com/zoriya/kyoo/transcoder/src/utils"
|
|
|
|
"github.com/lestrrat-go/httprc/v3"
|
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
|
|
|
echojwt "github.com/labstack/echo-jwt/v4"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
// 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.
|
|
|
|
// @contact.name Repository
|
|
// @contact.url https://github.com/zoriya/kyoo
|
|
|
|
// @license.name GPL-3.0
|
|
// @license.url https://www.gnu.org/licenses/gpl-3.0.en.html
|
|
|
|
// @host kyoo.zoriya.dev
|
|
// @BasePath /video
|
|
|
|
// @securityDefinitions.apiKey Token
|
|
// @in header
|
|
// @name Authorization
|
|
|
|
// @securityDefinitions.apiKey Jwt
|
|
// @in header
|
|
// @name Authorization
|
|
func main() {
|
|
e := echo.New()
|
|
e.Use(middleware.Logger())
|
|
e.GET("/video/swagger/*", echoSwagger.WrapHandler)
|
|
e.HTTPErrorHandler = ErrorHandler
|
|
|
|
metadata, err := src.NewMetadataService()
|
|
if err != nil {
|
|
e.Logger.Fatal("failed to create metadata service: ", err)
|
|
return
|
|
}
|
|
defer utils.CleanupWithErr(&err, metadata.Close, "failed to close metadata service")
|
|
|
|
transcoder, err := src.NewTranscoder(metadata)
|
|
if err != nil {
|
|
e.Logger.Fatal("failed to create transcoder: ", err)
|
|
return
|
|
}
|
|
|
|
h := Handler{
|
|
transcoder: transcoder,
|
|
metadata: metadata,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
jwks, err := jwk.NewCache(ctx, httprc.NewClient())
|
|
if err != nil {
|
|
e.Logger.Fatal("failed to create jwk cache: ", err)
|
|
return
|
|
}
|
|
jwks.Register(ctx, src.Settings.JwksUrl)
|
|
|
|
g := e.Group("/video")
|
|
g.Use(echojwt.WithConfig(echojwt.Config{
|
|
KeyFunc: func(token *jwt.Token) (any, error) {
|
|
return jwks.CachedSet(src.Settings.JwksUrl)
|
|
// kid, ok := token.Header["kid"]
|
|
// if !ok {
|
|
// return nil, errors.New("missing kid in jwt")
|
|
// }
|
|
// keys, err := jwks.CachedSet(src.Settings.JwksUrl)
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
// 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.RegisterPProfHandlers(e)
|
|
|
|
e.Logger.Fatal(e.Start(":7666"))
|
|
}
|