package photoprism

import (
	"errors"
	"fmt"
	"image"
	_ "image/gif"  // register GIF decoder
	_ "image/jpeg" // register JPEG decoder
	_ "image/png"  // register PNG decoder
	"io"
	"math"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"runtime/debug"
	"strings"
	"sync"
	"time"

	_ "golang.org/x/image/bmp"  // register BMP decoder
	_ "golang.org/x/image/tiff" // register TIFF decoder
	_ "golang.org/x/image/webp" // register WebP decoder

	"github.com/djherbis/times"
	"github.com/dustin/go-humanize"
	"github.com/mandykoh/prism/meta/autometa"

	"github.com/photoprism/photoprism/internal/entity"
	"github.com/photoprism/photoprism/internal/meta"
	"github.com/photoprism/photoprism/internal/thumb"
	"github.com/photoprism/photoprism/pkg/clean"
	"github.com/photoprism/photoprism/pkg/fs"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/media"
	"github.com/photoprism/photoprism/pkg/media/video"
	"github.com/photoprism/photoprism/pkg/txt"
)

// MediaFile represents a single photo, video, sidecar, or other supported media file.
type MediaFile struct {
	fileName         string
	fileNameResolved string
	fileRoot         string
	statErr          error
	mimeErr          error
	modTime          time.Time
	fileSize         int64
	fileType         fs.Type
	mimeType         string
	contentType      string
	takenAt          time.Time
	takenAtLocal     time.Time
	takenAtSrc       string
	hash             string
	checksum         string
	hasPreviewImage  bool
	noColorProfile   bool
	colorProfile     string
	width            int
	height           int
	metaData         meta.Data
	metaOnce         sync.Once
	videoInfo        video.Info
	videoOnce        sync.Once
	fileMutex        sync.Mutex
	location         *entity.Cell
	imageConfig      *image.Config
}

// NewMediaFile resolves fileName (following symlinks) and initializes a MediaFile
// instance. The returned instance is never nil; callers must check the error to
// learn whether the path existed or was readable.
func NewMediaFile(fileName string) (*MediaFile, error) {
	if fileNameResolved, err := fs.Resolve(fileName); err != nil {
		// Don't return nil on error, as this would change the previous behavior.
		return &MediaFile{}, err
	} else {
		return NewMediaFileSkipResolve(fileName, fileNameResolved)
	}
}

// NewMediaFileSkipResolve behaves like NewMediaFile but assumes fileNameResolved
// already points to the canonical location. This avoids an extra filesystem
// lookup when the caller has already resolved the path.
func NewMediaFileSkipResolve(fileName string, fileNameResolved string) (*MediaFile, error) {
	// Create and initialize the new media file.
	m := &MediaFile{
		fileName:         fileName,
		fileNameResolved: fileNameResolved,
		fileRoot:         entity.RootUnknown,
		fileType:         fs.TypeUnknown,
		metaData:         meta.NewData(),
		videoInfo:        video.NewInfo(),
		width:            -1,
		height:           -1,
	}

	// Check if the file exists and is not empty.
	if size, _, err := m.Stat(); err != nil {
		// Return error if os.Stat() failed.
		return m, fmt.Errorf("%s not found", clean.Log(m.RootRelName()))
	} else if size == 0 {
		// Notify the user that the file is empty.
		log.Infof("media: %s is empty", clean.Log(m.RootRelName()))
	}

	return m, nil
}

// Ok reports whether the file name is set, Stat succeeded and the file is not empty.
// It relies on cached metadata populated by Stat.
func (m *MediaFile) Ok() bool {
	return m.FileName() != "" && m.statErr == nil && !m.Empty()
}

// Empty reports whether Stat determined that the file has zero (or negative when
// stat failed) length.
func (m *MediaFile) Empty() bool {
	return m.FileSize() <= 0
}

// Stat populates cached file size / modification time information (respecting
// second precision) and returns the cached values. Subsequent calls reuse the
// cached details unless the size has not yet been determined.
func (m *MediaFile) Stat() (size int64, mod time.Time, err error) {
	if m.fileSize > 0 {
		return m.fileSize, m.modTime, m.statErr
	}

	if stat, statErr := os.Stat(m.fileNameResolved); statErr != nil {
		m.statErr = statErr
		m.modTime = time.Time{}
		m.fileSize = -1
	} else {
		stat.Mode()
		m.statErr = nil
		m.modTime = stat.ModTime().UTC().Truncate(time.Second)
		m.fileSize = stat.Size()
	}

	return m.fileSize, m.modTime, m.statErr
}

// ModTime returns the cached modification timestamp in UTC, fetching it via Stat
// if necessary.
func (m *MediaFile) ModTime() time.Time {
	_, modTime, _ := m.Stat()

	return modTime
}

// SetModTime updates the on-disk modification time and caches the new value on
// success. The receiver is returned so callers can chain additional method calls.
func (m *MediaFile) SetModTime(modTime time.Time) *MediaFile {
	modTime = modTime.UTC()

	if err := os.Chtimes(m.FileName(), time.Time{}, modTime); err != nil {
		log.Debugf("media: failed to set mtime for %s (%s)", clean.Log(m.RootRelName()), clean.Error(err))
	} else {
		m.modTime = modTime
	}

	return m
}

// FileSize returns the file size in bytes.
func (m *MediaFile) FileSize() int64 {
	fileSize, _, _ := m.Stat()

	return fileSize
}

// DateCreated returns the best-known creation timestamp in UTC. It is a thin
// wrapper around TakenAt() that discards the local time / source metadata.
func (m *MediaFile) DateCreated() time.Time {
	takenAt, _, _ := m.TakenAt()

	return takenAt
}

