From d6109a79402145345d560f7da0bf5ce111aa798a Mon Sep 17 00:00:00 2001 From: fryeggs <42050399+fryeggs@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:20:11 +0800 Subject: [PATCH 1/2] feat(task): persist task timestamps across restarts (#2914) Co-authored-by: OpenAI Codex --- internal/fs/copy_queue_persistence_test.go | 65 ++++++++++++++++++++++ internal/task/base.go | 14 ++--- 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 internal/fs/copy_queue_persistence_test.go diff --git a/internal/fs/copy_queue_persistence_test.go b/internal/fs/copy_queue_persistence_test.go new file mode 100644 index 000000000..d3c559b09 --- /dev/null +++ b/internal/fs/copy_queue_persistence_test.go @@ -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) + } +} diff --git a/internal/task/base.go b/internal/task/base.go index f969e6c27..6e778dd96 100644 --- a/internal/task/base.go +++ b/internal/task/base.go @@ -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) { From f18b4acc76f231dd425acf4e587446b0332f68c4 Mon Sep 17 00:00:00 2001 From: flyingrtx Date: Thu, 10 Sep 2026 19:45:24 +0800 Subject: [PATCH 2/2] feat(local): add PDF thumbnails on macOS (#3017) - add an opt-in Local driver setting for PDF thumbnails - render PDF first pages with macOS Quick Look - preserve unsupported-platform behavior and cover the renderer with tests Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Co-authored-by: ILoveScratch --- drivers/local/driver.go | 6 +-- drivers/local/meta.go | 1 + drivers/local/pdf_thumb_darwin.go | 45 ++++++++++++++++++++++ drivers/local/pdf_thumb_darwin_test.go | 53 ++++++++++++++++++++++++++ drivers/local/pdf_thumb_test.go | 40 +++++++++++++++++++ drivers/local/pdf_thumb_unsupported.go | 17 +++++++++ drivers/local/util.go | 24 +++++++++++- 7 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 drivers/local/pdf_thumb_darwin.go create mode 100644 drivers/local/pdf_thumb_darwin_test.go create mode 100644 drivers/local/pdf_thumb_test.go create mode 100644 drivers/local/pdf_thumb_unsupported.go diff --git a/drivers/local/driver.go b/drivers/local/driver.go index ba7c0850b..696e24804 100644 --- a/drivers/local/driver.go +++ b/drivers/local/driver.go @@ -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 { diff --git a/drivers/local/meta.go b/drivers/local/meta.go index a27e9eecd..f4536bd2f 100644 --- a/drivers/local/meta.go +++ b/drivers/local/meta.go @@ -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."` diff --git a/drivers/local/pdf_thumb_darwin.go b/drivers/local/pdf_thumb_darwin.go new file mode 100644 index 000000000..6542cb399 --- /dev/null +++ b/drivers/local/pdf_thumb_darwin.go @@ -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 +} diff --git a/drivers/local/pdf_thumb_darwin_test.go b/drivers/local/pdf_thumb_darwin_test.go new file mode 100644 index 000000000..ec00ed633 --- /dev/null +++ b/drivers/local/pdf_thumb_darwin_test.go @@ -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) + } +} diff --git a/drivers/local/pdf_thumb_test.go b/drivers/local/pdf_thumb_test.go new file mode 100644 index 000000000..614232fb7 --- /dev/null +++ b/drivers/local/pdf_thumb_test.go @@ -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) + } + }) + } +} diff --git a/drivers/local/pdf_thumb_unsupported.go b/drivers/local/pdf_thumb_unsupported.go new file mode 100644 index 000000000..deb8e99f2 --- /dev/null +++ b/drivers/local/pdf_thumb_unsupported.go @@ -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") +} diff --git a/drivers/local/util.go b/drivers/local/util.go index f9f4f4c45..b3ccc1fd8 100644 --- a/drivers/local/util.go +++ b/drivers/local/util.go @@ -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 {