Use libmagic to identify files type. (#1124)

* Use libmagic to identify file types.

* Fix mime array for database querying.

* go mod tidy

* Fix typo

* Another fix for the scheduled image build workflow for tag case (#1137)

* Try to fix the incorrect tag processing

* more debug code

* try with context=git

* hopefully, final debug

* the final commit with no debug code

---------

Co-authored-by: Konstantin Koval

* Add benchmark.

* rollback workflows.

* Fix AI comments.

* Build image.

* Fix AI comments.

* Fix AI comments.

* Rollback build workflow.

---------

Co-authored-by: Kostiantyn <32730812+kkovaletp@users.noreply.github.com>
This commit is contained in:
Googol Lee
2024-12-14 22:07:03 +01:00
committed by GitHub
parent f1d4d0a048
commit f88fcaba69
19 changed files with 572 additions and 494 deletions

View File

@@ -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

View File

@@ -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=

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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(),
}

View File

@@ -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(),
}

View File

@@ -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
}

5
scripts/benchmark_api.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -euxo pipefail
cd "$(dirname $0)/../api"
go test ./... -bench . -benchmem

View File

@@ -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

View File

@@ -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

View File

@@ -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)