// TakenAt returns the UTC creation timestamp, the local timestamp and the source
// used to derive it. The value is cached so repeated calls avoid re-reading
// metadata. Extraction order: EXIF metadata, filename parsing, file modification
// time; if none of those succeed the timestamps remain set to the current time
// captured when the method first ran.
func (m *MediaFile) TakenAt() (utc time.Time, local time.Time, source string) {
	// Check if creation time has been cached.
	if !m.takenAt.IsZero() {
		return m.takenAt, m.takenAtLocal, m.takenAtSrc
	}

	m.takenAtLocal = time.Now().Truncate(time.Second).Local()
	m.takenAt = m.takenAtLocal.UTC()

	// First try to extract the creation time from the file metadata,
	data := m.MetaData()

	if data.Error == nil && !data.TakenAt.IsZero() && data.TakenAt.Year() > 1000 {
		m.takenAtLocal = data.TakenAt.Truncate(time.Second).Local()
		m.takenAt = m.takenAtLocal.UTC()
		m.takenAtSrc = entity.SrcMeta

		log.Infof("media: %s was taken at %s (%s)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc)

		return m.takenAt, m.takenAtLocal, m.takenAtSrc
	}

	// Otherwise, try to determine creation time from file name and path.
	if nameTime := txt.DateFromFilePath(m.fileName); !nameTime.IsZero() {
		m.takenAtLocal = nameTime.Truncate(time.Second).Local()
		m.takenAt = nameTime.Truncate(time.Second).UTC()
		m.takenAtSrc = entity.SrcName
		log.Infof("media: %s was taken at %s (%s)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc)
		return m.takenAt, m.takenAtLocal, m.takenAtSrc
	}

	m.takenAtSrc = entity.SrcAuto

	fileInfo, err := times.Stat(m.FileName())

	if err != nil {
		log.Warnf("media: %s (stat call failed)", err.Error())
		log.Infof("media: %s was taken at %s (unknown mod time)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String())
		return m.takenAt, m.takenAtLocal, m.takenAtSrc
	}

	// Use file modification time as fallback.
	m.takenAtLocal = fileInfo.ModTime().Truncate(time.Second).Local()
	m.takenAt = m.takenAtLocal.UTC()
	log.Infof("media: %s was taken at %s (file mod time)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String())

	return m.takenAt, m.takenAtLocal, m.takenAtSrc
}

// HasTimeAndPlace reports whether both TakenAt and GPS coordinates are available.
func (m *MediaFile) HasTimeAndPlace() bool {
	data := m.MetaData()

	result := !data.TakenAt.IsZero() && data.Lat != 0 && data.Lng != 0

	return result
}

// CameraModel returns the camera model with which the media file was created.
func (m *MediaFile) CameraModel() string {
	data := m.MetaData()

	return data.CameraModel
}

// CameraMake returns the make of the camera with which the file was created.
func (m *MediaFile) CameraMake() string {
	data := m.MetaData()

	return data.CameraMake
}

// LensModel returns the lens model of a media file.
func (m *MediaFile) LensModel() string {
	data := m.MetaData()

	return data.LensModel
}

// LensMake returns the make of the Lens.
func (m *MediaFile) LensMake() string {
	data := m.MetaData()

	return data.LensMake
}

// FocalLength return the length of the focal for a file.
func (m *MediaFile) FocalLength() int {
	data := m.MetaData()

	return data.FocalLength
}

// FNumber returns the F number with which the media file was created.
func (m *MediaFile) FNumber() float32 {
	data := m.MetaData()

	return data.FNumber
}

// Iso returns the iso rating as int.
func (m *MediaFile) Iso() int {
	data := m.MetaData()

	return data.Iso
}

// Exposure returns the exposure time as string.
func (m *MediaFile) Exposure() string {
	data := m.MetaData()

	return data.Exposure
}

// CanonicalName returns the canonical name of a media file.
func (m *MediaFile) CanonicalName(pattern string) string {
	return fs.CanonicalName(m.DateCreated(), m.Checksum(), pattern)
}

// CanonicalNameDefault returns the default canonical name of a media file.
func (m *MediaFile) CanonicalNameDefault() string {
	return fs.CanonicalName(m.DateCreated(), m.Checksum(), "")
}

// CanonicalNameFromFile returns the canonical name of a file derived from the image name.
func (m *MediaFile) CanonicalNameFromFile() string {
	basename := filepath.Base(m.FileName())

	if end := strings.Index(basename, "."); end != -1 {
		return basename[:end] // Length of canonical name: 16 + 12
	}

	return basename
}

// CanonicalNameFromFileWithDirectory gets the canonical name for a MediaFile
// including the directory.
func (m *MediaFile) CanonicalNameFromFileWithDirectory() string {
	return m.Dir() + string(os.PathSeparator) + m.CanonicalNameFromFile()
}

// Hash returns the SHA1 hash of a media file.
func (m *MediaFile) Hash() string {
	if len(m.hash) == 0 {
		m.hash = fs.Hash(m.FileName())
	}

	return m.hash
}

// Checksum returns the CRC32 checksum of a media file.
func (m *MediaFile) Checksum() string {
	if len(m.checksum) == 0 {
		m.checksum = fs.Checksum(m.FileName())
	}

	return m.checksum
}

// PathNameInfo resolves the file root (originals/import/sidecar/etc) and returns
// the root identifier, file base prefix, relative directory and relative name
// for indexing / metadata persistence.
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
	fileRoot = m.Root()

	var rootPath string

	switch fileRoot {
	case entity.RootSidecar:
		rootPath = Config().SidecarPath()
	case entity.RootImport:
		rootPath = Config().ImportPath()
	case entity.RootExamples:
		rootPath = Config().ExamplesPath()
	case entity.RootOriginals:
		rootPath = Config().OriginalsPath()
	default:
		rootPath = Config().OriginalsPath()
	}

	fileBase = m.BasePrefix(stripSequence)
	relativePath = m.RelPath(rootPath)
	relativeName = m.RelName(rootPath)

	return fileRoot, fileBase, relativePath, relativeName
}

// FileName returns the absolute file name recorded for this media file.
func (m *MediaFile) FileName() string {
	return m.fileName
}

// BaseName returns just the final path component of the file.
func (m *MediaFile) BaseName() string {
	return filepath.Base(m.fileName)
}

// SetFileName updates the stored file name and resets the cached root hint so
// it will be recalculated on next access.
func (m *MediaFile) SetFileName(fileName string) {
	if m == nil {
		log.Errorf("media: file %s is nil - you may have found a bug", clean.Log(fileName))
		return
	}

	m.fileName = fileName
	m.fileRoot = entity.RootUnknown
}

// RootRelName returns the path of the file relative to the detected root (e.g.
// Originals, Import, Sidecar).
func (m *MediaFile) RootRelName() string {
	return m.RelName(m.RootPath())
}

// RelName returns the file name relative to directory, sanitizing the result for logging.
func (m *MediaFile) RelName(directory string) string {
	return fs.RelName(m.fileName, directory)
}

// RelPath returns the relative directory (without filename) by trimming the
// provided base directory from the stored file path.
func (m *MediaFile) RelPath(directory string) string {
	pathname := m.fileName

	if i := strings.Index(pathname, directory); i == 0 {
		if i = strings.LastIndex(directory, string(os.PathSeparator)); i == len(directory)-1 {
			pathname = pathname[len(directory):]
		} else if i = strings.LastIndex(directory, string(os.PathSeparator)); i != len(directory) {
			pathname = pathname[len(directory)+1:]
		}
	}

	if end := strings.LastIndex(pathname, string(os.PathSeparator)); end != -1 {
		pathname = pathname[:end]
	} else if end = strings.LastIndex(pathname, string(os.PathSeparator)); end == -1 {
		pathname = ""
	}

	// Remove hidden sub directory if exists.
	if path.Base(pathname) == fs.PPHiddenPathname {
		pathname = path.Dir(pathname)
	}

	// Use empty string for current / root directory.
	if pathname == "." || pathname == "/" || pathname == "\\" {
		pathname = ""
	}

	return pathname
}

// RootPath returns the absolute root directory for the media file (Originals,
// Import, Sidecar, Examples) based on its detected storage location and the
// current configuration.
func (m *MediaFile) RootPath() string {
	switch m.Root() {
	case entity.RootSidecar:
		return Config().SidecarPath()
	case entity.RootImport:
		return Config().ImportPath()
	case entity.RootExamples:
		return Config().ExamplesPath()
	default:
		return Config().OriginalsPath()
	}
}

// RootRelPath returns the file path relative to the detected root directory.
func (m *MediaFile) RootRelPath() string {
	return m.RelPath(m.RootPath())
}

// RelPrefix builds a relative path (without extension) suitable for deriving
// related files such as sidecars. When stripSequence is true the sequence
// suffix is removed from the filename prefix.
func (m *MediaFile) RelPrefix(directory string, stripSequence bool) string {
	if relativePath := m.RelPath(directory); relativePath != "" {
		return filepath.Join(relativePath, m.BasePrefix(stripSequence))
	}

	return m.BasePrefix(stripSequence)
}

// Dir returns the directory containing the media file.
func (m *MediaFile) Dir() string {
	return filepath.Dir(m.fileName)
}

// SubDir joins the media file's directory with the provided sub directory name.
func (m *MediaFile) SubDir(dir string) string {
	return filepath.Join(filepath.Dir(m.fileName), dir)
}

// AbsPrefix returns the absolute path (directory + filename) without any
// extensions, optionally stripping numeric sequence suffixes.
func (m *MediaFile) AbsPrefix(stripSequence bool) string {
	return fs.AbsPrefix(m.FileName(), stripSequence)
}

// BasePrefix returns the filename (without directory) stripped of all
// extensions; stripSequence removes trailing sequence tokens such as "_01".
func (m *MediaFile) BasePrefix(stripSequence bool) string {
	return fs.BasePrefix(m.FileName(), stripSequence)
}

// EditedName returns the alternate filename used by Apple Photos for edited
// JPEGs (e.g. IMG_E12345.JPG). An empty string indicates no edited companion is
// present.
func (m *MediaFile) EditedName() string {
	basename := filepath.Base(m.fileName)

	if strings.ToUpper(basename[:4]) == "IMG_" && strings.ToUpper(basename[:5]) != "IMG_E" {
		if filename := filepath.Dir(m.fileName) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:]; fs.FileExists(filename) {
			return filename
		}
	}

	return ""
}

// Root identifies which configured root the media file resides in (originals,
// import, sidecar, examples). The result is cached so repeated calls are cheap.
func (m *MediaFile) Root() string {
	if m.fileRoot != entity.RootUnknown {
		return m.fileRoot
	}

	if strings.HasPrefix(m.FileName(), Config().OriginalsPath()) {
		m.fileRoot = entity.RootOriginals
		return m.fileRoot
	}

	importPath := Config().ImportPath()

	if importPath != "" && strings.HasPrefix(m.FileName(), importPath) {
		m.fileRoot = entity.RootImport
		return m.fileRoot
	}

	sidecarPath := Config().SidecarPath()

	if sidecarPath != "" && strings.HasPrefix(m.FileName(), sidecarPath) {
		m.fileRoot = entity.RootSidecar
		return m.fileRoot
	}

	examplesPath := Config().ExamplesPath()

	if examplesPath != "" && strings.HasPrefix(m.FileName(), examplesPath) {
		m.fileRoot = entity.RootExamples
		return m.fileRoot
	}

	return m.fileRoot
}

// MimeType returns the mimetype of this file, or an empty string if it could not be determined.
//
// The IANA and IETF use the term "media type", and consider the term "MIME type" to be obsolete,
// since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure
func (m *MediaFile) MimeType() string {
	// Do not detect the MIME type again if it is already known,
	// or if the detection failed.
	if m.mimeType != "" || m.mimeErr != nil {
		return m.mimeType
	}

	var err error

	// Get the filename and resolve symbolic links, if necessary.
	fileName := m.FileName()
	if fileName, err = fs.Resolve(fileName); err != nil {
		return m.mimeType
	}

	// Detect the file's MIME type based on its content and file extension.
	m.mimeType, err = fs.DetectMimeType(fileName)

	// Log and remember the error if the MIME type detection has failed.
	if err != nil {
		log.Errorf("media: failed to detect mime type of %s (%s)", clean.Log(m.RootRelName()), clean.Error(err))
		m.mimeErr = err
		return m.mimeType
	}

	// Adjust the MIME type for MP4 files containing MPEG-2 transport streams.
	if m.mimeType == header.ContentTypeMp4 && m.MetaData().Codec == video.CodecM2TS {
		m.mimeType = header.ContentTypeM2TS
	}

	// Return MIME type.
	return m.mimeType
}

// BaseType returns the basic mime type, without any optional parameters.
func (m *MediaFile) BaseType() string {
	return fs.BaseType(m.MimeType())
}

// HasMimeType tests if the specified mime type is the same, except for any optional parameters.
func (m *MediaFile) HasMimeType(mimeType string) bool {
	return fs.SameType(m.MimeType(), mimeType)
}

// ContentType returns the media content type.
func (m *MediaFile) ContentType() string {
	if m.contentType != "" {
		return m.contentType
	}

	m.contentType = m.MimeType()

	// Generate normalized HTTP content type.
	if m.IsVideo() {
		m.contentType = video.ContentType(m.contentType, m.FileType().String(), m.MetaData().Codec, m.MetaData().IsHDR())
	} else {
		m.contentType = clean.ContentType(m.contentType)
	}

	log.Debugf("media: %s has content type %s", clean.Log(m.RootRelName()), clean.LogQuote(m.contentType))

	return m.contentType
}

// openFile opens the file and returns the descriptor.
func (m *MediaFile) openFile() (handle *os.File, err error) {
	fileName := m.FileName()

	// Resolve symlinks.
	if fileName, err = fs.Resolve(fileName); err != nil {
		return nil, fmt.Errorf("%s %s", err, clean.Log(m.RootRelName()))
	}

	// #nosec G304 -- fileName is resolved from trusted MediaFile path.
	handle, err = os.Open(fileName)

	if err != nil {
		log.Error(err.Error())
		return nil, err
	}

	return handle, nil
}

// Exists checks if a media file exists by filename.
func (m *MediaFile) Exists() bool {
	return fs.FileExists(m.FileName())
}

// Remove permanently removes a media file.
func (m *MediaFile) Remove() error {
	return os.Remove(m.FileName())
}

// HasSameName compares a media file with another media file and returns if
// their filenames are matching or not.
func (m *MediaFile) HasSameName(f *MediaFile) bool {
	if f == nil {
		return false
	}

	return m.FileName() == f.FileName()
}

// Move file to a new destination with the filename provided in parameter.
func (m *MediaFile) Move(filePath string, force bool) (err error) {
	// Check for obviously empty or invalid file paths.
	if filePath == "" || filePath == "." || filePath == ".." {
		return errors.New("move: invalid destination file path")
	}

	// Check whether a destination file
	// and directory name are specified.
	if filepath.Base(filePath) == "" {
		return errors.New("move: invalid destination name")
	} else if filepath.Dir(filePath) == "" {
		return errors.New("move: invalid destination path")
	}

	// Resolve absolute destination file path
	// and return an error if unsuccessful.
	if filePath, err = filepath.Abs(filePath); err != nil {
		return fmt.Errorf("move: could not resolve destination file path (%s)", err)
	}

	destName := filepath.Base(filePath)
	logName := clean.Log(destName)
	destDir := filepath.Dir(filePath)

	// Error if source and destination file path are the same.
	if filePath == m.FileName() {
		return fmt.Errorf("move: cannot overwrite file %s with itself", logName)
	}

	// Error if destination exists (and is not empty) without the force flag being used.
	if fs.Exists(filePath) {
		switch {
		case fs.FileExistsIsEmpty(filePath):
			log.Infof("move: replacing empty destination file %s", logName)
		case force:
			log.Warnf("move: overwriting destination file %s", logName)
		default:
			return fmt.Errorf("move: destination name %s already exists", logName)
		}
	}

	// Make sure the target directory exists.
	if err = fs.MkdirAll(destDir); err != nil {
		return fmt.Errorf("move: could not create target directory (%s)", err)
	}

	// Remember file modification time.
	modTime := m.ModTime()

	// First try to rename existing file as that's faster
	// than copying it and then deleting the original.
	if renameErr := os.Rename(m.fileName, filePath); renameErr != nil {
		log.Tracef("move: cannot rename %s, fallback to copy and delete (%s)", clean.Log(destName), clean.Error(renameErr))
	} else {
		m.SetFileName(filePath)
		m.SetModTime(modTime)

		return nil
	}

	// If renaming the file is not possible, copy its
	// contents and then delete the original file.
	if copyErr := m.Copy(filePath, force); copyErr != nil {
		return fmt.Errorf("%s (move fallback)", copyErr)
	}

	if rmErr := os.Remove(m.fileName); rmErr != nil {
		return fmt.Errorf("move: %s", rmErr)
	}

	m.SetFileName(filePath)

	return nil
}

// Copy copies the file contents to the specified destination.
// It only overwrites existing files when the force flag is used.
func (m *MediaFile) Copy(filePath string, force bool) (err error) {
	// Check for obviously empty or invalid file paths.
	if filePath == "" || filePath == "." || filePath == ".." {
		return errors.New("copy: invalid destination file path")
	}

	// Check whether a destination file and directory name are specified.
	if filepath.Base(filePath) == "" {
		return errors.New("copy: invalid destination name")
	} else if filepath.Dir(filePath) == "" {
		return errors.New("copy: invalid destination path")
	}

	// Resolve absolute destination file path and return an error if unsuccessful.
	if filePath, err = filepath.Abs(filePath); err != nil {
		return fmt.Errorf("copy: could not resolve destination file path (%s)", err)
	}

	destName := filepath.Base(filePath)
	logName := clean.Log(destName)
	destDir := filepath.Dir(filePath)

	// Error if source and destination file path are the same.
	if filePath == m.FileName() {
		return fmt.Errorf("copy: cannot overwrite file %s with itself", logName)
	}

	// Error if destination exists (and is not empty) without the force flag being used.
	if fs.Exists(filePath) {
		switch {
		case fs.FileExistsIsEmpty(filePath):
			log.Infof("copy: replacing empty destination file %s", logName)
		case force:
			log.Warnf("copy: overwriting destination file %s", logName)
		default:
			return fmt.Errorf("copy: destination name %s already exists", logName)
		}
	}

	// Make sure the target directory exists.
	if err = fs.MkdirAll(destDir); err != nil {
		return fmt.Errorf("copy: could not create target directory (%s)", err)
	}

	m.fileMutex.Lock()
	defer m.fileMutex.Unlock()

	thisFile, err := m.openFile()

	if err != nil {
		return fmt.Errorf("copy: source file %s cannot be opened (%s)", m.BaseName(), err)
	}

	defer thisFile.Close()

	// Open the target file path for writing, discarding any trailing bytes.
	// #nosec G304 -- destination path is validated and absolute.
	destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.ModeFile)

	if err != nil {
		log.Error(err.Error())
		return fmt.Errorf("copy: destination file %s cannot be opened (%s)", logName, err)
	}

	defer func() {
		// Update the file timestamp after the file has been copied and closed.
		if err = destFile.Close(); err != nil {
			log.Debugf("copy: could not close destination file %s (%s)", logName, clean.Error(err))
		} else if err = os.Chtimes(filePath, time.Time{}, m.ModTime()); err != nil {
			log.Debugf("copy: could not set Mtime for destination file %s (%s)", logName, clean.Error(err))
		}
	}()

	// Copy file contents to the destination.
	_, err = io.Copy(destFile, thisFile)

	if err != nil {
		return fmt.Errorf("copy: %s", err)
	}

	return nil
}

// Extension returns the filename extension of this media file.
func (m *MediaFile) Extension() string {
	if m == nil {
		return ""
	}

	return strings.ToLower(filepath.Ext(m.fileName))
}

// IsPreviewImage return true if this media file is a JPEG or PNG image.
func (m *MediaFile) IsPreviewImage() bool {
	if m == nil {
		return false
	}

	return m.IsJpeg() || m.IsPng()
}

// IsJpeg checks if the file is a JPEG image with a supported file type extension.
func (m *MediaFile) IsJpeg() bool {
	if m == nil {
		return false
	} else if fs.FileType(m.fileName) != fs.ImageJpeg {
		// Thumbnails and other JPEGs with an unsupported file extension are no longer indexed as
		// JPEG to improve performance (skips mime type detection) and to avoid follow-up issues
		// with external tools that rely on a correct file extension.
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeJpeg)
}

// IsJpegXL checks if the file is a JPEG XL image with a supported file type extension.
func (m *MediaFile) IsJpegXL() bool {
	if fs.FileType(m.fileName) != fs.ImageJpegXL {
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeJpegXL)
}

// IsPng checks if the file is a PNG image with a supported file type extension.
func (m *MediaFile) IsPng() bool {
	if fs.FileType(m.fileName) != fs.ImagePng {
		// Files with an incorrect file extension are no longer
		// recognized as PNG to improve indexing performance.
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypePng) || m.HasMimeType(header.ContentTypeAPng)
}

// IsGif checks if the file is a GIF image with a supported file type extension.
func (m *MediaFile) IsGif() bool {
	if fs.FileType(m.fileName) != fs.ImageGif {
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeGif)
}

// IsTiff checks if the file is a TIFF image with a supported file type extension.
func (m *MediaFile) IsTiff() bool {
	if fs.FileType(m.fileName) != fs.ImageTiff {
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeTiff)
}

// IsDng checks if the file is a Adobe Digital Negative (DNG) image with a supported file type extension.
func (m *MediaFile) IsDng() bool {
	if fs.FileType(m.fileName) != fs.ImageDng {
		return false
	}

	return m.HasMimeType(header.ContentTypeDng)
}

// IsHeif checks if the file is a High Efficiency Image File Format (HEIF) container with a supported file type extension.
func (m *MediaFile) IsHeif() bool {
	return m.IsHeic() || m.IsHeicS() || m.IsAvif() || m.IsAvifS()
}

// IsHeic checks if the file is a High Efficiency Image Container (HEIC) image with a supported file type extension.
func (m *MediaFile) IsHeic() bool {
	if t := fs.FileType(m.fileName); t != fs.ImageHeif && t != fs.ImageHeic {
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeHeic) || m.HasMimeType(header.ContentTypeHeicS)
}

// IsHeicS checks if the file is a HEIC image sequence with a supported file type extension.
func (m *MediaFile) IsHeicS() bool {
	return m.HasFileType(fs.ImageHeicS)
}

// IsAvif checks if the file is an AV1 Image File Format image with a supported file type extension.
func (m *MediaFile) IsAvif() bool {
	if t := fs.FileType(m.fileName); t != fs.ImageAvif {
		return false
	}

	return m.HasMimeType(header.ContentTypeAvif)
}

// IsAvifS checks if the file is an AVIF image sequence with a supported file type extension.
func (m *MediaFile) IsAvifS() bool {
	return m.HasFileType(fs.ImageAvifS)
}

// IsM2TS checks if the file is an MPEG-2 Transport Stream (M2TS) container.
func (m *MediaFile) IsM2TS() bool {
	if t := fs.FileType(m.fileName); t == fs.VideoM2TS {
		return true
	} else if t == fs.VideoMp4 || t == fs.VideoAVCHD {
		return m.HasMimeType(header.ContentTypeM2TS)
	}

	return false
}

// IsBmp checks if the file is a bitmap image with a supported file type extension.
func (m *MediaFile) IsBmp() bool {
	if fs.FileType(m.fileName) != fs.ImageBmp {
		return false
	}

	// Check the mime type after other tests have passed to improve performance.
	return m.HasMimeType(header.ContentTypeBmp)
}

// IsWebp checks if the file is a WebP image file with a supported file type extension.
func (m *MediaFile) IsWebp() bool {
	if fs.FileType(m.fileName) != fs.ImageWebp {
		return false
	}

	return m.HasMimeType(header.ContentTypeWebp)
}

// Duration returns the duration is the media content is playable.
func (m *MediaFile) Duration() time.Duration {
	return m.MetaData().Duration
}

// IsAnimatedImage checks if the file is an animated image.
func (m *MediaFile) IsAnimatedImage() bool {
	return fs.IsAnimatedImage(m.fileName) && (m.MetaData().Frames > 1 || m.MetaData().Duration > 0)
}

// IsJSON checks if the file is a JSON sidecar file with a supported file type extension.
func (m *MediaFile) IsJSON() bool {
	return m.HasFileType(fs.SidecarJson)
}

// FileType returns the file type (jpg, gif, tiff,...).
func (m *MediaFile) FileType() fs.Type {
	switch {
	case m.IsJpeg():
		return fs.ImageJpeg
	case m.IsPng():
		return fs.ImagePng
	case m.IsGif():
		return fs.ImageGif
	case m.IsBmp():
		return fs.ImageBmp
	case m.IsDng():
		return fs.ImageDng
	case m.IsAvif():
		return fs.ImageAvif
	case m.IsHeic():
		return fs.ImageHeic
	default:
		return fs.FileType(m.fileName)
	}
}

// CheckType returns an error if the file extension is missing or invalid,
// see https://github.com/photoprism/photoprism/issues/3518 for details.
func (m *MediaFile) CheckType() error {
	// Get extension and return error if missing.
	extension := m.Extension()

	if extension == "" {
		return fmt.Errorf("has no file extension")
	}

	// Detect file type and return error if unknown.
	fileType := fs.FileType(m.fileName)

	if fileType == fs.TypeUnknown {
		return fmt.Errorf("is an unknown file type")
	}

	// Detect media type (formerly known as a MIME type),
	// see https://en.wikipedia.org/wiki/Media_type
	mimeType := m.BaseType()

	// Perform mime type checks for selected file types.
	var valid bool
	switch fileType {
	case fs.ImageJpeg:
		valid = mimeType == header.ContentTypeJpeg
	case fs.ImagePng:
		valid = mimeType == header.ContentTypePng || mimeType == header.ContentTypeAPng
	case fs.ImageGif:
		valid = mimeType == header.ContentTypeGif
	case fs.ImageTiff:
		valid = mimeType == header.ContentTypeTiff
	case fs.ImageHeic, fs.ImageHeif:
		valid = mimeType == header.ContentTypeHeic || mimeType == header.ContentTypeHeicS
	default:
		// Skip mime type check. Note: Checks for additional formats and/or generic
		// checks based on the media content type can be added over time as needed.
		return nil
	}

	// Ok?
	if valid {
		return nil
	}

	// If the MIME type is empty, it is usually because the file could not be read.
	if mimeType == fs.MimeTypeUnknown {
		return fmt.Errorf("could not be identified")
	}

	return fmt.Errorf("has an invalid extension for media type %s", clean.LogQuote(mimeType))
}

// MediaType returns the media content type, e.g. video, image, raw, or sidecar.
func (m *MediaFile) MediaType() media.Type {
	return media.FromName(m.fileName)
}

// HasMediaType checks if the file has any of the given media types.
func (m *MediaFile) HasMediaType(mediaTypes ...media.Type) bool {
	mediaType := m.MediaType()

	for _, t := range mediaTypes {
		if mediaType == t {
			return true
		}
	}

	return false
}

// HasFileType checks if the file has the given file type.
func (m *MediaFile) HasFileType(fileType fs.Type) bool {
	if fileType == fs.ImageJpeg {
		return m.IsJpeg()
	}

	return m.FileType() == fileType
}

// IsImage checks if the file is an image.
func (m *MediaFile) IsImage() bool {
	return m.HasMediaType(media.Image)
}

// IsRaw returns true if this is a RAW file.
func (m *MediaFile) IsRaw() bool {
	return m.HasFileType(fs.ImageRaw) || m.HasMediaType(media.Raw) || m.IsDng()
}

// IsAnimated returns true if this is a video or animated image.
func (m *MediaFile) IsAnimated() bool {
	return m.IsVideo() || m.IsAnimatedImage()
}

// NotAnimated checks if this is not a video or an animated image.
func (m *MediaFile) NotAnimated() bool {
	return !m.IsAnimated()
}

// IsDocument returns true if this is a PDF document file.
func (m *MediaFile) IsDocument() bool {
	return m.HasMediaType(media.Document) && m.HasMimeType(header.ContentTypePDF)
}

// IsVector returns true if this is a vector graphics.
func (m *MediaFile) IsVector() bool {
	return m.HasMediaType(media.Vector) || m.IsSVG()
}

// IsVideo returns true if this is a video file.
func (m *MediaFile) IsVideo() bool {
	return m.HasMediaType(media.Video)
}

// IsMov returns true if this is a MOV (QuickTime) video file.
func (m *MediaFile) IsMov() bool {
	return fs.FileType(m.fileName) == fs.VideoMov
}

// IsSidecar checks if the file is a metadata sidecar file, independent of the storage location.
func (m *MediaFile) IsSidecar() bool {
	return !m.MediaType().IsMain()
}

// IsArchive returns true if this is an archive file.
func (m *MediaFile) IsArchive() bool {
	return m.HasFileType(fs.ArchiveZip) || m.HasMediaType(media.Archive)
}

// IsThumb checks if the file is a thumbnail image.
func (m *MediaFile) IsThumb() bool {
	return m.FileType() == fs.ImageThumb
}

// IsSVG returns true if this is a SVG vector graphics.
func (m *MediaFile) IsSVG() bool {
	return m.FileType() == fs.VectorSVG
}

// IsXMP returns true if this is a XMP sidecar file.
func (m *MediaFile) IsXMP() bool {
	return m.FileType() == fs.SidecarXMP
}

// InOriginals checks if the file is stored in the 'originals' folder.
func (m *MediaFile) InOriginals() bool {
	return m.Root() == entity.RootOriginals
}

// InSidecar checks if the file is stored in the 'sidecar' folder.
func (m *MediaFile) InSidecar() bool {
	return m.Root() == entity.RootSidecar
}

// NeedsTranscoding checks whether the media file is a video or an animated image and should be transcoded to a playable format.
func (m *MediaFile) NeedsTranscoding() bool {
	if m.NotAnimated() {
		return false
	} else if m.HasFileType(fs.VideoAvc) || m.HasFileType(fs.VideoMp4) && m.MetaData().CodecAvc() {
		return false
	}

	if m.IsAnimatedImage() {
		return fs.VideoMp4.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false) == ""
	}

	return fs.VideoAvc.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false) == ""
}

