Merge branch 'main' into feat/advanced-transfer-seeds

This commit is contained in:
Pikachu Ren
2026-09-12 23:48:59 +08:00
committed by GitHub
9 changed files with 252 additions and 13 deletions
+2 -4
View File
@@ -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 {
+1
View File
@@ -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."`
+45
View File
@@ -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
}
+53
View File
@@ -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)
}
}
+40
View File
@@ -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)
}
})
}
}
+17
View File
@@ -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
View File
@@ -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)
}
}
+7 -7
View File
@@ -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) {