Initial video support #17

Still need to add a player and index metadata. Work in progress.

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-11 18:29:17 +02:00
parent 510df88d7f
commit a61f2384b3
25 changed files with 359 additions and 205 deletions

Binary file not shown.

View file

@ -191,41 +191,6 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
})
}
// POST /api/v1/batch/photos/story
func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/photos/story", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if len(f.Photos) == 0 {
log.Error("no photos selected")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no photos selected")})
return
}
log.Infof("marking photos as story: %#v", f.Photos)
entity.Db().Model(entity.Photo{}).Where("photo_uuid IN (?)", f.Photos).Updates(map[string]interface{}{
"photo_story": gorm.Expr("IF (`photo_story`, 0, 1)"),
})
elapsed := time.Since(start)
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("photos marked as story in %s", elapsed)})
})
}
// POST /api/v1/batch/labels/delete
func BatchLabelsDelete(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/labels/delete", func(c *gin.Context) {

View file

@ -165,42 +165,6 @@ func TestBatchPhotosPrivate(t *testing.T) {
})
}
func TestBatchPhotosStory(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, conf := NewApiTest()
GetPhoto(router, conf)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh8")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "PhotoStory")
assert.Equal(t, "false", val.String())
BatchPhotosStory(router, conf)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/story", `{"photos": ["pt9jtdre2lvl0yh8", "pt9jtdre2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "photos marked as story")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh8")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "PhotoStory")
assert.Equal(t, "true", val3.String())
})
t.Run("no photos selected", func(t *testing.T) {
app, router, conf := NewApiTest()
BatchPhotosStory(router, conf)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/story", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "No photos selected", val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("invalid request", func(t *testing.T) {
app, router, conf := NewApiTest()
BatchPhotosStory(router, conf)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/story", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchLabelsDelete(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, conf := NewApiTest()

View file

@ -89,8 +89,10 @@ func configAction(ctx *cli.Context) error {
// External binaries
fmt.Printf("%-25s %s\n", "sips-bin", conf.SipsBin())
fmt.Printf("%-25s %s\n", "darktable-bin", conf.DarktableBin())
fmt.Printf("%-25s %s\n", "exiftool-bin", conf.ExifToolBin())
fmt.Printf("%-25s %s\n", "heifconvert-bin", conf.HeifConvertBin())
fmt.Printf("%-25s %s\n", "ffmpeg-bin", conf.FFmpegBin())
fmt.Printf("%-25s %s\n", "exiftool-bin", conf.ExifToolBin())
fmt.Printf("%-25s %t\n", "write-json", conf.WriteJson())
// Places / Geocoding API
fmt.Printf("%-25s %s\n", "geocoding-api", conf.GeoCodingApi())

View file

@ -132,24 +132,38 @@ func (c *Config) ImportPath() string {
return fs.Abs(c.params.ImportPath)
}
// SipsBin returns the sips binary file name.
// SipsBin returns the sips executable file name.
func (c *Config) SipsBin() string {
return findExecutable(c.params.SipsBin, "sips")
}
// DarktableBin returns the darktable-cli binary file name.
// DarktableBin returns the darktable-cli executable file name.
func (c *Config) DarktableBin() string {
return findExecutable(c.params.DarktableBin, "darktable-cli")
}
// HeifConvertBin returns the heif-convert binary file name.
// ExifToolBin returns the exiftool executable file name.
func (c *Config) ExifToolBin() string {
return findExecutable(c.params.ExifToolBin, "exiftool")
}
// WriteJson returns true if exiftool should be used for exporting metadata to json sidecar files.
func (c *Config) WriteJson() bool {
if c.ReadOnly() || c.ExifToolBin() == "" {
return false
}
return c.params.WriteJson
}
// HeifConvertBin returns the heif-convert executable file name.
func (c *Config) HeifConvertBin() string {
return findExecutable(c.params.HeifConvertBin, "heif-convert")
}
// ExifToolBin returns the exiftool binary file name.
func (c *Config) ExifToolBin() string {
return findExecutable(c.params.ExifToolBin, "exiftool")
// FFmpegBin returns the ffmpeg executable file name.
func (c *Config) FFmpegBin() string {
return findExecutable(c.params.FFmpegBin, "ffmpeg")
}
// TempPath returns a temporary directory name for uploads and downloads.

View file

@ -145,27 +145,38 @@ var GlobalFlags = []cli.Flag{
},
cli.StringFlag{
Name: "sips-bin",
Usage: "sips cli binary `FILENAME`",
Usage: "sips executable `FILENAME`",
Value: "sips",
EnvVar: "PHOTOPRISM_SIPS_BIN",
},
cli.StringFlag{
Name: "darktable-bin",
Usage: "darktable cli binary `FILENAME`",
Usage: "darktable-cli executable `FILENAME`",
Value: "darktable-cli",
EnvVar: "PHOTOPRISM_DARKTABLE_BIN",
},
cli.StringFlag{
Name: "heifconvert-bin",
Usage: "heif-convert executable `FILENAME`",
Value: "heif-convert",
EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN",
},
cli.StringFlag{
Name: "ffmpeg-bin",
Usage: "ffmpeg executable `FILENAME`",
Value: "ffmpeg",
EnvVar: "PHOTOPRISM_FFMPEG_BIN",
},
cli.StringFlag{
Name: "exiftool-bin",
Usage: "exiftool cli binary `FILENAME`",
Usage: "exiftool executable `FILENAME`",
Value: "exiftool",
EnvVar: "PHOTOPRISM_EXIFTOOL_BIN",
},
cli.StringFlag{
Name: "heifconvert-bin",
Usage: "heif conversion cli binary `FILENAME`",
Value: "heif-convert",
EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN",
cli.BoolFlag{
Name: "write-json",
Usage: "run exiftool for exporting metadata to json sidecar files",
EnvVar: "PHOTOPRISM_EXIFTOOL_JSON",
},
cli.IntFlag{
Name: "http-port",

View file

@ -65,8 +65,10 @@ type Params struct {
HttpServerPassword string `yaml:"http-password" flag:"http-password"`
SipsBin string `yaml:"sips-bin" flag:"sips-bin"`
DarktableBin string `yaml:"darktable-bin" flag:"darktable-bin"`
ExifToolBin string `yaml:"exiftool-bin" flag:"exiftool-bin"`
HeifConvertBin string `yaml:"heifconvert-bin" flag:"heifconvert-bin"`
FFmpegBin string `yaml:"ffmpeg-bin" flag:"ffmpeg-bin"`
ExifToolBin string `yaml:"exiftool-bin" flag:"exiftool-bin"`
WriteJson bool `yaml:"write-json" flag:"write-json"`
PIDFilename string `yaml:"pid-filename" flag:"pid-filename"`
LogFilename string `yaml:"log-filename" flag:"log-filename"`
DetachServer bool `yaml:"detach-server" flag:"detach-server"`

View file

@ -26,10 +26,11 @@ type File struct {
FileMime string `gorm:"type:varbinary(64)"`
FilePrimary bool
FileSidecar bool
FileVideo bool
FileMissing bool
FileDuplicate bool
FilePortrait bool
FileVideo bool
FileLength time.Duration
FileWidth int
FileHeight int
FileOrientation int

View file

@ -30,7 +30,7 @@ type Photo struct {
PhotoResolution int `gorm:"type:SMALLINT" json:"PhotoResolution"`
PhotoFavorite bool `json:"PhotoFavorite"`
PhotoPrivate bool `json:"PhotoPrivate"`
PhotoStory bool `json:"PhotoStory"`
PhotoVideo bool `json:"PhotoVideo"`
PhotoLat float32 `gorm:"type:FLOAT;index;" json:"PhotoLat"`
PhotoLng float32 `gorm:"type:FLOAT;index;" json:"PhotoLng"`
PhotoAltitude int `json:"PhotoAltitude"`

View file

@ -39,7 +39,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 48.519234,
PhotoLng: 9.057997,
PhotoAltitude: 0,
@ -93,7 +93,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: true,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 48.519234,
PhotoLng: 9.057997,
PhotoAltitude: 0,
@ -142,7 +142,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 48.519234,
PhotoLng: 9.057997,
PhotoAltitude: 0,
@ -191,7 +191,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 48.519234,
PhotoLng: 9.057997,
PhotoAltitude: 0,
@ -243,7 +243,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 48.519234,
PhotoLng: 9.057997,
PhotoAltitude: 0,
@ -296,7 +296,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: -21.342636,
PhotoLng: 55.466944,
PhotoAltitude: 0,
@ -345,7 +345,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 2,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: -21.342636,
PhotoLng: 55.466944,
PhotoAltitude: 0,
@ -394,7 +394,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: -21.342636,
PhotoLng: 55.466944,
PhotoAltitude: 0,
@ -443,7 +443,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -492,7 +492,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -541,7 +541,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -590,7 +590,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -639,7 +639,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -688,7 +688,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
@ -737,7 +737,7 @@ var PhotoFixtures = PhotoMap{
PhotoResolution: 0,
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,

View file

@ -59,7 +59,7 @@ func (m *Photo) QualityScore() (score int) {
score++
}
if score < 3 && m.EditedAt != nil {
if score < 3 && (m.PhotoVideo || m.EditedAt != nil) {
score = 3
}

View file

@ -19,7 +19,7 @@ func TestSavePhotoForm(t *testing.T) {
TitleSrc: "manual",
PhotoFavorite: true,
PhotoPrivate: true,
PhotoStory: false,
PhotoVideo: false,
PhotoLat: 7.9999,
PhotoLng: 8.8888,
PhotoAltitude: 2,
@ -52,7 +52,7 @@ func TestSavePhotoForm(t *testing.T) {
assert.Equal(t, "manual", m.TitleSrc)
assert.Equal(t, true, m.PhotoFavorite)
assert.Equal(t, true, m.PhotoPrivate)
assert.Equal(t, false, m.PhotoStory)
assert.Equal(t, false, m.PhotoVideo)
assert.Equal(t, float32(7.9999), m.PhotoLat)
assert.NotNil(t, m.EditedAt)
@ -70,7 +70,7 @@ func TestPhoto_Save(t *testing.T) {
TitleSrc: "manual",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoStory: true,
PhotoVideo: true,
PhotoLat: 9.9999,
PhotoLng: 8.8888,
PhotoAltitude: 2,

View file

@ -27,7 +27,7 @@ type Photo struct {
DescriptionSrc string `json:"DescriptionSrc"`
PhotoFavorite bool `json:"PhotoFavorite"`
PhotoPrivate bool `json:"PhotoPrivate"`
PhotoStory bool `json:"PhotoStory"`
PhotoVideo bool `json:"PhotoVideo"`
PhotoReview bool `json:"PhotoReview"`
PhotoLat float32 `json:"PhotoLat"`
PhotoLng float32 `json:"PhotoLng"`

View file

@ -10,7 +10,7 @@ func TestNewPhoto(t *testing.T) {
t.Run("success", func(t *testing.T) {
photo := Photo{TakenAt: time.Date(2008, 1, 1, 2, 0, 0, 0, time.UTC), TakenAtLocal: time.Date(2008, 1, 1, 2, 0, 0, 0, time.UTC),
TakenSrc: "exif", TimeZone: "UTC", PhotoTitle: "Black beach", TitleSrc: "manual",
PhotoFavorite: false, PhotoPrivate: false, PhotoStory: true, PhotoReview: false, PhotoLat: 9.9999, PhotoLng: 8.8888, PhotoAltitude: 2, PhotoIso: 5,
PhotoFavorite: false, PhotoPrivate: false, PhotoVideo: true, PhotoReview: false, PhotoLat: 9.9999, PhotoLng: 8.8888, PhotoAltitude: 2, PhotoIso: 5,
PhotoFocalLength: 10, PhotoFNumber: 3.3, PhotoExposure: "exposure", CameraID: uint(3), CameraSrc: "exif", LensID: uint(6), LocationID: "1234", LocationSrc: "geo",
PlaceID: "765", PhotoCountry: "de"}
@ -28,7 +28,7 @@ func TestNewPhoto(t *testing.T) {
assert.Equal(t, "manual", r.TitleSrc)
assert.Equal(t, false, r.PhotoFavorite)
assert.Equal(t, false, r.PhotoPrivate)
assert.Equal(t, true, r.PhotoStory)
assert.Equal(t, true, r.PhotoVideo)
assert.Equal(t, false, r.PhotoReview)
assert.Equal(t, float32(9.9999), r.PhotoLat)
assert.Equal(t, float32(8.8888), r.PhotoLng)

View file

@ -4,6 +4,8 @@ import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
@ -105,31 +107,83 @@ func (c *Convert) Start(path string) error {
}
// ConvertCommand returns the command for converting files to JPEG, depending on the format.
func (c *Convert) ConvertCommand(image *MediaFile, jpegName string, xmpName string) (result *exec.Cmd, useMutex bool, err error) {
if image.IsRaw() {
func (c *Convert) ConvertCommand(mf *MediaFile, jpegName string, xmpName string) (result *exec.Cmd, useMutex bool, err error) {
if mf.IsRaw() {
if c.conf.SipsBin() != "" {
result = exec.Command(c.conf.SipsBin(), "-s", "format", "jpeg", "--out", jpegName, image.fileName)
result = exec.Command(c.conf.SipsBin(), "-s", "format", "jpeg", "--out", jpegName, mf.FileName())
} else if c.conf.DarktableBin() != "" {
// Only one instance of darktable-cli allowed due to locking
useMutex = true
if xmpName != "" {
result = exec.Command(c.conf.DarktableBin(), image.fileName, xmpName, jpegName)
result = exec.Command(c.conf.DarktableBin(), mf.FileName(), xmpName, jpegName)
} else {
result = exec.Command(c.conf.DarktableBin(), image.fileName, jpegName)
result = exec.Command(c.conf.DarktableBin(), mf.FileName(), jpegName)
}
} else {
return nil, useMutex, fmt.Errorf("convert: no raw to jpeg converter installed (%s)", image.Base(c.conf.Settings().Index.Group))
return nil, useMutex, fmt.Errorf("convert: no raw to jpeg converter installed (%s)", mf.Base(c.conf.Settings().Index.Group))
}
} else if image.IsHEIF() {
result = exec.Command(c.conf.HeifConvertBin(), image.fileName, jpegName)
} else if mf.IsVideo() {
result = exec.Command(c.conf.FFmpegBin(), "-i", mf.FileName(), "-ss", "00:00:00.001", "-vframes", "1", jpegName)
} else if mf.IsHEIF() {
result = exec.Command(c.conf.HeifConvertBin(), mf.FileName(), jpegName)
} else {
return nil, useMutex, fmt.Errorf("convert: image type not supported for conversion (%s)", image.FileType())
return nil, useMutex, fmt.Errorf("convert: file type not supported for conversion (%s)", mf.FileType())
}
return result, useMutex, nil
}
// ToJson uses exiftool to export metadata to a json file.
func (c *Convert) ToJson(mf *MediaFile) (*MediaFile, error) {
jsonName := fs.TypeJson.Find(mf.FileName(), c.conf.Settings().Index.Group)
result, err := NewMediaFile(jsonName)
if err == nil {
return result, nil
}
jsonName = mf.AbsBase(c.conf.Settings().Index.Group) + ".json"
if c.conf.ReadOnly() {
return nil, fmt.Errorf("convert: metadata export to json disabled in read only mode (%s)", mf.RelativeName(c.conf.OriginalsPath()))
}
fileName := mf.RelativeName(c.conf.OriginalsPath())
log.Infof("convert: %s -> %s", fileName, fs.RelativeName(jsonName, c.conf.OriginalsPath()))
cmd := exec.Command(c.conf.ExifToolBin(), "-j", mf.FileName())
// Fetch command output.
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
// Run convert command.
if err := cmd.Run(); err != nil {
if stderr.String() != "" {
return nil, errors.New(stderr.String())
} else {
return nil, err
}
}
// Write output to file.
if err := ioutil.WriteFile(jsonName, []byte(out.String()), os.ModePerm); err != nil {
return nil, err
}
// Check if file exists.
if !fs.FileExists(jsonName) {
return nil, fmt.Errorf("convert: %s could not be created, check configuration", jsonName)
}
return NewMediaFile(jsonName)
}
// ToJpeg converts a single image file to JPEG if possible.
func (c *Convert) ToJpeg(image *MediaFile) (*MediaFile, error) {
if c.conf.ReadOnly() {

View file

@ -23,66 +23,170 @@ func TestConvert_ToJpeg(t *testing.T) {
}
conf := config.TestConfig()
conf.InitializeTestData(t)
convert := NewConvert(conf)
jpegFilename := conf.ImportPath() + "/fern_green.jpg"
t.Run("gopher-video.mp4", func(t *testing.T) {
fileName := conf.ExamplesPath() + "/gopher-video.mp4"
outputName := conf.ExamplesPath() + "/gopher-video.jpg"
assert.Truef(t, fs.FileExists(jpegFilename), "file does not exist: %s", jpegFilename)
_ = os.Remove(outputName)
t.Logf("Testing RAW to JPEG convert with %s", jpegFilename)
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
jpegMediaFile, err := NewMediaFile(jpegFilename)
mf, err := NewMediaFile(fileName)
assert.Nil(t, err)
if err != nil {
t.Fatal(err)
}
imageJpeg, err := convert.ToJpeg(jpegMediaFile)
jpegFile, err := convert.ToJpeg(mf)
assert.Empty(t, err, "ToJpeg() failed")
if err != nil {
t.Fatal(err)
}
infoJpeg, err := imageJpeg.MetaData()
assert.Equal(t, jpegFile.FileName(), outputName)
assert.Truef(t, fs.FileExists(jpegFile.FileName()), "output file does not exist: %s", jpegFile.FileName())
assert.Nilf(t, err, "UpdateExif() failed for "+imageJpeg.FileName())
metaData, err := jpegFile.MetaData()
if err != nil {
t.Fatalf("%s for %s", err.Error(), imageJpeg.FileName())
}
if err != nil {
t.Log(err)
} else {
t.Logf("video metadata: %+v", metaData)
}
assert.Equal(t, jpegFilename, imageJpeg.fileName)
_ = os.Remove(outputName)
})
assert.Equal(t, "Canon EOS 7D", infoJpeg.CameraModel)
t.Run("fern_green.jpg", func(t *testing.T) {
jpegFilename := conf.ImportPath() + "/fern_green.jpg"
rawFilename := conf.ImportPath() + "/raw/IMG_2567.CR2"
assert.Truef(t, fs.FileExists(jpegFilename), "file does not exist: %s", jpegFilename)
t.Logf("Testing RAW to JPEG convert with %s", rawFilename)
t.Logf("Testing RAW to JPEG convert with %s", jpegFilename)
rawMediaFile, err := NewMediaFile(rawFilename)
mf, err := NewMediaFile(jpegFilename)
if err != nil {
t.Fatalf("%s for %s", err.Error(), rawFilename)
}
if err != nil {
t.Fatal(err)
}
imageRaw, err := convert.ToJpeg(rawMediaFile)
imageJpeg, err := convert.ToJpeg(mf)
if err != nil {
t.Fatalf("%s for %s", err.Error(), rawFilename)
}
if err != nil {
t.Fatal(err)
}
assert.True(t, fs.FileExists(conf.ImportPath()+"/raw/IMG_2567.jpg"), "Jpeg file was not found - is Darktable installed?")
infoJpeg, err := imageJpeg.MetaData()
if imageRaw == nil {
t.Fatal("imageRaw is nil")
}
if err != nil {
t.Fatalf("%s for %s", err.Error(), imageJpeg.FileName())
}
assert.NotEqual(t, rawFilename, imageRaw.fileName)
assert.Equal(t, jpegFilename, imageJpeg.fileName)
infoRaw, err := imageRaw.MetaData()
assert.Equal(t, "Canon EOS 7D", infoJpeg.CameraModel)
assert.Equal(t, "Canon EOS 6D", infoRaw.CameraModel)
rawFilename := conf.ImportPath() + "/raw/IMG_2567.CR2"
t.Logf("Testing RAW to JPEG convert with %s", rawFilename)
rawMediaFile, err := NewMediaFile(rawFilename)
if err != nil {
t.Fatalf("%s for %s", err.Error(), rawFilename)
}
imageRaw, err := convert.ToJpeg(rawMediaFile)
if err != nil {
t.Fatalf("%s for %s", err.Error(), rawFilename)
}
assert.True(t, fs.FileExists(conf.ImportPath()+"/raw/IMG_2567.jpg"), "Jpeg file was not found - is Darktable installed?")
if imageRaw == nil {
t.Fatal("imageRaw is nil")
}
assert.NotEqual(t, rawFilename, imageRaw.fileName)
infoRaw, err := imageRaw.MetaData()
assert.Equal(t, "Canon EOS 6D", infoRaw.CameraModel)
})
}
func TestConvert_ToJson(t *testing.T) {
conf := config.TestConfig()
convert := NewConvert(conf)
t.Run("gopher-video.mp4", func(t *testing.T) {
fileName := conf.ExamplesPath() + "/gopher-video.mp4"
outputName := conf.ExamplesPath() + "/gopher-video.json"
_ = os.Remove(outputName)
assert.Truef(t, fs.FileExists(fileName), "input file does not exist: %s", fileName)
assert.Falsef(t, fs.FileExists(outputName), "output file must not exist: %s", outputName)
mf, err := NewMediaFile(fileName)
if err != nil {
t.Fatal(err)
}
jsonFile, err := convert.ToJson(mf)
if err != nil {
t.Fatal(err)
}
if jsonFile == nil {
t.Fatal("jsonFile should not be nil")
}
assert.Equal(t, jsonFile.FileName(), outputName)
assert.Truef(t, fs.FileExists(jsonFile.FileName()), "output file does not exist: %s", jsonFile.FileName())
assert.False(t, jsonFile.IsJpeg())
assert.False(t, jsonFile.IsMedia())
assert.False(t, jsonFile.IsVideo())
assert.True(t, jsonFile.IsSidecar())
_ = os.Remove(outputName)
})
t.Run("iphone_7.heic", func(t *testing.T) {
fileName := conf.ExamplesPath() + "/iphone_7.heic"
outputName := conf.ExamplesPath() + "/iphone_7.json"
assert.True(t, fs.FileExists(fileName))
assert.True(t, fs.FileExists(outputName))
mf, err := NewMediaFile(fileName)
if err != nil {
t.Fatal(err)
}
jsonFile, err := convert.ToJson(mf)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, jsonFile.FileName(), outputName)
assert.Truef(t, fs.FileExists(jsonFile.FileName()), "output file does not exist: %s", jsonFile.FileName())
assert.False(t, jsonFile.IsJpeg())
assert.False(t, jsonFile.IsMedia())
assert.False(t, jsonFile.IsVideo())
assert.True(t, jsonFile.IsSidecar())
})
}
func TestConvert_Start(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")

View file

@ -116,7 +116,7 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
mf, err := NewMediaFile(fileName)
if err != nil || !mf.IsPhoto() {
if err != nil || !mf.IsMedia() {
return nil
}

View file

@ -125,7 +125,7 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
mf, err := NewMediaFile(fileName)
if err != nil || !mf.IsPhoto() {
if err != nil || !mf.IsMedia() {
return nil
}

View file

@ -135,6 +135,10 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
photo.PhotoPath = filePath
photo.PhotoName = fileBase
if m.IsVideo() {
photo.PhotoVideo = true
}
if !file.FilePrimary {
if photoExists {
if q := ind.db.Where("file_type = 'jpg' AND file_primary = 1 AND photo_id = ?", photo.ID).First(&primaryFile); q.Error != nil {

View file

@ -34,6 +34,14 @@ func IndexWorker(jobs <-chan IndexJob) {
}
}
if ind.conf.WriteJson() && !f.HasJson() {
if converted, err := ind.convert.ToJson(f); err != nil {
log.Errorf("index: creating jpeg failed (%s)", err.Error())
} else {
related.Files = append(related.Files, converted)
}
}
res := ind.MediaFile(f, opt, "")
done[f.FileName()] = true

View file

@ -24,17 +24,17 @@ import (
// MediaFile represents a single photo, video or sidecar file.
type MediaFile struct {
fileName string
fileType fs.FileType
mimeType string
dateCreated time.Time
hash string
checksum string
width int
height int
once sync.Once
metaData meta.Data
location *entity.Location
fileName string
fileType fs.FileType
mimeType string
dateCreated time.Time
hash string
checksum string
width int
height int
metaData meta.Data
metaDataOnce sync.Once
location *entity.Location
}
// NewMediaFile returns a new media file.
@ -52,7 +52,7 @@ func NewMediaFile(fileName string) (*MediaFile, error) {
}
// Stat returns the media file size and modification time.
func (m MediaFile) Stat() (size int64, mod time.Time) {
func (m *MediaFile) Stat() (size int64, mod time.Time) {
s, err := os.Stat(m.FileName())
if err != nil {
@ -301,6 +301,8 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
result.Main = resultFile
} else if resultFile.IsImageOther() {
result.Main = resultFile
} else if resultFile.IsVideo() {
result.Main = resultFile
}
result.Files = append(result.Files, resultFile)
@ -312,7 +314,7 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
}
// FileName returns the filename.
func (m MediaFile) FileName() string {
func (m *MediaFile) FileName() string {
return m.fileName
}
@ -322,12 +324,12 @@ func (m *MediaFile) SetFileName(fileName string) {
}
// RelativeName returns the relative filename.
func (m MediaFile) RelativeName(directory string) string {
func (m *MediaFile) RelativeName(directory string) string {
return fs.RelativeName(m.fileName, directory)
}
// RelativePath returns the relative path without filename.
func (m MediaFile) RelativePath(directory string) string {
func (m *MediaFile) RelativePath(directory string) string {
pathname := m.fileName
if i := strings.Index(pathname, directory); i == 0 {
@ -348,7 +350,7 @@ func (m MediaFile) RelativePath(directory string) string {
}
// RelativeBase returns the relative filename.
func (m MediaFile) RelativeBase(directory string, stripSequence bool) string {
func (m *MediaFile) RelativeBase(directory string, stripSequence bool) string {
if relativePath := m.RelativePath(directory); relativePath != "" {
return filepath.Join(relativePath, m.Base(stripSequence))
}
@ -357,17 +359,17 @@ func (m MediaFile) RelativeBase(directory string, stripSequence bool) string {
}
// Directory returns the directory
func (m MediaFile) Directory() string {
func (m *MediaFile) Directory() string {
return filepath.Dir(m.fileName)
}
// Base returns the filename base without any extensions and path.
func (m MediaFile) Base(stripSequence bool) string {
func (m *MediaFile) Base(stripSequence bool) string {
return fs.Base(m.FileName(), stripSequence)
}
// AbsBase returns the directory and base filename without any extensions.
func (m MediaFile) AbsBase(stripSequence bool) string {
func (m *MediaFile) AbsBase(stripSequence bool) string {
return fs.AbsBase(m.FileName(), stripSequence)
}
@ -392,18 +394,18 @@ func (m *MediaFile) openFile() (*os.File, error) {
}
// Exists checks if a media file exists by filename.
func (m MediaFile) Exists() bool {
func (m *MediaFile) Exists() bool {
return fs.FileExists(m.FileName())
}
// Remove a media file.
func (m MediaFile) Remove() error {
func (m *MediaFile) Remove() error {
return os.Remove(m.FileName())
}
// HasSameName compares a media file with another media file and returns if
// their filenames are matching or not.
func (m MediaFile) HasSameName(f *MediaFile) bool {
func (m *MediaFile) HasSameName(f *MediaFile) bool {
if f == nil {
return false
}
@ -465,12 +467,12 @@ func (m *MediaFile) Copy(destinationFilename string) error {
}
// Extension returns the filename extension of this media file.
func (m MediaFile) Extension() string {
func (m *MediaFile) Extension() string {
return strings.ToLower(filepath.Ext(m.fileName))
}
// IsJpeg return true if this media file is a JPEG image.
func (m MediaFile) IsJpeg() bool {
func (m *MediaFile) IsJpeg() bool {
// Don't import/use existing thumbnail files (we create our own)
if m.Extension() == ".thm" {
return false
@ -479,18 +481,23 @@ func (m MediaFile) IsJpeg() bool {
return m.MimeType() == fs.MimeTypeJpeg
}
// IsJson return true if this media file is a json sidecar file.
func (m *MediaFile) IsJson() bool {
return m.HasFileType(fs.TypeJson)
}
// FileType returns the file type (jpg, gif, tiff,...).
func (m MediaFile) FileType() fs.FileType {
func (m *MediaFile) FileType() fs.FileType {
return fs.GetFileType(m.fileName)
}
// MediaType returns the media type (video, image, raw, sidecar,...).
func (m MediaFile) MediaType() fs.MediaType {
func (m *MediaFile) MediaType() fs.MediaType {
return fs.GetMediaType(m.fileName)
}
// HasFileType returns true if this media file is of a given type.
func (m MediaFile) HasFileType(t fs.FileType) bool {
// HasFileType returns true if this is the given type.
func (m *MediaFile) HasFileType(t fs.FileType) bool {
if t == fs.TypeJpeg {
return m.IsJpeg()
}
@ -498,23 +505,23 @@ func (m MediaFile) HasFileType(t fs.FileType) bool {
return m.FileType() == t
}
// IsRaw returns true if this media file a RAW file.
func (m MediaFile) IsRaw() bool {
// IsRaw returns true if this is a RAW file.
func (m *MediaFile) IsRaw() bool {
return m.HasFileType(fs.TypeRaw)
}
// IsPng returns true if this media file a PNG file.
func (m MediaFile) IsPng() bool {
// IsPng returns true if this is a PNG file.
func (m *MediaFile) IsPng() bool {
return m.HasFileType(fs.TypePng)
}
// IsTiff returns true if this media file a TIFF file.
func (m MediaFile) IsTiff() bool {
// IsTiff returns true if this is a TIFF file.
func (m *MediaFile) IsTiff() bool {
return m.HasFileType(fs.TypeTiff)
}
// IsImageOther returns true this media file a PNG, GIF, BMP or TIFF file.
func (m MediaFile) IsImageOther() bool {
// IsImageOther returns true if this is a PNG, GIF, BMP or TIFF file.
func (m *MediaFile) IsImageOther() bool {
switch m.FileType() {
case fs.TypeBitmap:
return true
@ -529,31 +536,36 @@ func (m MediaFile) IsImageOther() bool {
}
}
// IsHEIF returns true if this media file is a High Efficiency Image File Format file.
func (m MediaFile) IsHEIF() bool {
// IsHEIF returns true if this is a High Efficiency Image File Format file.
func (m *MediaFile) IsHEIF() bool {
return m.HasFileType(fs.TypeHEIF)
}
// IsXMP returns true if this file is a XMP sidecar file.
func (m MediaFile) IsXMP() bool {
// IsXMP returns true if this is a XMP sidecar file.
func (m *MediaFile) IsXMP() bool {
return m.FileType() == fs.TypeXMP
}
// IsSidecar returns true if this media file is a sidecar file (containing metadata).
func (m MediaFile) IsSidecar() bool {
// IsSidecar returns true if this is a sidecar file (containing metadata).
func (m *MediaFile) IsSidecar() bool {
return m.MediaType() == fs.MediaSidecar
}
// IsVideo returns true if this media file is a video file.
func (m MediaFile) IsVideo() bool {
// IsVideo returns true if this is a video file.
func (m *MediaFile) IsVideo() bool {
return m.MediaType() == fs.MediaVideo
}
// IsPhoto checks if this media file is a photo / image.
func (m MediaFile) IsPhoto() bool {
// IsPhoto returns true if this file is a photo / image.
func (m *MediaFile) IsPhoto() bool {
return m.IsJpeg() || m.IsRaw() || m.IsHEIF() || m.IsImageOther()
}
// IsMedia returns true if this is a media file (photo or video, not sidecar or other).
func (m *MediaFile) IsMedia() bool {
return m.IsJpeg() || m.IsVideo() || m.IsRaw() || m.IsHEIF() || m.IsImageOther()
}
// Jpeg returns a the JPEG version of an image or sidecar file (if exists).
func (m *MediaFile) Jpeg() (*MediaFile, error) {
if m.IsJpeg() {
@ -573,7 +585,7 @@ func (m *MediaFile) Jpeg() (*MediaFile, error) {
return NewMediaFile(jpegFilename)
}
// HasJpeg returns false if there is no jpeg representation of this media file.
// HasJpeg returns true if this file has or is a jpeg media file.
func (m *MediaFile) HasJpeg() bool {
if m.IsJpeg() {
return true
@ -582,6 +594,15 @@ func (m *MediaFile) HasJpeg() bool {
return fs.TypeJpeg.Find(m.FileName(), false) != ""
}
// HasJson returns true if this file has or is a json sidecar file.
func (m *MediaFile) HasJson() bool {
if m.IsJson() {
return true
}
return fs.TypeJson.Find(m.FileName(), false) != ""
}
func (m *MediaFile) decodeDimensions() error {
if !m.IsPhoto() {
return fmt.Errorf("not a photo: %s", m.FileName())

View file

@ -6,6 +6,6 @@ import (
// MetaData returns exif meta data of a media file.
func (m *MediaFile) MetaData() (result meta.Data, err error) {
m.once.Do(func() { m.metaData, err = meta.Exif(m.FileName()) })
m.metaDataOnce.Do(func() { m.metaData, err = meta.Exif(m.FileName()) })
return m.metaData, err
}

View file

@ -63,7 +63,6 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchPhotosArchive(v1, conf)
api.BatchPhotosRestore(v1, conf)
api.BatchPhotosPrivate(v1, conf)
api.BatchPhotosStory(v1, conf)
api.BatchAlbumsDelete(v1, conf)
api.BatchLabelsDelete(v1, conf)

View file

@ -133,7 +133,7 @@ func (s *Sync) download(a entity.Account) (complete bool, err error) {
mf, err := photoprism.NewMediaFile(baseDir + file.RemoteName)
if err != nil || !mf.IsPhoto() {
if err != nil || !mf.IsMedia() {
continue
}

View file

@ -17,6 +17,11 @@ func TestGetMediaType(t *testing.T) {
assert.Equal(t, MediaRaw, result)
})
t.Run("video", func(t *testing.T) {
result := GetMediaType("testdata/gopher.mp4")
assert.Equal(t, MediaVideo, result)
})
t.Run("sidecar", func(t *testing.T) {
result := GetMediaType("/IMG_4120.AAE")
assert.Equal(t, MediaSidecar, result)