// SkipTranscoding checks if the media file is not animated or has already been transcoded to a playable format.
func (m *MediaFile) SkipTranscoding() bool {
	return !m.NeedsTranscoding()
}

// IsImageOther returns true if this is a PNG, GIF, BMP, TIFF, or WebP file.
func (m *MediaFile) IsImageOther() bool {
	switch {
	case m.IsPng(), m.IsGif(), m.IsTiff(), m.IsBmp(), m.IsWebp():
		return true
	default:
		return false
	}
}

// IsImageNative returns true if it is a natively supported image file.
func (m *MediaFile) IsImageNative() bool {
	return m.IsJpeg() || m.IsImageOther()
}

// IsLive checks if the file is a live photo.
func (m *MediaFile) IsLive(videoDuration time.Duration) bool {
	switch {
	case !m.InOriginals():
		// Live Photos must be located in the Originals folder.
		return false
	case !m.HasMediaType(media.Video, media.Image, media.Live):
		// Live Photos may only consist of video, image, or live files.
		return false
	case videoDuration > media.LiveMaxDuration:
		// Live Photos can include a maximum of 3.1 seconds of video.
		return false
	}

	// Check for related image or video files in the expected formats.
	switch m.MediaType() {
	case media.Video:
		// Live Photos may only have MOV video sidecar files.
		if m.IsMov() {
			if fs.ImageHeic.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" ||
				fs.ImageJpeg.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
				return true
			}
		}
	case media.Image:
		// Live Photos must be either HEIC or JPEG image files.
		if m.IsHeic() || m.IsJpeg() {
			if fs.VideoMov.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" {
				return true
			}
		}
	}

	// If none of the above applies, check the metadata for embedded videos.
	return m.MetaData().MediaType == media.Live && m.VideoInfo().Compatible
}

