diff --git a/shell.nix b/shell.nix index e152c76d..7669e399 100644 --- a/shell.nix +++ b/shell.nix @@ -21,6 +21,7 @@ in go wgo mediainfo + libmediainfo ffmpeg postgresql_15 eslint_d diff --git a/transcoder/Dockerfile b/transcoder/Dockerfile index a7b81fb8..04cf6688 100644 --- a/transcoder/Dockerfile +++ b/transcoder/Dockerfile @@ -1,4 +1,5 @@ FROM golang:1.20-alpine +RUN apk add --no-cache build-base ffmpeg libmediainfo-dev WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/transcoder/Dockerfile.dev b/transcoder/Dockerfile.dev index 3420ece9..019cded1 100644 --- a/transcoder/Dockerfile.dev +++ b/transcoder/Dockerfile.dev @@ -1,4 +1,5 @@ FROM golang:1.20-alpine +RUN apk add --no-cache build-base ffmpeg libmediainfo-dev RUN go install github.com/bokwoon95/wgo@latest WORKDIR /app diff --git a/transcoder/go.mod b/transcoder/go.mod index f3923aaa..bccb0e8b 100644 --- a/transcoder/go.mod +++ b/transcoder/go.mod @@ -2,14 +2,16 @@ module github.com/zoriya/kyoo/transcoder go 1.20 +require github.com/labstack/echo/v4 v4.11.4 // direct + require ( github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/labstack/echo/v4 v4.11.4 // direct github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/zelenin/go-mediainfo v1.0.0 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect diff --git a/transcoder/go.sum b/transcoder/go.sum index 799ef213..2b8854f9 100644 --- a/transcoder/go.sum +++ b/transcoder/go.sum @@ -13,6 +13,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/zelenin/go-mediainfo v1.0.0 h1:P0rKGyrSwKBCj37Ul7TLkrhf5OVBPlh3fFe9ZqjiaKQ= +github.com/zelenin/go-mediainfo v1.0.0/go.mod h1:6jxR5y1gDozFdCD7NYq/kUjCo5nqeogYCal6wpv81nY= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= diff --git a/transcoder/main.go b/transcoder/main.go index 2e4c37d6..9e022ea5 100644 --- a/transcoder/main.go +++ b/transcoder/main.go @@ -54,6 +54,27 @@ func (h *Handler) GetMaster(c echo.Context) error { return c.String(http.StatusOK, ret) } +// Identify +// +// # Identify metadata about a file +// +// Path: /:resource/:slug/info +func GetInfo(c echo.Context) error { + resource := c.Param("resource") + slug := c.Param("slug") + + path, err := GetPath(resource, slug) + if err != nil { + return err + } + + ret, err := src.GetInfo(path) + if err != nil { + return err + } + return c.JSON(http.StatusOK, ret) +} + type Handler struct { transcoder *src.Transcoder } @@ -67,6 +88,7 @@ func main() { e.GET("/:resource/:slug/direct", DirectStream) e.GET("/:resource/:slug/master.m3u8", h.GetMaster) + e.GET("/:resource/:slug/info", GetInfo) e.Logger.Fatal(e.Start(":7666")) } diff --git a/transcoder/src/info.go b/transcoder/src/info.go new file mode 100644 index 00000000..cb0ef655 --- /dev/null +++ b/transcoder/src/info.go @@ -0,0 +1,166 @@ +package src + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strconv" + + "github.com/zelenin/go-mediainfo" +) + +type MediaInfo struct { + // The sha1 of the video file. + Sha string `json:"sha"` + /// The internal path of the video file. + Path string `json:"path"` + /// The extension currently used to store this video file + Extension string `json:"extension"` + /// The length of the media in seconds. + Length float32 `json:"length"` + /// The container of the video file of this episode. + Container string `json:"container"` + /// The video codec and infromations. + Video Video `json:"video"` + /// The list of audio tracks. + Audios []Audio `json:"audios"` + /// The list of subtitles tracks. + Subtitles []Subtitle `json:"subtitles"` + /// The list of fonts that can be used to display subtitles. + Fonts []string `json:"fonts"` + /// The list of chapters. See Chapter for more information. + Chapters []Chapter `json:"chapters"` +} + +type Video struct { + /// The codec of this stream (defined as the RFC 6381). + Codec string `json:"codec"` + /// The language of this stream (as a ISO-639-2 language code) + Language string `json:"language,omitempty"` + /// The max quality of this video track. + Quality Quality `json:"quality"` + /// The width of the video stream + Width uint32 `json:"width"` + /// The height of the video stream + Height uint32 `json:"height"` + /// The average bitrate of the video in bytes/s + Bitrate uint32 `json:"bitrate"` +} + +type Audio struct { + /// The index of this track on the media. + Index uint32 `json:"index"` + /// The title of the stream. + Title string `json:"title,omitempty"` + /// The language of this stream (as a ISO-639-2 language code) + Language string `json:"language,omitempty"` + /// The codec of this stream. + Codec string `json:"codec"` + /// Is this stream the default one of it's type? + IsDefault bool `json:"isDefault"` + /// Is this stream tagged as forced? (useful only for subtitles) + IsForced bool `json:"isForced"` +} + +type Subtitle struct { + /// The index of this track on the media. + Index uint32 `json:"index"` + /// The title of the stream. + Title string `json:"title,omitempty"` + /// The language of this stream (as a ISO-639-2 language code) + Language string `json:"language,omitempty"` + /// The codec of this stream. + Codec string `json:"codec"` + /// The extension for the codec. + Extension string `json:"extension,omitempty"` + /// Is this stream the default one of it's type? + IsDefault bool `json:"isDefault"` + /// Is this stream tagged as forced? (useful only for subtitles) + IsForced bool `json:"isForced"` + /// The link to access this subtitle. + Link string `json:"link,omitempty"` +} + +type Chapter struct { + /// The start time of the chapter (in second from the start of the episode). + StartTime float32 `json:"startTime"` + /// The end time of the chapter (in second from the start of the episode). + EndTime float32 `json:"endTime"` + /// The name of this chapter. This should be a human-readable name that could be presented to the user. + Name string `json:"name"` + // TODO: add a type field for Opening, Credits... +} + +func ParseFloat(str string) float32 { + f, err := strconv.ParseFloat(str, 32) + if err != nil { + panic(err) + } + return float32(f) +} + +func ParseUint(str string) uint32 { + i, err := strconv.ParseUint(str, 10, 32) + if err != nil { + panic(err) + } + return uint32(i) +} + +// Stolen from the cmp.Or code that is not yet released +// Or returns the first of its arguments that is not equal to the zero value. +// If no argument is non-zero, it returns the zero value. +func Or[T comparable](vals ...T) T { + var zero T + for _, val := range vals { + if val != zero { + return val + } + } + return zero +} + +func GetInfo(path string) (MediaInfo, error) { + mi, err := mediainfo.Open(path) + if err != nil { + return MediaInfo{}, err + } + defer mi.Close() + + // TODO: extract + + sha := mi.Parameter(mediainfo.StreamGeneral, 0, "UniqueID") + // Remove dummy values that some tools use. + if len(sha) <= 5 { + date := mi.Parameter(mediainfo.StreamGeneral, 0, "File_Modified_Date") + + h := sha1.New() + h.Write([]byte(path)) + h.Write([]byte(date)) + sha = hex.EncodeToString(h.Sum(nil)) + } + + return MediaInfo{ + Sha: sha, + Path: path, + // Remove leading . + Extension: filepath.Ext(path)[1:], + // convert seconds to ms + Length: ParseFloat(mi.Parameter(mediainfo.StreamGeneral, 0, "Duration")) / 1000, + Container: mi.Parameter(mediainfo.StreamGeneral, 0, "Format"), + Video: Video{ + // This codec is not in the right format (does not include bitdepth...). + Codec: mi.Parameter(mediainfo.StreamVideo, 0, "Format"), + Language: mi.Parameter(mediainfo.StreamVideo, 0, "Language"), + Quality: QualityFromHeight(ParseUint(mi.Parameter(mediainfo.StreamVideo, 0, "Height"))), + Width: ParseUint(mi.Parameter(mediainfo.StreamVideo, 0, "Width")), + Height: ParseUint(mi.Parameter(mediainfo.StreamVideo, 0, "Height")), + Bitrate: ParseUint( + Or( + mi.Parameter(mediainfo.StreamVideo, 0, "BitRate"), + mi.Parameter(mediainfo.StreamVideo, 0, "OverallBitRate"), + ), + ), + }, + }, nil +} diff --git a/transcoder/src/quality.go b/transcoder/src/quality.go index 22ba101d..702fe5ba 100644 --- a/transcoder/src/quality.go +++ b/transcoder/src/quality.go @@ -1,39 +1,49 @@ package src -type Quality int8 +type Quality string const ( - P240 Quality = iota - P360 - P480 - P720 - P1080 - P1440 - P4k - P8k - Original + P240 Quality = "240p" + P360 Quality = "360p" + P480 Quality = "480p" + P720 Quality = "720p" + P1080 Quality = "1080p" + P1440 Quality = "1440p" + P4k Quality = "4k" + P8k Quality = "8k" + Original Quality = "original" ) -func (q Quality) String() string { +func (q Quality) Height() uint32 { switch q { case P240: - return "240p" + return 240 case P360: - return "360p" + return 360 case P480: - return "480p" + return 480 case P720: - return "720p" + return 720 case P1080: - return "1080p" + return 1080 case P1440: - return "1440p" + return 1440 case P4k: - return "4k" + return 2160 case P8k: - return "8k" + return 4320 case Original: - return "Original" + panic("Original quality must be handled specially") } panic("Invalid quality value") } + +func QualityFromHeight(height uint32) Quality { + qualities := []Quality{P240, P360, P480, P720, P1080, P1440, P4k, P8k, Original} + for _, quality := range qualities { + if quality.Height() >= height { + return quality + } + } + return P240 +} diff --git a/transcoder/utils.go b/transcoder/utils.go index 5691e03e..911276c4 100644 --- a/transcoder/utils.go +++ b/transcoder/utils.go @@ -41,7 +41,7 @@ func GetPath(resource string, slug string) (string, error) { if res.StatusCode != 200 { return "", echo.NewHTTPError( - http.StatusNotAcceptable, + http.StatusNotFound, fmt.Sprintf("No %s found with the slug %s.", resource, slug), ) }