Media: Log underlying error when MIME type detection fails #5149

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-08-06 09:59:38 +02:00
parent 0d24ec5abb
commit a6d840056c
8 changed files with 139 additions and 81 deletions

View File

@@ -43,6 +43,7 @@ type MediaFile struct {
fileNameResolved string fileNameResolved string
fileRoot string fileRoot string
statErr error statErr error
mimeErr error
modTime time.Time modTime time.Time
fileSize int64 fileSize int64
fileType fs.Type fileType fs.Type
@@ -518,24 +519,36 @@ func (m *MediaFile) Root() string {
// since media types have become used in contexts unrelated to email, such as HTTP: // since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure // https://en.wikipedia.org/wiki/Media_type#Structure
func (m *MediaFile) MimeType() string { func (m *MediaFile) MimeType() string {
if m.mimeType != "" { // 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 return m.mimeType
} }
var err error var err error
fileName := m.FileName()
// Resolve symlinks. // Get the filename and resolve symbolic links, if necessary.
fileName := m.FileName()
if fileName, err = fs.Resolve(fileName); err != nil { if fileName, err = fs.Resolve(fileName); err != nil {
return m.mimeType return m.mimeType
} }
m.mimeType = fs.MimeType(fileName) // 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 { if m.mimeType == header.ContentTypeMp4 && m.MetaData().Codec == video.CodecM2TS {
m.mimeType = header.ContentTypeM2TS m.mimeType = header.ContentTypeM2TS
} }
// Return MIME type.
return m.mimeType return m.mimeType
} }
@@ -930,9 +943,9 @@ func (m *MediaFile) CheckType() error {
return nil return nil
} }
// Exclude mime type from the error message if it could not be detected. // If the MIME type is empty, it is usually because the file could not be read.
if mimeType == fs.MimeTypeUnknown { if mimeType == fs.MimeTypeUnknown {
return fmt.Errorf("has an invalid extension (unknown media type)") return fmt.Errorf("could not be identified")
} }
return fmt.Errorf("has an invalid extension for media type %s", clean.LogQuote(mimeType)) return fmt.Errorf("has an invalid extension for media type %s", clean.LogQuote(mimeType))

View File