// ExifSupported returns true if parsing exif metadata is supported for the media file type.
func (m *MediaFile) ExifSupported() bool {
	return m.IsJpeg() || m.IsRaw() || m.IsHeif() || m.IsPng() || m.IsTiff()
}

// IsMedia returns true if this is a media file (photo or video, not sidecar or other).
func (m *MediaFile) IsMedia() bool {
	return !m.IsThumb() && (m.IsImage() || m.IsRaw() || m.IsVideo() || m.IsVector() || m.IsDocument())
}

// PreviewImage returns the media file itself if it is already a JPEG/PNG, or
// locates a matching preview image (JPEG/PNG) stored alongside the file. The
// helper returns an error when no preview can be found.
func (m *MediaFile) PreviewImage() (*MediaFile, error) {
	if m.IsJpeg() {
		if !fs.FileExists(m.FileName()) {
			return nil, fmt.Errorf("jpeg should exist, but does not: %s", m.RootRelName())
		}

		return m, nil
	} else if m.Empty() {
		return nil, fmt.Errorf("%s is empty", m.RootRelName())
	}

	jpegName := fs.ImageJpeg.FindFirst(m.FileName(),
		[]string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false)

	if jpegName != "" {
		return NewMediaFile(jpegName)
	}

	pngName := fs.ImagePng.FindFirst(m.FileName(),
		[]string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false)

	if pngName != "" {
		return NewMediaFile(pngName)
	}

	return nil, fmt.Errorf("no preview image found for %s", m.RootRelName())
}

