chore: 初始化 Cloudreve 源码仓库

This commit is contained in:
2026-09-13 11:28:59 +08:00
commit 2a7b03ab1d
489 changed files with 163867 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
package thumb
import (
"bytes"
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"path/filepath"
//"github.com/nfnt/resize"
"golang.org/x/image/draw"
)
const thumbTempFolder = "thumb"
// maxDecodePixels caps the decoded pixel count to prevent pixel-bomb DoS:
// stdlib decoders allocate bytesPerPixel*W*H from the header before reading
// pixel data, so a tiny file with huge dimensions can otherwise OOM-kill the
// process. 64 MP covers any realistic camera output.
const maxDecodePixels = 64 * 1000 * 1000
// BuiltinSupportedExts lists file extensions supported by the built-in
// thumbnail generator. Extensions are lowercased and do not include the dot.
var BuiltinSupportedExts = []string{"jpg", "jpeg", "png", "gif"}
// Thumb 缩略图
type Thumb struct {
src image.Image
ext string
}
// NewThumbFromFile 从文件数据获取新的Thumb对象,
// 尝试通过文件名name解码图像
func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) {
// 无扩展名时
if ext == "" {
return nil, fmt.Errorf("unknown image format: %w", ErrPassThrough)
}
var hdr bytes.Buffer
cfg, _, err := image.DecodeConfig(io.TeeReader(file, &hdr))
if err != nil {
return nil, fmt.Errorf("failed to read image config: %w (%w)", err, ErrPassThrough)
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
return nil, fmt.Errorf("image dimensions too large (%dx%d): %w",
cfg.Width, cfg.Height, ErrPassThrough)
}
file = io.MultiReader(&hdr, file)
var img image.Image
switch ext {
case "jpg", "jpeg":
img, err = jpeg.Decode(file)
case "gif":
img, err = gif.Decode(file)
case "png":
img, err = png.Decode(file)
default:
return nil, fmt.Errorf("unknown image format %q: %w", ext, ErrPassThrough)
}
if err != nil {
return nil, fmt.Errorf("failed to parse image: %w (%w)", err, ErrPassThrough)
}
return &Thumb{
src: img,
ext: ext[1:],
}, nil
}
// GetThumb 生成给定最大尺寸的缩略图
func (image *Thumb) GetThumb(width, height uint) {
//image.src = resize.Thumbnail(width, height, image.src, resize.Lanczos3)
image.src = Thumbnail(width, height, image.src)
}
// GetSize 获取图像尺寸
func (image *Thumb) GetSize() (int, int) {
b := image.src.Bounds()
return b.Max.X, b.Max.Y
}
// Save 保存图像到给定路径
func (image *Thumb) Save(w io.Writer, encodeSetting *setting.ThumbEncode) (err error) {
switch encodeSetting.Format {
case "png":
err = png.Encode(w, image.src)
default:
err = jpeg.Encode(w, image.src, &jpeg.Options{Quality: encodeSetting.Quality})
}
return err
}
// Thumbnail will downscale provided image to max width and height preserving
// original aspect ratio and using the interpolation function interp.
// It will return original image, without processing it, if original sizes
// are already smaller than provided constraints.
func Thumbnail(maxWidth, maxHeight uint, img image.Image) image.Image {
origBounds := img.Bounds()
origWidth := uint(origBounds.Dx())
origHeight := uint(origBounds.Dy())
newWidth, newHeight := origWidth, origHeight
// Return original image if it have same or smaller size as constraints
if maxWidth >= origWidth && maxHeight >= origHeight {
return img
}
// Preserve aspect ratio
if origWidth > maxWidth {
newHeight = uint(origHeight * maxWidth / origWidth)
if newHeight < 1 {
newHeight = 1
}
newWidth = maxWidth
}
if newHeight > maxHeight {
newWidth = uint(newWidth * maxHeight / newHeight)
if newWidth < 1 {
newWidth = 1
}
newHeight = maxHeight
}
return Resize(newWidth, newHeight, img)
}
func Resize(newWidth, newHeight uint, img image.Image) image.Image {
// Set the expected size that you want:
dst := image.NewNRGBA(image.Rect(0, 0, int(newWidth), int(newHeight)))
// Resize:
draw.BiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Src, nil)
return dst
}
// CreateAvatar 创建头像
func (image *Thumb) CreateAvatar(width int) {
image.src = Resize(uint(width), uint(width), image.src)
}
type Builtin struct {
settings setting.Provider
}
func NewBuiltinGenerator(settings setting.Provider) *Builtin {
return &Builtin{
settings: settings,
}
}
func (b Builtin) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if es.Entity().Size() > b.settings.BuiltinThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
img, err := NewThumbFromFile(es, ext)
if err != nil {
return nil, err
}
w, h := b.settings.ThumbSize(ctx)
img.GetThumb(uint(w), uint(h))
tempPath := filepath.Join(
util.DataPath(b.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
)
thumbFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer thumbFile.Close()
if err := img.Save(thumbFile, b.settings.ThumbEncode(ctx)); err != nil {
return &Result{Path: tempPath}, err
}
return &Result{Path: tempPath}, nil
}
func (b Builtin) Priority() int {
return 300
}
func (b Builtin) Enabled(ctx context.Context) bool {
return b.settings.BuiltinThumbGeneratorEnabled(ctx)
}
+109
View File
@@ -0,0 +1,109 @@
package thumb
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
)
const (
urlTimeout = time.Duration(1) * time.Hour
)
func NewFfmpegGenerator(l logging.Logger, settings setting.Provider) *FfmpegGenerator {
return &FfmpegGenerator{l: l, settings: settings}
}
type FfmpegGenerator struct {
l logging.Logger
settings setting.Provider
}
func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if !util.IsInExtensionListExt(f.settings.FFMpegThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
if es.Entity().Size() > f.settings.FFMpegThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
tempOutputPath := filepath.Join(
util.DataPath(f.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s.png", uuid.Must(uuid.NewV4()).String()),
)
if err := util.CreatNestedFolder(filepath.Dir(tempOutputPath)); err != nil {
return nil, fmt.Errorf("failed to create temp folder: %w", err)
}
input := ""
expire := time.Now().Add(urlTimeout)
if es.IsLocal() && !es.Entity().Encrypted() {
input = es.LocalPath(ctx)
} else {
opts := []entitysource.EntitySourceOption{
entitysource.WithContext(ctx),
entitysource.WithExpire(&expire),
}
if !es.Entity().Encrypted() {
opts = append(opts, entitysource.WithNoInternalProxy())
}
src, err := es.Url(ctx, opts...)
if err != nil {
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to get entity url: %w", err)
}
input = src.Url
}
// Invoke ffmpeg
w, h := f.settings.ThumbSize(ctx)
scaleOpt := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", w, h)
args := []string{
"-ss", f.settings.FFMpegThumbSeek(ctx),
}
extraArgs := f.settings.FFMpegExtraArgs(ctx)
if extraArgs != "" {
args = append(args, strings.Split(extraArgs, " ")...)
}
args = append(args, []string{
"-i", input,
"-vf", scaleOpt,
"-vframes", "1",
tempOutputPath,
}...)
cmd := exec.CommandContext(ctx, f.settings.FFMpegPath(ctx), args...)
// Redirect IO
var stdErr bytes.Buffer
cmd.Stderr = &stdErr
if err := cmd.Run(); err != nil {
f.l.Warning("Failed to invoke ffmpeg: %s", stdErr.String())
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s", err, stdErr.String())
}
return &Result{Path: tempOutputPath}, nil
}
func (f *FfmpegGenerator) Priority() int {
return 200
}
func (f *FfmpegGenerator) Enabled(ctx context.Context) bool {
return f.settings.FFMpegThumbGeneratorEnabled(ctx)
}
+261
View File
@@ -0,0 +1,261 @@
package thumb
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
)
func NewLibRawGenerator(l logging.Logger, settings setting.Provider) *LibRawGenerator {
return &LibRawGenerator{l: l, settings: settings}
}
type LibRawGenerator struct {
l logging.Logger
settings setting.Provider
}
func (l *LibRawGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if !util.IsInExtensionListExt(l.settings.LibRawThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
if es.Entity().Size() > l.settings.LibRawThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
// If download/copy files to temp folder
tempFolder := filepath.Join(
util.DataPath(l.settings.TempPath(ctx)),
"thumb",
fmt.Sprintf("libraw_%s", uuid.Must(uuid.NewV4()).String()),
)
tempInputFileName := fmt.Sprintf("libraw_%s.%s", uuid.Must(uuid.NewV4()).String(), ext)
tempPath := filepath.Join(tempFolder, tempInputFileName)
tempInputFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempPath)
defer tempInputFile.Close()
if _, err = io.Copy(tempInputFile, es); err != nil {
return &Result{Path: tempPath}, fmt.Errorf("failed to write input file: %w", err)
}
tempInputFile.Close()
cmd := exec.CommandContext(ctx,
l.settings.LibRawThumbPath(ctx), "-e", tempPath)
// Redirect IO
var dcrawErr bytes.Buffer
cmd.Stderr = &dcrawErr
if err := cmd.Run(); err != nil {
l.l.Warning("Failed to invoke dcraw: %s", dcrawErr.String())
return &Result{Path: tempPath}, fmt.Errorf("failed to invoke dcraw: %w, raw output: %s", err, dcrawErr.String())
}
return &Result{
Path: filepath.Join(
tempFolder,
tempInputFileName+".thumb.jpg",
),
Continue: true,
Cleanup: []func(){func() { _ = os.RemoveAll(tempFolder) }},
}, nil
}
func (l *LibRawGenerator) Priority() int {
return 50
}
func (l *LibRawGenerator) Enabled(ctx context.Context) bool {
return l.settings.LibRawThumbGeneratorEnabled(ctx)
}
func rotateImg(filePath string, orientation int) error {
resultImg, err := os.OpenFile(filePath, os.O_RDWR, 0777)
if err != nil {
return err
}
defer func() { _ = resultImg.Close() }()
imgFlag := make([]byte, 3)
if _, err = io.ReadFull(resultImg, imgFlag); err != nil {
return err
}
if _, err = resultImg.Seek(0, 0); err != nil {
return err
}
var img image.Image
if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) {
img, err = jpeg.Decode(resultImg)
} else {
img, err = png.Decode(resultImg)
}
if err != nil {
return err
}
switch orientation {
case 8:
img = rotate90(img)
case 3:
img = rotate90(rotate90(img))
case 6:
img = rotate90(rotate90(rotate90(img)))
case 2:
img = mirrorImg(img)
case 7:
img = rotate90(mirrorImg(img))
case 4:
img = rotate90(rotate90(mirrorImg(img)))
case 5:
img = rotate90(rotate90(rotate90(mirrorImg(img))))
}
if err = resultImg.Truncate(0); err != nil {
return err
}
if _, err = resultImg.Seek(0, 0); err != nil {
return err
}
if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) {
return jpeg.Encode(resultImg, img, nil)
}
return png.Encode(resultImg, img)
}
func getJpegOrientation(fileName string) (int, error) {
f, err := os.Open(fileName)
if err != nil {
return 0, err
}
defer func() { _ = f.Close() }()
header := make([]byte, 6)
defer func() { header = nil }()
if _, err = io.ReadFull(f, header); err != nil {
return 0, err
}
// jpeg format header
if !bytes.Equal(header[:3], []byte{0xFF, 0xD8, 0xFF}) {
return 0, errors.New("not a jpeg")
}
// not a APP1 marker
if header[3] != 0xE1 {
return 1, nil
}
// exif data total length
totalLen := int(header[4])<<8 + int(header[5]) - 2
buf := make([]byte, totalLen)
defer func() { buf = nil }()
if _, err = io.ReadFull(f, buf); err != nil {
return 0, err
}
// remove Exif identifier code
buf = buf[6:]
// byte order
parse16, parse32, err := initParseMethod(buf[:2])
if err != nil {
return 0, err
}
// version
_ = buf[2:4]
// first IFD offset
offset := parse32(buf[4:8])
// first DE offset
offset += 2
buf = buf[offset:]
const (
orientationTag = 0x112
deEntryLength = 12
)
for len(buf) > deEntryLength {
tag := parse16(buf[:2])
if tag == orientationTag {
return int(parse32(buf[8:12])), nil
}
buf = buf[deEntryLength:]
}
return 0, errors.New("orientation not found")
}
func initParseMethod(buf []byte) (func([]byte) int16, func([]byte) int32, error) {
if bytes.Equal(buf, []byte{0x49, 0x49}) {
return littleEndian16, littleEndian32, nil
}
if bytes.Equal(buf, []byte{0x4D, 0x4D}) {
return bigEndian16, bigEndian32, nil
}
return nil, nil, errors.New("invalid byte order")
}
func littleEndian16(buf []byte) int16 {
return int16(buf[0]) | int16(buf[1])<<8
}
func bigEndian16(buf []byte) int16 {
return int16(buf[1]) | int16(buf[0])<<8
}
func littleEndian32(buf []byte) int32 {
return int32(buf[0]) | int32(buf[1])<<8 | int32(buf[2])<<16 | int32(buf[3])<<24
}
func bigEndian32(buf []byte) int32 {
return int32(buf[3]) | int32(buf[2])<<8 | int32(buf[1])<<16 | int32(buf[0])<<24
}
func rotate90(img image.Image) image.Image {
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
newImg := image.NewRGBA(image.Rect(0, 0, height, width))
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
newImg.Set(y, width-x-1, img.At(x, y))
}
}
return newImg
}
func mirrorImg(img image.Image) image.Image {
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
newImg := image.NewRGBA(image.Rect(0, 0, width, height))
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
newImg.Set(width-x-1, y, img.At(x, y))
}
}
return newImg
}
+101
View File
@@ -0,0 +1,101 @@
package thumb
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
)
func NewLibreOfficeGenerator(l logging.Logger, settings setting.Provider) *LibreOfficeGenerator {
return &LibreOfficeGenerator{l: l, settings: settings}
}
type LibreOfficeGenerator struct {
settings setting.Provider
l logging.Logger
}
func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if !util.IsInExtensionListExt(l.settings.LibreOfficeThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
if es.Entity().Size() > l.settings.LibreOfficeThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
tempOutputPath := filepath.Join(
util.DataPath(l.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("soffice_%s", uuid.Must(uuid.NewV4()).String()),
)
tempInputPath := ""
if es.IsLocal() && !es.Entity().Encrypted() {
tempInputPath = es.LocalPath(ctx)
} else {
// If not local policy files, download to temp folder
tempInputPath = filepath.Join(
util.DataPath(l.settings.TempPath(ctx)),
"thumb",
fmt.Sprintf("soffice_%s.%s", uuid.Must(uuid.NewV4()).String(), ext),
)
// Due to limitations of ffmpeg, we need to write the input file to disk first
tempInputFile, err := util.CreatNestedFile(tempInputPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempInputPath)
defer tempInputFile.Close()
if _, err = io.Copy(tempInputFile, es); err != nil {
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to write input file: %w", err)
}
tempInputFile.Close()
}
// Convert the document to an image
cmd := exec.CommandContext(ctx, l.settings.LibreOfficePath(ctx), "--headless",
"--nologo", "--nofirststartwizard", "--invisible", "--norestore", "--convert-to",
"png", "--outdir", tempOutputPath, tempInputPath)
// Redirect IO
var stdErr bytes.Buffer
cmd.Stderr = &stdErr
if err := cmd.Run(); err != nil {
l.l.Warning("Failed to invoke LibreOffice: %s", stdErr.String())
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s", err, stdErr.String())
}
return &Result{
Path: filepath.Join(
tempOutputPath,
strings.TrimSuffix(filepath.Base(tempInputPath), filepath.Ext(tempInputPath))+".png",
),
Continue: true,
Cleanup: []func(){func() { _ = os.RemoveAll(tempOutputPath) }},
}, nil
}
func (l *LibreOfficeGenerator) Priority() int {
return 50
}
func (l *LibreOfficeGenerator) Enabled(ctx context.Context) bool {
return l.settings.LibreOfficeThumbGeneratorEnabled(ctx)
}
+79
View File
@@ -0,0 +1,79 @@
package thumb
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/dhowden/tag"
"github.com/gofrs/uuid"
"os"
"path/filepath"
)
func NewMusicCoverGenerator(l logging.Logger, settings setting.Provider) *MusicCoverGenerator {
return &MusicCoverGenerator{l: l, settings: settings}
}
type MusicCoverGenerator struct {
l logging.Logger
settings setting.Provider
}
func (v *MusicCoverGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if !util.IsInExtensionListExt(v.settings.MusicCoverThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported music format: %w", ErrPassThrough)
}
if es.Entity().Size() > v.settings.MusicCoverThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
m, err := tag.ReadFrom(es)
if err != nil {
return nil, fmt.Errorf("faield to read audio tags from file: %w", err)
}
p := m.Picture()
if p == nil || len(p.Data) == 0 {
return nil, fmt.Errorf("no cover found in given file")
}
thumbExt := ".jpg"
if p.Ext != "" {
thumbExt = p.Ext
}
tempPath := filepath.Join(
util.DataPath(v.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s.%s", uuid.Must(uuid.NewV4()).String(), thumbExt),
)
thumbFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer thumbFile.Close()
if _, err := thumbFile.Write(p.Data); err != nil {
return &Result{Path: tempPath}, fmt.Errorf("failed to write cover to file: %w", err)
}
return &Result{
Path: tempPath,
Continue: true,
Cleanup: []func(){func() { _ = os.Remove(tempPath) }},
}, nil
}
func (v *MusicCoverGenerator) Priority() int {
return 50
}
func (v *MusicCoverGenerator) Enabled(ctx context.Context) bool {
return v.settings.MusicCoverThumbGeneratorEnabled(ctx)
}
+132
View File
@@ -0,0 +1,132 @@
package thumb
import (
"context"
"errors"
"fmt"
"io"
"reflect"
"sort"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
)
type (
// Generator generates a thumbnail for a given reader.
Generator interface {
// Generate generates a thumbnail for a given reader. Src is the original file path, only provided
// for local policy files. State is the result from previous generators, and can be read by current
// generator for intermedia result.
Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error)
// Priority of execution order, smaller value means higher priority.
Priority() int
// Enabled returns if current generator is enabled.
Enabled(ctx context.Context) bool
}
Result struct {
Path string
Ext string
Continue bool
Cleanup []func()
}
GeneratorType string
generatorList []Generator
pipeline struct {
generators generatorList
settings setting.Provider
l logging.Logger
}
)
var (
ErrPassThrough = errors.New("pass through")
ErrNotAvailable = fmt.Errorf("thumbnail not available: %w", ErrPassThrough)
)
func (g generatorList) Len() int {
return len(g)
}
func (g generatorList) Less(i, j int) bool {
return g[i].Priority() < g[j].Priority()
}
func (g generatorList) Swap(i, j int) {
g[i], g[j] = g[j], g[i]
}
// NewPipeline creates a new pipeline with all available generators.
func NewPipeline(settings setting.Provider, l logging.Logger) Generator {
generators := generatorList{}
generators = append(
generators,
NewBuiltinGenerator(settings),
NewFfmpegGenerator(l, settings),
NewVipsGenerator(l, settings),
NewLibreOfficeGenerator(l, settings),
NewMusicCoverGenerator(l, settings),
NewLibRawGenerator(l, settings),
)
sort.Sort(generators)
return pipeline{
generators: generators,
settings: settings,
l: l,
}
}
func (p pipeline) Generate(ctx context.Context, es entitysource.EntitySource, ext string, state *Result) (*Result, error) {
e := es.Entity()
for _, generator := range p.generators {
if generator.Enabled(ctx) {
if _, err := es.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("thumb: failed to seek to start of file: %w", err)
}
res, err := generator.Generate(ctx, es, ext, state)
if errors.Is(err, ErrPassThrough) {
p.l.Debug("Failed to generate thumbnail using %s for %s: %s, passing through to next generator.", reflect.TypeOf(generator).String(), e.Source(), err)
continue
}
if res != nil && res.Continue {
p.l.Debug("Generator %s for %s returned continue, passing through to next generator.", reflect.TypeOf(generator).String(), e.Source())
// defer cleanup functions
for _, cleanup := range res.Cleanup {
defer cleanup()
}
// prepare file reader for next generator
state = res
es, err = es.CloneToLocalSrc(types.EntityTypeVersion, res.Path)
if err != nil {
return nil, fmt.Errorf("thumb: failed to clone to local source: %w", err)
}
defer es.Close()
ext = util.Ext(res.Path)
continue
}
return res, err
}
}
return nil, ErrNotAvailable
}
func (p pipeline) Priority() int {
return 0
}
func (p pipeline) Enabled(ctx context.Context) bool {
return true
}
+110
View File
@@ -0,0 +1,110 @@
package thumb
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
var (
ErrUnknownGenerator = errors.New("unknown generator type")
ErrUnknownOutput = errors.New("unknown output from generator")
)
// TestGenerator tests thumb generator by getting lib version
func TestGenerator(ctx context.Context, name, executable string) (string, error) {
switch name {
case "vips":
return testVipsGenerator(ctx, executable)
case "ffmpeg":
return testFfmpegGenerator(ctx, executable)
case "libreOffice":
return testLibreOfficeGenerator(ctx, executable)
case "ffprobe":
return testFFProbeGenerator(ctx, executable)
case "libraw":
return testLibRawGenerator(ctx, executable)
default:
return "", ErrUnknownGenerator
}
}
func testFFProbeGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable, "-version")
var output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to invoke ffmpeg executable: %w", err)
}
if !strings.Contains(output.String(), "ffprobe") {
return "", ErrUnknownOutput
}
return output.String(), nil
}
func testVipsGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable, "--version")
var output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to invoke vips executable: %w", err)
}
if !strings.Contains(output.String(), "vips") {
return "", ErrUnknownOutput
}
return output.String(), nil
}
func testFfmpegGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable, "-version")
var output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to invoke ffmpeg executable: %w", err)
}
if !strings.Contains(output.String(), "ffmpeg") {
return "", ErrUnknownOutput
}
return output.String(), nil
}
func testLibreOfficeGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable, "--version")
var output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to invoke libreoffice executable: %w", err)
}
if !strings.Contains(output.String(), "LibreOffice") {
return "", ErrUnknownOutput
}
return output.String(), nil
}
func testLibRawGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable, "-L")
var output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to invoke libraw executable: %w", err)
}
if !strings.Contains(output.String(), "Sony") {
return "", ErrUnknownOutput
}
cameraList := strings.Split(output.String(), "\n")
return fmt.Sprintf("N/A, %d cameras supported", len(cameraList)), nil
}
+120
View File
@@ -0,0 +1,120 @@
package thumb
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
)
func NewVipsGenerator(l logging.Logger, settings setting.Provider) *VipsGenerator {
return &VipsGenerator{l: l, settings: settings}
}
type VipsGenerator struct {
l logging.Logger
settings setting.Provider
}
func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
if !util.IsInExtensionListExt(v.settings.VipsThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
if es.Entity().Size() > v.settings.VipsThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
outputOpt := ".png"
encode := v.settings.ThumbEncode(ctx)
if encode.Format == "jpg" || encode.Format == "webp" {
outputOpt = fmt.Sprintf(".%s[Q=%d]", encode.Format, encode.Quality)
}
input := "[descriptor=0]"
usePipe := true
if runtime.GOOS == "windows" {
// Pipe IO is not working on Windows for VIPS
if es.IsLocal() && !es.Entity().Encrypted() {
// escape [ and ] in file name
input = fmt.Sprintf("[filename=\"%s\"]", es.LocalPath(ctx))
usePipe = false
} else {
usePipe = false
// If not local policy files, download to temp folder
tempPath := filepath.Join(
util.DataPath(v.settings.TempPath(ctx)),
"thumb",
fmt.Sprintf("vips_%s.%s", uuid.Must(uuid.NewV4()).String(), ext),
)
input = fmt.Sprintf("[filename=\"%s\"]", tempPath)
// Due to limitations of ffmpeg, we need to write the input file to disk first
tempInputFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempPath)
defer tempInputFile.Close()
if _, err = io.Copy(tempInputFile, es); err != nil {
return &Result{Path: tempPath}, fmt.Errorf("failed to write input file: %w", err)
}
tempInputFile.Close()
}
}
w, h := v.settings.ThumbSize(ctx)
cmd := exec.CommandContext(ctx,
v.settings.VipsPath(ctx), "thumbnail_source", input, outputOpt, strconv.Itoa(w),
"--height", strconv.Itoa(h))
tempPath := filepath.Join(
util.DataPath(v.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
)
thumbFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer thumbFile.Close()
// Redirect IO
var vipsErr bytes.Buffer
if usePipe {
cmd.Stdin = es
}
cmd.Stdout = thumbFile
cmd.Stderr = &vipsErr
if err := cmd.Run(); err != nil {
v.l.Warning("Failed to invoke vips: %s", vipsErr.String())
return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s", err, vipsErr.String())
}
return &Result{Path: tempPath}, nil
}
func (v *VipsGenerator) Priority() int {
return 100
}
func (v *VipsGenerator) Enabled(ctx context.Context) bool {
return v.settings.VipsThumbGeneratorEnabled(ctx)
}