package manager import ( "context" "errors" "fmt" "github.com/cloudreve/Cloudreve/v4/pkg/thumb" "os" "runtime" "time" "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent/task" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/local" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/queue" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/samber/lo" ) // Thumbnail returns the thumbnail entity of the file. func (m *manager) Thumbnail(ctx context.Context, uri *fs.URI) (entitysource.EntitySource, error) { // retrieve file info file, err := m.fs.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithFilePublicMetadata()) if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } // 0. Check if thumb is disabled in this file. if _, ok := file.Metadata()[dbfs.ThumbDisabledKey]; ok || file.Type() != types.FileTypeFile { return nil, fs.ErrEntityNotExist } // 1. If thumbnail entity exist, use it. entities := file.Entities() thumbEntity, found := lo.Find(entities, func(e fs.Entity) bool { return e.Type() == types.EntityTypeThumbnail }) if found { thumbSource, err := m.GetEntitySource(ctx, 0, fs.WithEntity(thumbEntity)) if err != nil { return nil, fmt.Errorf("failed to get entity source: %w", err) } thumbSource.Apply(entitysource.WithDisplayName(file.DisplayName() + ".jpg")) return thumbSource, nil } latest := file.PrimaryEntity() // If primary entity not exist, or it's empty if latest == nil || latest.ID() == 0 { return nil, fmt.Errorf("failed to get latest version") } // 2. Thumb entity not exist, try native policy generator _, handler, err := m.getEntityPolicyDriver(ctx, latest, nil) if err != nil { return nil, fmt.Errorf("failed to get entity policy driver: %w", err) } capabilities := handler.Capabilities() // Check if file extension and size is supported by native policy generator. if capabilities.ThumbSupportAllExts || util.IsInExtensionList(capabilities.ThumbSupportedExts, file.DisplayName()) && (capabilities.ThumbMaxSize == 0 || latest.Size() <= capabilities.ThumbMaxSize) { thumbSource, err := m.GetEntitySource(ctx, 0, fs.WithEntity(latest), fs.WithUseThumb(true)) if err != nil { return nil, fmt.Errorf("failed to get latest entity source: %w", err) } thumbSource.Apply(entitysource.WithDisplayName(file.DisplayName())) return thumbSource, nil } else if capabilities.ThumbProxy { if err := m.fs.CheckCapability(ctx, uri, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityGenerateThumb)); err != nil { // Current FS does not support generate new thumb. return nil, fs.ErrEntityNotExist } thumbEntity, err := m.SubmitAndAwaitThumbnailTask(ctx, uri, file.Ext(), latest) if err != nil { return nil, fmt.Errorf("failed to execute thumb task: %w", err) } thumbSource, err := m.GetEntitySource(ctx, 0, fs.WithEntity(thumbEntity)) if err != nil { return nil, fmt.Errorf("failed to get entity source: %w", err) } return thumbSource, nil } else { // 4. If proxy generator not support, mark thumb as not available. _ = disableThumb(ctx, m, uri) } return nil, fs.ErrEntityNotExist } func (m *manager) SubmitAndAwaitThumbnailTask(ctx context.Context, uri *fs.URI, ext string, entity fs.Entity) (fs.Entity, error) { es, err := m.GetEntitySource(ctx, 0, fs.WithEntity(entity)) if err != nil { return nil, fmt.Errorf("failed to get entity source: %w", err) } defer es.Close() t := newGenerateThumbTask(ctx, m, uri, ext, es) if err := m.dep.ThumbQueue(ctx).QueueTask(ctx, t); err != nil { return nil, fmt.Errorf("failed to queue task: %w", err) } // Wait for task to finish select { case <-ctx.Done(): return nil, ctx.Err() case res := <-t.sig: if res.err != nil { return nil, fmt.Errorf("failed to generate thumb: %w", res.err) } return res.thumbEntity, nil } } func (m *manager) generateThumb(ctx context.Context, uri *fs.URI, ext string, es entitysource.EntitySource) (fs.Entity, error) { // Generate thumb pipeline := m.dep.ThumbPipeline() res, err := pipeline.Generate(ctx, es, ext, nil) if err != nil { if res != nil && res.Path != "" { _ = os.Remove(res.Path) } if !errors.Is(err, context.Canceled) && !m.stateless { if err := disableThumb(ctx, m, uri); err != nil { m.l.Warning("Failed to disable thumb: %v", err) } } return nil, fmt.Errorf("failed to generate thumb: %w", err) } defer os.Remove(res.Path) // Upload thumb entity thumbFile, err := os.Open(res.Path) if err != nil { return nil, fmt.Errorf("failed to open temp thumb %q: %w", res.Path, err) } defer thumbFile.Close() fileInfo, err := thumbFile.Stat() if err != nil { return nil, fmt.Errorf("failed to stat temp thumb %q: %w", res.Path, err) } var ( thumbEntity fs.Entity ) if m.stateless { _, d, err := m.getEntityPolicyDriver(ctx, es.Entity(), nil) if err != nil { return nil, fmt.Errorf("failed to get storage driver: %w", err) } savePath := es.Entity().Source() + m.settings.ThumbSlaveSidecarSuffix(ctx) if err := d.Put(ctx, &fs.UploadRequest{ File: thumbFile, Seeker: thumbFile, Props: &fs.UploadProps{SavePath: savePath}, }); err != nil { return nil, fmt.Errorf("failed to save thumb sidecar: %w", err) } thumbEntity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, savePath) if err != nil { return nil, fmt.Errorf("failed to create local thumb entity: %w", err) } } else { entityType := types.EntityTypeThumbnail req := &fs.UploadRequest{ Props: &fs.UploadProps{ Uri: uri, Size: fileInfo.Size(), SavePath: fmt.Sprintf( "%s.%s%s", es.Entity().Source(), util.RandStringRunes(16), m.settings.ThumbEntitySuffix(ctx), ), MimeType: m.dep.MimeDetector(ctx).TypeByName("thumb.jpg"), EntityType: &entityType, }, File: thumbFile, Seeker: thumbFile, } // Generating thumb can be triggered by users with read-only permission. We can bypass update permission check. ctx = dbfs.WithBypassOwnerCheck(ctx) file, err := m.Update(ctx, req, fs.WithEntityType(types.EntityTypeThumbnail)) if err != nil { return nil, fmt.Errorf("failed to upload thumb entity: %w", err) } entities := file.Entities() found := false thumbEntity, found = lo.Find(entities, func(e fs.Entity) bool { return e.Type() == types.EntityTypeThumbnail }) if !found { return nil, fmt.Errorf("failed to find thumb entity") } } if m.settings.ThumbGCAfterGen(ctx) { m.l.Debug("GC after thumb generation") runtime.GC() } return thumbEntity, nil } type ( GenerateThumbTask struct { *queue.InMemoryTask es entitysource.EntitySource ext string m *manager uri *fs.URI sig chan *generateRes } generateRes struct { thumbEntity fs.Entity err error } ) func newGenerateThumbTask(ctx context.Context, m *manager, uri *fs.URI, ext string, es entitysource.EntitySource) *GenerateThumbTask { t := &GenerateThumbTask{ InMemoryTask: &queue.InMemoryTask{ DBTask: &queue.DBTask{ Task: &ent.Task{ CorrelationID: logging.CorrelationID(ctx), PublicState: &types.TaskPublicState{}, }, }, }, es: es, ext: ext, m: m, uri: uri, sig: make(chan *generateRes, 2), } t.InMemoryTask.DBTask.Task.SetUser(m.user) return t } func (m *GenerateThumbTask) Do(ctx context.Context) (task.Status, error) { // Make sure user does not cancel request before we start generating thumb. select { case <-ctx.Done(): err := ctx.Err() return task.StatusError, err default: } res, err := m.m.generateThumb(ctx, m.uri, m.ext, m.es) if err != nil { if errors.Is(err, thumb.ErrNotAvailable) { m.sig <- &generateRes{nil, err} return task.StatusCompleted, nil } return task.StatusError, err } m.sig <- &generateRes{res, nil} return task.StatusCompleted, nil } func (m *GenerateThumbTask) OnError(err error, d time.Duration) { m.InMemoryTask.OnError(err, d) m.sig <- &generateRes{nil, err} } func disableThumb(ctx context.Context, m *manager, uri *fs.URI) error { return m.fs.PatchMetadata( dbfs.WithBypassOwnerCheck(ctx), []*fs.URI{uri}, fs.MetadataPatch{ Key: dbfs.ThumbDisabledKey, Value: "", Private: false, }) }