diff --git a/api/go.mod b/api/go.mod index f0498f53..f7bbb002 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,8 +1,6 @@ module github.com/photoview/photoview/api -go 1.22.5 - -toolchain go1.23.1 +go 1.23.1 require ( github.com/99designs/gqlgen v0.17.57 @@ -14,7 +12,7 @@ require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 - github.com/h2non/filetype v1.1.3 + github.com/hosom/gomagic v0.0.0-20160718182707-cbc00aac97a4 github.com/joho/godotenv v1.5.1 github.com/otiai10/copy v1.14.0 github.com/pkg/errors v0.9.1 diff --git a/api/go.sum b/api/go.sum index 2f1cdad4..0940f767 100644 --- a/api/go.sum +++ b/api/go.sum @@ -42,10 +42,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= -github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hosom/gomagic v0.0.0-20160718182707-cbc00aac97a4 h1:HWFAOHTnol5SkBTgR+A52tWHyH0SscmDjPJWOVWlwys= +github.com/hosom/gomagic v0.0.0-20160718182707-cbc00aac97a4/go.mod h1:2Oan8DZ6RtFP1c8gJGsw1sshEGsuCRzHFCkssb1PPyA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/api/scanner/media_encoding/encode_photo.go b/api/scanner/media_encoding/encode_photo.go index eccaaaed..73abb7dc 100644 --- a/api/scanner/media_encoding/encode_photo.go +++ b/api/scanner/media_encoding/encode_photo.go @@ -2,6 +2,7 @@ package media_encoding import ( "context" + "fmt" "image" "image/jpeg" "os" @@ -12,7 +13,6 @@ import ( "github.com/photoview/photoview/api/scanner/media_encoding/executable_worker" "github.com/photoview/photoview/api/scanner/media_encoding/media_utils" "github.com/photoview/photoview/api/scanner/media_type" - "github.com/photoview/photoview/api/utils" "github.com/pkg/errors" "gopkg.in/vansante/go-ffprobe.v2" @@ -73,25 +73,28 @@ type EncodeMediaData struct { Media *models.Media CounterpartPath *string _photoImage image.Image - _contentType *media_type.MediaType + _contentType media_type.MediaType _videoMetadata *ffprobe.ProbeData } func NewEncodeMediaData(media *models.Media) EncodeMediaData { + fileType := media_type.GetMediaType(media.Path) + return EncodeMediaData{ - Media: media, + Media: media, + _contentType: fileType, } } // ContentType reads the image to determine its content type -func (img *EncodeMediaData) ContentType() (*media_type.MediaType, error) { - if img._contentType != nil { +func (img *EncodeMediaData) ContentType() (media_type.MediaType, error) { + if img._contentType != media_type.TypeUnknown { return img._contentType, nil } - imgType, err := media_type.GetMediaType(img.Media.Path) - if err != nil { - return nil, err + imgType := media_type.GetMediaType(img.Media.Path) + if imgType == media_type.TypeUnknown { + return imgType, fmt.Errorf("unknown type of %q", img.Media.Path) } img._contentType = imgType @@ -109,78 +112,21 @@ func (img *EncodeMediaData) EncodeHighRes(outputPath string) error { } // Use magick if there is no counterpart JPEG file to use instead - if contentType.IsRaw() && img.CounterpartPath == nil { - if executable_worker.Magick.IsInstalled() { - err := executable_worker.Magick.EncodeJpeg(img.Media.Path, outputPath, 70) - if err != nil { - return err - } - } else { - return errors.New("could not convert photo as no RAW converter was found") - } - } else { - image, err := img.photoImage() - if err != nil { - return err + if contentType.IsImage() && !contentType.IsWebCompatible() { + imgPath := img.Media.Path + if img.CounterpartPath != nil { + imgPath = *img.CounterpartPath } - encodeImageJPEG(image, outputPath, 70) + err := executable_worker.Magick.EncodeJpeg(imgPath, outputPath, 70) + if err != nil { + return fmt.Errorf("failed to convert RAW photo %q to JPEG: %w", imgPath, err) + } } return nil } -// photoImage reads and decodes the image file and saves it in a cache so the photo in only decoded once -func (img *EncodeMediaData) photoImage() (image.Image, error) { - if img._photoImage != nil { - return img._photoImage, nil - } - - var photoPath string - if img.CounterpartPath != nil { - photoPath = *img.CounterpartPath - } else { - photoPath = img.Media.Path - } - - photoImg, err := img.decodeImage(photoPath) - if err != nil { - return nil, utils.HandleError("image decoding", err) - } - - img._photoImage = photoImg - return img._photoImage, nil -} - -func (img *EncodeMediaData) decodeImage(imagePath string) (image.Image, error) { - file, err := os.Open(imagePath) - if err != nil { - return nil, errors.Wrapf(err, "failed to open file to decode image (%s)", imagePath) - } - defer file.Close() - - mediaType, err := img.ContentType() - if err != nil { - return nil, errors.Wrapf(err, "failed to get media content type needed to decode it (%s)", imagePath) - } - - var decodedImage image.Image - - if *mediaType == media_type.TypeHeic { - decodedImage, _, err = image.Decode(file) - if err != nil { - return nil, errors.Wrapf(err, "failed to decode HEIF image (%s)", imagePath) - } - } else { - decodedImage, err = imaging.Decode(file, imaging.AutoOrientation(true)) - if err != nil { - return nil, errors.Wrapf(err, "failed to decode image (%s)", imagePath) - } - } - - return decodedImage, nil -} - func (enc *EncodeMediaData) VideoMetadata() (*ffprobe.ProbeData, error) { if enc._videoMetadata != nil { diff --git a/api/scanner/media_type/counterpart.go b/api/scanner/media_type/counterpart.go new file mode 100644 index 00000000..e8b1cfff --- /dev/null +++ b/api/scanner/media_type/counterpart.go @@ -0,0 +1,45 @@ +package media_type + +import ( + "path" + "path/filepath" + "strings" +) + +// FindWebCounterpart returns the filename if the file `imagePath` has a counterpart file competible with the browser. +func FindWebCounterpart(imagePath string) (string, bool) { + return findCounterpart(imagePath, func(filename string) bool { + mt := GetMediaType(filename) + return mt.IsImage() && mt.IsWebCompatible() + }) +} + +// FindRawCounterpart returns the filename if the file `imagePath` has a counterpart file which needs to be processed before showing in the browser. +func FindRawCounterpart(imagePath string) (string, bool) { + return findCounterpart(imagePath, func(filename string) bool { + mt := GetMediaType(filename) + return mt.IsImage() && !mt.IsWebCompatible() + }) +} + +func findCounterpart(filename string, acceptFn func(filepath string) bool) (string, bool) { + ext := path.Ext(filename) + filenamePattern := strings.TrimSuffix(filename, ext) + ".*" + + filenames, err := filepath.Glob(filenamePattern) + if err != nil { + return "", false + } + + for _, f := range filenames { + if f == filename { + continue + } + + if acceptFn(f) { + return f, true + } + } + + return "", false +} diff --git a/api/scanner/media_type/counterpart_test.go b/api/scanner/media_type/counterpart_test.go new file mode 100644 index 00000000..46fa2113 --- /dev/null +++ b/api/scanner/media_type/counterpart_test.go @@ -0,0 +1,66 @@ +package media_type + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/photoview/photoview/api/test_utils" +) + +func TestFindWebCounterpart(t *testing.T) { + mediaPath := test_utils.PathFromAPIRoot("./scanner/test_media/real_media") + + tests := []struct { + input string + wantFile string + wantOk bool + }{ + {"raw_with_jpg.tiff", "raw_with_jpg.jpg", true}, + {"raw_with_file.tiff", "", false}, + {"standalone_raw.tiff", "", false}, + } + + for _, tc := range tests { + input := filepath.Join(mediaPath, tc.input) + if _, err := os.Stat(input); err != nil { + t.Fatalf("input %q doesn't exist: %v", input, err) + } + + file, ok := FindWebCounterpart(input) + got := strings.TrimLeft(strings.TrimPrefix(file, mediaPath), "/") + + if got != tc.wantFile || ok != tc.wantOk { + t.Errorf("FindWebCounterpart(%q) = (%q, %v), want: (%q, %v)", tc.input, got, ok, tc.wantFile, tc.wantOk) + } + } +} + +func TestFindRawCounterpart(t *testing.T) { + mediaPath := test_utils.PathFromAPIRoot("./scanner/test_media/real_media") + + tests := []struct { + input string + wantFile string + wantOk bool + }{ + {"raw_with_jpg.jpg", "raw_with_jpg.tiff", true}, + {"jpg_with_file.jpg", "", false}, + {"standalone_jpg.jpg", "", false}, + } + + for _, tc := range tests { + input := filepath.Join(mediaPath, tc.input) + if _, err := os.Stat(input); err != nil { + t.Fatalf("input %q doesn't exist: %v", input, err) + } + + file, ok := FindRawCounterpart(input) + got := strings.TrimLeft(strings.TrimPrefix(file, mediaPath), "/") + + if got != tc.wantFile || ok != tc.wantOk { + t.Errorf("FindRawCounterpart(%q) = (%q, %v), want: (%q, %v)", tc.input, got, ok, tc.wantFile, tc.wantOk) + } + } +} diff --git a/api/scanner/media_type/magic.go b/api/scanner/media_type/magic.go new file mode 100644 index 00000000..191fded0 --- /dev/null +++ b/api/scanner/media_type/magic.go @@ -0,0 +1,78 @@ +package media_type + +import ( + "strings" + "sync" + + magic "github.com/hosom/gomagic" + "github.com/photoview/photoview/api/log" +) + +var libmagic struct { + mu sync.Mutex + libmagic *libMagic + err error +} + +func init() { + libmagic.libmagic, libmagic.err = newLibMagic() + if libmagic.err != nil { + libmagic.libmagic = nil + log.Error("Init libmagic error.", "error", libmagic.err) + } +} + +// GetMediaType returns a media type of file `f`. +// This function is thread-safe. +func GetMediaType(f string) MediaType { + libmagic.mu.Lock() + defer libmagic.mu.Unlock() + + if libmagic.err != nil { + log.Warn("GetMediaType() error.", "error", libmagic.err, "file", f) + return TypeUnknown + } + + mime, err := libmagic.libmagic.Type(f) + if err != nil { + log.Warn("GetMediaType() error.", "error", err, "file", f) + return TypeUnknown + } + + mime = strings.SplitN(mime, ";", 2)[0] + + return mediaType(mime) +} + +// libMagic parses the magic code in a file and returns the media type. +type libMagic struct { + magic *magic.Magic +} + +// newLibMagic creates an instance of Magic. +func newLibMagic() (*libMagic, error) { + // MAGIC_SYMLINK: Follow symlinks + // MAGIC_MIME: Return MIME type string + // MAGIC_ERROR: Handle errors in magic database + // MAGIC_NO_CHECK_COMPRESS: Don't check for compressed files + // MAGIC_NO_CHECK_ENCODING: Don't check for text encodings + m, err := magic.Open(magic.MAGIC_SYMLINK | magic.MAGIC_MIME | magic.MAGIC_ERROR | magic.MAGIC_NO_CHECK_COMPRESS | magic.MAGIC_NO_CHECK_ENCODING) + if err != nil { + return nil, err + } + + return &libMagic{ + magic: m, + }, nil +} + +// Close closes the library. +func (m *libMagic) Close() error { + return m.magic.Close() +} + +// Type returns the media type of the given file `f`. It returns TypeUnknown if the file is not a image nor a video. +// This function is not thread-safe. +func (m *libMagic) Type(f string) (string, error) { + return m.magic.File(f) +} diff --git a/api/scanner/media_type/magic_test.go b/api/scanner/media_type/magic_test.go new file mode 100644 index 00000000..c06f082d --- /dev/null +++ b/api/scanner/media_type/magic_test.go @@ -0,0 +1,146 @@ +package media_type + +import ( + "errors" + "path/filepath" + "sync" + "testing" + + magic "github.com/hosom/gomagic" + "github.com/photoview/photoview/api/test_utils" +) + +func TestMagic(t *testing.T) { + mediaPath := test_utils.PathFromAPIRoot("./scanner/test_media/real_media") + + tests := []struct { + filepath string + filetype MediaType + }{ + {"file.pdf", mediaType("application/pdf")}, + + {"bmp.bmp", TypeBMP}, + {"gif.gif", TypeGIF}, + {"jpeg.jpg", TypeJPEG}, + {"png.png", TypePNG}, + {"webp.webp", TypeWebP}, + + {"heif.heif", mediaType("image/heic")}, + {"jpeg2000.jp2", mediaType("image/jp2")}, + {"tiff.tiff", mediaType("image/tiff")}, + + {"mp4.mp4", TypeMP4}, + {"ogg.ogg", TypeOGG}, + {"mpeg.mpg", TypeMPEG}, + {"webm.webm", TypeWEBM}, + + {"avi.avi", mediaType("video/x-msvideo")}, + {"mkv.mkv", mediaType("video/x-matroska")}, + {"quicktime.mov", mediaType("video/quicktime")}, + {"wmv.wmv", mediaType("video/x-ms-asf")}, + } + + var wg sync.WaitGroup + defer wg.Wait() + + for _, tc := range tests { + wg.Add(1) + input, want := tc.filepath, tc.filetype + + go func() { + defer wg.Done() + + path := filepath.Join(mediaPath, input) + + got := GetMediaType(path) + if got != want { + t.Errorf("magic.Type(%q) = %v, want: %v", path, got, want) + } + }() + } +} + +func TestMagicNoInit(t *testing.T) { + org := libmagic.libmagic + libmagic.libmagic = nil + libmagic.err = errors.New("error") + t.Cleanup(func() { + libmagic.libmagic = org + libmagic.err = nil + }) + + mediaPath := test_utils.PathFromAPIRoot("./scanner/test_media/real_media") + file := filepath.Join(mediaPath, "file.pdf") + + got := GetMediaType(file) + if want := TypeUnknown; got != want { + t.Errorf("GetMediaType() = %v, want: %v", got, want) + } +} + +func getMediaFiles() []string { + mediaPath := test_utils.PathFromAPIRoot("./scanner/test_media/real_media") + var files []string + for _, f := range []string{ + "file.pdf", + + "bmp.bmp", + "gif.gif", + "jpeg.jpg", + "png.png", + "webp.webp", + + "heif.heif", + "jpeg2000.jp2", + "tiff.tiff", + + "mp4.mp4", + "ogg.ogg", + "mpeg.mpg", + "webm.webm", + + "avi.avi", + "mkv.mkv", + "quicktime.mov", + "wmv.wmv", + } { + files = append(files, filepath.Join(mediaPath, f)) + } + + return files +} + +func BenchmarkMagicAll(b *testing.B) { + files := getMediaFiles() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + m, err := magic.Open(magic.MAGIC_SYMLINK | magic.MAGIC_MIME | magic.MAGIC_ERROR | magic.MAGIC_NO_CHECK_COMPRESS | magic.MAGIC_NO_CHECK_ENCODING) + if err != nil { + b.Fatalf("magic.Open() error: %v", err) + } + + _, _ = m.File(files[i%len(files)]) + m.Close() + } +} + +func BenchmarkMagicType(b *testing.B) { + files := getMediaFiles() + + m, err := magic.Open(magic.MAGIC_SYMLINK | magic.MAGIC_MIME | magic.MAGIC_ERROR | magic.MAGIC_NO_CHECK_COMPRESS | magic.MAGIC_NO_CHECK_ENCODING) + if err != nil { + b.Fatalf("magic.Open() error: %v", err) + } + defer m.Close() + + var mu sync.Mutex + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + mu.Lock() + _, _ = m.File(files[i%len(files)]) + mu.Unlock() + } +} diff --git a/api/scanner/media_type/media_type.go b/api/scanner/media_type/media_type.go index 8f08d7c0..1dab8332 100644 --- a/api/scanner/media_type/media_type.go +++ b/api/scanner/media_type/media_type.go @@ -1,347 +1,121 @@ package media_type import ( - "io" - "os" - "path" - "path/filepath" "strings" - - "github.com/h2non/filetype" - "github.com/photoview/photoview/api/scanner/media_encoding/executable_worker" - "github.com/photoview/photoview/api/scanner/scanner_utils" - "github.com/pkg/errors" + "unique" ) -type MediaType string +type MediaType unique.Handle[string] -const ( - TypeJpeg MediaType = "image/jpeg" - TypePng MediaType = "image/png" - TypeTiff MediaType = "image/tiff" - TypeWebp MediaType = "image/webp" - TypeBmp MediaType = "image/bmp" - TypeHeic MediaType = "image/heic" - TypeGif MediaType = "image/gif" +func mediaType(mime string) MediaType { + return MediaType(unique.Make(mime)) +} - // Raw formats - TypeDNG MediaType = "image/x-adobe-dng" - TypeARW MediaType = "image/x-sony-arw" - TypeSR2 MediaType = "image/x-sony-sr2" - TypeSRF MediaType = "image/x-sony-srf" - TypeCR2 MediaType = "image/x-canon-cr2" - TypeCR3 MediaType = "image/x-canon-cr3" - TypeCRW MediaType = "image/x-canon-crw" - TypeERF MediaType = "image/x-epson-erf" - TypeDCS MediaType = "image/x-kodak-dcs" - TypeDRF MediaType = "image/x-kodak-drf" - TypeDCR MediaType = "image/x-kodak-dcr" - TypeK25 MediaType = "image/x-kodak-k25" - TypeKDC MediaType = "image/x-kodak-kdc" - TypeMRW MediaType = "image/x-minolta-mrw" - TypeMDC MediaType = "image/x-minolta-mdc" - TypeNEF MediaType = "image/x-nikon-nef" - TypeNRW MediaType = "image/x-nikon-nrw" - TypeORF MediaType = "image/x-olympus-orf" - TypePEF MediaType = "image/x-pentax-pef" - TypeRAF MediaType = "image/x-fuji-raf" - TypeRAW MediaType = "image/x-panasonic-raw" - TypeRW2 MediaType = "image/x-panasonic-rw2" - TypeGPR MediaType = "image/x-gopro-gpr" - Type3FR MediaType = "image/x-hasselblad-3fr" - TypeFFF MediaType = "image/x-hasselblad-fff" - TypeMEF MediaType = "image/x-mamiya-mef" - TypeCap MediaType = "image/x-phaseone-cap" - TypeIIQ MediaType = "image/x-phaseone-iiq" - TypeMOS MediaType = "image/x-leaf-mos" - TypeRWL MediaType = "image/x-leica-rwl" - TypeSRW MediaType = "image/x-samsung-srw" +var ( + TypeUnknown MediaType - // Video formats - TypeMP4 MediaType = "video/mp4" - TypeMPEG MediaType = "video/mpeg" - Type3GP MediaType = "video/3gpp" - Type3G2 MediaType = "video/3gpp2" - TypeOGG MediaType = "video/ogg" - TypeWMV MediaType = "video/x-ms-wmv" - TypeAVI MediaType = "video/x-msvideo" - TypeWEBM MediaType = "video/webm" - TypeMOV MediaType = "video/quicktime" - TypeTS MediaType = "video/mp2t" - TypeMTS MediaType = "video/MP2T" - TypeMKV MediaType = "video/x-matroska" + TypeImage = mediaType("image/") + TypeVideo = mediaType("video/") + + // Web Image formats + TypeJPEG = mediaType("image/jpeg") + TypePNG = mediaType("image/png") + TypeWebP = mediaType("image/webp") + TypeBMP = mediaType("image/bmp") + TypeGIF = mediaType("image/gif") + + // Web Video formats + TypeMP4 = mediaType("video/mp4") + TypeMPEG = mediaType("video/mpeg") + TypeOGG = mediaType("video/ogg") + TypeWEBM = mediaType("video/webm") ) -var SupportedMimetypes = [...]MediaType{ - TypeJpeg, - TypePng, - TypeTiff, - TypeWebp, - TypeBmp, - TypeHeic, - TypeGif, -} +var webImageMimetypes = arrayToSet([]MediaType{ + TypeJPEG, + TypePNG, + TypeWebP, + TypeBMP, + TypeGIF, +}) -var WebMimetypes = [...]MediaType{ - TypeJpeg, - TypePng, - TypeWebp, - TypeBmp, - TypeGif, -} - -var RawMimeTypes = [...]MediaType{ - TypeDNG, - TypeARW, - TypeSR2, - TypeSRF, - TypeCR2, - TypeCR3, - TypeCRW, - TypeERF, - TypeDCS, - TypeDRF, - TypeDCR, - TypeK25, - TypeKDC, - TypeMRW, - TypeMDC, - TypeNEF, - TypeNRW, - TypeORF, - TypePEF, - TypeRAF, - TypeRAW, - TypeRW2, - TypeGPR, - Type3FR, - TypeFFF, - TypeMEF, - TypeCap, - TypeIIQ, - TypeMOS, - TypeRWL, - TypeSRW, -} - -var VideoMimetypes = [...]MediaType{ - TypeMP4, - TypeMPEG, - Type3GP, - Type3G2, - TypeOGG, - TypeWMV, - TypeAVI, - TypeWEBM, - TypeMOV, - TypeTS, - TypeMTS, - TypeMKV, -} - -// WebVideoMimetypes are video types that can be played directly in the browser without transcoding -var WebVideoMimetypes = [...]MediaType{ +var webVideoMimetypes = arrayToSet([]MediaType{ TypeMP4, TypeMPEG, TypeWEBM, TypeOGG, +}) + +// Legacy function. Should be removed. +var WebMimetypes = []string{ + TypeJPEG.String(), + TypePNG.String(), + TypeWebP.String(), + TypeBMP.String(), + TypeGIF.String(), + + TypeMP4.String(), + TypeMPEG.String(), + TypeWEBM.String(), + TypeOGG.String(), } -var fileExtensions = map[string]MediaType{ - ".jpg": TypeJpeg, - ".jpeg": TypeJpeg, - ".png": TypePng, - ".tif": TypeTiff, - ".tiff": TypeTiff, - ".bmp": TypeBmp, - ".heic": TypeHeic, - ".gif": TypeGif, - - // RAW formats - ".dng": TypeDNG, - ".arw": TypeARW, - ".sr2": TypeSR2, - ".srf": TypeSRF, - ".srw": TypeSRW, - ".cr2": TypeCR2, - ".cr3": TypeCR3, - ".crw": TypeCRW, - ".erf": TypeERF, - ".dcr": TypeDCR, - ".k25": TypeK25, - ".kdc": TypeKDC, - ".mrw": TypeMRW, - ".nef": TypeNEF, - ".nrw": TypeNRW, - ".mdc": TypeMDC, - ".mef": TypeMEF, - ".orf": TypeORF, - ".pef": TypePEF, - ".raf": TypeRAF, - ".raw": TypeRAW, - ".rw2": TypeRW2, - ".dcs": TypeDCS, - ".drf": TypeDRF, - ".gpr": TypeGPR, - ".3fr": Type3FR, - ".fff": TypeFFF, - ".cap": TypeCap, - ".iiq": TypeIIQ, - ".mos": TypeMOS, - ".rwl": TypeRWL, - - // Video formats - ".mp4": TypeMP4, - ".m4v": TypeMP4, - ".mpeg": TypeMPEG, - ".3gp": Type3GP, - ".3g2": Type3G2, - ".ogv": TypeOGG, - ".wmv": TypeWMV, - ".avi": TypeAVI, - ".webm": TypeWEBM, - ".mov": TypeMOV, - ".qt": TypeMOV, - ".ts": TypeTS, - ".m2ts": TypeMTS, - ".mts": TypeMTS, - ".mkv": TypeMKV, +func arrayToSet[T comparable](array []T) map[T]struct{} { + ret := make(map[T]struct{}) + for _, item := range array { + ret[item] = struct{}{} + } + return ret } -func (imgType *MediaType) IsRaw() bool { - for _, raw_mime := range RawMimeTypes { - if raw_mime == *imgType { - return true - } +// IsWebCompatible returns true if the media type is compatible with the browser. +func (t MediaType) IsWebCompatible() bool { + if t == TypeUnknown { + return false } - return false -} - -func (imgType *MediaType) IsWebCompatible() bool { - for _, web_mime := range WebMimetypes { - if web_mime == *imgType { - return true - } - } - - for _, web_mime := range WebVideoMimetypes { - if web_mime == *imgType { - return true - } - } - - return false -} - -func (imgType *MediaType) IsVideo() bool { - for _, video_mime := range VideoMimetypes { - if video_mime == *imgType { - return true - } - } - - return false -} - -func (imgType *MediaType) IsBasicTypeSupported() bool { - for _, img_mime := range SupportedMimetypes { - if img_mime == *imgType { - return true - } - } - - return false -} - -// IsSupported determines if the given type can be processed -func (imgType *MediaType) IsSupported() bool { - if imgType.IsBasicTypeSupported() { + if _, ok := webImageMimetypes[t]; ok { return true } - if executable_worker.Magick.IsInstalled() && imgType.IsRaw() { - return true - } - - if executable_worker.Ffmpeg.IsInstalled() && imgType.IsVideo() { + if _, ok := webVideoMimetypes[t]; ok { return true } return false } -func GetExtensionMediaType(ext string) (MediaType, bool) { - result, found := fileExtensions[strings.ToLower(ext)] - return result, found +// IsImage returns true if the media type is image type. +func (t MediaType) IsImage() bool { + if t == TypeUnknown { + return false + } + + return strings.HasPrefix(t.String(), TypeImage.String()) } -func GetMediaType(path string) (*MediaType, error) { - ext := filepath.Ext(path) - - fileExtType, found := GetExtensionMediaType(ext) - - if found { - if fileExtType.IsSupported() { - return &fileExtType, nil - } else { - return nil, nil - } +// IsVideo returns true if the media type is video type. +func (t MediaType) IsVideo() bool { + if t == TypeUnknown { + return false } - // If extension was not recognized try to read file header - file, err := os.Open(path) - if err != nil { - return nil, errors.Wrapf(err, "could not open file to determine content-type %s", path) - } - defer file.Close() - - head := make([]byte, 261) - if _, err := file.Read(head); err != nil { - if err == io.EOF { - return nil, nil - } - - return nil, errors.Wrapf(err, "could not read file to determine content-type: %s", path) - } - - _imgType, err := filetype.Image(head) - if err != nil { - return nil, nil - } - - imgType := MediaType(_imgType.MIME.Value) - if imgType.IsSupported() { - return &imgType, nil - } - - return nil, nil + return strings.HasPrefix(t.String(), TypeVideo.String()) } -func (mediaType MediaType) FileExtensions() []string { - var extensions []string - - for ext, extType := range fileExtensions { - if extType == mediaType { - extensions = append(extensions, ext) - extensions = append(extensions, strings.ToUpper(ext)) - } +// IsSupported returns true if the media type can be processed. +func (t MediaType) IsSupported() bool { + if t == TypeUnknown { + return false } - return extensions + return t.IsImage() || t.IsVideo() } -func RawCounterpart(imagePath string) *string { - pathWithoutExt := strings.TrimSuffix(imagePath, path.Ext(imagePath)) - - for _, rawType := range RawMimeTypes { - for _, ext := range rawType.FileExtensions() { - testPath := pathWithoutExt + ext - if scanner_utils.FileExists(testPath) { - return &testPath - } - } +func (t MediaType) String() string { + if t == TypeUnknown { + return "unknown" } - return nil + return unique.Handle[string](t).Value() } diff --git a/api/scanner/media_type/media_type_test.go b/api/scanner/media_type/media_type_test.go index 4f723053..34118fe5 100644 --- a/api/scanner/media_type/media_type_test.go +++ b/api/scanner/media_type/media_type_test.go @@ -1,42 +1,85 @@ -package media_type_test +package media_type import ( - "os" "testing" - - "github.com/photoview/photoview/api/scanner/media_type" - "github.com/photoview/photoview/api/test_utils" - "github.com/stretchr/testify/assert" ) -func TestMain(m *testing.M) { - os.Exit(test_utils.UnitTestRun(m)) +type boolImage bool + +const isImage boolImage = true + +type boolVideo bool + +const isVideo boolVideo = true + +type boolWebCompatible bool + +const isWebCompatible boolWebCompatible = true + +type boolSupport bool + +const isSupport boolSupport = true + +func TestMediaTypeNoDeps(t *testing.T) { + tests := []struct { + mtype MediaType + wantIsImage boolImage + wantIsVideo boolVideo + wantIsWebCompatible boolWebCompatible + wantIsSupport boolSupport + }{ + // Unknown and unsupported types + {TypeUnknown, !isImage, !isVideo, !isWebCompatible, !isSupport}, + {mediaType("application/pdf"), !isImage, !isVideo, !isWebCompatible, !isSupport}, + + // Raw media types + {mediaType("image/some-raw-type"), isImage, !isVideo, !isWebCompatible, isSupport}, + {mediaType("video/some-video-type"), !isImage, isVideo, !isWebCompatible, isSupport}, + + // Generic types + {TypeImage, isImage, !isVideo, !isWebCompatible, isSupport}, + {TypeVideo, !isImage, isVideo, !isWebCompatible, isSupport}, + + // Web-compatible image types + {TypeJPEG, isImage, !isVideo, isWebCompatible, isSupport}, + {TypePNG, isImage, !isVideo, isWebCompatible, isSupport}, + {TypeWebP, isImage, !isVideo, isWebCompatible, isSupport}, + {TypeBMP, isImage, !isVideo, isWebCompatible, isSupport}, + {TypeGIF, isImage, !isVideo, isWebCompatible, isSupport}, + + // Web-compatible video types + {TypeMP4, !isImage, isVideo, isWebCompatible, isSupport}, + {TypeMPEG, !isImage, isVideo, isWebCompatible, isSupport}, + {TypeOGG, !isImage, isVideo, isWebCompatible, isSupport}, + {TypeWEBM, !isImage, isVideo, isWebCompatible, isSupport}, + } + + for _, tc := range tests { + gotImage := tc.mtype.IsImage() + if got, want := gotImage, bool(tc.wantIsImage); got != want { + t.Errorf("MediaType(%q).IsImage() = %v, want: %v", tc.mtype, got, want) + } + + gotVideo := tc.mtype.IsVideo() + if got, want := gotVideo, bool(tc.wantIsVideo); got != want { + t.Errorf("MediaType(%q).IsVideo() = %v, want: %v", tc.mtype, got, want) + } + + gotWebCompatible := tc.mtype.IsWebCompatible() + if got, want := gotWebCompatible, bool(tc.wantIsWebCompatible); got != want { + t.Errorf("MediaType(%q).IsWebCompatible() = %v, want: %v", tc.mtype, got, want) + } + + gotSupport := tc.mtype.IsSupported() + if got, want := gotSupport, bool(tc.wantIsSupport); got != want { + t.Errorf("MediaType(%q).IsSupported() = %v, want: %v", tc.mtype, got, want) + } + } } -func TestMediaTypeIsFunctions(t *testing.T) { - rawType := media_type.TypeCR2 - pngType := media_type.TypePng - mp4Type := media_type.TypeMP4 - - assert.True(t, rawType.IsRaw()) - assert.False(t, pngType.IsRaw()) - - assert.True(t, pngType.IsWebCompatible()) - assert.False(t, rawType.IsWebCompatible()) - - assert.True(t, mp4Type.IsVideo()) - assert.False(t, pngType.IsVideo()) - - assert.True(t, pngType.IsBasicTypeSupported()) -} - -func TestMediaTypeFromExtension(t *testing.T) { - pngType, found := media_type.GetExtensionMediaType(".PNG") - - assert.True(t, found) - assert.Equal(t, media_type.TypePng, pngType) -} - -func TestMediaTypeGetExtensions(t *testing.T) { - assert.ElementsMatch(t, []string{".jpg", ".JPG", ".jpeg", ".JPEG"}, media_type.TypeJpeg.FileExtensions()) +func TestMediaTypeUnknown(t *testing.T) { + var got MediaType + if want := TypeUnknown; got != want { + t.Errorf("MediaType zero value should be TypeUnknown, which is not") + } } diff --git a/api/scanner/scanner_cache/cache.go b/api/scanner/scanner_cache/cache.go index 50653bea..d4078646 100644 --- a/api/scanner/scanner_cache/cache.go +++ b/api/scanner/scanner_cache/cache.go @@ -1,6 +1,7 @@ package scanner_cache import ( + "fmt" "log" "os" "path" @@ -8,7 +9,6 @@ import ( "github.com/photoview/photoview/api/scanner/media_type" "github.com/photoview/photoview/api/scanner/scanner_utils" - "github.com/pkg/errors" ) type AlbumScannerCache struct { @@ -69,24 +69,21 @@ func (c *AlbumScannerCache) AlbumContainsPhotos(path string) *bool { // (c.photo_types)[path] = content_type // } -func (c *AlbumScannerCache) GetMediaType(path string) (*media_type.MediaType, error) { +func (c *AlbumScannerCache) GetMediaType(path string) (media_type.MediaType, error) { c.mutex.Lock() defer c.mutex.Unlock() result, found := c.photo_types[path] if found { - // log.Printf("Image cache hit: %s\n", path) - return &result, nil + return result, nil } - mediaType, err := media_type.GetMediaType(path) - if err != nil { - return nil, errors.Wrapf(err, "get media type (%s)", path) + mediaType := media_type.GetMediaType(path) + if mediaType == media_type.TypeUnknown { + return mediaType, fmt.Errorf("unknown media type (%s)", path) } - if mediaType != nil { - (c.photo_types)[path] = *mediaType - } + c.photo_types[path] = mediaType return mediaType, nil } @@ -122,16 +119,16 @@ func (c *AlbumScannerCache) IsPathMedia(mediaPath string) bool { return false } - if mediaType != nil { - // Make sure file isn't empty - fileStats, err := os.Stat(mediaPath) - if err != nil || fileStats.Size() == 0 { - return false - } - - return true + if !mediaType.IsSupported() { + log.Printf("Unsupported media type %q for file: %s\n", mediaType, mediaPath) + return false } - log.Printf("File is not a supported media %s\n", mediaPath) - return false + // Make sure file isn't empty + fileStats, err := os.Stat(mediaPath) + if err != nil || fileStats.Size() == 0 { + return false + } + + return true } diff --git a/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task.go b/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task.go index 72d4dbb9..85b1893b 100644 --- a/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task.go +++ b/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task.go @@ -1,17 +1,13 @@ package processing_tasks import ( + "fmt" "io/fs" - "path" - "path/filepath" - "strings" "github.com/photoview/photoview/api/scanner/media_encoding" "github.com/photoview/photoview/api/scanner/media_type" "github.com/photoview/photoview/api/scanner/scanner_task" - "github.com/photoview/photoview/api/scanner/scanner_utils" "github.com/photoview/photoview/api/utils" - "github.com/pkg/errors" ) type CounterpartFilesTask struct { @@ -19,14 +15,14 @@ type CounterpartFilesTask struct { } func (t CounterpartFilesTask) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (skip bool, err error) { - ext := filepath.Ext(mediaPath) - fileExtType, found := media_type.GetExtensionMediaType(ext) - if !found { + fileType := media_type.GetMediaType(mediaPath) + + if !fileType.IsSupported() { return true, nil } if utils.EnvDisableRawProcessing.GetBool() { - if fileExtType.IsRaw() { + if !fileType.IsWebCompatible() { return true, nil } @@ -34,13 +30,11 @@ func (t CounterpartFilesTask) MediaFound(ctx scanner_task.TaskContext, fileInfo return false, nil } - if !fileExtType.IsBasicTypeSupported() { - return false, nil - } - - rawPath := media_type.RawCounterpart(mediaPath) - if rawPath != nil { - return true, nil + if fileType.IsWebCompatible() { + _, existed := media_type.FindRawCounterpart(mediaPath) + if existed { + return true, nil + } } return false, nil @@ -50,38 +44,19 @@ func (t CounterpartFilesTask) BeforeProcessMedia(ctx scanner_task.TaskContext, m mediaType, err := ctx.GetCache().GetMediaType(mediaData.Media.Path) if err != nil { - return ctx, errors.Wrap(err, "scan for counterpart file") + return ctx, fmt.Errorf("scan for counterpart file error: %w", err) } - if !mediaType.IsRaw() { + if mediaType.IsWebCompatible() { return ctx, nil } - counterpartFile := scanForCompressedCounterpartFile(mediaData.Media.Path) - if counterpartFile != nil { - mediaData.CounterpartPath = counterpartFile + counterpartFile, ok := media_type.FindWebCounterpart(mediaData.Media.Path) + if !ok { + return ctx, nil } + mediaData.CounterpartPath = &counterpartFile + return ctx, nil } - -func scanForCompressedCounterpartFile(imagePath string) *string { - ext := filepath.Ext(imagePath) - fileExtType, found := media_type.GetExtensionMediaType(ext) - - if found { - if fileExtType.IsBasicTypeSupported() { - return nil - } - } - - pathWithoutExt := strings.TrimSuffix(imagePath, path.Ext(imagePath)) - for _, ext := range media_type.TypeJpeg.FileExtensions() { - testPath := pathWithoutExt + ext - if scanner_utils.FileExists(testPath) { - return &testPath - } - } - - return nil -} diff --git a/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task_test.go b/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task_test.go index f0efb838..7431d618 100644 --- a/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task_test.go +++ b/api/scanner/scanner_tasks/processing_tasks/counterpart_files_task_test.go @@ -13,6 +13,8 @@ import ( ) func TestCounterpartFilesTaskMediaFound(t *testing.T) { + mediaPath := test_utils.PathFromAPIRoot("scanner/test_media/real_media") + tests := []struct { name string file string @@ -21,56 +23,54 @@ func TestCounterpartFilesTaskMediaFound(t *testing.T) { }{ { name: "StandaloneProcessRaw", - file: "standalone.jpg", + file: "standalone_jpg.jpg", disableRawProcessing: false, wantSkip: false, }, { name: "StandaloneNotProcessRaw", - file: "standalone.jpg", + file: "standalone_jpg.jpg", disableRawProcessing: true, wantSkip: false, }, { name: "RawJpegProcessRaw", - file: "fujifilm_raw.jpg", + file: "raw_with_jpg.jpg", disableRawProcessing: false, wantSkip: true, }, { name: "RawJpegNotProcessRaw", - file: "fujifilm_raw.jpg", + file: "raw_with_jpg.jpg", disableRawProcessing: true, wantSkip: false, }, { name: "RawProcessRaw", - file: "fujifilm_raw.raf", + file: "raw_with_jpg.tiff", disableRawProcessing: false, wantSkip: false, }, { name: "RawNotProcessRaw", - file: "fujifilm_raw.raf", + file: "raw_with_jpg.tiff", disableRawProcessing: true, wantSkip: true, }, { name: "UnknownProcessRaw", - file: "file.unknown", + file: "file.pdf", disableRawProcessing: false, wantSkip: true, }, { name: "UnknownNotProcessRaw", - file: "file.unknown", + file: "file.pdf", disableRawProcessing: true, wantSkip: true, }, } - mediaPath := test_utils.PathFromAPIRoot("scanner/test_media/fake_media") - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { done := test_utils.SetEnv(string(utils.EnvDisableRawProcessing), fmt.Sprintf("%v", tc.disableRawProcessing)) diff --git a/api/scanner/scanner_tasks/processing_tasks/process_video_task.go b/api/scanner/scanner_tasks/processing_tasks/process_video_task.go index 0112efc3..fb2407c8 100644 --- a/api/scanner/scanner_tasks/processing_tasks/process_video_task.go +++ b/api/scanner/scanner_tasks/processing_tasks/process_video_task.go @@ -52,7 +52,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * videoType, err := mediaData.ContentType() if err != nil { - return []*models.MediaURL{}, errors.Wrap(err, "error getting video content type") + return []*models.MediaURL{}, fmt.Errorf("getting video content type error: %w", err) } if videoOriginalURL == nil && videoType.IsWebCompatible() { @@ -75,7 +75,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * Width: webMetadata.Width, Height: webMetadata.Height, Purpose: models.MediaOriginal, - ContentType: string(*videoType), + ContentType: videoType.String(), FileSize: fileStats.Size(), } diff --git a/api/scanner/scanner_tasks/processing_tasks/processing_helpers.go b/api/scanner/scanner_tasks/processing_tasks/processing_helpers.go index bdc5a74b..33aedfc4 100644 --- a/api/scanner/scanner_tasks/processing_tasks/processing_helpers.go +++ b/api/scanner/scanner_tasks/processing_tasks/processing_helpers.go @@ -70,7 +70,7 @@ func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *media_en Width: photoDimensions.Width, Height: photoDimensions.Height, Purpose: models.MediaOriginal, - ContentType: string(*contentType), + ContentType: contentType.String(), FileSize: fileStats.Size(), } diff --git a/api/scanner/scanner_tasks/processing_tasks/sidecar_task.go b/api/scanner/scanner_tasks/processing_tasks/sidecar_task.go index abad1df7..a8f48b73 100644 --- a/api/scanner/scanner_tasks/processing_tasks/sidecar_task.go +++ b/api/scanner/scanner_tasks/processing_tasks/sidecar_task.go @@ -30,7 +30,7 @@ func (t SidecarTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models return errors.Wrap(err, "scan for sidecar file") } - if !mediaType.IsRaw() { + if mediaType.IsWebCompatible() { return nil } @@ -58,7 +58,7 @@ func (t SidecarTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, process media") } - if !mediaType.IsRaw() { + if mediaType.IsWebCompatible() { return []*models.MediaURL{}, nil } diff --git a/scripts/benchmark_api.sh b/scripts/benchmark_api.sh new file mode 100755 index 00000000..9bae92c5 --- /dev/null +++ b/scripts/benchmark_api.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euxo pipefail + +cd "$(dirname $0)/../api" +go test ./... -bench . -benchmem diff --git a/scripts/install_build_dependencies.sh b/scripts/install_build_dependencies.sh index 261e2d0f..3953007a 100755 --- a/scripts/install_build_dependencies.sh +++ b/scripts/install_build_dependencies.sh @@ -11,5 +11,8 @@ apt-get install -y libdav1d-dev:${DEB_HOST_ARCH} libde265-dev:${DEB_HOST_ARCH} l apt-get install -y \ libdlib-dev:${DEB_HOST_ARCH} libblas-dev:${DEB_HOST_ARCH} libatlas-base-dev:${DEB_HOST_ARCH} liblapack-dev:${DEB_HOST_ARCH} libjpeg62-turbo-dev:${DEB_HOST_ARCH} +# Install gomagic dependencies +apt-get install -y libmagic-dev:${DEB_HOST_ARCH} + # Install tools for development apt-get install -y reflex sqlite3 diff --git a/scripts/install_runtime_dependencies.sh b/scripts/install_runtime_dependencies.sh index 4bd039fc..8b6cca93 100755 --- a/scripts/install_runtime_dependencies.sh +++ b/scripts/install_runtime_dependencies.sh @@ -12,3 +12,5 @@ apt-get install -y libjpeg62-turbo liblcms2-2 zlib1g libgomp1 apt-get install -y libjxl0.7 liblcms2-2 liblqr-1-0 libdjvulibre21 libjpeg62-turbo libopenjp2-7 libopenexr-3-1-30 libpng16-16 libtiff6 libwebpmux3 libwebpdemux2 libwebp7 libxml2 zlib1g liblzma5 libbz2-1.0 libgomp1 # go-face dependencies apt-get install -y libdlib19.1 libblas3 liblapack3 libjpeg62-turbo +# libmagic dependencies +apt-get install -y libmagic1 diff --git a/scripts/test_api_coverage.sh b/scripts/test_api_coverage.sh index e925770e..c4444b7d 100755 --- a/scripts/test_api_coverage.sh +++ b/scripts/test_api_coverage.sh @@ -1,5 +1,5 @@ #!/bin/bash set -euxo pipefail -cd $(dirname $0)/../api +cd "$(dirname $0)/../api" go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic 2>&1 | tee >(go-junit-report >test-api-coverage-report.xml)