// HasPreviewImage reports whether a JPEG/PNG preview exists. The result is
// cached, so expensive lookups only happen once per MediaFile instance.
func (m *MediaFile) HasPreviewImage() bool {
	if m.hasPreviewImage {
		return true
	}

	if m.IsPreviewImage() {
		m.hasPreviewImage = true
		return true
	}

	jpegName := fs.ImageJpeg.FindFirst(m.FileName(),
		[]string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false)

	if m.hasPreviewImage = fs.MimeType(jpegName) == header.ContentTypeJpeg; m.hasPreviewImage {
		return true
	}

	pngName := fs.ImagePng.FindFirst(m.FileName(),
		[]string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false)

	if m.hasPreviewImage = fs.MimeType(pngName) == header.ContentTypePng; m.hasPreviewImage {
		return true
	}

	return false
}

func (m *MediaFile) decodeDimensions() error {
	// Media dimensions already known?
	if m.width > 0 && m.height > 0 {
		return nil
	}

	// Valid media file?
	if !m.Ok() || !m.IsMedia() {
		return fmt.Errorf("%s is not a valid media file", clean.Log(m.Extension()))
	}

	// Extract the actual width and height from natively supported formats.
	if m.IsImageNative() {
		cfg, err := m.DecodeConfig()

		if err != nil {
			return err
		}

		orientation := m.Orientation()

		if orientation > 4 && orientation <= 8 {
			m.width = cfg.Height
			m.height = cfg.Width
		} else {
			m.width = cfg.Width
			m.height = cfg.Height
		}

		return nil
	}

	// Extract the width and height from metadata for other formats.
	if data := m.MetaData(); data.Error != nil {
		return data.Error
	} else {
		m.width = data.ActualWidth()
		m.height = data.ActualHeight()

		return nil
	}
}