@@ -149,7 +149,7 @@ func (m *MediaFile) GenerateThumbnails(thumbPath string, force bool) (err error)
msg := imgErr.Error() msg := imgErr.Error()
// Non-repairable file error? // Non-repairable file error?
if !(strings.Contains(msg, "EOF") || if !(strings.Contains(msg, fs.EOF.Error()) ||
strings.HasPrefix(msg, "invalid JPEG")) { strings.HasPrefix(msg, "invalid JPEG")) {
log.Debugf("media: %s in %s", msg, clean.Log(m.RootRelName())) log.Debugf("media: %s in %s", msg, clean.Log(m.RootRelName()))
return imgErr return imgErr

View File

@@ -32,7 +32,7 @@ func TestCollage(t *testing.T) {
err = imaging.Save(preview, saveName) err = imaging.Save(preview, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypeJpeg, mimeType) assert.Equal(t, header.ContentTypeJpeg, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)
@@ -56,7 +56,7 @@ func TestCollage(t *testing.T) {
err = imaging.Save(preview, saveName) err = imaging.Save(preview, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypeJpeg, mimeType) assert.Equal(t, header.ContentTypeJpeg, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)
@@ -73,7 +73,7 @@ func TestCollage(t *testing.T) {
err = imaging.Save(preview, saveName) err = imaging.Save(preview, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypeJpeg, mimeType) assert.Equal(t, header.ContentTypeJpeg, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)
@@ -100,7 +100,7 @@ func TestCollage(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypeJpeg, mimeType) assert.Equal(t, header.ContentTypeJpeg, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)

View File

@@ -26,7 +26,7 @@ func TestImage(t *testing.T) {
err = imaging.Save(out, saveName) err = imaging.Save(out, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypePng, mimeType) assert.Equal(t, header.ContentTypePng, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)
@@ -46,7 +46,7 @@ func TestImage(t *testing.T) {
err = imaging.Save(out, saveName) err = imaging.Save(out, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypePng, mimeType) assert.Equal(t, header.ContentTypePng, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)

View File

@@ -26,7 +26,7 @@ func TestPolaroid(t *testing.T) {
err = imaging.Save(out, saveName) err = imaging.Save(out, saveName)
assert.NoError(t, err) assert.NoError(t, err)
mimeType := fs.MimeType(saveName) mimeType, _ := fs.DetectMimeType(saveName)
assert.Equal(t, header.ContentTypePng, mimeType) assert.Equal(t, header.ContentTypePng, mimeType)
_ = os.Remove(saveName) _ = os.Remove(saveName)

17
pkg/fs/errors.go Normal file
View File

@@ -0,0 +1,17 @@
package fs
import (
"errors"
"io"
)
// Generic errors that may occur when accessing files and folders:
var (
EOF = io.EOF
ErrUnexpectedEOF = io.ErrUnexpectedEOF
ErrShortWrite = io.ErrShortWrite
ErrShortBuffer = io.ErrShortBuffer
ErrNoProgress = io.ErrNoProgress
ErrInvalidWrite = errors.New("invalid write result")
ErrPermissionDenied = errors.New("permission denied")
)

View File

@@ -1,6 +1,7 @@
package fs package fs
import ( import (
"errors"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -13,14 +14,16 @@ const (
MimeTypeUnknown = "" MimeTypeUnknown = ""
) )
// MimeType returns the mimetype of a file, or an empty string if it could not be determined. // DetectMimeType returns the MIME type of the specified file,
// or an error if the type could not be detected.
// //
// The IANA and IETF use the term "media type", and consider the term "MIME type" to be obsolete, // 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: // since media types have become used in contexts unrelated to email, such as HTTP:
// https://en.wikipedia.org/wiki/Media_type#Structure // https://en.wikipedia.org/wiki/Media_type#Structure
func MimeType(filename string) (mimeType string) { func DetectMimeType(filename string) (mimeType string, err error) {
// Abort if no filename was specified.
if filename == "" { if filename == "" {
return MimeTypeUnknown return MimeTypeUnknown, errors.New("missing filename")
} }
// Detect file type based on the filename extension. // Detect file type based on the filename extension.
@@ -31,44 +34,51 @@ func MimeType(filename string) (mimeType string) {
switch fileType { switch fileType {
// MPEG-2 Transport Stream // MPEG-2 Transport Stream
case VideoM2TS, VideoAVCHD: case VideoM2TS, VideoAVCHD:
return header.ContentTypeM2TS return header.ContentTypeM2TS, nil
// Apple QuickTime Container // Apple QuickTime Container
case VideoMov: case VideoMov:
return header.ContentTypeMov return header.ContentTypeMov, nil
// MPEG-4 AVC Video // MPEG-4 AVC Video
case VideoAvc: case VideoAvc:
return header.ContentTypeMp4Avc return header.ContentTypeMp4Avc, nil
// MPEG-4 HEVC Video // MPEG-4 HEVC Video
case VideoHvc: case VideoHvc:
return header.ContentTypeMp4Hvc return header.ContentTypeMp4Hvc, nil
// MPEG-4 HEVC Bitstream // MPEG-4 HEVC Bitstream
case VideoHev: case VideoHev:
return header.ContentTypeMp4Hev return header.ContentTypeMp4Hev, nil
// Adobe Digital Negative // Adobe Digital Negative
case ImageDng: case ImageDng:
return header.ContentTypeDng return header.ContentTypeDng, nil
// Adobe Illustrator // Adobe Illustrator
case VectorAI: case VectorAI:
return header.ContentTypeAI return header.ContentTypeAI, nil
// Adobe PostScript // Adobe PostScript
case VectorPS: case VectorPS:
return header.ContentTypePS return header.ContentTypePS, nil
// Adobe Embedded PostScript // Adobe Embedded PostScript
case VectorEPS: case VectorEPS:
return header.ContentTypeEPS return header.ContentTypeEPS, nil
// Adobe PDF // Adobe PDF
case DocumentPDF: case DocumentPDF:
return header.ContentTypePDF return header.ContentTypePDF, nil
// Scalable Vector Graphics // Scalable Vector Graphics
case VectorSVG: case VectorSVG:
return header.ContentTypeSVG return header.ContentTypeSVG, nil
} }
// Detect mime type based on the file content. // Use "gabriel-vasile/mimetype" to automatically detect the MIME type.
detectedType, err := mimetype.DetectFile(filename) detectedType, err := mimetype.DetectFile(filename)
if detectedType != nil && err == nil { // Check if type could be successfully detected.
mimeType = detectedType.String() if err == nil {
if detectedType != nil {
mimeType = detectedType.String()
}
} else if e := err.Error(); strings.HasSuffix(e, ErrPermissionDenied.Error()) {
return MimeTypeUnknown, ErrPermissionDenied
} else if strings.Contains(e, EOF.Error()) {
return MimeTypeUnknown, ErrUnexpectedEOF
} }
// Treat "application/octet-stream" as unknown. // Treat "application/octet-stream" as unknown.
@@ -81,25 +91,32 @@ func MimeType(filename string) (mimeType string) {
switch fileType { switch fileType {
// MPEG-4 Multimedia Container // MPEG-4 Multimedia Container
case VideoMp4: case VideoMp4:
return header.ContentTypeMp4 return header.ContentTypeMp4, nil
// AV1 Image File // AV1 Image File
case ImageAvif: case ImageAvif:
return header.ContentTypeAvif return header.ContentTypeAvif, nil
// AV1 Image File Sequence // AV1 Image File Sequence
case ImageAvifS: case ImageAvifS:
return header.ContentTypeAvifS return header.ContentTypeAvifS, nil
// High Efficiency Image Container // High Efficiency Image Container
case ImageHeic, ImageHeif: case ImageHeic, ImageHeif:
return header.ContentTypeHeic return header.ContentTypeHeic, nil
// High Efficiency Image Container Sequence // High Efficiency Image Container Sequence
case ImageHeicS: case ImageHeicS:
return header.ContentTypeHeicS return header.ContentTypeHeicS, nil
// ZIP Archive File: // ZIP Archive File:
case ArchiveZip: case ArchiveZip:
return header.ContentTypeZip return header.ContentTypeZip, nil
} }
} }
return mimeType, err
}
// MimeType returns the MIME type of the specified file,
// or an empty string if the type could not be detected.
func MimeType(filename string) (mimeType string) {
mimeType, _ = DetectMimeType(filename)
return mimeType return mimeType
} }

View File

@@ -8,147 +8,158 @@ import (
"github.com/photoprism/photoprism/pkg/media/http/header" "github.com/photoprism/photoprism/pkg/media/http/header"
) )
func TestMimeType(t *testing.T) { func TestDetectMimeType(t *testing.T) {
t.Run("Mp4", func(t *testing.T) { t.Run("MP4", func(t *testing.T) {
filename := Abs("./testdata/test.mp4") filename := Abs("./testdata/test.mp4")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "video/mp4", mimeType) assert.Equal(t, "video/mp4", mimeType)
}) })
t.Run("MOV", func(t *testing.T) { t.Run("MOV", func(t *testing.T) {
filename := Abs("./testdata/test.mov") filename := Abs("./testdata/test.mov")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "video/quicktime", mimeType) assert.Equal(t, "video/quicktime", mimeType)
assert.Equal(t, "video/quicktime", MimeType(filename))
}) })
t.Run("JPEG", func(t *testing.T) { t.Run("JPEG", func(t *testing.T) {
filename := Abs("./testdata/test.jpg") filename := Abs("./testdata/test.jpg")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/jpeg", mimeType) assert.Equal(t, "image/jpeg", mimeType)
assert.Equal(t, "image/jpeg", MimeType(filename))
}) })
t.Run("InvalidFilename", func(t *testing.T) { t.Run("InvalidFilename", func(t *testing.T) {
filename := Abs("./testdata/xxx.jpg") filename := Abs("./testdata/xxx.jpg")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "", mimeType) assert.Equal(t, "", mimeType)
assert.Equal(t, "", MimeType(filename))
}) })
t.Run("EmptyFilename", func(t *testing.T) { t.Run("EmptyFilename", func(t *testing.T) {
mimeType := MimeType("") mimeType, _ := DetectMimeType("")
assert.Equal(t, "", mimeType) assert.Equal(t, "", mimeType)
assert.Equal(t, "", MimeType(""))
}) })
t.Run("AVIF", func(t *testing.T) { t.Run("AVIF", func(t *testing.T) {
filename := Abs("./testdata/test.avif") filename := Abs("./testdata/test.avif")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/avif", mimeType) assert.Equal(t, "image/avif", mimeType)
assert.Equal(t, "image/avif", MimeType(filename))
}) })
t.Run("AVIFS", func(t *testing.T) { t.Run("AVIFS", func(t *testing.T) {
filename := Abs("./testdata/test.avifs") filename := Abs("./testdata/test.avifs")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/avif-sequence", mimeType) assert.Equal(t, "image/avif-sequence", mimeType)
assert.Equal(t, "image/avif-sequence", MimeType(filename))
}) })
t.Run("HEIC", func(t *testing.T) { t.Run("HEIC", func(t *testing.T) {
filename := Abs("./testdata/test.heic") filename := Abs("./testdata/test.heic")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/heic", mimeType) assert.Equal(t, "image/heic", mimeType)
assert.Equal(t, "image/heic", MimeType(filename))
}) })
t.Run("HEICS", func(t *testing.T) { t.Run("HEICS", func(t *testing.T) {
filename := Abs("./testdata/test.heics") filename := Abs("./testdata/test.heics")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/heic-sequence", mimeType) assert.Equal(t, "image/heic-sequence", mimeType)
}) })
t.Run("DNG", func(t *testing.T) { t.Run("DNG", func(t *testing.T) {
filename := Abs("./testdata/test.dng") filename := Abs("./testdata/test.dng")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/dng", mimeType) assert.Equal(t, "image/dng", mimeType)
}) })
t.Run("SVG", func(t *testing.T) { t.Run("SVG", func(t *testing.T) {
filename := Abs("./testdata/test.svg") filename := Abs("./testdata/test.svg")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/svg+xml", mimeType) assert.Equal(t, "image/svg+xml", mimeType)
assert.Equal(t, "image/svg+xml", MimeType(filename))
}) })
t.Run("AI", func(t *testing.T) { t.Run("AI", func(t *testing.T) {
filename := Abs("./testdata/test.ai") filename := Abs("./testdata/test.ai")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "application/vnd.adobe.illustrator", mimeType) assert.Equal(t, "application/vnd.adobe.illustrator", mimeType)
assert.Equal(t, "application/vnd.adobe.illustrator", MimeType(filename))
}) })
t.Run("PS", func(t *testing.T) { t.Run("PS", func(t *testing.T) {
filename := Abs("./testdata/test.ps") filename := Abs("./testdata/test.ps")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "application/postscript", mimeType) assert.Equal(t, "application/postscript", mimeType)
assert.Equal(t, "application/postscript", MimeType(filename))
}) })
t.Run("EPS", func(t *testing.T) { t.Run("EPS", func(t *testing.T) {
filename := Abs("./testdata/test.eps") filename := Abs("./testdata/test.eps")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/eps", mimeType) assert.Equal(t, "image/eps", mimeType)
assert.Equal(t, "image/eps", MimeType(filename))
}) })
} }
func TestBaseType(t *testing.T) { func TestBaseType(t *testing.T) {
t.Run("Mp4", func(t *testing.T) { t.Run("MP4", func(t *testing.T) {
filename := Abs("./testdata/test.mp4") filename := Abs("./testdata/test.mp4")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "video/mp4", mimeType) assert.Equal(t, "video/mp4", result)
}) })
t.Run("MOV", func(t *testing.T) { t.Run("MOV", func(t *testing.T) {
filename := Abs("./testdata/test.mov") filename := Abs("./testdata/test.mov")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "video/quicktime", mimeType) assert.Equal(t, "video/quicktime", result)
}) })
t.Run("JPEG", func(t *testing.T) { t.Run("JPEG", func(t *testing.T) {
filename := Abs("./testdata/test.jpg") filename := Abs("./testdata/test.jpg")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/jpeg", mimeType) assert.Equal(t, "image/jpeg", result)
}) })
t.Run("InvalidFilename", func(t *testing.T) { t.Run("InvalidFilename", func(t *testing.T) {
filename := Abs("./testdata/xxx.jpg") filename := Abs("./testdata/xxx.jpg")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "", mimeType) assert.Equal(t, "", result)
}) })
t.Run("EmptyFilename", func(t *testing.T) { t.Run("EmptyFilename", func(t *testing.T) {
mimeType := BaseType("") assert.Equal(t, "", BaseType(""))
assert.Equal(t, "", mimeType)
}) })
t.Run("AVIF", func(t *testing.T) { t.Run("AVIF", func(t *testing.T) {
filename := Abs("./testdata/test.avif") filename := Abs("./testdata/test.avif")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/avif", mimeType) assert.Equal(t, "image/avif", result)
}) })
t.Run("AVIFS", func(t *testing.T) { t.Run("AVIFS", func(t *testing.T) {
filename := Abs("./testdata/test.avifs") filename := Abs("./testdata/test.avifs")
mimeType := MimeType(filename) mimeType, _ := DetectMimeType(filename)
assert.Equal(t, "image/avif-sequence", mimeType) assert.Equal(t, "image/avif-sequence", mimeType)
assert.Equal(t, "image/avif-sequence", BaseType(mimeType))
}) })
t.Run("HEIC", func(t *testing.T) { t.Run("HEIC", func(t *testing.T) {
filename := Abs("./testdata/test.heic") filename := Abs("./testdata/test.heic")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/heic", mimeType) assert.Equal(t, "image/heic", result)
}) })
t.Run("HEICS", func(t *testing.T) { t.Run("HEICS", func(t *testing.T) {
filename := Abs("./testdata/test.heics") filename := Abs("./testdata/test.heics")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/heic-sequence", mimeType) assert.Equal(t, "image/heic-sequence", result)
}) })
t.Run("DNG", func(t *testing.T) { t.Run("DNG", func(t *testing.T) {
filename := Abs("./testdata/test.dng") filename := Abs("./testdata/test.dng")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/dng", mimeType) assert.Equal(t, "image/dng", result)
}) })
t.Run("SVG", func(t *testing.T) { t.Run("SVG", func(t *testing.T) {
filename := Abs("./testdata/test.svg") filename := Abs("./testdata/test.svg")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/svg+xml", mimeType) assert.Equal(t, "image/svg+xml", result)
}) })
t.Run("AI", func(t *testing.T) { t.Run("AI", func(t *testing.T) {
filename := Abs("./testdata/test.ai") filename := Abs("./testdata/test.ai")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "application/vnd.adobe.illustrator", mimeType) assert.Equal(t, "application/vnd.adobe.illustrator", result)
}) })
t.Run("PS", func(t *testing.T) { t.Run("PS", func(t *testing.T) {
filename := Abs("./testdata/test.ps") filename := Abs("./testdata/test.ps")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "application/postscript", mimeType) assert.Equal(t, "application/postscript", result)
}) })
t.Run("EPS", func(t *testing.T) { t.Run("EPS", func(t *testing.T) {
filename := Abs("./testdata/test.eps") filename := Abs("./testdata/test.eps")
mimeType := BaseType(MimeType(filename)) result := BaseType(MimeType(filename))
assert.Equal(t, "image/eps", mimeType) assert.Equal(t, "image/eps", result)
}) })
} }