Init V4 community edition (#2265)

* Init V4 community edition

* Init V4 community edition
This commit is contained in:
AaronLiu
2025-04-20 17:31:25 +08:00
committed by GitHub
parent da4e44b77a
commit 21d158db07
597 changed files with 119415 additions and 41692 deletions

View File

@@ -3,24 +3,21 @@ package thumb
import (
"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"
"strings"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"github.com/gofrs/uuid"
//"github.com/nfnt/resize"
"golang.org/x/image/draw"
)
func init() {
RegisterGenerator(&Builtin{})
}
const thumbTempFolder = "thumb"
// Thumb 缩略图
type Thumb struct {
@@ -30,16 +27,15 @@ type Thumb struct {
// NewThumbFromFile 从文件数据获取新的Thumb对象
// 尝试通过文件名name解码图像
func NewThumbFromFile(file io.Reader, name string) (*Thumb, error) {
ext := strings.ToLower(filepath.Ext(name))
func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) {
// 无扩展名时
if len(ext) == 0 {
if ext == "" {
return nil, fmt.Errorf("unknown image format: %w", ErrPassThrough)
}
var err error
var img image.Image
switch ext[1:] {
switch ext {
case "jpg", "jpeg":
img, err = jpeg.Decode(file)
case "gif":
@@ -47,7 +43,7 @@ func NewThumbFromFile(file io.Reader, name string) (*Thumb, error) {
case "png":
img, err = png.Decode(file)
default:
return nil, fmt.Errorf("unknown image format: %w", ErrPassThrough)
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)
@@ -72,12 +68,12 @@ func (image *Thumb) GetSize() (int, int) {
}
// Save 保存图像到给定路径
func (image *Thumb) Save(w io.Writer) (err error) {
switch model.GetSettingByNameWithDefault("thumb_encode_method", "jpg") {
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: model.GetIntSetting("thumb_encode_quality", 85)})
err = jpeg.Encode(w, image.src, &jpeg.Options{Quality: encodeSetting.Quality})
}
return err
@@ -127,46 +123,35 @@ func Resize(newWidth, newHeight uint, img image.Image) image.Image {
}
// CreateAvatar 创建头像
func (image *Thumb) CreateAvatar(uid uint) error {
// 读取头像相关设定
savePath := util.RelativePath(model.GetSettingByName("avatar_path"))
s := model.GetIntSetting("avatar_size_s", 50)
m := model.GetIntSetting("avatar_size_m", 130)
l := model.GetIntSetting("avatar_size_l", 200)
// 生成头像缩略图
src := image.src
for k, size := range []int{s, m, l} {
out, err := util.CreatNestedFile(filepath.Join(savePath, fmt.Sprintf("avatar_%d_%d.png", uid, k)))
if err != nil {
return err
}
defer out.Close()
image.src = Resize(uint(size), uint(size), src)
err = image.Save(out)
if err != nil {
return err
}
}
return nil
func (image *Thumb) CreateAvatar(width int) {
image.src = Resize(uint(width), uint(width), image.src)
}
type Builtin struct{}
type Builtin struct {
settings setting.Provider
}
func (b Builtin) Generate(ctx context.Context, file io.Reader, src, name string, options map[string]string) (*Result, error) {
img, err := NewThumbFromFile(file, name)
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
}
img.GetThumb(thumbSize(options))
w, h := b.settings.ThumbSize(ctx)
img.GetThumb(uint(w), uint(h))
tempPath := filepath.Join(
util.RelativePath(model.GetSettingByName("temp_path")),
"thumb",
util.DataPath(b.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
)
@@ -176,8 +161,8 @@ func (b Builtin) Generate(ctx context.Context, file io.Reader, src, name string,
}
defer thumbFile.Close()
if err := img.Save(thumbFile); err != nil {
return nil, err
if err := img.Save(thumbFile, b.settings.ThumbEncode(ctx)); err != nil {
return &Result{Path: tempPath}, err
}
return &Result{Path: tempPath}, nil
@@ -187,6 +172,6 @@ func (b Builtin) Priority() int {
return 300
}
func (b Builtin) EnableFlag() string {
return "thumb_builtin_enabled"
func (b Builtin) Enabled(ctx context.Context) bool {
return b.settings.BuiltinThumbGeneratorEnabled(ctx)
}

View File

@@ -4,90 +4,77 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
"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 init() {
RegisterGenerator(&FfmpegGenerator{})
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 {
exts []string
lastRawExts string
l logging.Logger
settings setting.Provider
}
func (f *FfmpegGenerator) Generate(ctx context.Context, file io.Reader, src, name string, options map[string]string) (*Result, error) {
const (
thumbFFMpegPath = "thumb_ffmpeg_path"
thumbFFMpegExts = "thumb_ffmpeg_exts"
thumbFFMpegSeek = "thumb_ffmpeg_seek"
thumbEncodeMethod = "thumb_encode_method"
tempPath = "temp_path"
)
ffmpegOpts := model.GetSettingByNames(thumbFFMpegPath, thumbFFMpegExts, thumbFFMpegSeek, thumbEncodeMethod, tempPath)
if f.lastRawExts != ffmpegOpts[thumbFFMpegExts] {
f.exts = strings.Split(ffmpegOpts[thumbFFMpegExts], ",")
f.lastRawExts = ffmpegOpts[thumbFFMpegExts]
}
if !util.IsInExtensionList(f.exts, name) {
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.RelativePath(ffmpegOpts[tempPath]),
"thumb",
fmt.Sprintf("thumb_%s.%s", uuid.Must(uuid.NewV4()).String(), ffmpegOpts[thumbEncodeMethod]),
util.DataPath(f.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s.%s", uuid.Must(uuid.NewV4()).String(), f.settings.ThumbEncode(ctx).Format),
)
tempInputPath := src
if tempInputPath == "" {
// If not local policy files, download to temp folder
tempInputPath = filepath.Join(
util.RelativePath(ffmpegOpts[tempPath]),
"thumb",
fmt.Sprintf("ffmpeg_%s%s", uuid.Must(uuid.NewV4()).String(), filepath.Ext(name)),
)
if err := util.CreatNestedFolder(filepath.Dir(tempOutputPath)); err != nil {
return nil, fmt.Errorf("failed to create temp folder: %w", err)
}
// Due to limitations of ffmpeg, we need to write the input file to disk first
tempInputFile, err := util.CreatNestedFile(tempInputPath)
input := ""
expire := time.Now().Add(urlTimeout)
if es.IsLocal() {
input = es.LocalPath(ctx)
} else {
src, err := es.Url(driver.WithForcePublicEndpoint(ctx, false), entitysource.WithNoInternalProxy(), entitysource.WithContext(ctx), entitysource.WithExpire(&expire))
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to get entity url: %w", err)
}
defer os.Remove(tempInputPath)
defer tempInputFile.Close()
if _, err = io.Copy(tempInputFile, file); err != nil {
return nil, fmt.Errorf("failed to write input file: %w", err)
}
tempInputFile.Close()
input = src.Url
}
// Invoke ffmpeg
scaleOpt := fmt.Sprintf("scale=%s:%s:force_original_aspect_ratio=decrease", options["thumb_width"], options["thumb_height"])
w, h := f.settings.ThumbSize(ctx)
scaleOpt := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", w, h)
cmd := exec.CommandContext(ctx,
ffmpegOpts[thumbFFMpegPath], "-ss", ffmpegOpts[thumbFFMpegSeek], "-i", tempInputPath,
f.settings.FFMpegPath(ctx), "-ss", f.settings.FFMpegThumbSeek(ctx), "-i", input,
"-vf", scaleOpt, "-vframes", "1", tempOutputPath)
// Redirect IO
var stdErr bytes.Buffer
cmd.Stdin = file
cmd.Stderr = &stdErr
if err := cmd.Run(); err != nil {
util.Log().Warning("Failed to invoke ffmpeg: %s", stdErr.String())
return nil, fmt.Errorf("failed to invoke ffmpeg: %w", err)
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
@@ -97,6 +84,6 @@ func (f *FfmpegGenerator) Priority() int {
return 200
}
func (f *FfmpegGenerator) EnableFlag() string {
return "thumb_ffmpeg_enabled"
func (f *FfmpegGenerator) Enabled(ctx context.Context) bool {
return f.settings.FFMpegThumbGeneratorEnabled(ctx)
}

View File

@@ -1,283 +0,0 @@
package thumb
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"github.com/gofrs/uuid"
)
func init() {
RegisterGenerator(&LibRawGenerator{})
}
type LibRawGenerator struct {
exts []string
lastRawExts string
}
func (f *LibRawGenerator) Generate(ctx context.Context, file io.Reader, _ string, name string, options map[string]string) (*Result, error) {
const (
thumbLibRawPath = "thumb_libraw_path"
thumbLibRawExt = "thumb_libraw_exts"
thumbTempPath = "temp_path"
)
opts := model.GetSettingByNames(thumbLibRawPath, thumbLibRawExt, thumbTempPath)
if f.lastRawExts != opts[thumbLibRawExt] {
f.exts = strings.Split(opts[thumbLibRawExt], ",")
f.lastRawExts = opts[thumbLibRawExt]
}
if !util.IsInExtensionList(f.exts, name) {
return nil, fmt.Errorf("unsupported image format: %w", ErrPassThrough)
}
inputFilePath := filepath.Join(
util.RelativePath(opts[thumbTempPath]),
"thumb",
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
)
defer func() { _ = os.Remove(inputFilePath) }()
inputFile, err := util.CreatNestedFile(inputFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
if _, err = io.Copy(inputFile, file); err != nil {
_ = inputFile.Close()
return nil, fmt.Errorf("failed to write input file: %w", err)
}
_ = inputFile.Close()
cmd := exec.CommandContext(ctx, opts[thumbLibRawPath], "-e", inputFilePath)
var stdErr bytes.Buffer
cmd.Stderr = &stdErr
if err = cmd.Run(); err != nil {
util.Log().Warning("Failed to invoke LibRaw: %s", stdErr.String())
return nil, fmt.Errorf("failed to invoke LibRaw: %w", err)
}
outputFilePath := inputFilePath + ".thumb.jpg"
defer func() { _ = os.Remove(outputFilePath) }()
ff, err := os.Open(outputFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open temp file: %w", err)
}
defer func() { _ = ff.Close() }()
// use builtin generator
result, err := new(Builtin).Generate(ctx, ff, outputFilePath, filepath.Base(outputFilePath), options)
if err != nil {
return nil, fmt.Errorf("failed to generate thumbnail: %w", err)
}
orientation, err := getJpegOrientation(outputFilePath)
if err != nil {
return nil, fmt.Errorf("failed to get jpeg orientation: %w", err)
}
if orientation == 1 {
return result, nil
}
if err = rotateImg(result.Path, orientation); err != nil {
return nil, fmt.Errorf("failed to rotate image: %w", err)
}
return result, nil
}
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
}
func (f *LibRawGenerator) Priority() int {
return 250
}
func (f *LibRawGenerator) EnableFlag() string {
return "thumb_libraw_enabled"
}
var _ Generator = (*LibRawGenerator)(nil)

View File

@@ -10,51 +10,46 @@ import (
"path/filepath"
"strings"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"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 init() {
RegisterGenerator(&LibreOfficeGenerator{})
func NewLibreOfficeGenerator(l logging.Logger, settings setting.Provider) *LibreOfficeGenerator {
return &LibreOfficeGenerator{l: l, settings: settings}
}
type LibreOfficeGenerator struct {
exts []string
lastRawExts string
settings setting.Provider
l logging.Logger
}
func (l *LibreOfficeGenerator) Generate(ctx context.Context, file io.Reader, src string, name string, options map[string]string) (*Result, error) {
const (
thumbLibreOfficePath = "thumb_libreoffice_path"
thumbLibreOfficeExts = "thumb_libreoffice_exts"
thumbEncodeMethod = "thumb_encode_method"
tempPath = "temp_path"
)
sofficeOpts := model.GetSettingByNames(thumbLibreOfficePath, thumbLibreOfficeExts, thumbEncodeMethod, tempPath)
if l.lastRawExts != sofficeOpts[thumbLibreOfficeExts] {
l.exts = strings.Split(sofficeOpts[thumbLibreOfficeExts], ",")
l.lastRawExts = sofficeOpts[thumbLibreOfficeExts]
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 !util.IsInExtensionList(l.exts, name) {
return nil, fmt.Errorf("unsupported document 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.RelativePath(sofficeOpts[tempPath]),
"thumb",
util.DataPath(l.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("soffice_%s", uuid.Must(uuid.NewV4()).String()),
)
tempInputPath := src
if tempInputPath == "" {
tempInputPath := ""
if es.IsLocal() {
tempInputPath = es.LocalPath(ctx)
} else {
// If not local policy files, download to temp folder
tempInputPath = filepath.Join(
util.RelativePath(sofficeOpts[tempPath]),
util.DataPath(l.settings.TempPath(ctx)),
"thumb",
fmt.Sprintf("soffice_%s%s", uuid.Must(uuid.NewV4()).String(), filepath.Ext(name)),
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
@@ -66,32 +61,32 @@ func (l *LibreOfficeGenerator) Generate(ctx context.Context, file io.Reader, src
defer os.Remove(tempInputPath)
defer tempInputFile.Close()
if _, err = io.Copy(tempInputFile, file); err != nil {
return nil, fmt.Errorf("failed to write input file: %w", err)
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, sofficeOpts[thumbLibreOfficePath], "--headless",
encode := l.settings.ThumbEncode(ctx)
cmd := exec.CommandContext(ctx, l.settings.LibreOfficePath(ctx), "--headless",
"-nologo", "--nofirststartwizard", "--invisible", "--norestore", "--convert-to",
sofficeOpts[thumbEncodeMethod], "--outdir", tempOutputPath, tempInputPath)
encode.Format, "--outdir", tempOutputPath, tempInputPath)
// Redirect IO
var stdErr bytes.Buffer
cmd.Stdin = file
cmd.Stderr = &stdErr
if err := cmd.Run(); err != nil {
util.Log().Warning("Failed to invoke LibreOffice: %s", stdErr.String())
return nil, fmt.Errorf("failed to invoke LibreOffice: %w", err)
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))+"."+sofficeOpts[thumbEncodeMethod],
strings.TrimSuffix(filepath.Base(tempInputPath), filepath.Ext(tempInputPath))+"."+encode.Format,
),
Continue: true,
Cleanup: []func(){func() { _ = os.RemoveAll(tempOutputPath) }},
@@ -102,6 +97,6 @@ func (l *LibreOfficeGenerator) Priority() int {
return 50
}
func (l *LibreOfficeGenerator) EnableFlag() string {
return "thumb_libreoffice_enabled"
func (l *LibreOfficeGenerator) Enabled(ctx context.Context) bool {
return l.settings.LibreOfficeThumbGeneratorEnabled(ctx)
}

79
pkg/thumb/music.go Normal file
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)
}

View File

@@ -4,93 +4,114 @@ import (
"context"
"errors"
"fmt"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"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"
"io"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
)
// Generator generates a thumbnail for a given reader.
type Generator interface {
// Generate generates a thumbnail for a given reader. Src is the original file path, only provided
// for local policy files.
Generate(ctx context.Context, file io.Reader, src string, name string, options map[string]string) (*Result, error)
// Priority of execution order, smaller value means higher priority.
Priority() int
// EnableFlag returns the setting name to enable this generator.
EnableFlag() string
}
type Result struct {
Path string
Continue bool
Cleanup []func()
}
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
generatorList []Generator
pipeline struct {
generators generatorList
settings setting.Provider
l logging.Logger
}
)
var (
Generators = GeneratorList{}
ErrPassThrough = errors.New("pass through")
ErrNotAvailable = fmt.Errorf("thumbnail not available: %w", ErrPassThrough)
)
func (g GeneratorList) Len() int {
func (g generatorList) Len() int {
return len(g)
}
func (g GeneratorList) Less(i, j int) bool {
func (g generatorList) Less(i, j int) bool {
return g[i].Priority() < g[j].Priority()
}
func (g GeneratorList) Swap(i, j int) {
func (g generatorList) Swap(i, j int) {
g[i], g[j] = g[j], g[i]
}
// RegisterGenerator registers a thumbnail generator.
func RegisterGenerator(generator Generator) {
Generators = append(Generators, generator)
sort.Sort(Generators)
// 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),
)
sort.Sort(generators)
return pipeline{
generators: generators,
settings: settings,
l: l,
}
}
func (p GeneratorList) Generate(ctx context.Context, file io.Reader, src, name string, options map[string]string) (*Result, error) {
inputFile, inputSrc, inputName := file, src, name
for _, generator := range p {
if model.IsTrueVal(options[generator.EnableFlag()]) {
res, err := generator.Generate(ctx, inputFile, inputSrc, inputName, options)
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) {
util.Log().Debug("Failed to generate thumbnail using %s for %s: %s, passing through to next generator.", reflect.TypeOf(generator).String(), name, err)
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 {
util.Log().Debug("Generator %s for %s returned continue, passing through to next generator.", reflect.TypeOf(generator).String(), name)
p.l.Debug("Generator %s for %s returned continue, passing through to next generator.", reflect.TypeOf(generator).String(), e.Source())
// defer cleanup funcs
// defer cleanup functions
for _, cleanup := range res.Cleanup {
defer cleanup()
}
// prepare file reader for next generator
intermediate, err := os.Open(res.Path)
state = res
es, err = es.CloneToLocalSrc(types.EntityTypeVersion, res.Path)
if err != nil {
return nil, fmt.Errorf("failed to open intermediate thumb file: %w", err)
return nil, fmt.Errorf("thumb: failed to clone to local source: %w", err)
}
defer intermediate.Close()
inputFile = intermediate
inputSrc = res.Path
inputName = filepath.Base(res.Path)
defer es.Close()
ext = util.Ext(res.Path)
continue
}
@@ -100,23 +121,10 @@ func (p GeneratorList) Generate(ctx context.Context, file io.Reader, src, name s
return nil, ErrNotAvailable
}
func (p GeneratorList) Priority() int {
func (p pipeline) Priority() int {
return 0
}
func (p GeneratorList) EnableFlag() string {
return ""
}
func thumbSize(options map[string]string) (uint, uint) {
w, h := uint(400), uint(300)
if wParsed, err := strconv.Atoi(options["thumb_width"]); err == nil {
w = uint(wParsed)
}
if hParsed, err := strconv.Atoi(options["thumb_height"]); err == nil {
h = uint(hParsed)
}
return w, h
func (p pipeline) Enabled(ctx context.Context) bool {
return true
}

View File

@@ -23,13 +23,28 @@ func TestGenerator(ctx context.Context, name, executable string) (string, error)
return testFfmpegGenerator(ctx, executable)
case "libreOffice":
return testLibreOfficeGenerator(ctx, executable)
case "libRaw":
return testLibRawGenerator(ctx, executable)
case "ffprobe":
return testFFProbeGenerator(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
@@ -74,18 +89,3 @@ func testLibreOfficeGenerator(ctx context.Context, executable string) (string, e
return output.String(), nil
}
func testLibRawGenerator(ctx context.Context, executable string) (string, error) {
cmd := exec.CommandContext(ctx, executable)
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(), "LibRaw") {
return "", ErrUnknownOutput
}
return output.String(), nil
}

View File

@@ -5,59 +5,90 @@ import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"runtime"
"strconv"
model "github.com/cloudreve/Cloudreve/v3/models"
"github.com/cloudreve/Cloudreve/v3/pkg/util"
"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 init() {
RegisterGenerator(&VipsGenerator{})
func NewVipsGenerator(l logging.Logger, settings setting.Provider) *VipsGenerator {
return &VipsGenerator{l: l, settings: settings}
}
type VipsGenerator struct {
exts []string
lastRawExts string
l logging.Logger
settings setting.Provider
}
func (v *VipsGenerator) Generate(ctx context.Context, file io.Reader, src, name string, options map[string]string) (*Result, error) {
const (
thumbVipsPath = "thumb_vips_path"
thumbVipsExts = "thumb_vips_exts"
thumbEncodeQuality = "thumb_encode_quality"
thumbEncodeMethod = "thumb_encode_method"
tempPath = "temp_path"
)
vipsOpts := model.GetSettingByNames(thumbVipsPath, thumbVipsExts, thumbEncodeQuality, thumbEncodeMethod, tempPath)
if v.lastRawExts != vipsOpts[thumbVipsExts] {
v.exts = strings.Split(vipsOpts[thumbVipsExts], ",")
v.lastRawExts = vipsOpts[thumbVipsExts]
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 !util.IsInExtensionList(v.exts, name) {
return nil, fmt.Errorf("unsupported image format: %w", ErrPassThrough)
if es.Entity().Size() > v.settings.VipsThumbMaxSize(ctx) {
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
}
outputOpt := ".png"
if vipsOpts[thumbEncodeMethod] == "jpg" {
outputOpt = fmt.Sprintf(".jpg[Q=%s]", vipsOpts[thumbEncodeQuality])
encode := v.settings.ThumbEncode(ctx)
if encode.Format == "jpg" {
outputOpt = fmt.Sprintf(".jpg[Q=%d]", encode.Quality)
}
cmd := exec.CommandContext(ctx,
vipsOpts[thumbVipsPath], "thumbnail_source", "[descriptor=0]", outputOpt, options["thumb_width"],
"--height", options["thumb_height"])
input := "[descriptor=0]"
usePipe := true
if runtime.GOOS == "windows" {
// Pipe IO is not working on Windows for VIPS
if es.IsLocal() {
// 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)
outTempPath := filepath.Join(
util.RelativePath(vipsOpts[tempPath]),
"thumb",
// 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(outTempPath)
thumbFile, err := util.CreatNestedFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
@@ -66,22 +97,24 @@ func (v *VipsGenerator) Generate(ctx context.Context, file io.Reader, src, name
// Redirect IO
var vipsErr bytes.Buffer
cmd.Stdin = file
if usePipe {
cmd.Stdin = es
}
cmd.Stdout = thumbFile
cmd.Stderr = &vipsErr
if err := cmd.Run(); err != nil {
util.Log().Warning("Failed to invoke vips: %s", vipsErr.String())
return nil, fmt.Errorf("failed to invoke vips: %w", err)
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: outTempPath}, nil
return &Result{Path: tempPath}, nil
}
func (v *VipsGenerator) Priority() int {
return 100
}
func (v *VipsGenerator) EnableFlag() string {
return "thumb_vips_enabled"
func (v *VipsGenerator) Enabled(ctx context.Context) bool {
return v.settings.VipsThumbGeneratorEnabled(ctx)
}