mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Added support for storing transcoder metadata in S3
Signed-off-by: Fred Heinecke <fred.heinecke@yahoo.com>
This commit is contained in:
parent
cf7bc456e8
commit
265386f289
@ -4,8 +4,6 @@
|
||||
|
||||
# where to store temporary transcoded files
|
||||
GOCODER_CACHE_ROOT="/cache"
|
||||
# where to store extracted data (subtitles/attachments/comptuted thumbnails for scrubbing)
|
||||
GOCODER_METADATA_ROOT="/metadata"
|
||||
# path prefix needed to reach the http endpoint
|
||||
GOCODER_PREFIX=""
|
||||
# base absolute path that contains video files (everything in this directory can be served)
|
||||
@ -40,3 +38,26 @@ POSTGRES_SSLMODE="disable"
|
||||
# If this is not "disabled", the schema will be created (if it does not exists) and
|
||||
# the search_path of the user will be ignored (only the schema specified will be used).
|
||||
POSTGRES_SCHEMA=gocoder
|
||||
|
||||
# Storage backend
|
||||
# There are two currently supported backends: local filesystem and s3.
|
||||
# S3 must be used when running multiple instances of the service. The local filesystem is fine
|
||||
# for a single instance.
|
||||
|
||||
# Local filesystem
|
||||
GOCODER_METADATA_ROOT="/metadata"
|
||||
|
||||
# S3
|
||||
# Setting this configures the transcoder to use S3 as a backend.
|
||||
# S3_BUCKET_NAME=my-transcoder-bucket
|
||||
# All environment variables supported by the AWS SDK for Go (v2) are supported:
|
||||
# https://docs.aws.amazon.com/sdkref/latest/guide/settings-reference.html#EVarSettings
|
||||
# AWS_ACCESS_KEY_ID=abc123
|
||||
# AWS_SECRET_ACCESS_KEY=def456
|
||||
# AWS_ENDPOINT_URL_S3=https://s3.my-ceph-rgw-deployment.example
|
||||
# Unless you're running on an actual EC2 instance, you should set this to true.
|
||||
# This will disable the SDK from trying to use the EC2 metadata service to get credentials,
|
||||
# reducing startup time.
|
||||
# If you are actually using an IAM role profile for authentication, this should be set to false
|
||||
# or be left unset.
|
||||
AWS_EC2_METADATA_DISABLED="true"
|
||||
|
@ -1,8 +1,6 @@
|
||||
module github.com/zoriya/kyoo/transcoder
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.2
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
@ -14,6 +12,31 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3
|
||||
golang.org/x/sync v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
|
@ -2,6 +2,42 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6URlm++yPUt9QPmQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
|
||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
|
||||
@ -81,6 +117,8 @@ golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
|
@ -2,11 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src"
|
||||
"github.com/zoriya/kyoo/transcoder/src/api"
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
@ -43,7 +47,7 @@ func (h *Handler) GetMaster(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetMaster(path, client, sha)
|
||||
ret, err := h.transcoder.GetMaster(c.Request().Context(), path, client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -75,7 +79,7 @@ func (h *Handler) GetVideoIndex(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetVideoIndex(path, uint32(video), quality, client, sha)
|
||||
ret, err := h.transcoder.GetVideoIndex(c.Request().Context(), path, uint32(video), quality, client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -103,7 +107,7 @@ func (h *Handler) GetAudioIndex(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetAudioIndex(path, uint32(audio), client, sha)
|
||||
ret, err := h.transcoder.GetAudioIndex(c.Request().Context(), path, uint32(audio), client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -138,6 +142,7 @@ func (h *Handler) GetVideoSegment(c echo.Context) error {
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetVideoSegment(
|
||||
c.Request().Context(),
|
||||
path,
|
||||
uint32(video),
|
||||
quality,
|
||||
@ -174,7 +179,7 @@ func (h *Handler) GetAudioSegment(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetAudioSegment(path, uint32(audio), segment, client, sha)
|
||||
ret, err := h.transcoder.GetAudioSegment(c.Request().Context(), path, uint32(audio), segment, client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -192,7 +197,7 @@ func (h *Handler) GetInfo(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.metadata.GetMetadata(path, sha)
|
||||
ret, err := h.metadata.GetMetadata(c.Request().Context(), path, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -208,7 +213,7 @@ func (h *Handler) GetInfo(c echo.Context) error {
|
||||
// Get a specific attachment.
|
||||
//
|
||||
// Path: /:path/attachment/:name
|
||||
func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
func (h *Handler) GetAttachment(c echo.Context) (err error) {
|
||||
_, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -218,11 +223,18 @@ func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.metadata.GetAttachmentPath(sha, false, name)
|
||||
attachementStream, err := h.metadata.GetAttachment(c.Request().Context(), sha, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.File(ret)
|
||||
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
|
||||
@ -230,7 +242,7 @@ func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
// Get a specific subtitle.
|
||||
//
|
||||
// Path: /:path/subtitle/:name
|
||||
func (h *Handler) GetSubtitle(c echo.Context) error {
|
||||
func (h *Handler) GetSubtitle(c echo.Context) (err error) {
|
||||
_, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -240,11 +252,23 @@ func (h *Handler) GetSubtitle(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.metadata.GetAttachmentPath(sha, true, name)
|
||||
subtitleStream, err := h.metadata.GetSubtitle(c.Request().Context(), sha, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.File(ret)
|
||||
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
|
||||
@ -252,17 +276,19 @@ func (h *Handler) GetSubtitle(c echo.Context) error {
|
||||
// Get a sprite file containing all the thumbnails of the show.
|
||||
//
|
||||
// Path: /:path/thumbnails.png
|
||||
func (h *Handler) GetThumbnails(c echo.Context) error {
|
||||
func (h *Handler) GetThumbnails(c echo.Context) (err error) {
|
||||
path, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sprite, _, err := h.metadata.GetThumb(path, sha)
|
||||
|
||||
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.File(sprite)
|
||||
return c.Stream(200, "image/png", sprite)
|
||||
}
|
||||
|
||||
// Get thumbnail vtt
|
||||
@ -271,17 +297,19 @@ func (h *Handler) GetThumbnails(c echo.Context) error {
|
||||
// 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) error {
|
||||
func (h *Handler) GetThumbnailsVtt(c echo.Context) (err error) {
|
||||
path, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, vtt, err := h.metadata.GetThumb(path, sha)
|
||||
|
||||
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.File(vtt)
|
||||
return c.Stream(200, "text/vtt", vtt)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
@ -289,21 +317,67 @@ type Handler struct {
|
||||
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
|
||||
}
|
||||
|
||||
func main() {
|
||||
e := echo.New()
|
||||
|
||||
if err := run(e); err != nil {
|
||||
e.Logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(e *echo.Echo) (err error) {
|
||||
e.Use(middleware.Logger())
|
||||
e.HTTPErrorHandler = ErrorHandler
|
||||
|
||||
metadata, err := src.NewMetadataService()
|
||||
if err != nil {
|
||||
e.Logger.Fatal(err)
|
||||
return
|
||||
return fmt.Errorf("failed to create metadata service: %w", err)
|
||||
}
|
||||
defer utils.CleanupWithErr(&err, metadata.Close, "failed to close metadata service")
|
||||
|
||||
transcoder, err := src.NewTranscoder(metadata)
|
||||
if err != nil {
|
||||
e.Logger.Fatal(err)
|
||||
return
|
||||
return fmt.Errorf("failed to create transcoder: %w", err)
|
||||
}
|
||||
|
||||
h := Handler{
|
||||
transcoder: transcoder,
|
||||
metadata: metadata,
|
||||
@ -325,5 +399,8 @@ func main() {
|
||||
|
||||
api.RegisterPProfHandlers(e)
|
||||
|
||||
e.Logger.Fatal(e.Start(":7666"))
|
||||
if err := e.Start(":7666"); err != nil {
|
||||
return fmt.Errorf("failed to start server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -1,21 +1,28 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/storage"
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const ExtractVersion = 1
|
||||
|
||||
func (s *MetadataService) ExtractSubs(info *MediaInfo) (interface{}, error) {
|
||||
func (s *MetadataService) ExtractSubs(ctx context.Context, info *MediaInfo) (interface{}, error) {
|
||||
get_running, set := s.extractLock.Start(info.Sha)
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
err := extractSubs(info)
|
||||
err := s.extractSubs(ctx, info)
|
||||
if err != nil {
|
||||
return set(nil, err)
|
||||
}
|
||||
@ -23,32 +30,70 @@ func (s *MetadataService) ExtractSubs(info *MediaInfo) (interface{}, error) {
|
||||
return set(nil, err)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAttachmentPath(sha string, is_sub bool, name string) (string, error) {
|
||||
func (s *MetadataService) GetAttachment(ctx context.Context, sha string, name string) (io.ReadCloser, error) {
|
||||
_, err := s.extractLock.WaitFor(sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := "att"
|
||||
if is_sub {
|
||||
dir = "sub"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s/%s", Settings.Metadata, sha, dir, name), nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func extractSubs(info *MediaInfo) error {
|
||||
defer printExecTime("extraction of %s", info.Path)()
|
||||
itemPath := fmt.Sprintf("%s/%s/%s", sha, "att", name)
|
||||
|
||||
attachment_path := fmt.Sprintf("%s/%s/att", Settings.Metadata, info.Sha)
|
||||
subs_path := fmt.Sprintf("%s/%s/sub", Settings.Metadata, info.Sha)
|
||||
item, err := s.storage.GetItem(ctx, itemPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attachment with path %q: %w", itemPath, err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
os.MkdirAll(attachment_path, 0o755)
|
||||
os.MkdirAll(subs_path, 0o755)
|
||||
func (s *MetadataService) GetSubtitle(ctx context.Context, sha string, name string) (io.ReadCloser, error) {
|
||||
_, err := s.extractLock.WaitFor(sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemPath := fmt.Sprintf("%s/%s/%s", sha, "sub", name)
|
||||
|
||||
item, err := s.storage.GetItem(ctx, itemPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subtitle with path %q: %w", itemPath, err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) extractSubs(ctx context.Context, info *MediaInfo) (err error) {
|
||||
defer utils.PrintExecTime("extraction of %s", info.Path)()
|
||||
|
||||
// If there is no subtitles, there is nothing to extract (also fonts would be useless).
|
||||
if len(info.Subtitles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a temporary directory for writing attachments
|
||||
// TODO if the transcoder ever uses the ffmpeg library directly, remove this
|
||||
// and write directly to storage via stream instead
|
||||
tempWorkingDirectory := filepath.Join(os.TempDir(), info.Sha)
|
||||
if err := os.MkdirAll(tempWorkingDirectory, 0660); err != nil {
|
||||
return fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
|
||||
attachmentWorkingDirectory := filepath.Join(tempWorkingDirectory, "att")
|
||||
if err := os.MkdirAll(attachmentWorkingDirectory, 0660); err != nil {
|
||||
return fmt.Errorf("failed to create attachment directory: %w", err)
|
||||
}
|
||||
|
||||
subtitlesWorkingDirectory := filepath.Join(tempWorkingDirectory, "sub")
|
||||
if err := os.MkdirAll(subtitlesWorkingDirectory, 0660); err != nil {
|
||||
return fmt.Errorf("failed to create subtitles directory: %w", err)
|
||||
}
|
||||
|
||||
defer utils.CleanupWithErr(
|
||||
&err, func() error {
|
||||
return os.RemoveAll(attachmentWorkingDirectory)
|
||||
},
|
||||
"failed to remove attachment working directory",
|
||||
)
|
||||
|
||||
// Dump the attachments and subtitles
|
||||
cmd := exec.Command(
|
||||
"ffmpeg",
|
||||
"-dump_attachment:t", "",
|
||||
@ -56,7 +101,7 @@ func extractSubs(info *MediaInfo) error {
|
||||
"-y",
|
||||
"-i", info.Path,
|
||||
)
|
||||
cmd.Dir = attachment_path
|
||||
cmd.Dir = attachmentWorkingDirectory
|
||||
|
||||
for _, sub := range info.Subtitles {
|
||||
if ext := sub.Extension; ext != nil {
|
||||
@ -68,16 +113,39 @@ func extractSubs(info *MediaInfo) error {
|
||||
cmd.Args,
|
||||
"-map", fmt.Sprintf("0:s:%d", *sub.Index),
|
||||
"-c:s", "copy",
|
||||
fmt.Sprintf("%s/%d.%s", subs_path, *sub.Index, *ext),
|
||||
fmt.Sprintf("%s/%d.%s", subtitlesWorkingDirectory, *sub.Index, *ext),
|
||||
)
|
||||
}
|
||||
}
|
||||
log.Printf("Starting extraction with the command: %s", cmd)
|
||||
cmd.Stdout = nil
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Println("Error starting ffmpeg extract:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Move attachments and subtitles to the storage backend
|
||||
var saveGroup errgroup.Group
|
||||
saveGroup.Go(func() error {
|
||||
err := storage.SaveFilesToBackend(ctx, s.storage, attachmentWorkingDirectory, filepath.Join(info.Sha, "att"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save attachments to backend: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
saveGroup.Go(func() error {
|
||||
err := storage.SaveFilesToBackend(ctx, s.storage, subtitlesWorkingDirectory, filepath.Join(info.Sha, "sub"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save subtitles to backend: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := saveGroup.Wait(); err != nil {
|
||||
return fmt.Errorf("failed while saving files to backend: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
)
|
||||
|
||||
type FileStream struct {
|
||||
@ -24,7 +27,7 @@ type VideoKey struct {
|
||||
quality Quality
|
||||
}
|
||||
|
||||
func (t *Transcoder) newFileStream(path string, sha string) *FileStream {
|
||||
func (t *Transcoder) newFileStream(ctx context.Context, path string, sha string) *FileStream {
|
||||
ret := &FileStream{
|
||||
transcoder: t,
|
||||
Out: fmt.Sprintf("%s/%s", Settings.Outpath, sha),
|
||||
@ -35,7 +38,7 @@ func (t *Transcoder) newFileStream(path string, sha string) *FileStream {
|
||||
ret.ready.Add(1)
|
||||
go func() {
|
||||
defer ret.ready.Done()
|
||||
info, err := t.metadataService.GetMetadata(path, sha)
|
||||
info, err := t.metadataService.GetMetadata(ctx, path, sha)
|
||||
ret.Info = info
|
||||
if err != nil {
|
||||
ret.err = err
|
||||
@ -107,7 +110,7 @@ func (fs *FileStream) GetMaster() string {
|
||||
}
|
||||
|
||||
if def_video != nil {
|
||||
qualities := Filter(Qualities, func(quality Quality) bool {
|
||||
qualities := utils.Filter(Qualities, func(quality Quality) bool {
|
||||
return quality.Height() < def_video.Height
|
||||
})
|
||||
transcode_count := len(qualities)
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
@ -228,7 +229,7 @@ var SubtitleExtensions = map[string]string{
|
||||
}
|
||||
|
||||
func RetriveMediaInfo(path string, sha string) (*MediaInfo, error) {
|
||||
defer printExecTime("mediainfo for %s", path)()
|
||||
defer utils.PrintExecTime("mediainfo for %s", path)()
|
||||
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancelFn()
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
)
|
||||
|
||||
const KeyframeVersion = 1
|
||||
@ -162,7 +163,7 @@ func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32
|
||||
// Returns when all key frames are retrived (or an error occurs)
|
||||
// info.ready.Done() is called when more than 100 are retrived (or extraction is done)
|
||||
func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
|
||||
defer printExecTime("ffprobe keyframe analysis for %s video n%d", path, video_idx)()
|
||||
defer utils.PrintExecTime("ffprobe keyframe analysis for %s video n%d", path, video_idx)()
|
||||
// run ffprobe to return all IFrames, IFrames are points where we can split the video in segments.
|
||||
// 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
|
||||
@ -254,7 +255,7 @@ const DummyKeyframeDuration = float64(4)
|
||||
|
||||
// we can pretty much cut audio at any point so no need to get specific frames, just cut every 4s
|
||||
func getAudioKeyframes(info *MediaInfo, audio_idx uint32, kf *Keyframe) error {
|
||||
defer printExecTime("ffprobe keyframe analysis for %s audio n%d", info.Path, audio_idx)()
|
||||
defer utils.PrintExecTime("ffprobe keyframe analysis for %s audio n%d", info.Path, audio_idx)()
|
||||
// Format's duration CAN be different than audio's duration. To make sure we do not
|
||||
// miss a segment or make one more, we need to check the audio's duration.
|
||||
//
|
||||
|
@ -1,17 +1,21 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/lib/pq"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/zoriya/kyoo/transcoder/src/storage"
|
||||
)
|
||||
|
||||
type MetadataService struct {
|
||||
@ -20,9 +24,60 @@ type MetadataService struct {
|
||||
thumbLock RunLock[string, interface{}]
|
||||
extractLock RunLock[string, interface{}]
|
||||
keyframeLock RunLock[KeyframeKey, *Keyframe]
|
||||
storage storage.StorageBackend
|
||||
}
|
||||
|
||||
func NewMetadataService() (*MetadataService, error) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s := &MetadataService{
|
||||
lock: NewRunLock[string, *MediaInfo](),
|
||||
thumbLock: NewRunLock[string, interface{}](),
|
||||
extractLock: NewRunLock[string, interface{}](),
|
||||
keyframeLock: NewRunLock[KeyframeKey, *Keyframe](),
|
||||
}
|
||||
|
||||
db, err := s.setupDb()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to setup database: %w", err)
|
||||
}
|
||||
s.database = db
|
||||
|
||||
storage, err := s.setupStorage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to setup storage: %w", err)
|
||||
}
|
||||
s.storage = storage
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) Close() error {
|
||||
cleanupErrs := make([]error, 0, 2)
|
||||
if s.database != nil {
|
||||
err := s.database.Close()
|
||||
if err != nil {
|
||||
cleanupErrs = append(cleanupErrs, fmt.Errorf("failed to close database: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if s.storage != nil {
|
||||
if storageCloser, ok := s.storage.(storage.StorageBackendCloser); ok {
|
||||
err := storageCloser.Close()
|
||||
if err != nil {
|
||||
cleanupErrs = append(cleanupErrs, fmt.Errorf("failed to close storage: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := errors.Join(cleanupErrs...); err != nil {
|
||||
return fmt.Errorf("failed to cleanup resources: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) setupDb() (*sql.DB, error) {
|
||||
schema := GetEnvOr("POSTGRES_SCHEMA", "gocoder")
|
||||
|
||||
connectionString := os.Getenv("POSTGRES_URL")
|
||||
@ -64,26 +119,44 @@ func NewMetadataService() (*MetadataService, error) {
|
||||
}
|
||||
m.Up()
|
||||
|
||||
return &MetadataService{
|
||||
database: db,
|
||||
lock: NewRunLock[string, *MediaInfo](),
|
||||
thumbLock: NewRunLock[string, interface{}](),
|
||||
extractLock: NewRunLock[string, interface{}](),
|
||||
keyframeLock: NewRunLock[KeyframeKey, *Keyframe](),
|
||||
}, nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetMetadata(path string, sha string) (*MediaInfo, error) {
|
||||
func (s *MetadataService) setupStorage(ctx context.Context) (storage.StorageBackend, error) {
|
||||
s3BucketName := os.Getenv("S3_BUCKET_NAME")
|
||||
if s3BucketName != "" {
|
||||
// Use S3 storage
|
||||
// Create the client (use all standard AWS config sources like env vars, config files, etc.)
|
||||
awsConfig, err := config.LoadDefaultConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
||||
}
|
||||
s3Client := s3.NewFromConfig(awsConfig)
|
||||
|
||||
return storage.NewS3StorageBackend(s3Client, s3BucketName), nil
|
||||
}
|
||||
|
||||
// Use local file storage
|
||||
storageRoot := GetEnvOr("GOCODER_METADATA_ROOT", "/metadata")
|
||||
|
||||
localStorage, err := storage.NewFileStorageBackend(storageRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create local storage backend: %w", err)
|
||||
}
|
||||
return localStorage, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetMetadata(ctx context.Context, path string, sha string) (*MediaInfo, error) {
|
||||
ret, err := s.getMetadata(path, sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ret.Versions.Thumbs < ThumbsVersion {
|
||||
go s.ExtractThumbs(path, sha)
|
||||
go s.ExtractThumbs(ctx, path, sha)
|
||||
}
|
||||
if ret.Versions.Extract < ExtractVersion {
|
||||
go s.ExtractSubs(ret)
|
||||
go s.ExtractSubs(ctx, ret)
|
||||
}
|
||||
if ret.Versions.Keyframes < KeyframeVersion && ret.Versions.Keyframes != 0 {
|
||||
for _, video := range ret.Videos {
|
||||
@ -229,6 +302,10 @@ func (s *MetadataService) storeFreshMetadata(path string, sha string) (*MediaInf
|
||||
}
|
||||
|
||||
tx, err := s.database.Begin()
|
||||
if err != nil {
|
||||
return set(ret, err)
|
||||
}
|
||||
|
||||
// it needs to be a delete instead of a on conflict do update because we want to trigger delete casquade for
|
||||
// videos/audios & co.
|
||||
tx.Exec(`delete from info where path = $1`, path)
|
||||
|
@ -15,7 +15,6 @@ func GetEnvOr(env string, def string) string {
|
||||
|
||||
type SettingsT struct {
|
||||
Outpath string
|
||||
Metadata string
|
||||
RoutePrefix string
|
||||
SafePath string
|
||||
HwAccel HwAccelT
|
||||
@ -32,7 +31,6 @@ type HwAccelT struct {
|
||||
var Settings = SettingsT{
|
||||
// we manually add a folder to make sure we do not delete user data.
|
||||
Outpath: path.Join(GetEnvOr("GOCODER_CACHE_ROOT", "/cache"), "kyoo_cache"),
|
||||
Metadata: GetEnvOr("GOCODER_METADATA_ROOT", "/metadata"),
|
||||
RoutePrefix: GetEnvOr("GOCODER_PREFIX", ""),
|
||||
SafePath: GetEnvOr("GOCODER_SAFE_PATH", "/video"),
|
||||
HwAccel: DetectHardwareAccel(),
|
||||
|
201
transcoder/src/storage/filestorage.go
Normal file
201
transcoder/src/storage/filestorage.go
Normal file
@ -0,0 +1,201 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
)
|
||||
|
||||
type FileStorageBackend struct {
|
||||
// Base directory for the storage backend
|
||||
baseDirectory string
|
||||
baseDirectoryRoot *os.Root
|
||||
}
|
||||
|
||||
// NewFileStorageBackend creates a new FileStorageBackend with the specified base directory.
|
||||
func NewFileStorageBackend(baseDirectory string) (*FileStorageBackend, error) {
|
||||
// Attempt to create the directory if it doesn't exist
|
||||
// This should be the only filesystem call in this file that does not use os.Root.
|
||||
// This is to prevent directory traversal attacks when the provided input is untrusted.
|
||||
if err := os.MkdirAll(baseDirectory, 0770); err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage base directory: %w", err)
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(baseDirectory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open storage base directory %q: %w", baseDirectory, err)
|
||||
}
|
||||
|
||||
return &FileStorageBackend{
|
||||
baseDirectory: baseDirectory,
|
||||
baseDirectoryRoot: root,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fsb *FileStorageBackend) Close() error {
|
||||
if fsb.baseDirectoryRoot != nil {
|
||||
return fsb.baseDirectoryRoot.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DoesItemExist checks if an item exists in the file storage backend.
|
||||
func (fsb *FileStorageBackend) DoesItemExist(_ context.Context, path string) (bool, error) {
|
||||
_, err := fsb.baseDirectoryRoot.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to check if item %q exists: %w", path, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListItemsWithPrefix returns a list of items in the storage backend that match the given prefix.
|
||||
func (fsb *FileStorageBackend) ListItemsWithPrefix(_ context.Context, pathPrefix string) ([]string, error) {
|
||||
var items []string
|
||||
rootFS := fsb.baseDirectoryRoot.FS()
|
||||
// This is split so that a smaller subset of files are checked, rather than literally everything under the base directory.
|
||||
// All matching files for the path prefix will also have the prefixDirPath as their parent directory.
|
||||
prefixDirPath := filepath.Dir(pathPrefix)
|
||||
|
||||
err := fs.WalkDir(rootFS, prefixDirPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// This can happen if prefixDirPath does not exist. The walk function will handle
|
||||
// checking this.
|
||||
if os.IsNotExist(err) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return fmt.Errorf("failed on %q while walking directory %q: %w", path, pathPrefix, err)
|
||||
}
|
||||
|
||||
// If the path does not start with the prefix, skip it.
|
||||
if !strings.HasPrefix(path, pathPrefix) {
|
||||
if d.IsDir() {
|
||||
// Skip directories that do not match the prefix
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect matching non-directory items
|
||||
if !d.IsDir() {
|
||||
items = append(items, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory %q: %w", pathPrefix, err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DeleteItem deletes an item from the storage backend. If the item does not exist, it returns nil.
|
||||
func (fsb *FileStorageBackend) DeleteItem(_ context.Context, path string) error {
|
||||
err := fsb.baseDirectoryRoot.Remove(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete item %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteItemsWithPrefix deletes all items in the storage backend that match the given prefix.
|
||||
// Deletion should be "syncronous" (i.e. the function should block until the write is complete).
|
||||
func (fsb *FileStorageBackend) DeleteItemsWithPrefix(ctx context.Context, pathPrefix string) error {
|
||||
// Unfortunately this implementation is needed until https://go-review.googlesource.com/c/go/+/661595 is released.
|
||||
// The new os.Root type does not yet have a RemoveAll method. The next Go release will have this.
|
||||
// Once RemoveAll is available, this function can be reduced to a single ReadDir call, a filter, and a RemoveAll call.
|
||||
|
||||
// Get all items with the prefix
|
||||
items, err := fsb.ListItemsWithPrefix(ctx, pathPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list items with prefix %q: %w", pathPrefix, err)
|
||||
}
|
||||
|
||||
// Delete all items. This will leave behind empty directories, but that shouldn't really matter. A future
|
||||
// implementation that uses os.Root.RemoveAll will handle this.
|
||||
for _, item := range items {
|
||||
err = fsb.DeleteItem(ctx, item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete item %q: %w", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveItemWithCallback saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
// The writeContents function is called with a writer to write the contents of the item.
|
||||
func (fsb *FileStorageBackend) SaveItemWithCallback(ctx context.Context, path string, writeContents ContentsWriterCallback) (err error) {
|
||||
// Open the file for writing
|
||||
file, err := fsb.openFileForWriting(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %q for writing: %w", path, err)
|
||||
}
|
||||
defer utils.CleanupWithErr(&err, file.Close, "failed to close file %q", path)
|
||||
|
||||
// Write the contents using the provided callback
|
||||
if err := writeContents(ctx, file); err != nil {
|
||||
return fmt.Errorf("failed to write contents to file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveItem saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
func (fsb *FileStorageBackend) SaveItem(ctx context.Context, path string, contents io.Reader) (err error) {
|
||||
// Open the file for writing
|
||||
file, err := fsb.openFileForWriting(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %q for writing: %w", path, err)
|
||||
}
|
||||
defer utils.CleanupWithErr(&err, file.Close, "failed to close file %q", path)
|
||||
|
||||
// Copy the contents to the file
|
||||
if _, err := io.Copy(file, contents); err != nil {
|
||||
return fmt.Errorf("failed to copy contents to file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// openFileForWriting opens a file for writing. If the file already exists, it overwrites it.
|
||||
// The parent directory is created if it doesn't exist.
|
||||
// This function is used internally to create files in the storage backend.
|
||||
// The returned file should be closed by the caller.
|
||||
func (fsb *FileStorageBackend) openFileForWriting(path string) (*os.File, error) {
|
||||
// Create the parent directory if it doesn't exist
|
||||
dir := filepath.Dir(path)
|
||||
if err := fsb.baseDirectoryRoot.Mkdir(dir, 0770); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
// Open the file for writing
|
||||
file, err := fsb.baseDirectoryRoot.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0660)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// GetItem retrieves an item from the storage backend.
|
||||
func (fsb *FileStorageBackend) GetItem(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
file, err := fsb.baseDirectoryRoot.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
226
transcoder/src/storage/s3storage.go
Normal file
226
transcoder/src/storage/s3storage.go
Normal file
@ -0,0 +1,226 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var noSuchKeyError = &s3types.NoSuchKey{}
|
||||
|
||||
// S3StorageBackend is a struct that implements the StorageBackend interface,
|
||||
// backed by S3-compatible object storage.
|
||||
// It is up to the user to ensure that the object storage service supports
|
||||
// consistent reads, writes, and deletes. This known to be supported by:
|
||||
// - AWS S3
|
||||
// - MinIO
|
||||
// - Ceph RGW
|
||||
// This is known to _not_ be supported by:
|
||||
// - SeaweedFS
|
||||
type S3StorageBackend struct {
|
||||
s3Client *s3.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewS3StorageBackend creates a new S3StorageBackend with the specified bucket name and S3 client.
|
||||
func NewS3StorageBackend(s3Client *s3.Client, bucket string) *S3StorageBackend {
|
||||
return &S3StorageBackend{
|
||||
s3Client: s3Client,
|
||||
bucket: bucket,
|
||||
}
|
||||
}
|
||||
|
||||
// DoesItemExist checks if an item exists in the file storage backend.
|
||||
func (ssb *S3StorageBackend) DoesItemExist(ctx context.Context, path string) (bool, error) {
|
||||
_, err := ssb.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: &ssb.bucket,
|
||||
Key: &path,
|
||||
})
|
||||
if err != nil {
|
||||
var responseError *awshttp.ResponseError
|
||||
if errors.As(err, &responseError) && responseError.ResponseError.HTTPStatusCode() == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to check if item %q exists in bucket %q: %w", path, ssb.bucket, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListItemsWithPrefix returns a list of items in the storage backend that match the given prefix.
|
||||
func (ssb *S3StorageBackend) ListItemsWithPrefix(ctx context.Context, pathPrefix string) ([]string, error) {
|
||||
listObjectsInput := &s3.ListObjectsV2Input{
|
||||
Bucket: &ssb.bucket,
|
||||
Prefix: &pathPrefix,
|
||||
}
|
||||
|
||||
paginator := s3.NewListObjectsV2Paginator(ssb.s3Client, listObjectsInput)
|
||||
|
||||
var items []string
|
||||
for paginator.HasMorePages() {
|
||||
resp, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list items with prefix %q in bucket %q: %w", pathPrefix, ssb.bucket, err)
|
||||
}
|
||||
|
||||
for _, item := range resp.Contents {
|
||||
if item.Key != nil {
|
||||
items = append(items, *item.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DeleteItem deletes an item from the storage backend. If the item does not exist, it returns nil.
|
||||
func (ssb *S3StorageBackend) DeleteItem(ctx context.Context, path string) error {
|
||||
_, err := ssb.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &ssb.bucket,
|
||||
Key: &path,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, noSuchKeyError) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete item %q from bucket %q: %w", path, ssb.bucket, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteItemsWithPrefix deletes all items in the storage backend that match the given prefix.
|
||||
// Deletion should be "syncronous" (i.e. the function should block until the write is complete).
|
||||
func (ssb *S3StorageBackend) DeleteItemsWithPrefix(ctx context.Context, pathPrefix string) error {
|
||||
// Get all items with the prefix
|
||||
items, err := ssb.ListItemsWithPrefix(ctx, pathPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list items with prefix %q: %w", pathPrefix, err)
|
||||
}
|
||||
|
||||
// Fast path: if there are no items, return early.
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete all items. This uses the DeleteObjects API call, which is more efficient
|
||||
// than deleting each item individually.
|
||||
|
||||
// The API call supports up to 1000 items at a time, so chunk the items if needed.
|
||||
chunkSize := min(len(items), 1000)
|
||||
chunkItems := make([]s3types.ObjectIdentifier, chunkSize)
|
||||
var deletionRequests errgroup.Group
|
||||
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
chunkIndex := i % chunkSize
|
||||
|
||||
chunkItems[chunkIndex] = s3types.ObjectIdentifier{
|
||||
Key: &item,
|
||||
}
|
||||
|
||||
// If the chunk is full, delete the objects in this chunk.
|
||||
if chunkIndex == chunkSize-1 {
|
||||
deletionRequests.Go(func() error {
|
||||
_, err := ssb.s3Client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: &ssb.bucket,
|
||||
Delete: &s3types.Delete{
|
||||
Objects: chunkItems,
|
||||
// Only include keys in the response that encountered an error.
|
||||
Quiet: aws.Bool(true),
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
chunkNumber := 1 + i/chunkSize // Int division in Go rounds down.
|
||||
// TODO if the error doesn't include sufficient information, the below line
|
||||
// will need to pull in error details from the response.
|
||||
return fmt.Errorf("failed to delete items in chunk %d with prefix %q: %w", chunkNumber, pathPrefix, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = deletionRequests.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete one or more items with prefix %q: %w", pathPrefix, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveItemWithCallback saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
// The writeContents function is called with a writer to write the contents of the item.
|
||||
func (ssb *S3StorageBackend) SaveItemWithCallback(ctx context.Context, path string, writeContents ContentsWriterCallback) (err error) {
|
||||
// Create a pipe to connect the writer and reader.
|
||||
pr, pw := io.Pipe()
|
||||
defer utils.CleanupWithErr(&err, pr.Close, "failed to close pipe reader")
|
||||
|
||||
// Start a goroutine to write to the pipe.
|
||||
// Writing and reading must occur concurrently to avoid deadlocks.
|
||||
|
||||
// Use a separate context for the writer to allow cancellation if the upload fails. This is important to avoid
|
||||
// a hung goroutine leak if the upload fails.
|
||||
writeCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var writerGroup errgroup.Group
|
||||
writerGroup.Go(func() (err error) {
|
||||
defer utils.CleanupWithErr(&err, pw.Close, "failed to close pipe writer")
|
||||
return writeContents(writeCtx, pw)
|
||||
})
|
||||
|
||||
// Wait for the write to complete and check for errors.
|
||||
// This should always happen even if saving fails, to prevent a goroutine leak.
|
||||
defer utils.CleanupWithErr(&err, writerGroup.Wait, "writer callback failed")
|
||||
|
||||
// Upload the object to S3 using the pipe as the body.
|
||||
if err := ssb.SaveItem(ctx, path, pr); err != nil {
|
||||
// Ensure that the writer context is cancelled prior to awaiting for the writer to finish.
|
||||
// This is important to avoid a hung goroutine leak if the upload fails.
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveItem saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
func (ssb *S3StorageBackend) SaveItem(ctx context.Context, path string, contents io.Reader) error {
|
||||
// Upload the object to S3 using the provided reader as the body.
|
||||
_, err := ssb.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &ssb.bucket,
|
||||
Key: &path,
|
||||
Body: contents,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save item %q to bucket %q: %w", path, ssb.bucket, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetItem retrieves an item from the storage backend.
|
||||
func (ssb *S3StorageBackend) GetItem(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
resp, err := ssb.s3Client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &ssb.bucket,
|
||||
Key: &path,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get item %q from bucket %q: %w", path, ssb.bucket, err)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
95
transcoder/src/storage/storage.go
Normal file
95
transcoder/src/storage/storage.go
Normal file
@ -0,0 +1,95 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type ContentsWriterCallback func(ctx context.Context, writer io.Writer) error
|
||||
|
||||
// Handles storage and retrieval of items in a storage backend.
|
||||
// "Items" are pieces of data that can be stored and retrieved.
|
||||
// Paths with ".." are not allowed, and may result in unexpected behavior.
|
||||
type StorageBackend interface {
|
||||
// DoesItemExist checks if an item exists in the storage backend.
|
||||
DoesItemExist(ctx context.Context, path string) (bool, error)
|
||||
// ListItemsWithPrefix returns a list of items in the storage backend that match the given prefix.
|
||||
// Note: returned items may have a "/" in them, e.g. "foo/bar/baz".
|
||||
ListItemsWithPrefix(ctx context.Context, pathPrefix string) ([]string, error)
|
||||
// DeleteItem deletes an item from the storage backend. If the item does not exist, it returns nil.
|
||||
// Deletion should be "synchronous" (i.e. the function should block until the write is complete).
|
||||
DeleteItem(ctx context.Context, path string) error
|
||||
// DeleteItemsWithPrefix deletes all items in the storage backend that match the given prefix.
|
||||
// Deletion should be "synchronous" (i.e. the function should block until the write is complete).
|
||||
DeleteItemsWithPrefix(ctx context.Context, pathPrefix string) error
|
||||
// SaveItemWithCallback saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
// The writeContents function is called with a writer to write the contents of the item.
|
||||
// Writes should be "synchronous" (i.e. the function should block until the write is complete).
|
||||
SaveItemWithCallback(ctx context.Context, path string, writeContents ContentsWriterCallback) error
|
||||
// SaveItem saves an item to the storage backend. If the item already exists, it overwrites it.
|
||||
SaveItem(ctx context.Context, path string, contents io.Reader) error
|
||||
// GetItem retrieves an item from the storage backend.
|
||||
GetItem(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type StorageBackendCloser interface {
|
||||
StorageBackend
|
||||
// Close closes the storage backend. This should be called when the backend is no longer needed.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SaveFilesToBackend is a helper function that saves files from the source directory to the backend storage.
|
||||
func SaveFilesToBackend(ctx context.Context, backend StorageBackend, sourceDirectory, destinationBasePath string) error {
|
||||
// Allow for save in parallel. Running in series can be slow with many files.
|
||||
var saveGroup errgroup.Group
|
||||
|
||||
err := filepath.WalkDir(sourceDirectory, func(sourcePath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error walking the path %q: %v", sourcePath, err)
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path relative to the source directory
|
||||
relativePath, err := filepath.Rel(sourceDirectory, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Destination path in the backend storage
|
||||
destinationPath := filepath.Join(destinationBasePath, relativePath)
|
||||
saveGroup.Go(func() (err error) {
|
||||
file, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %q: %w", sourcePath, err)
|
||||
}
|
||||
utils.CleanupWithErr(&err, file.Close, "failed to close file %q", sourcePath)
|
||||
|
||||
if err := backend.SaveItem(ctx, destinationPath, file); err != nil {
|
||||
return fmt.Errorf("failed to save file %q to backend: %w", sourcePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed while walking over files to save to the backend: %w", err)
|
||||
}
|
||||
|
||||
if err := saveGroup.Wait(); err != nil {
|
||||
return fmt.Errorf("failed to save files to backend: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
@ -72,7 +73,7 @@ outer:
|
||||
// "hi" by itself means a language code, but when combined with other lang flags it means Hearing Impaired.
|
||||
// In case Hindi was not detected before, but "hi" is present, assume it is Hindi.
|
||||
if sub.Language == nil {
|
||||
hiCount := Count(flags, "hi")
|
||||
hiCount := utils.Count(flags, "hi")
|
||||
if hiCount > 0 {
|
||||
languageStr := language.Hindi.String()
|
||||
sub.Language = &languageStr
|
||||
|
@ -1,17 +1,19 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/zoriya/kyoo/transcoder/src/utils"
|
||||
"gitlab.com/opennota/screengen"
|
||||
)
|
||||
|
||||
@ -29,33 +31,54 @@ type Thumbnail struct {
|
||||
|
||||
const ThumbsVersion = 1
|
||||
|
||||
func getThumbGlob(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v*.*", Settings.Metadata, sha)
|
||||
// getThumbGlob returns the path prefix for all thumbnail files for a given sha.
|
||||
func getThumbPrefix(sha string) string {
|
||||
return sha
|
||||
}
|
||||
|
||||
func getThumbPath(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v%d.png", Settings.Metadata, sha, ThumbsVersion)
|
||||
return fmt.Sprintf("%s/thumbs-v%d.png", sha, ThumbsVersion)
|
||||
}
|
||||
|
||||
func getThumbVttPath(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v%d.vtt", Settings.Metadata, sha, ThumbsVersion)
|
||||
return fmt.Sprintf("%s/thumbs-v%d.vtt", sha, ThumbsVersion)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetThumb(path string, sha string) (string, string, error) {
|
||||
_, err := s.ExtractThumbs(path, sha)
|
||||
func (s *MetadataService) GetThumbVtt(ctx context.Context, path string, sha string) (io.ReadCloser, error) {
|
||||
_, err := s.ExtractThumbs(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return getThumbPath(sha), getThumbVttPath(sha), nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *MetadataService) ExtractThumbs(path string, sha string) (interface{}, error) {
|
||||
vttPath := getThumbVttPath(sha)
|
||||
vtt, err := s.storage.GetItem(ctx, vttPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get thumbnail vtt with path %q: %w", vttPath, err)
|
||||
}
|
||||
return vtt, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetThumbSprite(ctx context.Context, path string, sha string) (io.ReadCloser, error) {
|
||||
_, err := s.ExtractThumbs(ctx, path, sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spritePath := getThumbPath(sha)
|
||||
sprite, err := s.storage.GetItem(ctx, spritePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get thumbnail sprite with path %q: %w", spritePath, err)
|
||||
}
|
||||
return sprite, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) ExtractThumbs(ctx context.Context, path string, sha string) (interface{}, error) {
|
||||
get_running, set := s.thumbLock.Start(sha)
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
err := extractThumbnail(path, sha)
|
||||
err := s.extractThumbnail(ctx, path, sha)
|
||||
if err != nil {
|
||||
return set(nil, err)
|
||||
}
|
||||
@ -63,12 +86,18 @@ func (s *MetadataService) ExtractThumbs(path string, sha string) (interface{}, e
|
||||
return set(nil, err)
|
||||
}
|
||||
|
||||
func extractThumbnail(path string, sha string) error {
|
||||
defer printExecTime("extracting thumbnails for %s", path)()
|
||||
func (s *MetadataService) extractThumbnail(ctx context.Context, path string, sha string) (err error) {
|
||||
defer utils.PrintExecTime("extracting thumbnails for %s", path)()
|
||||
|
||||
os.MkdirAll(fmt.Sprintf("%s/%s", Settings.Metadata, sha), 0o755)
|
||||
vttPath := getThumbVttPath(sha)
|
||||
spritePath := getThumbPath(sha)
|
||||
newItemPaths := []string{spritePath, vttPath}
|
||||
|
||||
if _, err := os.Stat(getThumbPath(sha)); err == nil {
|
||||
doAllExist, err := s.doAllThumbnailFilesExist(ctx, newItemPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if thumbnail files exist: %w", err)
|
||||
}
|
||||
if doAllExist {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -77,7 +106,7 @@ func extractThumbnail(path string, sha string) error {
|
||||
log.Printf("Error reading video file: %v", err)
|
||||
return err
|
||||
}
|
||||
defer gen.Close()
|
||||
defer utils.CleanupWithErr(&err, gen.Close, "failed to close screengen generator")
|
||||
|
||||
gen.Fast = true
|
||||
|
||||
@ -128,24 +157,47 @@ func extractThumbnail(path string, sha string) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Cleanup old versions of thumbnails
|
||||
files, err := filepath.Glob(getThumbGlob(sha))
|
||||
if err == nil {
|
||||
for _, f := range files {
|
||||
// ignore errors
|
||||
os.Remove(f)
|
||||
}
|
||||
// Cleanup old thumbnails
|
||||
if err := s.storage.DeleteItemsWithPrefix(ctx, getThumbPrefix(sha)); err != nil {
|
||||
return fmt.Errorf("failed to delete old thumbnails: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(getThumbVttPath(sha), []byte(vtt), 0o644)
|
||||
// Store the new items
|
||||
// Thumbnail vtt
|
||||
if err = s.storage.SaveItem(ctx, vttPath, strings.NewReader(vtt)); err != nil {
|
||||
return fmt.Errorf("failed to save thumbnail vtt with path %q: %w", vttPath, err)
|
||||
}
|
||||
|
||||
// Thumbnail sprite
|
||||
spriteFormat, err := imaging.FormatFromFilename(spritePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = imaging.Save(sprite, getThumbPath(sha))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
err = s.storage.SaveItemWithCallback(ctx, spritePath, func(_ context.Context, writer io.Writer) error {
|
||||
if err := imaging.Encode(writer, sprite, spriteFormat); err != nil {
|
||||
return fmt.Errorf("failed to encode thumbnail sprite: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save thumbnail sprite with path %q: %w", spritePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) doAllThumbnailFilesExist(ctx context.Context, filePaths []string) (bool, error) {
|
||||
for _, filePath := range filePaths {
|
||||
doesExist, err := s.storage.DoesItemExist(ctx, filePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check if thumbnail file %q exists: %w", filePath, err)
|
||||
}
|
||||
if !doesExist {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func tsToVttTime(ts int) string {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
@ -36,9 +37,9 @@ func NewTranscoder(metadata *MetadataService) (*Transcoder, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *Transcoder) getFileStream(path string, sha string) (*FileStream, error) {
|
||||
func (t *Transcoder) getFileStream(ctx context.Context, path string, sha string) (*FileStream, error) {
|
||||
ret, _ := t.streams.GetOrCreate(sha, func() *FileStream {
|
||||
return t.newFileStream(path, sha)
|
||||
return t.newFileStream(ctx, path, sha)
|
||||
})
|
||||
ret.ready.Wait()
|
||||
if ret.err != nil {
|
||||
@ -48,8 +49,8 @@ func (t *Transcoder) getFileStream(path string, sha string) (*FileStream, error)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetMaster(path string, client string, sha string) (string, error) {
|
||||
stream, err := t.getFileStream(path, sha)
|
||||
func (t *Transcoder) GetMaster(ctx context.Context, path string, client string, sha string) (string, error) {
|
||||
stream, err := t.getFileStream(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -66,13 +67,14 @@ func (t *Transcoder) GetMaster(path string, client string, sha string) (string,
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetVideoIndex(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
video uint32,
|
||||
quality Quality,
|
||||
client string,
|
||||
sha string,
|
||||
) (string, error) {
|
||||
stream, err := t.getFileStream(path, sha)
|
||||
stream, err := t.getFileStream(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -89,12 +91,13 @@ func (t *Transcoder) GetVideoIndex(
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetAudioIndex(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
audio uint32,
|
||||
client string,
|
||||
sha string,
|
||||
) (string, error) {
|
||||
stream, err := t.getFileStream(path, sha)
|
||||
stream, err := t.getFileStream(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -110,6 +113,7 @@ func (t *Transcoder) GetAudioIndex(
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetVideoSegment(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
video uint32,
|
||||
quality Quality,
|
||||
@ -117,7 +121,7 @@ func (t *Transcoder) GetVideoSegment(
|
||||
client string,
|
||||
sha string,
|
||||
) (string, error) {
|
||||
stream, err := t.getFileStream(path, sha)
|
||||
stream, err := t.getFileStream(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -134,13 +138,14 @@ func (t *Transcoder) GetVideoSegment(
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetAudioSegment(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
audio uint32,
|
||||
segment int32,
|
||||
client string,
|
||||
sha string,
|
||||
) (string, error) {
|
||||
stream, err := t.getFileStream(path, sha)
|
||||
stream, err := t.getFileStream(ctx, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -1,38 +0,0 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func printExecTime(message string, args ...any) func() {
|
||||
msg := fmt.Sprintf(message, args...)
|
||||
start := time.Now()
|
||||
log.Printf("Running %s", msg)
|
||||
|
||||
return func() {
|
||||
log.Printf("%s finished in %s", msg, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func Filter[E any](s []E, f func(E) bool) []E {
|
||||
s2 := make([]E, 0, len(s))
|
||||
for _, e := range s {
|
||||
if f(e) {
|
||||
s2 = append(s2, e)
|
||||
}
|
||||
}
|
||||
return s2
|
||||
}
|
||||
|
||||
// Count returns the number of elements in s that are equal to e.
|
||||
func Count[S []E, E comparable](s S, e E) int {
|
||||
var n int
|
||||
for _, v := range s {
|
||||
if v == e {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
54
transcoder/src/utils/utils.go
Normal file
54
transcoder/src/utils/utils.go
Normal file
@ -0,0 +1,54 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func PrintExecTime(message string, args ...any) func() {
|
||||
msg := fmt.Sprintf(message, args...)
|
||||
start := time.Now()
|
||||
log.Printf("Running %s", msg)
|
||||
|
||||
return func() {
|
||||
log.Printf("%s finished in %s", msg, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func Filter[E any](s []E, f func(E) bool) []E {
|
||||
s2 := make([]E, 0, len(s))
|
||||
for _, e := range s {
|
||||
if f(e) {
|
||||
s2 = append(s2, e)
|
||||
}
|
||||
}
|
||||
return s2
|
||||
}
|
||||
|
||||
// Count returns the number of elements in s that are equal to e.
|
||||
func Count[S []E, E comparable](s S, e E) int {
|
||||
var n int
|
||||
for _, v := range s {
|
||||
if v == e {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CleanupWithErr runs a cleanup function and checks if it returns an error.
|
||||
// If the cleanup function returns an error, it is joined with the original error
|
||||
// and assigned to the original error pointer.
|
||||
func CleanupWithErr(err *error, fn func() error, msg string, args ...any) {
|
||||
cleanupErr := fn()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cleanupErr != nil {
|
||||
*err = fmt.Errorf("%s: %w", fmt.Sprintf(msg, args...), cleanupErr)
|
||||
}
|
||||
*err = errors.Join(*err, cleanupErr)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user