297 lines
10 KiB
Go
297 lines
10 KiB
Go
package preview
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache"
|
|
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
|
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
|
"github.com/gtsteffaniak/go-logger/logger"
|
|
)
|
|
|
|
var (
|
|
ErrUnsupportedFormat = errors.New("preview is not available for provided file format")
|
|
ErrUnsupportedMedia = errors.New("unsupported media type")
|
|
service *Service
|
|
)
|
|
|
|
type Service struct {
|
|
sem chan struct{}
|
|
ffmpegPath string
|
|
ffprobePath string
|
|
fileCache diskcache.Interface
|
|
debug bool
|
|
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
|
|
}
|
|
|
|
func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
|
|
var fileCache diskcache.Interface
|
|
// Use file cache if cacheDir is specified
|
|
if cacheDir != "" {
|
|
var err error
|
|
fileCache, err = diskcache.NewFileCache(cacheDir)
|
|
if err != nil {
|
|
if cacheDir == "tmp" {
|
|
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. filebrowser is trying to use the default `server.cacheDir: tmp` , but you can change this location if you need to. Please see configuration wiki for more information about this error. https://github.com/gtsteffaniak/filebrowser/wiki/Configuration")
|
|
}
|
|
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
|
}
|
|
} else {
|
|
// No-op cache if no cacheDir is specified
|
|
fileCache = diskcache.NewNoOp()
|
|
}
|
|
// Create directories recursively with 0755 permissions
|
|
err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), 0755)
|
|
if err != nil {
|
|
logger.Error(err)
|
|
}
|
|
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos"), 0755)
|
|
if err != nil {
|
|
logger.Error(err)
|
|
}
|
|
ffmpegMainPath, err := CheckValidFFmpeg(ffmpegPath)
|
|
if err != nil && ffmpegPath != "" {
|
|
logger.Fatalf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", ffmpegPath, err)
|
|
}
|
|
ffprobePath, errprobe := CheckValidFFprobe(ffmpegPath)
|
|
if errprobe != nil && ffmpegPath != "" {
|
|
logger.Fatalf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", ffmpegPath, err)
|
|
}
|
|
if errprobe == nil && err == nil {
|
|
logger.Debugf("Media Enabled : %v", errprobe == nil)
|
|
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
|
|
}
|
|
settings.Config.Server.MuPdfAvailable = docEnabled()
|
|
logger.Debugf("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
|
|
return &Service{
|
|
sem: make(chan struct{}, concurrencyLimit),
|
|
ffmpegPath: ffmpegMainPath,
|
|
ffprobePath: ffprobePath,
|
|
fileCache: fileCache,
|
|
debug: settings.Config.Server.DebugMedia,
|
|
}
|
|
}
|
|
|
|
func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) error {
|
|
service = NewPreviewGenerator(concurrencyLimit, ffmpegPath, cacheDir)
|
|
return nil
|
|
}
|
|
|
|
func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
|
|
if !AvailablePreview(file) {
|
|
return nil, ErrUnsupportedMedia
|
|
}
|
|
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
|
|
if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil {
|
|
return nil, fmt.Errorf("failed to load from cache: %w", err)
|
|
} else if found {
|
|
return data, nil
|
|
}
|
|
|
|
return GeneratePreview(file, previewSize, url, seekPercentage)
|
|
}
|
|
|
|
func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
|
|
ext := strings.ToLower(filepath.Ext(file.Name))
|
|
var (
|
|
err error
|
|
imageBytes []byte
|
|
)
|
|
// Generate thumbnail image from video
|
|
hasher := md5.New() //nolint:gosec
|
|
_, _ = hasher.Write([]byte(CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)))
|
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
|
// Generate an image from office document
|
|
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
|
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs", hash) + ".txt"
|
|
imageBytes, err = service.GenerateImageFromDoc(file, tempFilePath, 0) // 0 for the first page
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
|
|
}
|
|
} else if file.OnlyOfficeId != "" {
|
|
imageBytes, err = service.GenerateOfficePreview(filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create image for office file: %w", err)
|
|
}
|
|
} else if strings.HasPrefix(file.Type, "image") {
|
|
logger.Infof("🔍 PREVIEW DEBUG: Image file detected - Name: %s, Type: %s, Extension: %s", file.Name, file.Type, filepath.Ext(file.Name))
|
|
|
|
// Quick HEIC identification and delegation
|
|
if isHEICFile(file) {
|
|
logger.Infof("🔄 HEIC DEBUG: HEIC file detected for %s", filepath.Base(file.Name))
|
|
|
|
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
|
|
imageBytes, err = service.processHEICFile(file, previewSize)
|
|
if err != nil {
|
|
logger.Errorf("❌ HEIC DEBUG: HEIC processing failed: %v", err)
|
|
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
|
}
|
|
|
|
logger.Infof("✅ HEIC DEBUG: HEIC processing successful, got %d bytes", len(imageBytes))
|
|
|
|
// For HEIC files, we've already done the resize/conversion, so cache and return directly
|
|
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
|
|
if err = service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
|
|
logger.Errorf("failed to cache HEIC image: %v", err)
|
|
}
|
|
return imageBytes, nil
|
|
} else {
|
|
// get image bytes from file (non-HEIC images)
|
|
logger.Infof("📖 PREVIEW DEBUG: Reading non-HEIC image file directly")
|
|
imageBytes, err = os.ReadFile(file.RealPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read image file: %w", err)
|
|
}
|
|
}
|
|
} else if strings.HasPrefix(file.Type, "video") {
|
|
if seekPercentage == 0 {
|
|
seekPercentage = 10
|
|
}
|
|
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos", hash) + ".jpg"
|
|
defer os.Remove(outPathPattern) // cleanup
|
|
imageBytes, err = service.GenerateVideoPreview(file.RealPath, outPathPattern, seekPercentage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("unsupported media type: %s", ext)
|
|
}
|
|
if len(imageBytes) < 100 {
|
|
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
|
|
}
|
|
|
|
if previewSize != "original" {
|
|
// resize image
|
|
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
|
}
|
|
// Cache and return
|
|
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
|
|
if err := service.fileCache.Store(context.Background(), cacheKey, resizedBytes); err != nil {
|
|
logger.Errorf("failed to cache resized image: %v", err)
|
|
}
|
|
return resizedBytes, nil
|
|
} else {
|
|
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
|
|
if err := service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
|
|
logger.Errorf("failed to cache resized image: %v", err)
|
|
}
|
|
return imageBytes, nil
|
|
}
|
|
|
|
}
|
|
|
|
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
|
|
var (
|
|
width int
|
|
height int
|
|
options []Option
|
|
)
|
|
|
|
switch previewSize {
|
|
case "large":
|
|
width, height = 640, 640
|
|
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
|
|
case "small":
|
|
width, height = 256, 256
|
|
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
|
|
default:
|
|
return nil, ErrUnsupportedFormat
|
|
}
|
|
|
|
input := bytes.NewReader(data)
|
|
output := &bytes.Buffer{}
|
|
|
|
if err := s.Resize(input, width, height, output, options...); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return output.Bytes(), nil
|
|
}
|
|
|
|
func CacheKey(realPath, previewSize string, modTime time.Time, percentage int) string {
|
|
return fmt.Sprintf("%x%x%x%x", realPath, modTime.Unix(), previewSize, percentage)
|
|
}
|
|
|
|
func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
|
|
errSmall := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "small", file.ModTime, 0))
|
|
if errSmall != nil {
|
|
errLarge := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "large", file.ModTime, 0))
|
|
if errLarge != nil {
|
|
logger.Debugf("Could not delete thumbnail: %v", file.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func AvailablePreview(file iteminfo.ExtendedFileInfo) bool {
|
|
if strings.HasPrefix(file.Type, "video") && service.ffmpegPath != "" {
|
|
return true
|
|
}
|
|
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
|
return true
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(file.Name))
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".bmp", ".tiff":
|
|
return true
|
|
case ".heic", ".heif":
|
|
return service.ffmpegPath != "" // HEIC support available when FFmpeg is available
|
|
}
|
|
return file.OnlyOfficeId != ""
|
|
}
|
|
|
|
// CheckValidFFmpeg checks for a valid ffmpeg executable.
|
|
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
|
func CheckValidFFmpeg(path string) (string, error) {
|
|
return checkExecutable(path, "ffmpeg")
|
|
}
|
|
|
|
// CheckValidFFprobe checks for a valid ffprobe executable.
|
|
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
|
func CheckValidFFprobe(path string) (string, error) {
|
|
return checkExecutable(path, "ffprobe")
|
|
}
|
|
|
|
// checkExecutable is an internal helper function to find and validate an executable.
|
|
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
|
|
func checkExecutable(providedPath, execName string) (string, error) {
|
|
// Add .exe extension for Windows systems
|
|
if runtime.GOOS == "windows" {
|
|
execName += ".exe"
|
|
}
|
|
|
|
var finalPath string
|
|
var err error
|
|
|
|
if providedPath != "" {
|
|
// A path was provided, so we'll use it.
|
|
finalPath = filepath.Join(providedPath, execName)
|
|
} else {
|
|
// No path was provided, so search the system's PATH for the executable.
|
|
finalPath, err = exec.LookPath(execName)
|
|
if err != nil {
|
|
// The executable was not found in the system's PATH.
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// Verify the executable is valid by running the "-version" command.
|
|
cmd := exec.Command(finalPath, "-version")
|
|
err = cmd.Run()
|
|
|
|
return finalPath, err
|
|
}
|