// DecodeConfig extracts the raw dimensions from the header of natively supported image file formats.
func (m *MediaFile) DecodeConfig() (_ *image.Config, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic %s while decoding %s dimensions\nstack: %s", r, clean.Log(m.Extension()), debug.Stack())
		}
	}()

	if m.imageConfig != nil {
		return m.imageConfig, nil
	}

	if !m.IsImageNative() {
		return nil, fmt.Errorf("%s not supported natively", clean.Log(m.Extension()))
	}

	var info image.Config

	if info, err = thumb.FileInfo(m.FileName()); err != nil {
		return nil, fmt.Errorf("%s while decoding %s dimensions", err, clean.Log(m.Extension()))
	}

	m.imageConfig = &info

	return m.imageConfig, nil
}

// Width return the width dimension of a MediaFile.
func (m *MediaFile) Width() int {
	// Valid media file?
	if !m.Ok() || !m.IsMedia() {
		return 0
	}

	if m.width < 0 {
		if err := m.decodeDimensions(); err != nil {
			log.Debugf("media: %s", err)
		}
	}

	return m.width
}

// Height returns the height dimension of a MediaFile.
func (m *MediaFile) Height() int {
	// Valid media file?
	if !m.Ok() || !m.IsMedia() {
		return 0
	}

	if m.height < 0 {
		if err := m.decodeDimensions(); err != nil {
			log.Debugf("media: %s", err)
		}
	}

	return m.height
}

