mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 04:53:09 +08:00
Merge branch 'main' into feat/advanced-transfer-seeds
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
@@ -153,8 +152,7 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([
|
||||
func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj {
|
||||
thumb := ""
|
||||
if d.Thumbnail {
|
||||
typeName := utils.GetFileType(f.Name())
|
||||
if typeName == conf.IMAGE || typeName == conf.VIDEO {
|
||||
if d.supportsThumbnail(f.Name()) {
|
||||
thumb = common.GetApiUrl(ctx) + stdpath.Join("/d", reqPath, f.Name())
|
||||
thumb = utils.EncodePath(thumb, true)
|
||||
thumb += "?type=thumb&sign=" + sign.Sign(stdpath.Join(reqPath, f.Name()))
|
||||
@@ -240,7 +238,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
var thumbPath *string
|
||||
err := d.thumbTokenBucket.Do(ctx, func() error {
|
||||
var err error
|
||||
buf, thumbPath, err = d.getThumb(file)
|
||||
buf, thumbPath, err = d.getThumb(ctx, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ type Addition struct {
|
||||
driver.RootPath
|
||||
DirectorySize bool `json:"directory_size" default:"false" help:"This might impact host performance"`
|
||||
Thumbnail bool `json:"thumbnail" required:"true" help:"enable thumbnail"`
|
||||
PDFThumbnail bool `json:"pdf_thumbnail" default:"false" required:"false" help:"Generate PDF first-page thumbnails with Quick Look on macOS"`
|
||||
ThumbCacheFolder string `json:"thumb_cache_folder"`
|
||||
ThumbConcurrency string `json:"thumb_concurrency" default:"16" required:"false" help:"Number of concurrent thumbnail generation goroutines. This controls how many thumbnails can be generated in parallel."`
|
||||
VideoThumbPos string `json:"video_thumb_pos" default:"20%" required:"false" help:"The position of the video thumbnail. If the value is a number (integer ot floating point), it represents the time in seconds. If the value ends with '%', it represents the percentage of the video duration."`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pdfThumbnailSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func renderPDFThumbnail(ctx context.Context, fullPath string) (*bytes.Buffer, error) {
|
||||
tempDir, err := os.MkdirTemp("", "openlist-pdf-thumb-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
renderCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(renderCtx, "/usr/bin/qlmanage", "-t", "-s", "512", "-o", tempDir, fullPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
if renderCtx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("render PDF thumbnail timed out: %w", renderCtx.Err())
|
||||
}
|
||||
if renderCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("render PDF thumbnail canceled: %w", renderCtx.Err())
|
||||
}
|
||||
return nil, fmt.Errorf("render PDF thumbnail: %w: %s", err, bytes.TrimSpace(output))
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(tempDir, filepath.Base(fullPath)+".png")
|
||||
data, err := os.ReadFile(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read rendered PDF thumbnail: %w", err)
|
||||
}
|
||||
return bytes.NewBuffer(data), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderPDFThumbnailDarwin(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
textPath := filepath.Join(tempDir, "source.txt")
|
||||
pdfPath := filepath.Join(tempDir, "source 文件.pdf")
|
||||
if err := os.WriteFile(textPath, []byte("OpenList PDF thumbnail integration test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/sbin/cupsfilter", textPath)
|
||||
pdfData, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("create fixture PDF: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(pdfPath, pdfData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumb, err := renderPDFThumbnail(context.Background(), pdfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(thumb.Bytes(), []byte("\x89PNG\r\n\x1a\n")) {
|
||||
t.Fatal("rendered thumbnail is not PNG")
|
||||
}
|
||||
cfg, err := png.DecodeConfig(bytes.NewReader(thumb.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode thumbnail: %v", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
t.Fatalf("invalid thumbnail dimensions: %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := renderPDFThumbnail(canceledCtx, pdfPath); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("renderPDFThumbnail with canceled context returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
)
|
||||
|
||||
func TestSupportsThumbnail(t *testing.T) {
|
||||
oldImages := conf.SlicesMap[conf.ImageTypes]
|
||||
oldVideos := conf.SlicesMap[conf.VideoTypes]
|
||||
conf.SlicesMap[conf.ImageTypes] = []string{"jpg"}
|
||||
conf.SlicesMap[conf.VideoTypes] = []string{"mp4"}
|
||||
t.Cleanup(func() {
|
||||
conf.SlicesMap[conf.ImageTypes] = oldImages
|
||||
conf.SlicesMap[conf.VideoTypes] = oldVideos
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
pdfThumbnail bool
|
||||
want bool
|
||||
}{
|
||||
{name: "image", fileName: "cover.jpg", want: true},
|
||||
{name: "video", fileName: "movie.mp4", want: true},
|
||||
{name: "PDF disabled by default", fileName: "document.pdf", want: false},
|
||||
{name: "unrelated document", fileName: "document.txt", pdfThumbnail: true, want: false},
|
||||
{name: "PDF enabled when renderer is available", fileName: "document.PDF", pdfThumbnail: true, want: pdfThumbnailSupported()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := &Local{Addition: Addition{PDFThumbnail: tt.pdfThumbnail}}
|
||||
if got := d.supportsThumbnail(tt.fileName); got != tt.want {
|
||||
t.Fatalf("supportsThumbnail(%q) = %v, want %v", tt.fileName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func pdfThumbnailSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func renderPDFThumbnail(context.Context, string) (*bytes.Buffer, error) {
|
||||
return nil, errors.New("PDF thumbnails are not supported on this platform")
|
||||
}
|
||||
+22
-2
@@ -2,6 +2,7 @@ package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -127,7 +128,19 @@ func (d *Local) removeThumbCache(fullPath string) {
|
||||
_ = os.Remove(thumbPath)
|
||||
}
|
||||
|
||||
func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
func (d *Local) supportsThumbnail(name string) bool {
|
||||
typeName := utils.GetFileType(name)
|
||||
if typeName == conf.IMAGE || typeName == conf.VIDEO {
|
||||
return true
|
||||
}
|
||||
return d.supportsPDFThumbnail(name)
|
||||
}
|
||||
|
||||
func (d *Local) supportsPDFThumbnail(name string) bool {
|
||||
return d.PDFThumbnail && pdfThumbnailSupported() && strings.EqualFold(filepath.Ext(name), ".pdf")
|
||||
}
|
||||
|
||||
func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
fullPath := file.GetPath()
|
||||
if d.ThumbCacheFolder != "" {
|
||||
// skip if the file is a thumbnail
|
||||
@@ -140,12 +153,19 @@ func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
}
|
||||
}
|
||||
var srcBuf *bytes.Buffer
|
||||
if utils.GetFileType(file.GetName()) == conf.VIDEO {
|
||||
typeName := utils.GetFileType(file.GetName())
|
||||
if typeName == conf.VIDEO {
|
||||
videoBuf, err := d.GetSnapshot(fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
srcBuf = videoBuf
|
||||
} else if d.supportsPDFThumbnail(file.GetName()) {
|
||||
pdfBuf, err := renderPDFThumbnail(ctx, fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
srcBuf = pdfBuf
|
||||
} else {
|
||||
imgData, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/tache"
|
||||
)
|
||||
|
||||
func TestMigratedCopyTaskRecoversNativeFields(t *testing.T) {
|
||||
previousConf := conf.Conf
|
||||
conf.Conf = &conf.Config{}
|
||||
t.Cleanup(func() { conf.Conf = previousConf })
|
||||
|
||||
raw := []byte(`{
|
||||
"id":"task-one",
|
||||
"state":0,
|
||||
"retry":0,
|
||||
"max_retry":0,
|
||||
"Creator":{"id":1,"username":"admin","password":"","base_path":"/","role":2,"disabled":false,"permission":511,"sso_id":"","allow_ldap":true},
|
||||
"start_time":"2026-08-11T01:00:00Z",
|
||||
"end_time":"2026-08-11T01:01:00Z",
|
||||
"TotalBytes":42,
|
||||
"ApiUrl":"http://openlist.test:5244",
|
||||
"src_path":"/folder/file",
|
||||
"dst_path":"/backup",
|
||||
"src_storage_mp":"/source",
|
||||
"dst_storage_mp":"/target",
|
||||
"TaskType":0
|
||||
}`)
|
||||
|
||||
var task FileTransferTask
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
t.Fatalf("unmarshal migrated task: %v", err)
|
||||
}
|
||||
if task.GetID() != "task-one" || task.GetState() != tache.StatePending {
|
||||
t.Fatalf("unexpected base fields: id=%q state=%d", task.GetID(), task.GetState())
|
||||
}
|
||||
if task.GetCreator() == nil || task.GetCreator().Username != "admin" {
|
||||
t.Fatal("creator was not recovered")
|
||||
}
|
||||
if task.GetStartTime() == nil || task.GetEndTime() == nil {
|
||||
t.Fatal("task timestamps were not recovered")
|
||||
}
|
||||
if task.TaskType != copy {
|
||||
t.Fatalf("unexpected task type: %d", task.TaskType)
|
||||
}
|
||||
if task.SrcActualPath != "/folder/file" || task.DstActualPath != "/backup" {
|
||||
t.Fatal("copy paths were not recovered")
|
||||
}
|
||||
|
||||
_, maxRetry := task.GetRetry()
|
||||
if maxRetry != 0 {
|
||||
t.Fatalf("migration must defer retry initialization, got %d", maxRetry)
|
||||
}
|
||||
task.SetRetry(0, 2)
|
||||
_, maxRetry = task.GetRetry()
|
||||
if maxRetry != 2 {
|
||||
t.Fatalf("retry initialization failed, got %d", maxRetry)
|
||||
}
|
||||
if task.groupID != "/target/backup" {
|
||||
t.Fatalf("task group was not rebuilt: %q", task.groupID)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
type TaskExtension struct {
|
||||
tache.Base
|
||||
Creator *model.User
|
||||
startTime *time.Time
|
||||
endTime *time.Time
|
||||
StartTime *time.Time `json:"start_time,omitempty"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
TotalBytes int64
|
||||
ApiUrl string
|
||||
}
|
||||
@@ -38,23 +38,23 @@ func (t *TaskExtension) GetCreator() *model.User {
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetStartTime(startTime time.Time) {
|
||||
t.startTime = &startTime
|
||||
t.StartTime = &startTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) GetStartTime() *time.Time {
|
||||
return t.startTime
|
||||
return t.StartTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetEndTime(endTime time.Time) {
|
||||
t.endTime = &endTime
|
||||
t.EndTime = &endTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) GetEndTime() *time.Time {
|
||||
return t.endTime
|
||||
return t.EndTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) ClearEndTime() {
|
||||
t.endTime = nil
|
||||
t.EndTime = nil
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetTotalBytes(totalBytes int64) {
|
||||
|
||||
Reference in New Issue
Block a user