From 6c22cb3b321287c7d2acc0cbfd5909a6b7d32c70 Mon Sep 17 00:00:00 2001 From: Cy Allen Scott Date: Thu, 21 Apr 2022 18:16:24 -0400 Subject: [PATCH] feat: added thumbnail support for videos --- files/file.go | 84 +++++++++++++++---- fileutils/dir.go | 17 +++- frontend/src/components/files/ListingItem.vue | 8 +- frontend/src/views/files/Listing.vue | 1 + http/preview.go | 12 +++ http/resource.go | 64 ++++++++++++++ 6 files changed, 165 insertions(+), 21 deletions(-) diff --git a/files/file.go b/files/file.go index 569b0be4..e0ef153d 100644 --- a/files/file.go +++ b/files/file.go @@ -26,20 +26,22 @@ import ( // FileInfo describes a file. type FileInfo struct { *Listing - Fs afero.Fs `json:"-"` - Path string `json:"path"` - Name string `json:"name"` - Size int64 `json:"size"` - Extension string `json:"extension"` - ModTime time.Time `json:"modified"` - Mode os.FileMode `json:"mode"` - IsDir bool `json:"isDir"` - IsSymlink bool `json:"isSymlink"` - Type string `json:"type"` - Subtitles []string `json:"subtitles,omitempty"` - Content string `json:"content,omitempty"` - Checksums map[string]string `json:"checksums,omitempty"` - Token string `json:"token,omitempty"` + Fs afero.Fs `json:"-"` + Dir string `json:"dir"` + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + Extension string `json:"extension"` + ModTime time.Time `json:"modified"` + Mode os.FileMode `json:"mode"` + IsDir bool `json:"isDir"` + IsSymlink bool `json:"isSymlink"` + IsThumbsEnabled bool `json:"isThumbsEnabled"` + Type string `json:"type"` + Subtitles []string `json:"subtitles,omitempty"` + Content string `json:"content,omitempty"` + Checksums map[string]string `json:"checksums,omitempty"` + Token string `json:"token,omitempty"` } // FileOptions are the options when getting a file info. @@ -54,6 +56,11 @@ type FileOptions struct { Content bool } +type FileThumbnail struct { + Dir string + Path string +} + // NewFileInfo creates a File object from a path and a given user. This File // object will be automatically filled depending on if it is a directory // or a file. If it's a video file, it will also detect any subtitles. @@ -84,6 +91,34 @@ func NewFileInfo(opts FileOptions) (*FileInfo, error) { return file, err } +func NewThumbnailInfo(opts FileOptions) (*FileInfo, error) { + return NewFileInfo(FileOptions{ + Fs: opts.Fs, + Path: NewFileThumbnail(opts).Path, + Modify: opts.Modify, + Expand: opts.Expand, + ReadHeader: opts.ReadHeader, + Checker: opts.Checker, + }) +} + +func NewFileThumbnail(opts FileOptions) FileThumbnail { + dir, name := filepath.Split(opts.Path) + + hash := md5.Sum([]byte(name)) + + thumbnailName := hex.EncodeToString(hash[:]) + ".jpg" + + thumbnailPath := path.Join(dir, ".filebrowser", thumbnailName) + + dir, _ = filepath.Split(thumbnailPath) + + return FileThumbnail{ + Dir: dir, + Path: thumbnailPath, + } +} + func stat(opts FileOptions) (*FileInfo, error) { var file *FileInfo @@ -92,8 +127,10 @@ func stat(opts FileOptions) (*FileInfo, error) { if err != nil { return nil, err } + dir, _ := filepath.Split(opts.Path) file = &FileInfo{ Fs: opts.Fs, + Dir: dir, Path: opts.Path, Name: info.Name(), ModTime: info.ModTime(), @@ -128,8 +165,10 @@ func stat(opts FileOptions) (*FileInfo, error) { return file, nil } + dir, _ := filepath.Split(opts.Path) file = &FileInfo{ Fs: opts.Fs, + Dir: dir, Path: opts.Path, Name: info.Name(), ModTime: info.ModTime(), @@ -224,12 +263,14 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error { switch { case strings.HasPrefix(mimetype, "video"): i.Type = "video" + i.detectThumbnail() i.detectSubtitles() return nil case strings.HasPrefix(mimetype, "audio"): i.Type = "audio" return nil case strings.HasPrefix(mimetype, "image"): + i.IsThumbsEnabled = true i.Type = "image" return nil case strings.HasSuffix(mimetype, "pdf"): @@ -301,6 +342,21 @@ func (i *FileInfo) detectSubtitles() { } } +func (i *FileInfo) detectThumbnail() { + dir, name := filepath.Split(i.RealPath()) + + hash := md5.Sum([]byte(name)) + thumbnailName := hex.EncodeToString(hash[:]) + + path := path.Join(dir, ".filebrowser", thumbnailName+".jpg") + + _, err := os.Stat(path) + + if err == nil { + i.IsThumbsEnabled = true + } +} + func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error { afs := &afero.Afero{Fs: i.Fs} dir, err := afs.ReadDir(i.Path) diff --git a/fileutils/dir.go b/fileutils/dir.go index 07a3528e..d32426dc 100644 --- a/fileutils/dir.go +++ b/fileutils/dir.go @@ -6,10 +6,7 @@ import ( "github.com/spf13/afero" ) -// CopyDir copies a directory from source to dest and all -// of its sub-directories. It doesn't stop if it finds an error -// during the copy. Returns an error if any. -func CopyDir(fs afero.Fs, source, dest string) error { +func CreateDir(fs afero.Fs, source, dest string) error { // Get properties of source. srcinfo, err := fs.Stat(source) if err != nil { @@ -22,6 +19,18 @@ func CopyDir(fs afero.Fs, source, dest string) error { return err } + return nil +} + +// CopyDir copies a directory from source to dest and all +// of its sub-directories. It doesn't stop if it finds an error +// during the copy. Returns an error if any. +func CopyDir(fs afero.Fs, source, dest string) error { + err := CreateDir(fs, source, dest) + if err != nil { + return err + } + dir, _ := fs.Open(source) obs, err := dir.Readdir(-1) if err != nil { diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue index 351fba1f..c3c58e0d 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -8,6 +8,7 @@ @dragover="dragOver" @drop="drop" @click="itemClick" + :data-thumbs-enabled="isThumbsEnabled" :data-dir="isDir" :data-type="type" :aria-label="name" @@ -15,7 +16,7 @@ >
@@ -52,6 +53,7 @@ export default { props: [ "name", "isDir", + "isThumbsEnabled", "url", "type", "size", @@ -90,8 +92,8 @@ export default { return `${baseURL}/api/preview/thumb/${path}?k=${key}&inline=true`; }, - isThumbsEnabled() { - return enableThumbs; + hasThumbnailUrl() { + return this.readOnly == undefined && this.isThumbsEnabled && enableThumbs; }, }, methods: { diff --git a/frontend/src/views/files/Listing.vue b/frontend/src/views/files/Listing.vue index 4286e8dc..e358e565 100644 --- a/frontend/src/views/files/Listing.vue +++ b/frontend/src/views/files/Listing.vue @@ -221,6 +221,7 @@ v-bind:index="item.index" v-bind:name="item.name" v-bind:isDir="item.isDir" + v-bind:isThumbsEnabled="item.isThumbsEnabled" v-bind:url="item.url" v-bind:modified="item.modified" v-bind:type="item.type" diff --git a/http/preview.go b/http/preview.go index 163d7e49..a0cd4441 100644 --- a/http/preview.go +++ b/http/preview.go @@ -59,6 +59,18 @@ func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, re setContentDisposition(w, r, file) + thumbnail, err := files.NewThumbnailInfo(files.FileOptions{ + Fs: d.user.Fs, + Path: "/" + vars["path"], + Modify: d.user.Perm.Modify, + Expand: true, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + if err == nil && thumbnail != nil { + return rawFileHandler(w, r, thumbnail) + } + switch file.Type { case "image": return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview) diff --git a/http/resource.go b/http/resource.go index 9264f268..09e403ee 100644 --- a/http/resource.go +++ b/http/resource.go @@ -77,6 +77,18 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc { return errToStatus(err), err } + thumbnail, err := files.NewThumbnailInfo(files.FileOptions{ + Fs: d.user.Fs, + Path: r.URL.Path, + Modify: d.user.Perm.Modify, + Expand: false, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + if err == nil && thumbnail != nil { + d.user.Fs.RemoveAll(thumbnail.Path) + } + err = d.RunHook(func() error { return d.user.Fs.RemoveAll(r.URL.Path) }, "delete", r.URL.Path, "", d.user) @@ -299,6 +311,32 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach return errors.ErrPermissionDenied } + srcThumbnail, err := files.NewThumbnailInfo(files.FileOptions{ + Fs: d.user.Fs, + Path: src, + Modify: d.user.Perm.Modify, + Expand: false, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + if err == nil && srcThumbnail != nil { + destThumbnail := files.NewFileThumbnail(files.FileOptions{ + Fs: d.user.Fs, + Path: dst, + Modify: d.user.Perm.Modify, + Expand: false, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + + _, err := os.Stat(destThumbnail.Dir) + if err != nil { + fileutils.CreateDir(d.user.Fs, srcThumbnail.Dir, destThumbnail.Dir) + } + + fileutils.Copy(d.user.Fs, srcThumbnail.Path, destThumbnail.Path) + } + return fileutils.Copy(d.user.Fs, src, dst) case "rename": if !d.user.Perm.Rename { @@ -325,6 +363,32 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach return err } + srcThumbnail, err := files.NewThumbnailInfo(files.FileOptions{ + Fs: d.user.Fs, + Path: src, + Modify: d.user.Perm.Modify, + Expand: false, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + if err == nil && srcThumbnail != nil { + destThumbnail := files.NewFileThumbnail(files.FileOptions{ + Fs: d.user.Fs, + Path: dst, + Modify: d.user.Perm.Modify, + Expand: false, + ReadHeader: d.server.TypeDetectionByHeader, + Checker: d, + }) + + _, err := os.Stat(destThumbnail.Dir) + if err != nil { + fileutils.CreateDir(d.user.Fs, srcThumbnail.Dir, destThumbnail.Dir) + } + + fileutils.MoveFile(d.user.Fs, srcThumbnail.Path, destThumbnail.Path) + } + return fileutils.MoveFile(d.user.Fs, src, dst) default: return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)