// Megapixels returns the resolution in megapixels if possible.
func (m *MediaFile) Megapixels() (resolution int) {
	// Valid media file?
	if !m.Ok() || !m.IsMedia() {
		return 0
	}

	if cfg, err := m.DecodeConfig(); err == nil {
		resolution = int(math.Round(float64(cfg.Width*cfg.Height) / 1000000))
	}

	if resolution <= 0 {
		resolution = m.metaData.Megapixels()
	}

	return resolution
}

// ExceedsBytes checks if the file exceeds the specified size limit in bytes.
func (m *MediaFile) ExceedsBytes(limit int64) (fileSize int64, err error) {
	fileSize = m.FileSize()

	switch {
	case limit <= 0:
		return fileSize, nil
	case fileSize <= 0 || fileSize <= limit:
		return fileSize, nil
	default:
		return fileSize, fmt.Errorf("%s exceeds file size limit (%s / %s)", clean.Log(m.RootRelName()), humanize.Bytes(uint64(fileSize)), humanize.Bytes(uint64(limit)))
	}
}

// ExceedsResolution checks if an image in a natively supported format exceeds the configured resolution limit in megapixels.
func (m *MediaFile) ExceedsResolution(limit int) (resolution int, err error) {
	switch {
	case limit <= 0:
		return resolution, nil
	case !m.IsImage():
		return resolution, nil
	default:
		resolution = m.Megapixels()

		if resolution <= 0 || resolution <= limit {
			return resolution, nil
		}

		return resolution, fmt.Errorf("%s exceeds resolution limit (%d / %d MP)", clean.Log(m.RootRelName()), resolution, limit)
	}
}

// AspectRatio returns the aspect ratio of a MediaFile.
func (m *MediaFile) AspectRatio() float32 {
	width := float64(m.Width())
	height := float64(m.Height())

	if width <= 0 || height <= 0 {
		return 0
	}

	aspectRatio := float32(math.Round((width/height)*100) / 100)

	return aspectRatio
}

// Square checks if the width and height of this media file are the same.
func (m *MediaFile) Square() bool {
	width := m.Width()
	height := m.Height()

	if width <= 0 || height <= 0 {
		return false
	}

	return width == height
}

// Portrait tests if the image is a portrait.
func (m *MediaFile) Portrait() bool {
	return m.Width() < m.Height()
}

// Orientation returns the Exif orientation of the media file.
func (m *MediaFile) Orientation() int {
	if data := m.MetaData(); data.Error == nil {
		return data.Orientation
	}

	return 1
}

// RenameSidecarFiles moves related sidecar files.
func (m *MediaFile) RenameSidecarFiles(oldFileName string) (renamed map[string]string, err error) {
	renamed = make(map[string]string)

	sidecarPath := Config().SidecarPath()
	originalsPath := Config().OriginalsPath()

	newName := m.RelPrefix(originalsPath, false)
	oldPrefix := fs.RelPrefix(oldFileName, originalsPath, false)
	globPrefix := filepath.Join(sidecarPath, oldPrefix) + "."

	matches, err := filepath.Glob(regexp.QuoteMeta(globPrefix) + "*")

	if err != nil {
		return renamed, err
	}

	for _, srcName := range matches {
		destName := filepath.Join(sidecarPath, newName+fs.Ext(srcName))

		if fs.FileExists(destName) {
			renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath)

			if rmErr := os.Remove(srcName); rmErr != nil {
				log.Errorf("files: could not remove sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath)))
			} else {
				log.Infof("files: removed sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath)))
			}

			continue
		}

		if moveErr := fs.Move(srcName, destName, true); moveErr != nil {
			return renamed, moveErr
		} else {
			log.Infof("files: moved existing sidecar to %s", clean.Log(newName+filepath.Ext(srcName)))
			renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath)
		}
	}

	return renamed, nil
}

// RemoveSidecarFiles permanently removes related sidecar files.
func (m *MediaFile) RemoveSidecarFiles() (numFiles int, err error) {
	fileName := m.FileName()

	if fileName == "" {
		return numFiles, fmt.Errorf("empty filename")
	}

	sidecarPath := Config().SidecarPath()
	originalsPath := Config().OriginalsPath()

	prefix := fs.RelPrefix(fileName, originalsPath, false)
	globPrefix := filepath.Join(sidecarPath, prefix) + "."

	matches, err := filepath.Glob(regexp.QuoteMeta(globPrefix) + "*")

	if err != nil {
		return numFiles, err
	}

	for _, sidecarName := range matches {
		if err = os.Remove(sidecarName); err != nil {
			log.Errorf("files: failed to delete sidecar %s", clean.Log(fs.RelName(sidecarName, sidecarPath)))
		} else {
			numFiles++
			log.Infof("files: deleted sidecar %s", clean.Log(fs.RelName(sidecarName, sidecarPath)))
		}
	}

	return numFiles, nil
}

// ColorProfile returns the ICC color profile name.
func (m *MediaFile) ColorProfile() string {
	if !m.IsJpeg() || m.colorProfile != "" || m.noColorProfile {
		return m.colorProfile
	}

	start := time.Now()
	logName := clean.Log(m.BaseName())

	m.fileMutex.Lock()
	defer m.fileMutex.Unlock()

	var err error
	fileName := m.FileName()

	// Resolve symlinks.
	if fileName, err = fs.Resolve(fileName); err != nil {
		return m.colorProfile
	}

	// Open file.
	// #nosec G304 -- fileName is resolved from trusted MediaFile path.
	fileReader, err := os.Open(fileName)

	if err != nil {
		m.noColorProfile = true
		return ""
	}

	defer fileReader.Close()

	// Reset file offset.
	// see https://github.com/golang/go/issues/45902#issuecomment-1007953723
	_, err = fileReader.Seek(0, 0)

	if err != nil {
		log.Warnf("media: %s in %s on seek [%s]", err, logName, time.Since(start))
		return ""
	}

	// Read color metadata.
	md, _, err := autometa.Load(fileReader)

	if err != nil || md == nil {
		m.noColorProfile = true
		return ""
	}

	// Read ICC profile and convert colors if possible.
	if iccProfile, err := md.ICCProfile(); err != nil || iccProfile == nil {
		// Do nothing.
	} else if profile, err := iccProfile.Description(); err == nil && profile != "" {
		log.Debugf("media: %s has color profile %s [%s]", logName, clean.Log(profile), time.Since(start))
		m.colorProfile = profile
		return m.colorProfile
	}

	log.Tracef("media: %s has no color profile [%s]", logName, time.Since(start))
	m.noColorProfile = true
	return ""
}
