精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package exif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/externaltools/exiftool"
|
||||
)
|
||||
|
||||
var globalExifParser *exiftool.Exiftool
|
||||
var globalInit sync.Once
|
||||
|
||||
func Initialize() (func(), error) {
|
||||
var err error
|
||||
globalInit.Do(func() {
|
||||
globalExifParser, err = exiftool.New()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info(nil, "Found exiftool.", "binary_path", globalExifParser.BinaryPath(), "version", globalExifParser.Version())
|
||||
|
||||
return func() {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := globalExifParser.Close(); err != nil {
|
||||
log.Error(nil, "Cleanup exiftool error", "error", err)
|
||||
return
|
||||
}
|
||||
globalExifParser = nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
var globalMu sync.Mutex
|
||||
|
||||
func Parse(filepath string) (*models.MediaEXIF, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return nil, fmt.Errorf("no exif parser initialized")
|
||||
}
|
||||
|
||||
var values struct {
|
||||
exiftool.PhotoMeta
|
||||
exiftool.TimeAll
|
||||
exiftool.GPS
|
||||
}
|
||||
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values.PhotoMeta.SanitizeFloats()
|
||||
|
||||
ret := models.MediaEXIF{
|
||||
Camera: values.Model,
|
||||
Maker: values.Make,
|
||||
Lens: values.LensModel,
|
||||
Iso: values.ISO,
|
||||
Flash: values.Flash,
|
||||
Orientation: values.Orientation,
|
||||
ExposureProgram: values.ExposureProgram,
|
||||
Exposure: values.ExposureTime,
|
||||
Aperture: values.Aperture,
|
||||
FocalLength: values.FocalLength,
|
||||
Description: values.ImageDescription,
|
||||
}
|
||||
|
||||
dateShot := values.TimeAll.TimeInLocal()
|
||||
if !dateShot.IsZero() {
|
||||
ret.DateShot = new(dateShot)
|
||||
}
|
||||
|
||||
offsetSec, ok := values.TimeAll.OffsetSecs(dateShot)
|
||||
if ok {
|
||||
ret.OffsetSecShot = &offsetSec
|
||||
}
|
||||
|
||||
if values.GPS.IsValid() {
|
||||
ret.GPSLatitude = values.GPS.GPSLatitude
|
||||
ret.GPSLongitude = values.GPS.GPSLongitude
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func MIMEType(filepath string) (string, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return "", fmt.Errorf("no exif parser initialized")
|
||||
}
|
||||
|
||||
var mime exiftool.MIMEType
|
||||
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &mime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if mime.MIMEType == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return *mime.MIMEType, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package exif
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
func resetForTest() {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
if globalExifParser != nil {
|
||||
_ = globalExifParser.Close()
|
||||
}
|
||||
globalExifParser = nil
|
||||
// Allow Initialize() to run again in subsequent tests
|
||||
globalInit = sync.Once{}
|
||||
}
|
||||
|
||||
func TestParseWithoutInit(t *testing.T) {
|
||||
resetForTest()
|
||||
if _, err := Parse("./test_data/bird.jpg"); err == nil {
|
||||
t.Fatalf("Parse() without Init() doesn't return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
cleanup, err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
filename := "./test_data/bird.jpg"
|
||||
|
||||
metadata, err := Parse(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() returns an error: %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Errorf("Parse(%q) should not return nil", filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMIMEType(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
cleanup, err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
filename := "./test_data/bird.jpg"
|
||||
|
||||
mime, err := MIMEType(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("MIMEType() returns an error: %v", err)
|
||||
}
|
||||
|
||||
if mime == "" {
|
||||
t.Errorf("MIMEType(%q) should not return an empty string", filename)
|
||||
}
|
||||
}
|
||||
|
||||
func fileModifyDateLiteralInUTC(t *testing.T, file string) time.Time {
|
||||
fstat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Stat(%q) error: %v", file, err)
|
||||
}
|
||||
|
||||
ret := fstat.ModTime().Truncate(time.Second)
|
||||
_, offset := ret.Zone()
|
||||
ret = ret.Add(time.Duration(offset) * time.Second).UTC()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func mustParseNoTimeZone(t *testing.T, timeStr string) time.Time {
|
||||
layout := "2006:01:02 15:04:05.999"
|
||||
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("time.Parse(%q) error: %v", timeStr, err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestSamplesTime(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
cleanup, err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
file string
|
||||
wantTime time.Time
|
||||
wantOffsetSec *int
|
||||
}{
|
||||
{"./test_data/sample1.heif", fileModifyDateLiteralInUTC(t, "./test_data/sample1.heif"), nil},
|
||||
{"./test_data/sample1_nef.jpg", mustParseNoTimeZone(t, "2008:03:15 07:44:21.49"), new(-7 * 60 * 60)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
metadata, err := Parse(tc.file)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q) returns an error: %v", tc.file, err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatalf("metadata == nil")
|
||||
}
|
||||
if metadata.DateShot == nil {
|
||||
t.Fatalf("metadata.DateShot == nil")
|
||||
}
|
||||
|
||||
if got := *metadata.DateShot; !got.Equal(tc.wantTime) {
|
||||
t.Errorf("metadata.DateShot = %v, want: %v", got, tc.wantTime)
|
||||
}
|
||||
|
||||
if tc.wantOffsetSec == nil {
|
||||
if got := metadata.OffsetSecShot; got != nil {
|
||||
t.Errorf("metadata.OffsetSecShot = %+v, want: nil", got)
|
||||
}
|
||||
} else {
|
||||
if got, want := *metadata.OffsetSecShot, *tc.wantOffsetSec; got != want {
|
||||
t.Errorf("metadata.OffsetSecShot = &(%v), want: &(%v)", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Exiftool launches an external `exiftool` process to query photos' exif info.
|
||||
// It doesn't support concurrency usage.
|
||||
type Exiftool struct {
|
||||
path string
|
||||
version string
|
||||
marker string
|
||||
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdinBuf *bufio.Writer
|
||||
stdout *MarkReader
|
||||
stderr *MarkReader
|
||||
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
const marker = "{ready}\n"
|
||||
const bufferSize = 10240
|
||||
|
||||
// New returns a new instance of Exiftool.
|
||||
func New() (*Exiftool, error) {
|
||||
path, err := exec.LookPath("exiftool")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
voutput, err := exec.Command(path, "-ver").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run `exiftool -ver` error: %w", err)
|
||||
}
|
||||
|
||||
version := string(bytes.Trim(voutput, " \t\n\r"))
|
||||
if version == "" {
|
||||
return nil, fmt.Errorf("run `exiftool -ver` error: no output")
|
||||
}
|
||||
|
||||
cmd := exec.Command(path, "-stay_open", "True", "-@", "-")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdin error: %w", err)
|
||||
}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdout error: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := NewMarkReader(stdoutPipe, bufferSize, marker)
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stderr error: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := NewMarkReader(stderrPipe, bufferSize, marker)
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("run `exiftool -stay_open True -@ -` error: %w", err)
|
||||
}
|
||||
|
||||
return &Exiftool{
|
||||
path: path,
|
||||
version: version,
|
||||
marker: marker,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdinBuf: bufio.NewWriterSize(stdin, bufferSize),
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BinaryPath returns the path of `exiftool` binary.
|
||||
func (e *Exiftool) BinaryPath() string {
|
||||
return e.path
|
||||
}
|
||||
|
||||
// Version returns the version of `exiftool` binary.
|
||||
func (e *Exiftool) Version() string {
|
||||
return e.version
|
||||
}
|
||||
|
||||
// Close stops the external process of `exiftool`.
|
||||
func (e *Exiftool) Close() (err error) {
|
||||
e.closeOnce.Do(func() {
|
||||
defer e.cmd.Wait()
|
||||
defer e.stdin.Close()
|
||||
|
||||
if err = e.rawSendCommand("-stay_open", "False"); err != nil {
|
||||
_ = e.cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawSendCommand(args ...string) error {
|
||||
e.stdout.Reset()
|
||||
e.stderr.Reset()
|
||||
|
||||
for _, arg := range args {
|
||||
if _, err := e.stdinBuf.WriteString(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.stdinBuf.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range []string{"-echo4\n", "{ready}\n", "-execute\n"} {
|
||||
if _, err := e.stdinBuf.WriteString(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.stdinBuf.Flush()
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawReadStderr() error {
|
||||
stderrOutput, err := io.ReadAll(e.stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(stderrOutput, " \n\t\r"))
|
||||
if outStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(outStr)
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawGetTags(v any, args ...string) (err error) {
|
||||
if err = e.rawSendCommand(append(args, "-json")...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
if err = json.NewDecoder(e.stdout).Decode(v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawSaveEmbedFile(outputPath string, args ...string) (hasEmbededFile bool, err error) {
|
||||
if err = e.rawSendCommand(append(args, "-b", "-W", outputPath)...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
if output, err = io.ReadAll(e.stdout); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(output, " \n\r\t"))
|
||||
switch outStr {
|
||||
case "0 output files created":
|
||||
return
|
||||
case "1 output files created":
|
||||
default:
|
||||
err = fmt.Errorf("invalid output: %s", outStr)
|
||||
return
|
||||
}
|
||||
|
||||
if err = e.rawReadStderr(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hasEmbededFile = true
|
||||
return
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawUpdateFile(args ...string) (err error) {
|
||||
if err = e.rawSendCommand(args...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
if output, err = io.ReadAll(e.stdout); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(output, " \n\r\t"))
|
||||
if outStr != "1 image files updated" {
|
||||
err = fmt.Errorf("invalid output: %s", outStr)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// QueryJSONTagsByNumber queries the exif info of `file` with a given struct `value`. Tags are fields of the `value`. The values are a number if possible.
|
||||
// See values.go for example structs.
|
||||
func (e *Exiftool) QueryJSONTagsByNumber(file string, value any) error {
|
||||
rows := []any{value}
|
||||
if err := e.rawGetTags(&rows, "-n", file); err != nil {
|
||||
return fmt.Errorf("query %q tags error: %w", file, err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
return fmt.Errorf("query %q tags error: return %d responses, should be only 1", file, len(rows))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveJPEGPreview saves a preview jpeg from `src` to `previewOutput`.
|
||||
func (e *Exiftool) SaveJPEGPreview(src string, previewOutput string) (bool, error) {
|
||||
saved, err := e.rawSaveEmbedFile(previewOutput, "-JpgFromRaw", src)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("save jpeg preview for %q error: %w", src, err)
|
||||
}
|
||||
|
||||
if !saved {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err = e.rawUpdateFile("-TagsFromFile", src, "-overwrite_original", previewOutput); err != nil {
|
||||
return false, fmt.Errorf("save tags to jpeg preview for %q error: %w", src, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
func TestExiftool(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("create instance error: %v", err)
|
||||
}
|
||||
|
||||
t.Log("bin:", instance.BinaryPath())
|
||||
t.Log("version:", instance.Version())
|
||||
|
||||
if instance.BinaryPath() == "" {
|
||||
t.Errorf("want exiftool binary, but got an empty string")
|
||||
}
|
||||
|
||||
if instance.Version() == "" {
|
||||
t.Errorf("want exiftool version, but got an emtpy string")
|
||||
}
|
||||
|
||||
if err := instance.Close(); err != nil {
|
||||
t.Errorf("close instance error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryJSONTagsWithEmbed(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
file := "./test_data/correct_gps.jpg"
|
||||
var value struct {
|
||||
TimeAll
|
||||
MIMEType
|
||||
PhotoMeta
|
||||
}
|
||||
|
||||
if err := instance.QueryJSONTagsByNumber(file, &value); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", file, err)
|
||||
return
|
||||
}
|
||||
|
||||
if time := value.TimeAll.TimeInLocal(); time.IsZero() {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid TimeAll", file)
|
||||
}
|
||||
if value.MIMEType.MIMEType == nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid MIMEType", file)
|
||||
}
|
||||
if value.PhotoMeta.Model == nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid PhotoMeta", file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryMIMEType(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
want string
|
||||
}{
|
||||
{"./test_data/no_timezone.jpg", "image/jpeg"},
|
||||
{"./test_data/subsec_timezone.heic", "image/heic"},
|
||||
{"./test_data/raw_with_preview_jpg.cr3", "image/x-canon-cr3"},
|
||||
{"./test_data/no_exif.jpg", "image/jpeg"},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ MIMEType }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
if got := value.MIMEType.MIMEType; got == nil || *got != tc.want {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) = %v, want: %q", tc.file, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkTimeallFieldsHasValue(t *testing.T, time TimeAll, fields []string) {
|
||||
t.Helper()
|
||||
|
||||
tv := reflect.ValueOf(time)
|
||||
|
||||
for _, field := range fields {
|
||||
fv := tv.FieldByName(field)
|
||||
if !fv.IsValid() {
|
||||
t.Errorf("can't find field %q in TimeAll", field)
|
||||
continue
|
||||
}
|
||||
|
||||
if fv.IsNil() || fv.Elem().IsZero() {
|
||||
t.Errorf("field %q is type %T with value %v", field, fv.Type(), fv.Interface())
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryTimeAllHasOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantKeys []string
|
||||
wantTime time.Time
|
||||
wantOffsetSec int
|
||||
}{
|
||||
{"./test_data/raw_with_preview_jpg.cr3", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"MediaCreateDate",
|
||||
"OffsetTime",
|
||||
"OffsetTimeOriginal",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
"TimeZone",
|
||||
"TrackCreateDate",
|
||||
}, mustParseInUTC(t, "2019:09:13 14:36:48.87"), 7200},
|
||||
{"./test_data/subsec_timezone.heic", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"OffsetTime",
|
||||
"OffsetTimeOriginal",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), 3600},
|
||||
{"./test_data/createdate_timezone_separate.jpg", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"TimeZone",
|
||||
"FileModifyDate",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2008:03:15 07:44:21.49"), -7 * 60 * 60},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ TimeAll }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
|
||||
|
||||
gotTime := value.TimeAll.TimeInLocal()
|
||||
if !gotTime.Equal(tc.wantTime) {
|
||||
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
|
||||
}
|
||||
|
||||
if got, ok := value.TimeAll.OffsetSecs(gotTime); !ok || got != tc.wantOffsetSec {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (%v, %v), want: (%v, true)", got, ok, tc.wantOffsetSec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fileModifyDateLiteralUTC(t *testing.T, file string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Dir(file)
|
||||
file = filepath.Base(file)
|
||||
|
||||
fsys := os.DirFS(dir)
|
||||
info, err := fs.Stat(fsys, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read file stat error: %v", err)
|
||||
}
|
||||
|
||||
ret := info.ModTime().Truncate(time.Second)
|
||||
|
||||
_, offsetSec := ret.Zone()
|
||||
ret = ret.Add(time.Duration(offsetSec) * time.Second).UTC()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestExiftoolQueryTimeAllNoOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantKeys []string
|
||||
wantTime time.Time
|
||||
wantHasOffset bool
|
||||
wantOffset int
|
||||
}{
|
||||
{"./test_data/no_timezone.jpg", []string{
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
}, mustParseInUTC(t, "2012:05:06 15:39:44"), false, 0},
|
||||
{"./test_data/subsec_no_timezone.heic", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"OffsetTime",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), true, 3600},
|
||||
{"./test_data/no_exif.jpg", []string{
|
||||
"FileModifyDate",
|
||||
}, fileModifyDateLiteralUTC(t, "./test_data/no_exif.jpg"), false, 0},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ TimeAll }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
|
||||
|
||||
gotTime := value.TimeAll.TimeInLocal()
|
||||
if !gotTime.Equal(tc.wantTime) {
|
||||
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
|
||||
}
|
||||
|
||||
gotOffset, gotHasOffset := value.TimeAll.OffsetSecs(gotTime)
|
||||
if gotHasOffset != tc.wantHasOffset {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (_, %v), want: (_, %v)", gotHasOffset, tc.wantHasOffset)
|
||||
}
|
||||
if !gotHasOffset {
|
||||
return
|
||||
}
|
||||
if gotOffset != tc.wantOffset {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (%v, _), want: (%v, _)", gotOffset, tc.wantHasOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryGPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
hasGPS bool
|
||||
wantLat, wantLong float64
|
||||
}{
|
||||
{"./test_data/correct_gps.jpg", true, 44.4789972, 11.2979222},
|
||||
{"./test_data/incorrect_gps.jpg", false, 0, 0},
|
||||
{"./test_data/no_exif.jpg", false, 0, 0},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ GPS }
|
||||
if err := instance.QueryJSONTagsByNumber(tc.file, &value); err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
hasGPS := value.GPS.IsValid()
|
||||
if hasGPS != tc.hasGPS {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) has GPS: %v, want GPS: %v", tc.file, hasGPS, tc.hasGPS)
|
||||
return
|
||||
}
|
||||
if !tc.hasGPS {
|
||||
return
|
||||
}
|
||||
|
||||
gpsToString := func(latitude, longitude float64) string {
|
||||
return fmt.Sprintf("(%.7f, %.7f)", latitude, longitude)
|
||||
}
|
||||
|
||||
lat := *value.GPS.GPSLatitude
|
||||
long := *value.GPS.GPSLongitude
|
||||
if got, want := gpsToString(lat, long), gpsToString(tc.wantLat, tc.wantLong); got != want {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) = %s, want: %s", tc.file, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolSaveJPEGPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantOK bool
|
||||
}{
|
||||
{"./test_data/raw_with_preview_jpg.cr3", true},
|
||||
{"./test_data/no_timezone.jpg", false},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
outputDir := t.TempDir()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
output := filepath.Join(outputDir, "preview.jpg")
|
||||
ok, err := instance.SaveJPEGPreview(tc.file, output)
|
||||
if err != nil {
|
||||
t.Errorf("SaveJPEGPreview(%q, %q) error: %v", tc.file, output, err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("SaveJPEGPreview(%q, %q) = %v, want: %v", tc.file, output, ok, tc.wantOK)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var jpg struct {
|
||||
MIMEType
|
||||
TimeAll
|
||||
}
|
||||
if err := instance.QueryJSONTagsByNumber(output, &jpg); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", output, err)
|
||||
return
|
||||
}
|
||||
|
||||
if got, want := jpg.MIMEType.MIMEType, "image/jpeg"; got == nil || *got != want {
|
||||
t.Errorf("MIMEType(%q) = %v, want: %q", output, got, want)
|
||||
return
|
||||
}
|
||||
|
||||
var raw struct{ TimeAll }
|
||||
if err := instance.QueryJSONTagsByNumber(tc.file, &raw); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
}
|
||||
|
||||
if got, want := jpg.TimeAll.TimeInLocal(), raw.TimeAll.TimeInLocal(); !got.Equal(want) {
|
||||
t.Errorf("jpg.TimeAll.TimeInLocal() = %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestExiftoolError(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
tests := []struct {
|
||||
file string
|
||||
errStr string
|
||||
}{
|
||||
{"./test_data/non_exist.jpg", "File not found"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
checkErr := func(err error, fmtStr string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
if want := tc.errStr; err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf(fmtStr+" %v, want %v", append(args, err, want)...)
|
||||
}
|
||||
}
|
||||
|
||||
var value MIMEType
|
||||
err = instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
checkErr(err, "QueryJSONTagsByNumber(%q)", tc.file)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "output.jpg")
|
||||
_, err = instance.SaveJPEGPreview(tc.file, output)
|
||||
checkErr(err, "SaveJPEGPreview(%q, %q)", tc.file, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// MarkReader reads from upstream and stops at marker boundaries.
|
||||
type MarkReader struct {
|
||||
upstream io.Reader
|
||||
buf []byte
|
||||
mark []byte
|
||||
|
||||
// valid data: buf[start:pending]
|
||||
// pending, need to check if contains mark: buf[pending:end]
|
||||
start int
|
||||
pending int
|
||||
end int
|
||||
|
||||
// if true, mark begins from buf[pending]
|
||||
hasMark bool
|
||||
// if true, upstream is EOF
|
||||
upstreamEOF bool
|
||||
// if true, this reader returns EOF
|
||||
paused bool
|
||||
}
|
||||
|
||||
// NewMarkReader creates a reader that stops at each marker and requires Reset to continue.
|
||||
func NewMarkReader(upstream io.Reader, bufferSize int, mark string) (*MarkReader, error) {
|
||||
if bufferSize < 2*len(mark) {
|
||||
return nil, errors.New("buffer too small")
|
||||
}
|
||||
|
||||
return &MarkReader{
|
||||
upstream: upstream,
|
||||
buf: make([]byte, bufferSize),
|
||||
mark: []byte(mark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Reset resumes reading after a previously encountered marker boundary.
|
||||
func (r *MarkReader) Reset() {
|
||||
r.paused = false
|
||||
}
|
||||
|
||||
// Read returns bytes up to (but excluding) the next marker, then reports EOF until Reset.
|
||||
func (r *MarkReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if r.paused {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if r.start == r.pending {
|
||||
// compact buffer
|
||||
copy(r.buf[0:], r.buf[r.start:r.end])
|
||||
r.end -= r.start
|
||||
r.pending -= r.start
|
||||
r.start = 0
|
||||
|
||||
// fill buffer
|
||||
n, err := io.ReadAtLeast(r.upstream, r.buf[r.end:], len(r.mark))
|
||||
r.end += n
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
r.upstreamEOF = true
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
r.checkMarkInPending()
|
||||
}
|
||||
|
||||
// read from valid data
|
||||
n := copy(p, r.buf[r.start:r.pending])
|
||||
r.start += n
|
||||
|
||||
if r.start == r.pending {
|
||||
switch {
|
||||
case r.hasMark:
|
||||
r.pending += len(r.mark)
|
||||
r.start += len(r.mark)
|
||||
r.hasMark = false
|
||||
r.paused = true
|
||||
|
||||
r.checkMarkInPending()
|
||||
case r.upstreamEOF:
|
||||
r.paused = r.start == r.end
|
||||
}
|
||||
}
|
||||
|
||||
if r.paused {
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *MarkReader) checkMarkInPending() {
|
||||
if markAt := bytes.Index(r.buf[r.pending:r.end], r.mark); markAt >= 0 {
|
||||
r.hasMark = true
|
||||
r.pending = r.pending + markAt
|
||||
return
|
||||
}
|
||||
|
||||
if r.upstreamEOF {
|
||||
r.pending = r.end
|
||||
return
|
||||
}
|
||||
|
||||
r.pending = max(r.pending, r.end-len(r.mark)+1)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
func readAllString(t *testing.T, r io.Reader) string {
|
||||
t.Helper()
|
||||
|
||||
var data []byte
|
||||
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
var n int
|
||||
var err error
|
||||
|
||||
var memstats runtime.MemStats
|
||||
runtime.ReadMemStats(&memstats)
|
||||
mallocs := 0 - memstats.Mallocs
|
||||
|
||||
n, err = r.Read(buf)
|
||||
|
||||
runtime.ReadMemStats(&memstats)
|
||||
mallocs += memstats.Mallocs
|
||||
|
||||
if mallocs > 0 {
|
||||
t.Errorf("r.Read() allocs %d bytes", mallocs)
|
||||
}
|
||||
|
||||
data = append(data, buf[:n]...)
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
t.Error("read error, return 0 byte without errors")
|
||||
}
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestNewMarkReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bufferSize int
|
||||
mark string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "BufferTooSmall",
|
||||
bufferSize: 5,
|
||||
mark: "abcd",
|
||||
wantErr: "buffer too small",
|
||||
},
|
||||
{
|
||||
name: "TwiceMarkLen",
|
||||
bufferSize: 8,
|
||||
mark: "abcd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewMarkReader(strings.NewReader("x"), tc.bufferSize, tc.mark)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("NewMarkReader() returns error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil || err.Error() != tc.wantErr {
|
||||
t.Errorf("err = %v, want: %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mark string
|
||||
input string
|
||||
bufferSize int
|
||||
wantSegments []string
|
||||
}{
|
||||
{
|
||||
name: "CrossRead",
|
||||
mark: "<<MARK>>",
|
||||
input: "hello<<MARK>>world",
|
||||
bufferSize: 2 * 8, /* len(mark) = 8 */
|
||||
wantSegments: []string{"hello", "world"},
|
||||
},
|
||||
{
|
||||
name: "EndWithMark",
|
||||
mark: "<<MARK>>",
|
||||
input: "foo<<MARK>>bar<<MARK>>",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "TrailingPrefixMark",
|
||||
mark: "<<MARK>>",
|
||||
input: "foo<<MARK>>bar<<MAR",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"foo", "bar<<MAR"},
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
mark: "<<MARK>>",
|
||||
input: "<<MARK>><<MARK>>abc<<MARK>>",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"", "", "abc"},
|
||||
},
|
||||
{
|
||||
name: "LongMark",
|
||||
mark: "01234567890123456789",
|
||||
input: "a01234567890123456789b",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "RepeatMark",
|
||||
mark: "aaaaa", /* a * 5 */
|
||||
input: "0aaaaa1aaa2aaaa3aaaaa4aaaaaa5",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"0", "1aaa2aaaa3", "4", "a5"},
|
||||
},
|
||||
{
|
||||
name: "OneByteMark",
|
||||
mark: "|",
|
||||
input: "0|1|234|5|6",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"0", "1", "234", "5", "6"},
|
||||
},
|
||||
}
|
||||
|
||||
readWraper := []struct {
|
||||
name string
|
||||
wrap func(r io.Reader) io.Reader
|
||||
}{
|
||||
{"Original", func(r io.Reader) io.Reader { return r }},
|
||||
{"Half", iotest.HalfReader},
|
||||
{"OneByte", iotest.OneByteReader},
|
||||
}
|
||||
|
||||
for _, wrapper := range readWraper {
|
||||
t.Run(wrapper.name, func(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
up := wrapper.wrap(strings.NewReader(tc.input))
|
||||
|
||||
r, err := NewMarkReader(up, tc.bufferSize, tc.mark)
|
||||
if err != nil {
|
||||
t.Fatal("NewMarkReader() returns error:", err)
|
||||
}
|
||||
|
||||
for i, want := range tc.wantSegments {
|
||||
if got := readAllString(t, r); got != want {
|
||||
t.Errorf("segment[%d] = %q, want: %q", i, got, want)
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
if _, err := r.Read(buf[:]); err != io.EOF {
|
||||
t.Error("r.Read() after readAllString() returns non-EOF error:", err)
|
||||
}
|
||||
|
||||
r.Reset()
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
if _, err := r.Read(buf[:]); err != io.EOF {
|
||||
t.Error("r.Read() after all segments returns non-EOF error:", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GPS stores gps-related tags.
|
||||
type GPS struct {
|
||||
GPSLatitude *float64
|
||||
GPSLongitude *float64
|
||||
}
|
||||
|
||||
// IsValid returns true when GPS data is valid.
|
||||
func (gps GPS) IsValid() bool {
|
||||
if gps.GPSLongitude == nil || gps.GPSLatitude == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.IsNaN(*gps.GPSLatitude) {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.IsNaN(*gps.GPSLongitude) {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.Abs(*gps.GPSLatitude) > 90 || math.Abs(*gps.GPSLongitude) > 180 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (gps GPS) String() string {
|
||||
if !gps.IsValid() {
|
||||
return "GPS(invalid)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("GPS(%.9f, %.9f)", *gps.GPSLatitude, *gps.GPSLongitude)
|
||||
}
|
||||
|
||||
// TimeAll stores tags returned by -time:all.
|
||||
type TimeAll struct {
|
||||
SubSecDateTimeOriginal *string
|
||||
SubSecCreateDate *string
|
||||
|
||||
DateTimeOriginal *string
|
||||
CreateDate *string
|
||||
TrackCreateDate *string
|
||||
MediaCreateDate *string
|
||||
FileModifyDate *string
|
||||
|
||||
OffsetTimeOriginal *string
|
||||
OffsetTime *string
|
||||
TimeZone *int
|
||||
|
||||
GPSDateTime *string
|
||||
}
|
||||
|
||||
const layout = "2006:01:02 15:04:05.999"
|
||||
const layoutWithTimezone = "2006:01:02 15:04:05.999Z07:00"
|
||||
|
||||
// TimeInLocal returns most likely time. The date and time are in local. The timezone is meaningless and always be in UTC. Use `OffsetSecs()` to determine the timezone.
|
||||
func (t TimeAll) TimeInLocal() time.Time {
|
||||
for _, dateP := range []*string{
|
||||
// Keep the order for the priority to generate DateShot
|
||||
t.SubSecDateTimeOriginal,
|
||||
t.SubSecCreateDate,
|
||||
t.DateTimeOriginal,
|
||||
t.CreateDate,
|
||||
t.TrackCreateDate,
|
||||
t.MediaCreateDate,
|
||||
t.FileModifyDate,
|
||||
} {
|
||||
if dateP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
date := *dateP
|
||||
|
||||
// Ignore timezone
|
||||
if zoneIndex := strings.IndexAny(date, "+-Z"); zoneIndex >= 0 {
|
||||
date = date[:zoneIndex]
|
||||
}
|
||||
|
||||
if date, err := time.ParseInLocation(layout, date, time.UTC); err == nil {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// OffsetSecs returns seconds offset by UTC.
|
||||
func (t TimeAll) OffsetSecs(local time.Time) (int, bool) {
|
||||
for _, offsetP := range []*string{
|
||||
t.OffsetTimeOriginal,
|
||||
t.OffsetTime,
|
||||
} {
|
||||
if offsetP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if t, err := time.Parse("-07:00", *offsetP); err == nil {
|
||||
_, offsetSecs := t.Zone()
|
||||
return offsetSecs, true
|
||||
}
|
||||
}
|
||||
|
||||
// TimeZone is in minutes
|
||||
if t.TimeZone != nil {
|
||||
return *t.TimeZone * 60, true
|
||||
}
|
||||
|
||||
// Calculate offset sec with GPS time and local time.
|
||||
if local.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if t.GPSDateTime == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
gpsDate, err := time.Parse(layoutWithTimezone, *t.GPSDateTime)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
gpsDate = gpsDate.UTC()
|
||||
|
||||
// GPS time is always UTC per EXIF spec
|
||||
// offset = local time (in UTC) - GPS UTC time
|
||||
offset := int(local.Sub(gpsDate).Seconds())
|
||||
return offset, true
|
||||
}
|
||||
|
||||
type PhotoMeta struct {
|
||||
ImageDescription *string
|
||||
Model *string
|
||||
Make *string
|
||||
LensModel *string
|
||||
ISO *int64
|
||||
Flash *int64
|
||||
Orientation *int64
|
||||
ExposureProgram *int64
|
||||
ExposureTime *float64
|
||||
Aperture *float64
|
||||
FocalLength *float64
|
||||
}
|
||||
|
||||
func (m *PhotoMeta) SanitizeFloats() {
|
||||
for _, value := range []**float64{
|
||||
&m.ExposureTime,
|
||||
&m.Aperture,
|
||||
&m.FocalLength,
|
||||
} {
|
||||
if *value == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if math.IsNaN(**value) || math.IsInf(**value, 0) {
|
||||
*value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MIMEType struct {
|
||||
MIMEType *string
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGPSIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gps GPS
|
||||
wantValid bool
|
||||
}{
|
||||
{"LatNormalLongNormal", GPS{new(10.0), new(10.0)}, true},
|
||||
{"ZeroValue", GPS{new(0.0), new(0.0)}, true},
|
||||
|
||||
{"LatNilLongNormal", GPS{new(math.NaN()), new(10.0)}, false},
|
||||
{"LatNormalLongNil", GPS{new(10.0), new(math.NaN())}, false},
|
||||
|
||||
{"Lat>90LongNormal", GPS{new(100.0), new(10.0)}, false},
|
||||
{"Lat<-90LongNormal", GPS{new(-100.0), new(10.0)}, false},
|
||||
|
||||
{"LatNormalLong>180", GPS{new(10.0), new(190.0)}, false},
|
||||
{"LatNormalLong<-180", GPS{new(10.0), new(-190.0)}, false},
|
||||
|
||||
{"Empty", GPS{}, false},
|
||||
{"EmptyLat", GPS{nil, new(0.0)}, false},
|
||||
{"EmptyLong", GPS{new(0.0), nil}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.gps.IsValid()
|
||||
if got != tc.wantValid {
|
||||
t.Fatalf("gps.IsValid(%v) = %v, want: %v", tc.gps, got, tc.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseInUTC(t *testing.T, timeStr string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("time.ParseInLocation(%q) returns error: %v", timeStr, err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestTimeAllTimeLocal(t *testing.T) {
|
||||
wantStr := "2025:10:28 14:20:22.164"
|
||||
otherStr := "2024:10:28 14:20:22.164"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want time.Time
|
||||
}{
|
||||
{"DateTimeOriginal", TimeAll{
|
||||
DateTimeOriginal: &wantStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"CreateDate", TimeAll{
|
||||
CreateDate: &wantStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"TrackCreateDate", TimeAll{
|
||||
TrackCreateDate: &wantStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"MediaCreateDate", TimeAll{
|
||||
MediaCreateDate: &wantStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"FileModifyDate", TimeAll{
|
||||
FileModifyDate: &wantStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
|
||||
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllTimeWithTimezone(t *testing.T) {
|
||||
wantStr := "2025:10:28 14:20:22.164"
|
||||
otherStr := "2024:10:28 14:20:22.164"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want time.Time
|
||||
}{
|
||||
{"SubSecDateTimeOriginal", TimeAll{
|
||||
SubSecDateTimeOriginal: &wantStr,
|
||||
SubSecCreateDate: &otherStr,
|
||||
DateTimeOriginal: &otherStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"SubSecCreateDate", TimeAll{
|
||||
SubSecCreateDate: &wantStr,
|
||||
DateTimeOriginal: &otherStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
|
||||
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllTimeEmpty(t *testing.T) {
|
||||
var timeAll TimeAll
|
||||
if got := timeAll.TimeInLocal(); !got.IsZero() {
|
||||
t.Errorf("timeAll.Time() is a valid time, which it should not be")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllOffsetSecs(t *testing.T) {
|
||||
wantStr := "+01:00"
|
||||
otherStr := "-01:00"
|
||||
gpsDateTime := "2025:10:28 14:20:22Z"
|
||||
otherTimezone := -120
|
||||
localTime := mustParseInUTC(t, "2025:10:28 13:20:22")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want int
|
||||
}{
|
||||
{"OffsetTimeOriginal", TimeAll{
|
||||
OffsetTimeOriginal: &wantStr,
|
||||
OffsetTime: &otherStr,
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, 60 * 60},
|
||||
{"OffsetTime", TimeAll{
|
||||
OffsetTime: &wantStr,
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, 60 * 60},
|
||||
{"TimeZone", TimeAll{
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, -120 * 60},
|
||||
{"GPS", TimeAll{
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, -60 * 60},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := tc.timeAll.OffsetSecs(localTime)
|
||||
if !ok {
|
||||
t.Fatalf("timeAll.OffsetSecs() is not valid, which it should be")
|
||||
}
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("timeAll.OffsetSecs() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllOffsetSecsEmptyLocal(t *testing.T) {
|
||||
timeAll := TimeAll{
|
||||
GPSDateTime: new("14:20:22 2025:10:28Z"),
|
||||
}
|
||||
|
||||
got, ok := timeAll.OffsetSecs(time.Time{})
|
||||
if ok || got != 0 {
|
||||
t.Errorf("timeAll.OffsetSecs() = (%d, %v), want: (%d, %v)", got, ok, 0, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package externaltools provides wrappers for tools outside the Go runtime.
|
||||
// These tools are provided by the runtime environment. Some require initialization
|
||||
// and must be cleaned up properly when finished.
|
||||
// Packages under externaltools should have as few dependencies as possible to avoid cycles.
|
||||
package externaltools
|
||||
@@ -0,0 +1,17 @@
|
||||
package face_detection
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
)
|
||||
|
||||
type FaceDetector interface {
|
||||
ReloadFacesFromDatabase(db *gorm.DB) error
|
||||
DetectFaces(db *gorm.DB, media *models.Media) error
|
||||
MergeCategories(sourceID int32, destID int32)
|
||||
MergeImageFaces(imageFaceIDs []int, destFaceGroupID int32)
|
||||
RecognizeUnlabeledFaces(tx *gorm.DB, user *models.User) ([]*models.ImageFace, error)
|
||||
}
|
||||
|
||||
var GlobalFaceDetector FaceDetector = nil
|
||||
@@ -0,0 +1,307 @@
|
||||
//go:build !no_face_detection
|
||||
|
||||
package face_detection
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/Kagami/go-face"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type faceDetector struct {
|
||||
mutex sync.Mutex
|
||||
rec *face.Recognizer
|
||||
faceDescriptors []face.Descriptor
|
||||
faceGroupIDs []int32
|
||||
imageFaceIDs []int
|
||||
}
|
||||
|
||||
func InitializeFaceDetector(db *gorm.DB) error {
|
||||
if utils.EnvDisableFaceRecognition.GetBool() {
|
||||
log.Printf("Face detection disabled (%s=1)\n", utils.EnvDisableFaceRecognition.GetName())
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Initializing face detector")
|
||||
|
||||
rec, err := face.NewRecognizer(utils.FaceRecognitionModelsPath())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "initialize facedetect recognizer")
|
||||
}
|
||||
|
||||
faceDescriptors, faceGroupIDs, imageFaceIDs, err := getSamplesFromDatabase(db)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "get face detection samples from database")
|
||||
}
|
||||
|
||||
GlobalFaceDetector = &faceDetector{
|
||||
rec: rec,
|
||||
faceDescriptors: faceDescriptors,
|
||||
faceGroupIDs: faceGroupIDs,
|
||||
imageFaceIDs: imageFaceIDs,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, faceGroupIDs []int32, imageFaceIDs []int, err error) {
|
||||
|
||||
var imageFaces []*models.ImageFace
|
||||
|
||||
if err = db.Find(&imageFaces).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
samples = make([]face.Descriptor, len(imageFaces))
|
||||
faceGroupIDs = make([]int32, len(imageFaces))
|
||||
imageFaceIDs = make([]int, len(imageFaces))
|
||||
|
||||
for i, imgFace := range imageFaces {
|
||||
samples[i] = face.Descriptor(imgFace.Descriptor)
|
||||
faceGroupIDs[i] = int32(imgFace.FaceGroupID)
|
||||
imageFaceIDs[i] = imgFace.ID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ReloadFacesFromDatabase replaces the in-memory face descriptors with the ones in the database
|
||||
func (fd *faceDetector) ReloadFacesFromDatabase(db *gorm.DB) error {
|
||||
faceDescriptors, faceGroupIDs, imageFaceIDs, err := getSamplesFromDatabase(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
fd.faceDescriptors = faceDescriptors
|
||||
fd.faceGroupIDs = faceGroupIDs
|
||||
fd.imageFaceIDs = imageFaceIDs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectFaces finds the faces in the given image and saves them to the database
|
||||
func (fd *faceDetector) DetectFaces(db *gorm.DB, media *models.Media) error {
|
||||
if err := db.Model(media).Preload("MediaURL").First(&media).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var thumbnailURL *models.MediaURL
|
||||
for _, url := range media.MediaURL {
|
||||
if url.Purpose == models.PhotoThumbnail {
|
||||
thumbnailURL = &url
|
||||
thumbnailURL.Media = media
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if thumbnailURL == nil {
|
||||
return errors.New("thumbnail url is missing")
|
||||
}
|
||||
|
||||
thumbnailPath, err := thumbnailURL.CachedPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fd.mutex.Lock()
|
||||
faces, err := fd.rec.RecognizeFile(thumbnailPath)
|
||||
fd.mutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error read faces")
|
||||
}
|
||||
|
||||
for _, face := range faces {
|
||||
fd.classifyFace(db, &face, media, thumbnailPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fd *faceDetector) classifyDescriptor(descriptor face.Descriptor) int32 {
|
||||
return int32(fd.rec.ClassifyThreshold(descriptor, 0.2))
|
||||
}
|
||||
|
||||
func (fd *faceDetector) classifyFace(db *gorm.DB, face *face.Face, media *models.Media, imagePath string) error {
|
||||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
match := fd.classifyDescriptor(face.Descriptor)
|
||||
|
||||
dimension, err := media_encoding.GetPhotoDimensions(imagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageFace := models.ImageFace{
|
||||
MediaID: media.ID,
|
||||
Descriptor: models.FaceDescriptor(face.Descriptor),
|
||||
Rectangle: models.FaceRectangle{
|
||||
// Converts a pixel absolute rectangle to a relative FaceRectangle.
|
||||
MinX: float64(face.Rectangle.Min.X) / float64(dimension.Width),
|
||||
MaxX: float64(face.Rectangle.Max.X) / float64(dimension.Width),
|
||||
MinY: float64(face.Rectangle.Min.Y) / float64(dimension.Height),
|
||||
MaxY: float64(face.Rectangle.Max.Y) / float64(dimension.Height),
|
||||
},
|
||||
}
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
|
||||
// If no match add it new to samples
|
||||
if match < 0 {
|
||||
log.Println("No match, assigning new face")
|
||||
|
||||
faceGroup = models.FaceGroup{
|
||||
ImageFaces: []models.ImageFace{imageFace},
|
||||
}
|
||||
|
||||
if err := db.Create(&faceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Println("Found match")
|
||||
|
||||
if err := db.First(&faceGroup, int(match)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Model(&faceGroup).Association("ImageFaces").Append(&imageFace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fd.faceDescriptors = append(fd.faceDescriptors, face.Descriptor)
|
||||
fd.faceGroupIDs = append(fd.faceGroupIDs, int32(faceGroup.ID))
|
||||
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFace.ID)
|
||||
|
||||
fd.rec.SetSamples(fd.faceDescriptors, fd.faceGroupIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fd *faceDetector) MergeCategories(sourceID int32, destID int32) {
|
||||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
for i := range fd.faceGroupIDs {
|
||||
if fd.faceGroupIDs[i] == sourceID {
|
||||
fd.faceGroupIDs[i] = destID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fd *faceDetector) MergeImageFaces(imageFaceIDs []int, destFaceGroupID int32) {
|
||||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
for i := range fd.faceGroupIDs {
|
||||
imageFaceID := fd.imageFaceIDs[i]
|
||||
|
||||
for _, id := range imageFaceIDs {
|
||||
if imageFaceID == id {
|
||||
fd.faceGroupIDs[i] = destFaceGroupID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fd *faceDetector) RecognizeUnlabeledFaces(tx *gorm.DB, user *models.User) ([]*models.ImageFace, error) {
|
||||
unrecognizedDescriptors := make([]face.Descriptor, 0)
|
||||
unrecognizedFaceGroupIDs := make([]int32, 0)
|
||||
unrecognizedImageFaceIDs := make([]int, 0)
|
||||
|
||||
newFaceGroupIDs := make([]int32, 0)
|
||||
newDescriptors := make([]face.Descriptor, 0)
|
||||
newImageFaceIDs := make([]int, 0)
|
||||
|
||||
var unlabeledFaceGroups []*models.FaceGroup
|
||||
|
||||
err := tx.
|
||||
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Joins("JOIN media ON image_faces.media_id = media.id").
|
||||
Where("face_groups.label IS NULL").
|
||||
Where("media.album_id IN (?)",
|
||||
tx.Select("album_id").Table("user_albums").Where("user_id = ?", user.ID),
|
||||
).
|
||||
Find(&unlabeledFaceGroups).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fd.mutex.Lock()
|
||||
defer fd.mutex.Unlock()
|
||||
|
||||
for i := range fd.faceDescriptors {
|
||||
descriptor := fd.faceDescriptors[i]
|
||||
faceGroupID := fd.faceGroupIDs[i]
|
||||
imageFaceID := fd.imageFaceIDs[i]
|
||||
|
||||
isUnlabeled := false
|
||||
for _, unlabeledFaceGroup := range unlabeledFaceGroups {
|
||||
if faceGroupID == int32(unlabeledFaceGroup.ID) {
|
||||
isUnlabeled = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if isUnlabeled {
|
||||
unrecognizedFaceGroupIDs = append(unrecognizedFaceGroupIDs, faceGroupID)
|
||||
unrecognizedDescriptors = append(unrecognizedDescriptors, descriptor)
|
||||
unrecognizedImageFaceIDs = append(unrecognizedImageFaceIDs, imageFaceID)
|
||||
} else {
|
||||
newFaceGroupIDs = append(newFaceGroupIDs, faceGroupID)
|
||||
newDescriptors = append(newDescriptors, descriptor)
|
||||
newImageFaceIDs = append(newImageFaceIDs, imageFaceID)
|
||||
}
|
||||
}
|
||||
|
||||
fd.faceGroupIDs = newFaceGroupIDs
|
||||
fd.faceDescriptors = newDescriptors
|
||||
fd.imageFaceIDs = newImageFaceIDs
|
||||
|
||||
updatedImageFaces := make([]*models.ImageFace, 0)
|
||||
|
||||
for i := range unrecognizedDescriptors {
|
||||
descriptor := unrecognizedDescriptors[i]
|
||||
faceGroupID := unrecognizedFaceGroupIDs[i]
|
||||
imageFaceID := unrecognizedImageFaceIDs[i]
|
||||
|
||||
match := fd.classifyDescriptor(descriptor)
|
||||
|
||||
if match < 0 {
|
||||
// still no match, we can readd it to the list
|
||||
fd.faceGroupIDs = append(fd.faceGroupIDs, faceGroupID)
|
||||
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
|
||||
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
|
||||
} else {
|
||||
// found new match, update the database
|
||||
var imageFace models.ImageFace
|
||||
if err := tx.Model(&models.ImageFace{}).First(imageFace, imageFaceID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Model(&imageFace).Update("face_group_id", int(faceGroupID)).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedImageFaces = append(updatedImageFaces, &imageFace)
|
||||
|
||||
fd.faceGroupIDs = append(fd.faceGroupIDs, match)
|
||||
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
|
||||
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
|
||||
}
|
||||
}
|
||||
|
||||
return updatedImageFaces, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build no_face_detection
|
||||
|
||||
package face_detection
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func InitializeFaceDetector(db *gorm.DB) error {
|
||||
log.Printf("Face detection disabled (at build-time)")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package media_encoding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
|
||||
"github.com/photoview/photoview/api/scanner/media_type"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Dimension presents the Dimension of a image.
|
||||
type Dimension struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// ThumbnailScale generates a new dimension for thumbnails.
|
||||
func (d *Dimension) ThumbnailScale() Dimension {
|
||||
if d.Height == 0 || d.Width == 0 {
|
||||
return Dimension{Width: 0, Height: 0}
|
||||
}
|
||||
|
||||
aspect := float64(d.Width) / float64(d.Height)
|
||||
|
||||
var width, height int
|
||||
|
||||
if aspect > 1 {
|
||||
width = 1024
|
||||
height = int(1024 / aspect)
|
||||
} else {
|
||||
width = int(1024 * aspect)
|
||||
height = 1024
|
||||
}
|
||||
|
||||
if width > d.Width {
|
||||
width = d.Width
|
||||
height = d.Height
|
||||
}
|
||||
|
||||
return Dimension{
|
||||
Width: width,
|
||||
Height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPhotoDimensions returns the dimension of the image `imagePath`.
|
||||
func GetPhotoDimensions(imagePath string) (Dimension, error) {
|
||||
w, h, err := executable_worker.Magick.IdentifyDimension(imagePath)
|
||||
if err != nil {
|
||||
return Dimension{}, fmt.Errorf("identify dimension %q error: %w", imagePath, err)
|
||||
}
|
||||
|
||||
return Dimension{
|
||||
Width: int(w),
|
||||
Height: int(h),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncodeThumbnail encodes a thumbnail of `inputPath`, and store it as `outputPath`.
|
||||
// It returns the dimension of the thumbnail. The thumbnail will be not bigger than 1024x1024.
|
||||
func EncodeThumbnail(db *gorm.DB, inputPath string, outputPath string) (Dimension, error) {
|
||||
w, h, err := executable_worker.Magick.IdentifyDimension(inputPath)
|
||||
if err != nil {
|
||||
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
|
||||
}
|
||||
|
||||
origin := Dimension{
|
||||
Width: int(w),
|
||||
Height: int(h),
|
||||
}
|
||||
thumbnail := origin.ThumbnailScale()
|
||||
|
||||
if err := executable_worker.Magick.GenerateThumbnail(inputPath, outputPath, uint(thumbnail.Width), uint(thumbnail.Height)); err != nil {
|
||||
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
|
||||
}
|
||||
|
||||
w, h, err = executable_worker.Magick.IdentifyDimension(outputPath)
|
||||
if err != nil {
|
||||
return Dimension{}, fmt.Errorf("can't generate thumbnail of file %q: %w", inputPath, err)
|
||||
}
|
||||
thumbnail = Dimension{
|
||||
Width: int(w),
|
||||
Height: int(h),
|
||||
}
|
||||
|
||||
return thumbnail, nil
|
||||
}
|
||||
|
||||
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated
|
||||
type EncodeMediaData struct {
|
||||
Media *models.Media
|
||||
CounterpartPath *string
|
||||
_photoImage image.Image
|
||||
_contentType media_type.MediaType
|
||||
_videoMetadata *ffprobe.ProbeData
|
||||
}
|
||||
|
||||
func NewEncodeMediaData(media *models.Media) EncodeMediaData {
|
||||
fileType := media_type.GetMediaType(media.Path)
|
||||
|
||||
return EncodeMediaData{
|
||||
Media: media,
|
||||
_contentType: fileType,
|
||||
}
|
||||
}
|
||||
|
||||
// ContentType reads the image to determine its content type
|
||||
func (img *EncodeMediaData) ContentType() (media_type.MediaType, error) {
|
||||
if img._contentType != media_type.TypeUnknown {
|
||||
return img._contentType, nil
|
||||
}
|
||||
|
||||
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
|
||||
return imgType, nil
|
||||
}
|
||||
|
||||
func (img *EncodeMediaData) EncodeHighRes(outputPath string) error {
|
||||
contentType, err := img.ContentType()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !contentType.IsSupported() {
|
||||
return errors.New("could not convert photo as file format is not supported")
|
||||
}
|
||||
|
||||
// Use magick if there is no counterpart JPEG file to use instead
|
||||
if contentType.IsImage() && !contentType.IsWebCompatible() {
|
||||
imgPath := img.Media.Path
|
||||
if img.CounterpartPath != nil {
|
||||
imgPath = *img.CounterpartPath
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (enc *EncodeMediaData) VideoMetadata() (*ffprobe.ProbeData, error) {
|
||||
|
||||
if enc._videoMetadata != nil {
|
||||
return enc._videoMetadata, nil
|
||||
}
|
||||
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), utils.MediaProbeTimeout())
|
||||
defer cancelFn()
|
||||
data, err := ffprobe.ProbeURL(ctx, enc.Media.Path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not read video metadata (%s)", enc.Media.Title)
|
||||
}
|
||||
|
||||
enc._videoMetadata = data
|
||||
return enc._videoMetadata, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package executable_worker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
var ErrNoDependency = errors.New("dependency not found")
|
||||
var ErrDisabledFunction = errors.New("function disabled")
|
||||
|
||||
// Initialize Initializes all workers. It returns a function to terminate workers, which should be called before the program closing.
|
||||
func Initialize() func() {
|
||||
Magick = newMagickWand()
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
if err := SetFfprobePath(); err != nil {
|
||||
log.Error(nil, "Init ffprobe fail.", "error", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
Magick.Terminate()
|
||||
Magick = nil
|
||||
}
|
||||
}
|
||||
|
||||
var Magick *MagickWand = nil
|
||||
var Ffmpeg *FfmpegCli = nil
|
||||
|
||||
type ExecutableWorker interface {
|
||||
Path() string
|
||||
}
|
||||
|
||||
func SetFfprobePath() error {
|
||||
path, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Executable ffprobe not found: %w", err)
|
||||
}
|
||||
|
||||
version, err := exec.Command(path, "-version").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Executable ffprobe(%q) not executable: %w", path, err)
|
||||
}
|
||||
|
||||
log.Info(nil, "Found ffprobe", "path", path, "version", strings.Split(string(version), "\n")[0])
|
||||
ffprobe.SetFFProbeBinPath(path)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package executable_worker
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
const testdataBinPath = "./test_data/mock_bin"
|
||||
|
||||
// SetPathWithCurrent sets PATH env to `paths` in the directory of testing files. The PATH will restore to the previous value when the test is done.
|
||||
func SetPathWithCurrent(t *testing.T, paths ...string) {
|
||||
_, file, _, ok := runtime.Caller(1)
|
||||
if !ok {
|
||||
t.Log("Can't get the test file. Ignore setting PATH.")
|
||||
return
|
||||
}
|
||||
|
||||
base := filepath.Dir(file)
|
||||
|
||||
for i, path := range paths {
|
||||
paths[i] = filepath.Join(base, path)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", strings.Join(paths, ":"))
|
||||
}
|
||||
|
||||
func TestInitFfprobePath(t *testing.T) {
|
||||
t.Run("PathFail", func(t *testing.T) {
|
||||
SetPathWithCurrent(t, "non_exist_path")
|
||||
|
||||
err := SetFfprobePath()
|
||||
if err == nil {
|
||||
t.Fatalf("InitFfprobePath() returns nil, want an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VersionFail", func(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
err := SetFfprobePath()
|
||||
if err == nil {
|
||||
t.Fatalf("InitFfprobePath() returns nil, want an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Succeed", func(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
|
||||
err := SetFfprobePath()
|
||||
if err != nil {
|
||||
t.Fatalf("InitFfprobePath() returns %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package executable_worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
const defaultCodec = "h264"
|
||||
|
||||
var hwAccToCodec = map[string]string{
|
||||
"qsv": defaultCodec + "_qsv",
|
||||
"vaapi": defaultCodec + "_vaapi",
|
||||
"nvenc": defaultCodec + "_nvenc",
|
||||
}
|
||||
|
||||
type FfmpegCli struct {
|
||||
path string
|
||||
videoCodec string
|
||||
err error
|
||||
}
|
||||
|
||||
func newFfmpegCli() *FfmpegCli {
|
||||
if utils.EnvDisableVideoEncoding.GetBool() {
|
||||
log.Warn(nil, "Executable ffmpeg worker disabled", utils.EnvDisableVideoEncoding.GetName(), utils.EnvDisableVideoEncoding.GetValue())
|
||||
return &FfmpegCli{
|
||||
err: ErrDisabledFunction,
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error(nil, "Executable ffmpeg worker not found")
|
||||
return &FfmpegCli{
|
||||
err: ErrNoDependency,
|
||||
}
|
||||
}
|
||||
|
||||
version, err := exec.Command(path, "-version").Output()
|
||||
if err != nil {
|
||||
log.Error(nil, "Executable ffmpeg worker getting version error", "error", err)
|
||||
return &FfmpegCli{
|
||||
err: ErrNoDependency,
|
||||
}
|
||||
}
|
||||
|
||||
hwAcc := utils.EnvVideoHardwareAcceleration.GetValue()
|
||||
codec, ok := hwAccToCodec[hwAcc]
|
||||
if !ok {
|
||||
if strings.HasPrefix(hwAcc, "_") {
|
||||
// A secret way to set the codec directly.
|
||||
codec = hwAcc[1:]
|
||||
} else {
|
||||
codec = defaultCodec
|
||||
}
|
||||
}
|
||||
|
||||
log.Info(nil, "Found executable worker: ffmpeg", "version", strings.Split(string(version), "\n")[0], "codec", codec)
|
||||
|
||||
return &FfmpegCli{
|
||||
path: path,
|
||||
videoCodec: codec,
|
||||
}
|
||||
}
|
||||
|
||||
func (cli *FfmpegCli) IsInstalled() bool {
|
||||
return cli.err == nil
|
||||
}
|
||||
|
||||
func (cli *FfmpegCli) EncodeMp4(inputPath string, outputPath string) error {
|
||||
if cli.err != nil {
|
||||
return fmt.Errorf("encoding video %q error: ffmpeg: %w", inputPath, cli.err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vcodec", cli.videoCodec,
|
||||
"-acodec", "aac",
|
||||
"-vf", "scale='min(1080,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||
"-movflags", "+faststart+use_metadata_tags",
|
||||
outputPath,
|
||||
}
|
||||
|
||||
cmd := exec.Command(cli.path, args...)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("encoding video with %q %v error: %w", cli.path, args, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *FfmpegCli) EncodeVideoThumbnail(inputPath string, outputPath string, probeData *ffprobe.ProbeData) error {
|
||||
if cli.err != nil {
|
||||
return fmt.Errorf("encoding video thumbnail %q error: ffmpeg: %w", inputPath, cli.err)
|
||||
}
|
||||
|
||||
thumbnailOffsetSeconds := fmt.Sprintf("%.f", probeData.Format.DurationSeconds*0.25)
|
||||
|
||||
args := []string{
|
||||
"-ss", thumbnailOffsetSeconds, // grab frame at time offset
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vframes", "1", // output one frame
|
||||
"-an", // disable audio
|
||||
"-vf", "scale='min(1024,iw)':'min(1024,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||
outputPath,
|
||||
}
|
||||
|
||||
cmd := exec.Command(cli.path, args...)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("encoding video thumbnail with %q %v error: %w", cli.path, args, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package executable_worker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
func TestFfmpegNotExist(t *testing.T) {
|
||||
SetPathWithCurrent(t, "")
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
if got, want := Ffmpeg.err, ErrNoDependency; got != want {
|
||||
t.Errorf("Ffmpeg.err = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if Ffmpeg.IsInstalled() {
|
||||
t.Error("Ffmpeg should not be installed, but is found:", Ffmpeg)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeMp4("input", "output"), ErrNoDependency; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeVideoThumbnail("input", "output", nil), ErrNoDependency; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFfmpegVersionFail(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
if got, want := Ffmpeg.err, ErrNoDependency; got != want {
|
||||
t.Errorf("Ffmpeg.err = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if Ffmpeg.IsInstalled() {
|
||||
t.Error("Ffmpeg should not be installed, but is found:", Ffmpeg)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeMp4("input", "output"), ErrNoDependency; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeVideoThumbnail("input", "output", nil), ErrNoDependency; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFfmpegIgnore(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
t.Setenv("PHOTOVIEW_DISABLE_VIDEO_ENCODING", "true")
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
if got, want := Ffmpeg.err, ErrDisabledFunction; got != want {
|
||||
t.Errorf("Ffmpeg.err = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if Ffmpeg.IsInstalled() {
|
||||
t.Error("Ffmpeg should be ignored (as it is disabled), but is initialized:", Ffmpeg)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeMp4("input", "output"), ErrDisabledFunction; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
if got, want := Ffmpeg.EncodeVideoThumbnail("input", "output", nil), ErrDisabledFunction; !errors.Is(got, want) {
|
||||
t.Errorf("Ffmpge.EncodeMp4() = %v, want: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFfmpeg(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
if !Ffmpeg.IsInstalled() {
|
||||
t.Fatal("Ffmpeg should be installed")
|
||||
}
|
||||
|
||||
t.Run("EncodeMp4Failed", func(t *testing.T) {
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
err := Ffmpeg.EncodeMp4("input", "output")
|
||||
if err == nil {
|
||||
t.Fatalf("Ffmpeg.EncodeMp4(...) = nil, should be an error.")
|
||||
}
|
||||
if got, want := err.Error(), `^encoding video with ".*/test_data/mock_bin/ffmpeg" \[-i input -vcodec h264 .* output\] error: .*$`; !regexp.MustCompile(want).MatchString(got) {
|
||||
t.Errorf("Ffmpeg.EncodeMp4(...) = %q, should be as reg pattern %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EncodeMp4Succeeded", func(t *testing.T) {
|
||||
err := Ffmpeg.EncodeMp4("input", "output")
|
||||
if err != nil {
|
||||
t.Fatalf("Ffmpeg.EncodeMp4(...) = %v, should be nil.", err)
|
||||
}
|
||||
})
|
||||
|
||||
probeData := &ffprobe.ProbeData{
|
||||
Format: &ffprobe.Format{
|
||||
DurationSeconds: 10,
|
||||
},
|
||||
}
|
||||
t.Run("EncodeVideoThumbnailMp4Failed", func(t *testing.T) {
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
err := Ffmpeg.EncodeVideoThumbnail("input", "output", probeData)
|
||||
if err == nil {
|
||||
t.Fatalf("Ffmpeg.EncodeVideoThumbnail(...) = nil, should be an error.")
|
||||
}
|
||||
if got, want := err.Error(), `^encoding video thumbnail with ".*/test_data/mock_bin/ffmpeg" \[-ss 2 -i input .* output\] error: .*$`; !regexp.MustCompile(want).MatchString(got) {
|
||||
t.Errorf("Ffmpeg.EncodeVideoThumbnail(...) = %q, should be as reg pattern %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EncodeVideoThumbnailSucceeded", func(t *testing.T) {
|
||||
err := Ffmpeg.EncodeVideoThumbnail("input", "output", probeData)
|
||||
if err != nil {
|
||||
t.Fatalf("Ffmpeg.EncodeVideoThumbnail(...) = %v, should be nil.", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFfmpegWithHWAcc(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
t.Setenv(utils.EnvVideoHardwareAcceleration.GetName(), "qsv")
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
err := Ffmpeg.EncodeMp4("input", "output")
|
||||
if err == nil {
|
||||
t.Fatalf("Ffmpeg.EncodeMp4(...) = nil, should be an error.")
|
||||
}
|
||||
if got, want := err.Error(), `^encoding video with ".*/test_data/mock_bin/ffmpeg" \[-i input -vcodec h264_qsv .* output\] error: .*$`; !regexp.MustCompile(want).MatchString(got) {
|
||||
t.Errorf("Ffmpeg.EncodeMp4(...) = %q, should be as reg pattern %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFfmpegWithCustomCodec(t *testing.T) {
|
||||
SetPathWithCurrent(t, testdataBinPath)
|
||||
t.Setenv(utils.EnvVideoHardwareAcceleration.GetName(), "_custom")
|
||||
|
||||
Ffmpeg = newFfmpegCli()
|
||||
|
||||
t.Setenv("FAIL_WITH", "expect failure")
|
||||
|
||||
err := Ffmpeg.EncodeMp4("input", "output")
|
||||
if err == nil {
|
||||
t.Fatalf("Ffmpeg.EncodeMp4(...) = nil, should be an error.")
|
||||
}
|
||||
if got, want := err.Error(), `^encoding video with ".*/test_data/mock_bin/ffmpeg" \[-i input -vcodec custom .* output\] error: .*$`; !regexp.MustCompile(want).MatchString(got) {
|
||||
t.Errorf("Ffmpeg.EncodeMp4(...) = %q, should be as reg pattern %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package executable_worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"gopkg.in/gographics/imagick.v3/imagick"
|
||||
)
|
||||
|
||||
type MagickWand struct {
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func newMagickWand() *MagickWand {
|
||||
imagick.Initialize()
|
||||
|
||||
verstr, vernum := imagick.GetVersion()
|
||||
|
||||
log.Info(nil, "Found magickwand worker: "+verstr, "version", vernum)
|
||||
|
||||
return &MagickWand{
|
||||
initialized: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (cli *MagickWand) Terminate() {
|
||||
cli.initialized = false
|
||||
imagick.Terminate()
|
||||
}
|
||||
|
||||
func (cli *MagickWand) IsInstalled() bool {
|
||||
return cli != nil && cli.initialized
|
||||
}
|
||||
|
||||
func (cli *MagickWand) EncodeJpeg(inputPath string, outputPath string, jpegQuality uint) error {
|
||||
wand, err := cli.createWandFromFile(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer wand.Destroy()
|
||||
|
||||
if err := wand.SetFormat("JPEG"); err != nil {
|
||||
return fmt.Errorf("ImagickWand set JPEG format for %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.SetImageCompressionQuality(jpegQuality); err != nil {
|
||||
return fmt.Errorf("ImagickWand set JPEG quality %d for %q error: %w", jpegQuality, inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.WriteImage(outputPath); err != nil {
|
||||
return fmt.Errorf("ImagickWand write %q error: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *MagickWand) GenerateThumbnail(inputPath string, outputPath string, width, height uint) error {
|
||||
wand, err := cli.createWandFromFile(inputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer wand.Destroy()
|
||||
|
||||
if err := wand.ThumbnailImage(width, height); err != nil {
|
||||
return fmt.Errorf("ImagickWand generate thumbnail for %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.SetFormat("JPEG"); err != nil {
|
||||
return fmt.Errorf("ImagickWand set JPEG format for %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.SetImageCompressionQuality(70); err != nil {
|
||||
return fmt.Errorf("ImagickWand set JPEG quality %d for %q error: %w", 70, inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.WriteImage(outputPath); err != nil {
|
||||
return fmt.Errorf("ImagickWand write %q error: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cli *MagickWand) IdentifyDimension(inputPath string) (width, height uint, reterr error) {
|
||||
wand, err := cli.createWandFromFile(inputPath)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
return
|
||||
}
|
||||
defer wand.Destroy()
|
||||
|
||||
width = wand.GetImageWidth()
|
||||
height = wand.GetImageHeight()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (cli *MagickWand) createWandFromFile(inputPath string) (*imagick.MagickWand, error) {
|
||||
if !cli.IsInstalled() {
|
||||
return nil, fmt.Errorf("ImagickWand is not initialized")
|
||||
}
|
||||
|
||||
wand := imagick.NewMagickWand()
|
||||
|
||||
if err := wand.ReadImage(inputPath); err != nil {
|
||||
return nil, fmt.Errorf("ImagickWand read %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
if err := wand.AutoOrientImage(); err != nil {
|
||||
return nil, fmt.Errorf("ImagickWand auto-orient %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
// Reset EXIF orientation to 1 (top-left) since image is now properly oriented
|
||||
if err := wand.SetImageOrientation(imagick.ORIENTATION_TOP_LEFT); err != nil {
|
||||
return nil, fmt.Errorf("ImagickWand set orientation for %q error: %w", inputPath, err)
|
||||
}
|
||||
|
||||
return wand, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: ${FAIL_WITH=""}
|
||||
|
||||
case "$1" in
|
||||
"--version")
|
||||
echo ffmpeg: version fake
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${FAIL_WITH}" != "" ]
|
||||
then
|
||||
echo ${FAIL_WITH}
|
||||
exit -1
|
||||
fi
|
||||
|
||||
echo $@
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: ${FAIL_WITH=""}
|
||||
|
||||
case "$1" in
|
||||
"--version")
|
||||
echo ffprobe: version fake
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${FAIL_WITH}" != "" ]
|
||||
then
|
||||
echo ${FAIL_WITH}
|
||||
exit -1
|
||||
fi
|
||||
|
||||
echo $@
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: ${FAIL_WITH=""}
|
||||
: ${INVALID_OUTPUT=""}
|
||||
|
||||
if [ "${FAIL_WITH}" != "" ]
|
||||
then
|
||||
echo ${FAIL_WITH}
|
||||
exit -1
|
||||
fi
|
||||
|
||||
if [ "$#" = "0" ] # no args
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
"-version")
|
||||
echo magick: version fake
|
||||
exit 0
|
||||
;;
|
||||
"identify")
|
||||
if [ "${INVALID_OUTPUT}" != "" ]
|
||||
then
|
||||
echo -n ${INVALID_OUTPUT}
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -n '{"height":800, "width":1000}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo $@
|
||||
@@ -0,0 +1,40 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func scanMedia(ctx scanner_task.TaskContext, media *models.Media, mediaData *media_encoding.EncodeMediaData, mediaIndex int, mediaTotal int) error {
|
||||
newCtx, err := scanner_tasks.Tasks.BeforeProcessMedia(ctx, mediaData)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "before process media (%s)", media.Path)
|
||||
}
|
||||
|
||||
mediaCachePath, err := media.CachePath()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "cache directory error (%s)", media.Path)
|
||||
}
|
||||
|
||||
transactionError := newCtx.DatabaseTransaction(func(ctx scanner_task.TaskContext) error {
|
||||
updatedURLs, err := scanner_tasks.Tasks.ProcessMedia(newCtx, mediaData, mediaCachePath)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "process media (%s)", media.Path)
|
||||
}
|
||||
|
||||
if err = scanner_tasks.Tasks.AfterProcessMedia(newCtx, mediaData, updatedURLs, mediaIndex, mediaTotal); err != nil {
|
||||
return errors.Wrap(err, "after process media")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return errors.Wrap(transactionError, "process media database transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package media_type
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unique"
|
||||
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/externaltools/exif"
|
||||
)
|
||||
|
||||
type MediaType unique.Handle[string]
|
||||
|
||||
// GetMediaType returns a media type of file `f`.
|
||||
// This function is thread-safe.
|
||||
func GetMediaType(f string) MediaType {
|
||||
mime, err := exif.MIMEType(f)
|
||||
if err != nil {
|
||||
log.Warn(nil, "GetMediaType() error.", "error", err, "file", f)
|
||||
return TypeUnknown
|
||||
}
|
||||
|
||||
return mediaType(mime)
|
||||
}
|
||||
|
||||
func mediaType(mime string) MediaType {
|
||||
return MediaType(unique.Make(mime))
|
||||
}
|
||||
|
||||
var (
|
||||
TypeUnknown MediaType
|
||||
|
||||
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 webImageMimetypes = arrayToSet([]MediaType{
|
||||
TypeJPEG,
|
||||
TypePNG,
|
||||
TypeWebP,
|
||||
TypeBMP,
|
||||
TypeGIF,
|
||||
})
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
func arrayToSet[T comparable](array []T) map[T]struct{} {
|
||||
ret := make(map[T]struct{})
|
||||
for _, item := range array {
|
||||
ret[item] = struct{}{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// IsWebCompatible returns true if the media type is compatible with the browser.
|
||||
func (t MediaType) IsWebCompatible() bool {
|
||||
if t == TypeUnknown {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := webImageMimetypes[t]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := webVideoMimetypes[t]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// IsVideo returns true if the media type is video type.
|
||||
func (t MediaType) IsVideo() bool {
|
||||
if t == TypeUnknown {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(t.String(), TypeVideo.String())
|
||||
}
|
||||
|
||||
// IsSupported returns true if the media type can be processed.
|
||||
func (t MediaType) IsSupported() bool {
|
||||
if t == TypeUnknown {
|
||||
return false
|
||||
}
|
||||
|
||||
return t.IsImage() || t.IsVideo()
|
||||
}
|
||||
|
||||
func (t MediaType) String() string {
|
||||
if t == TypeUnknown {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return unique.Handle[string](t).Value()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package media_type
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
|
||||
func TestGetMediaType(t *testing.T) {
|
||||
mediaPath := test_utils.PathFromAPIRoot("scanner", "test_media", "real_media")
|
||||
|
||||
tests := []struct {
|
||||
filepath string
|
||||
filetype MediaType
|
||||
}{
|
||||
{"file.pdf", mediaType("text/plain")},
|
||||
|
||||
{"bmp.bmp", TypeBMP},
|
||||
{"gif.gif", TypeGIF},
|
||||
{"jpeg.jpg", TypeJPEG},
|
||||
{"png.png", TypePNG},
|
||||
{"webp.webp", TypeWebP},
|
||||
|
||||
{"heif.heif", mediaType("image/heic")},
|
||||
{"jpg2000.jp2", mediaType("image/jp2")},
|
||||
{"tiff.tiff", mediaType("image/tiff")},
|
||||
{"cr3.cr3", mediaType("image/x-canon-cr3")},
|
||||
|
||||
{"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-wmv")},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
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 TestMediaTypeUnknown(t *testing.T) {
|
||||
var got MediaType
|
||||
if want := TypeUnknown; got != want {
|
||||
t.Errorf("MediaType zero value should be TypeUnknown, which is not")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/gographics/imagick.v3/imagick"
|
||||
)
|
||||
|
||||
func BenchmarkStdlib(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
|
||||
for b.Loop() {
|
||||
func() {
|
||||
input, err := os.Open("./test_media/real_media/png.png")
|
||||
if err != nil {
|
||||
b.Fatal("open error:", err)
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
img, _, err := image.Decode(input)
|
||||
if err != nil {
|
||||
b.Fatal("decode error:", err)
|
||||
}
|
||||
|
||||
outfile := filepath.Join(dir, "test.jpg")
|
||||
defer os.Remove(outfile)
|
||||
|
||||
output, err := os.Create(outfile)
|
||||
if err != nil {
|
||||
b.Fatal("create error:", err)
|
||||
}
|
||||
defer output.Close()
|
||||
|
||||
if err := jpeg.Encode(output, img, &jpeg.Options{Quality: 70}); err != nil {
|
||||
b.Fatal("encode error:", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMagickWand(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
|
||||
imagick.Initialize()
|
||||
defer imagick.Terminate()
|
||||
|
||||
for b.Loop() {
|
||||
func() {
|
||||
mw := imagick.NewMagickWand()
|
||||
defer mw.Destroy()
|
||||
|
||||
if err := mw.ReadImage("./test_media/real_media/png.png"); err != nil {
|
||||
b.Fatal("read error:", err)
|
||||
}
|
||||
|
||||
output := filepath.Join(dir, "test.jpg")
|
||||
defer os.Remove(output)
|
||||
|
||||
if err := mw.SetFormat("JPEG"); err != nil {
|
||||
b.Fatal("set format error:", err)
|
||||
}
|
||||
|
||||
if err := mw.SetImageCompressionQuality(70); err != nil {
|
||||
b.Fatal("set quality error:", err)
|
||||
}
|
||||
|
||||
if err := mw.WriteImage(output); err != nil {
|
||||
b.Fatal("write error:", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package periodic_scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_queue"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ScannerQueue interface {
|
||||
AddAllToQueue() error
|
||||
}
|
||||
|
||||
type RealScannerQueue struct{}
|
||||
|
||||
func (r *RealScannerQueue) AddAllToQueue() error {
|
||||
return scanner_queue.AddAllToQueue()
|
||||
}
|
||||
|
||||
type periodicScanner struct {
|
||||
ticker *time.Ticker
|
||||
tickerLocker sync.Mutex
|
||||
ticker_changed chan bool
|
||||
done chan struct{}
|
||||
db *gorm.DB
|
||||
scannerQueue ScannerQueue
|
||||
}
|
||||
|
||||
var mainPeriodicScanner *periodicScanner = nil
|
||||
var mainPeriodicScannerLocker sync.Mutex
|
||||
|
||||
func getPeriodicScanInterval(db *gorm.DB) (time.Duration, error) {
|
||||
var siteInfo models.SiteInfo
|
||||
if err := db.First(&siteInfo).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return time.Duration(siteInfo.PeriodicScanInterval) * time.Second, nil
|
||||
}
|
||||
|
||||
func InitializePeriodicScannerWithQueue(db *gorm.DB, queue ScannerQueue) error {
|
||||
mainPeriodicScannerLocker.Lock()
|
||||
defer mainPeriodicScannerLocker.Unlock()
|
||||
|
||||
if mainPeriodicScanner != nil {
|
||||
return fmt.Errorf("periodic scanner has already been initialized")
|
||||
}
|
||||
|
||||
scanInterval, err := getPeriodicScanInterval(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mainPeriodicScanner = &periodicScanner{
|
||||
db: db,
|
||||
ticker_changed: make(chan bool),
|
||||
done: make(chan struct{}),
|
||||
tickerLocker: sync.Mutex{},
|
||||
scannerQueue: queue,
|
||||
}
|
||||
|
||||
go mainPeriodicScanner.scanIntervalRunner()
|
||||
|
||||
var newTicker *time.Ticker = nil
|
||||
if scanInterval > 0 {
|
||||
newTicker = time.NewTicker(scanInterval)
|
||||
log.Info(nil, "Periodic scan interval changed: "+scanInterval.String())
|
||||
} else {
|
||||
log.Info(nil, "Periodic scan interval changed: disabled")
|
||||
}
|
||||
|
||||
mainPeriodicScanner.ticker = newTicker
|
||||
|
||||
select {
|
||||
case mainPeriodicScanner.ticker_changed <- true:
|
||||
default:
|
||||
// Channel might be full, but that's okay
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializePeriodicScanner(db *gorm.DB) error {
|
||||
return InitializePeriodicScannerWithQueue(db, &RealScannerQueue{})
|
||||
}
|
||||
|
||||
func ChangePeriodicScanInterval(duration time.Duration) {
|
||||
var newTicker *time.Ticker = nil
|
||||
if duration > 0 {
|
||||
newTicker = time.NewTicker(duration)
|
||||
log.Info(nil, "Periodic scan interval changed: "+duration.String())
|
||||
} else {
|
||||
log.Info(nil, "Periodic scan interval changed: disabled")
|
||||
}
|
||||
|
||||
mainPeriodicScannerLocker.Lock()
|
||||
scanner := mainPeriodicScanner
|
||||
mainPeriodicScannerLocker.Unlock()
|
||||
if scanner != nil {
|
||||
scanner.tickerLocker.Lock()
|
||||
defer scanner.tickerLocker.Unlock()
|
||||
|
||||
if scanner.ticker != nil {
|
||||
scanner.ticker.Stop()
|
||||
}
|
||||
|
||||
scanner.ticker = newTicker
|
||||
select {
|
||||
case scanner.ticker_changed <- true:
|
||||
default:
|
||||
// Channel might be full, but that's okay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShutdownPeriodicScanner gracefully shuts down the periodic scanner
|
||||
func ShutdownPeriodicScanner() {
|
||||
mainPeriodicScannerLocker.Lock()
|
||||
defer mainPeriodicScannerLocker.Unlock()
|
||||
|
||||
if mainPeriodicScanner != nil {
|
||||
log.Info(nil, "Shutting down periodic scanner")
|
||||
|
||||
// Signal the runner goroutine to stop
|
||||
close(mainPeriodicScanner.done)
|
||||
|
||||
// Stop the ticker if it exists
|
||||
mainPeriodicScanner.tickerLocker.Lock()
|
||||
if mainPeriodicScanner.ticker != nil {
|
||||
mainPeriodicScanner.ticker.Stop()
|
||||
mainPeriodicScanner.ticker = nil
|
||||
}
|
||||
mainPeriodicScanner.tickerLocker.Unlock()
|
||||
|
||||
// Reset the global scanner
|
||||
mainPeriodicScanner = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *periodicScanner) scanIntervalRunner() {
|
||||
for {
|
||||
log.Info(nil, "Scan interval runner: Waiting for signal")
|
||||
|
||||
ps.tickerLocker.Lock()
|
||||
ticker := ps.ticker
|
||||
ps.tickerLocker.Unlock()
|
||||
|
||||
if ticker != nil {
|
||||
select {
|
||||
case <-ps.done:
|
||||
log.Info(nil, "Scan interval runner: Shutting down")
|
||||
return
|
||||
case <-ps.ticker_changed:
|
||||
log.Info(nil, "Scan interval runner: New ticker detected")
|
||||
case <-ticker.C:
|
||||
log.Info(nil, "Scan interval runner: Starting periodic scan")
|
||||
if err := ps.scannerQueue.AddAllToQueue(); err != nil {
|
||||
log.Error(nil, "Scan interval runner: Failed to add all users to queue", "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case <-ps.done:
|
||||
log.Info(nil, "Scan interval runner: Shutting down")
|
||||
return
|
||||
case <-ps.ticker_changed:
|
||||
log.Info(nil, "Scan interval runner: New ticker detected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package periodic_scanner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MockScannerQueue implements the ScannerQueue interface for testing
|
||||
type MockScannerQueue struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockScannerQueue) AddAllToQueue() error {
|
||||
return m.Called().Error(0)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.UnitTestRun(m)
|
||||
}
|
||||
|
||||
func resetPeriodicScanner() {
|
||||
|
||||
if mainPeriodicScanner != nil {
|
||||
select {
|
||||
case <-mainPeriodicScanner.done:
|
||||
// Already closed
|
||||
default:
|
||||
close(mainPeriodicScanner.done)
|
||||
}
|
||||
if mainPeriodicScanner.ticker != nil {
|
||||
mainPeriodicScanner.ticker.Stop()
|
||||
}
|
||||
mainPeriodicScanner = nil
|
||||
}
|
||||
}
|
||||
|
||||
func createTestSiteInfo(db *gorm.DB, interval int) error {
|
||||
siteInfo := models.SiteInfo{
|
||||
InitialSetup: false,
|
||||
PeriodicScanInterval: interval,
|
||||
ConcurrentWorkers: 1,
|
||||
}
|
||||
return db.Create(&siteInfo).Error
|
||||
}
|
||||
|
||||
func TestGetPeriodicScanInterval(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
t.Run("successful retrieval", func(t *testing.T) {
|
||||
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
|
||||
|
||||
duration, err := getPeriodicScanInterval(db)
|
||||
assert.NoError(t, err, "Failed to retrieve periodic scan interval from database")
|
||||
assert.Equal(t, 300*time.Second, duration,
|
||||
"Periodic scan interval should be 300 seconds but got %v", duration)
|
||||
})
|
||||
|
||||
t.Run("database error - no site info", func(t *testing.T) {
|
||||
db.Exec("DELETE FROM site_info")
|
||||
|
||||
duration, err := getPeriodicScanInterval(db)
|
||||
assert.Error(t, err, "Expected error when no site info exists in database")
|
||||
assert.Equal(t, time.Duration(0), duration,
|
||||
"Duration should be zero when database error occurs, but got %v", duration)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitializePeriodicScanner(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
t.Run("successful initialization with injection", func(t *testing.T) {
|
||||
defer resetPeriodicScanner()
|
||||
|
||||
mockQueue := &MockScannerQueue{}
|
||||
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
|
||||
assert.NoError(t, InitializePeriodicScannerWithQueue(db, mockQueue),
|
||||
"Failed to initialize periodic scanner with mock queue")
|
||||
|
||||
// Verify initialization
|
||||
assert.NotNil(t, mainPeriodicScanner, "mainPeriodicScanner should not be nil after successful initialization")
|
||||
assert.NotNil(t, mainPeriodicScanner.scannerQueue, "Scanner queue should not be nil after initialization")
|
||||
assert.Equal(t, mockQueue, mainPeriodicScanner.scannerQueue, "Scanner should use the injected mock queue instance")
|
||||
|
||||
// Verify ticker is set up
|
||||
mainPeriodicScanner.tickerLocker.Lock()
|
||||
tickerExists := mainPeriodicScanner.ticker != nil
|
||||
mainPeriodicScanner.tickerLocker.Unlock()
|
||||
assert.True(t, tickerExists, "Ticker should be created and set up after scanner initialization")
|
||||
})
|
||||
|
||||
t.Run("backward compatibility with original function", func(t *testing.T) {
|
||||
defer resetPeriodicScanner()
|
||||
|
||||
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
|
||||
assert.NoError(t, InitializePeriodicScanner(db),
|
||||
"Failed to initialize periodic scanner using original function")
|
||||
|
||||
// Verify it uses RealScannerQueue
|
||||
assert.NotNil(t, mainPeriodicScanner,
|
||||
"mainPeriodicScanner should be initialized by original InitializePeriodicScanner function")
|
||||
assert.IsType(t, &RealScannerQueue{}, mainPeriodicScanner.scannerQueue,
|
||||
"Original InitializePeriodicScanner should use RealScannerQueue by default")
|
||||
})
|
||||
|
||||
t.Run("double initialization error", func(t *testing.T) {
|
||||
defer resetPeriodicScanner()
|
||||
|
||||
mockQueue := &MockScannerQueue{}
|
||||
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
|
||||
assert.NoError(t, InitializePeriodicScannerWithQueue(db, mockQueue),
|
||||
"Failed first initialization for double initialization test")
|
||||
|
||||
err := InitializePeriodicScannerWithQueue(db, mockQueue)
|
||||
assert.Error(t, err, "Second initialization attempt should return an error")
|
||||
assert.Contains(t, err.Error(), "already been initialized",
|
||||
"Double initialization error should contain 'already been initialized' message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestScanIntervalRunnerWithMocking(t *testing.T) {
|
||||
t.Run("runner calls queue on ticker events", func(t *testing.T) {
|
||||
mockQueue := &MockScannerQueue{}
|
||||
|
||||
// Use a channel to synchronize and count calls
|
||||
callChan := make(chan struct{}, 5) // Buffer for multiple calls
|
||||
mockQueue.On("AddAllToQueue").Return(nil).Run(func(args mock.Arguments) {
|
||||
select {
|
||||
case callChan <- struct{}{}:
|
||||
default:
|
||||
// Channel full, but that's okay
|
||||
}
|
||||
}).Maybe()
|
||||
|
||||
ps := &periodicScanner{
|
||||
ticker: time.NewTicker(50 * time.Millisecond),
|
||||
ticker_changed: make(chan bool, 1),
|
||||
done: make(chan struct{}),
|
||||
tickerLocker: sync.Mutex{},
|
||||
scannerQueue: mockQueue,
|
||||
}
|
||||
|
||||
// Start runner
|
||||
go ps.scanIntervalRunner()
|
||||
|
||||
// Wait for at least one call with timeout
|
||||
select {
|
||||
case <-callChan:
|
||||
// Success - at least one call received
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("Expected at least one call to AddAllToQueue within 200ms timeout, but ticker events were not processed")
|
||||
}
|
||||
|
||||
// Proper cleanup - stop ticker first, then close done
|
||||
ps.ticker.Stop()
|
||||
close(ps.done)
|
||||
|
||||
// Give time for goroutine to finish
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify the queue was called as expected
|
||||
mockQueue.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("runner handles queue errors gracefully", func(t *testing.T) {
|
||||
mockQueue := &MockScannerQueue{}
|
||||
// Mock queue to return an error
|
||||
mockQueue.On("AddAllToQueue").Return(errors.New("queue error")).Maybe()
|
||||
|
||||
ps := &periodicScanner{
|
||||
ticker: time.NewTicker(30 * time.Millisecond),
|
||||
ticker_changed: make(chan bool, 1),
|
||||
done: make(chan struct{}),
|
||||
tickerLocker: sync.Mutex{},
|
||||
scannerQueue: mockQueue,
|
||||
}
|
||||
|
||||
// Start runner and let it run briefly
|
||||
go ps.scanIntervalRunner()
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
// Proper cleanup
|
||||
ps.ticker.Stop()
|
||||
close(ps.done)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Test passes if no panic occurred - errors should be logged gracefully
|
||||
mockQueue.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("runner responds to shutdown signal", func(t *testing.T) {
|
||||
mockQueue := &MockScannerQueue{}
|
||||
|
||||
ps := &periodicScanner{
|
||||
ticker: nil, // No ticker to avoid timing issues
|
||||
ticker_changed: make(chan bool, 1),
|
||||
done: make(chan struct{}),
|
||||
tickerLocker: sync.Mutex{},
|
||||
scannerQueue: mockQueue,
|
||||
}
|
||||
|
||||
runnerDone := make(chan bool)
|
||||
go func() {
|
||||
ps.scanIntervalRunner()
|
||||
close(runnerDone)
|
||||
}()
|
||||
|
||||
close(ps.done)
|
||||
|
||||
select {
|
||||
case <-runnerDone:
|
||||
// Success
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("scanIntervalRunner goroutine did not exit within 1 second after closing done channel")
|
||||
}
|
||||
|
||||
mockQueue.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("runner responds to ticker changes", func(t *testing.T) {
|
||||
mockQueue := &MockScannerQueue{}
|
||||
|
||||
ps := &periodicScanner{
|
||||
ticker: nil, // Start without ticker
|
||||
ticker_changed: make(chan bool, 1),
|
||||
done: make(chan struct{}),
|
||||
tickerLocker: sync.Mutex{},
|
||||
scannerQueue: mockQueue,
|
||||
}
|
||||
|
||||
runnerDone := make(chan bool)
|
||||
go func() {
|
||||
ps.scanIntervalRunner()
|
||||
close(runnerDone)
|
||||
}()
|
||||
|
||||
// Send ticker change signal
|
||||
select {
|
||||
case ps.ticker_changed <- true:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Could not send ticker change signal within 100ms - channel may be blocked")
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
close(ps.done)
|
||||
|
||||
// Wait for completion
|
||||
select {
|
||||
case <-runnerDone:
|
||||
// Success
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("scanIntervalRunner did not exit within 500ms after receiving ticker change signal and shutdown")
|
||||
}
|
||||
|
||||
// Test passes if no deadlock occurs
|
||||
mockQueue.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealScannerQueue(t *testing.T) {
|
||||
t.Run("real scanner queue interface compliance", func(t *testing.T) {
|
||||
queue := &RealScannerQueue{}
|
||||
|
||||
// Just verify it implements the interface correctly
|
||||
var _ ScannerQueue = queue
|
||||
|
||||
// Test that it doesn't panic when created
|
||||
assert.NotNil(t, queue, "RealScannerQueue instance should not be nil after creation")
|
||||
|
||||
// We don't test the actual call since it requires external setup
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_utils"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewRootAlbum(db *gorm.DB, rootPath string, owner *models.User) (*models.Album, error) {
|
||||
|
||||
if !ValidRootPath(rootPath) {
|
||||
return nil, ErrorInvalidRootPath
|
||||
}
|
||||
|
||||
if !path.IsAbs(rootPath) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rootPath = path.Join(wd, rootPath)
|
||||
}
|
||||
|
||||
owners := []models.User{
|
||||
*owner,
|
||||
}
|
||||
|
||||
var matchedAlbums []models.Album
|
||||
if err := db.Where("path_hash = ?", models.MD5Hash(rootPath)).Find(&matchedAlbums).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(matchedAlbums) > 0 {
|
||||
album := matchedAlbums[0]
|
||||
|
||||
var matchedUserAlbumCount int64
|
||||
if err := db.Table("user_albums").Where("user_id = ?", owner.ID).Where("album_id = ?", album.ID).Count(&matchedUserAlbumCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if matchedUserAlbumCount > 0 {
|
||||
return nil, errors.New(fmt.Sprintf("user already owns a path containing this path: %s", rootPath))
|
||||
}
|
||||
|
||||
if err := db.Model(&owner).Association("Albums").Append(&album); err != nil {
|
||||
return nil, errors.Wrap(err, "add owner to already existing album")
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
} else {
|
||||
album := models.Album{
|
||||
Title: path.Base(rootPath),
|
||||
Path: rootPath,
|
||||
Owners: owners,
|
||||
}
|
||||
|
||||
if err := db.Create(&album).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
}
|
||||
|
||||
var ErrorInvalidRootPath = errors.New("invalid root path")
|
||||
|
||||
func ValidRootPath(rootPath string) bool {
|
||||
_, err := os.Stat(rootPath)
|
||||
if err != nil {
|
||||
log.Warn(nil, "invalid root path", "root_path", rootPath, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func ScanAlbum(ctx scanner_task.TaskContext) error {
|
||||
newCtx, err := scanner_tasks.Tasks.BeforeScanAlbum(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "before scan album (%s)", ctx.GetAlbum().Path)
|
||||
}
|
||||
ctx = newCtx
|
||||
|
||||
// Scan for photos
|
||||
albumMedia, err := findMediaForAlbum(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "find media for album (%s): %s", ctx.GetAlbum().Path, err)
|
||||
}
|
||||
|
||||
changedMedia := make([]*models.Media, 0)
|
||||
for i, media := range albumMedia {
|
||||
mediaData := media_encoding.NewEncodeMediaData(media)
|
||||
|
||||
if err := scanMedia(ctx, media, &mediaData, i, len(albumMedia)); err != nil {
|
||||
scanner_utils.ScannerError(ctx, "Error scanning media for album (%d) file (%s): %s\n", ctx.GetAlbum().ID, media.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner_tasks.Tasks.AfterScanAlbum(ctx, changedMedia, albumMedia); err != nil {
|
||||
return errors.Wrap(err, "after scan album")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findMediaForAlbum(ctx scanner_task.TaskContext) ([]*models.Media, error) {
|
||||
|
||||
albumMedia := make([]*models.Media, 0)
|
||||
|
||||
dirContent, err := os.ReadDir(ctx.GetAlbum().Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range dirContent {
|
||||
mediaPath := path.Join(ctx.GetAlbum().Path, item.Name())
|
||||
log.Info(ctx, "Check the media", "media_path", mediaPath)
|
||||
|
||||
isDirSymlink, err := utils.IsDirSymlink(mediaPath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Cannot detect whether the path is symlink to a directory. Pretending it is not", "media_path", mediaPath)
|
||||
isDirSymlink = false
|
||||
}
|
||||
|
||||
if !item.IsDir() && !isDirSymlink && ctx.GetCache().IsPathMedia(mediaPath) {
|
||||
itemInfo, err := item.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skip, err := scanner_tasks.Tasks.MediaFound(ctx, itemInfo, mediaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
|
||||
err = ctx.DatabaseTransaction(func(ctx scanner_task.TaskContext) error {
|
||||
media, isNewMedia, err := ScanMedia(ctx.GetDB(), mediaPath, ctx.GetAlbum().ID, ctx.GetCache())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "scanning media error (%s)", mediaPath)
|
||||
}
|
||||
|
||||
if err = scanner_tasks.Tasks.AfterMediaFound(ctx, media, isNewMedia); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
albumMedia = append(albumMedia, media)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
scanner_utils.ScannerError(ctx, "Error scanning media for album (%d): %s\n", ctx.GetAlbum().ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return albumMedia, nil
|
||||
}
|
||||
|
||||
func processMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData) ([]*models.MediaURL, error) {
|
||||
|
||||
// Make sure media cache directory exists
|
||||
mediaCachePath, err := mediaData.Media.CachePath()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "cache directory error")
|
||||
}
|
||||
|
||||
return scanner_tasks.Tasks.ProcessMedia(ctx, mediaData, mediaCachePath)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package scanner_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testDataPath = "./test_media/library"
|
||||
|
||||
func TestNewRootPath(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user := models.User{
|
||||
Username: "user1",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Insert valid root album", func(t *testing.T) {
|
||||
album, err := scanner.NewRootAlbum(db, testDataPath, &user)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, album)
|
||||
assert.Contains(t, album.Path, "/api/scanner/test_media")
|
||||
assert.NotEmpty(t, album.Owners)
|
||||
})
|
||||
|
||||
t.Run("Insert duplicate root album", func(t *testing.T) {
|
||||
|
||||
_, err := scanner.NewRootAlbum(db, testDataPath, &user)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user already owns a path containing this path:")
|
||||
})
|
||||
|
||||
t.Run("Insert invalid root album", func(t *testing.T) {
|
||||
|
||||
_, err := scanner.NewRootAlbum(db, "./invalid_path", &user)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, err.Error(), "invalid root path")
|
||||
})
|
||||
|
||||
t.Run("Add existing root album to new user", func(t *testing.T) {
|
||||
|
||||
user2 := models.User{
|
||||
Username: "user2",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user2).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
album, err := scanner.NewRootAlbum(db, testDataPath, &user2)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, album)
|
||||
assert.Contains(t, album.Path, "/api/scanner/test_media")
|
||||
|
||||
ownerCount := db.Model(&album).Association("Owners").Count()
|
||||
assert.EqualValues(t, 2, ownerCount)
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package scanner_cache
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"github.com/photoview/photoview/api/scanner/media_type"
|
||||
)
|
||||
|
||||
type AlbumScannerCache struct {
|
||||
path_contains_photos map[string]bool
|
||||
photo_types map[string]media_type.MediaType
|
||||
ignore_data map[string][]string
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func MakeAlbumCache() *AlbumScannerCache {
|
||||
return &AlbumScannerCache{
|
||||
path_contains_photos: make(map[string]bool),
|
||||
photo_types: make(map[string]media_type.MediaType),
|
||||
ignore_data: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Insert single album directory in cache
|
||||
func (c *AlbumScannerCache) InsertAlbumPath(path string, containsPhoto bool) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
c.path_contains_photos[path] = containsPhoto
|
||||
}
|
||||
|
||||
// Insert album path and all parent directories up to the given root directory in cache
|
||||
func (c *AlbumScannerCache) InsertAlbumPaths(endPath string, root string, containsPhoto bool) {
|
||||
currPath := path.Clean(endPath)
|
||||
rootPath := path.Clean(root)
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
for currPath != rootPath || currPath == "." {
|
||||
|
||||
c.path_contains_photos[currPath] = containsPhoto
|
||||
|
||||
currPath = path.Dir(currPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AlbumScannerCache) AlbumContainsPhotos(path string) *bool {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
contains_photo, found := c.path_contains_photos[path]
|
||||
if found {
|
||||
// log.Printf("Album cache hit: %s\n", path)
|
||||
return &contains_photo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// func (c *AlbumScannerCache) InsertPhotoType(path string, content_type MediaType) {
|
||||
// c.mutex.Lock()
|
||||
// defer c.mutex.Unlock()
|
||||
|
||||
// (c.photo_types)[path] = content_type
|
||||
// }
|
||||
|
||||
func (c *AlbumScannerCache) GetMediaType(path string) media_type.MediaType {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
result, found := c.photo_types[path]
|
||||
if found {
|
||||
return result
|
||||
}
|
||||
|
||||
mediaType := media_type.GetMediaType(path)
|
||||
if mediaType == media_type.TypeUnknown {
|
||||
return mediaType
|
||||
}
|
||||
|
||||
c.photo_types[path] = mediaType
|
||||
|
||||
return mediaType
|
||||
}
|
||||
|
||||
func (c *AlbumScannerCache) GetAlbumIgnore(path string) *[]string {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
ignore_data, found := c.ignore_data[path]
|
||||
if found {
|
||||
return &ignore_data
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AlbumScannerCache) InsertAlbumIgnore(path string, ignoreData []string) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.ignore_data[path] = ignoreData
|
||||
}
|
||||
|
||||
func (c *AlbumScannerCache) IsPathMedia(mediaPath string) bool {
|
||||
mediaType := c.GetMediaType(mediaPath)
|
||||
|
||||
// Ignore hidden files
|
||||
if path.Base(mediaPath)[0:1] == "." {
|
||||
return false
|
||||
}
|
||||
|
||||
if !mediaType.IsSupported() {
|
||||
log.Printf("Unsupported media type %q for file: %s\n", mediaType, mediaPath)
|
||||
return false
|
||||
}
|
||||
|
||||
// Make sure file isn't empty
|
||||
fileStats, err := os.Stat(mediaPath)
|
||||
if err != nil || fileStats.Size() == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/media_type"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_cache"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ProcessSingleMediaFunc = ProcessSingleMedia
|
||||
|
||||
func ScanMedia(tx *gorm.DB, mediaPath string, albumId int, cache *scanner_cache.AlbumScannerCache) (*models.Media, bool, error) {
|
||||
mediaName := path.Base(mediaPath)
|
||||
|
||||
// Check if media already exists
|
||||
{
|
||||
var media []*models.Media
|
||||
|
||||
result := tx.Where("path_hash = ?", models.MD5Hash(mediaPath)).Find(&media)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, false, errors.Wrap(result.Error, "scan media fetch from database")
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
// log.Printf("Media already scanned: %s\n", mediaPath)
|
||||
return media[0], false, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Scanning media: %s\n", mediaPath)
|
||||
|
||||
mediaType := cache.GetMediaType(mediaPath)
|
||||
if mediaType == media_type.TypeUnknown {
|
||||
return nil, false, fmt.Errorf("could not determine if media %s of type %s was photo or video", mediaPath, mediaType)
|
||||
}
|
||||
|
||||
var mediaTypeText models.MediaType
|
||||
|
||||
if mediaType.IsVideo() {
|
||||
mediaTypeText = models.MediaTypeVideo
|
||||
} else {
|
||||
mediaTypeText = models.MediaTypePhoto
|
||||
}
|
||||
|
||||
stat, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
media := models.Media{
|
||||
Title: mediaName,
|
||||
Path: mediaPath,
|
||||
AlbumID: albumId,
|
||||
Type: mediaTypeText,
|
||||
DateShot: stat.ModTime(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&media).Error; err != nil {
|
||||
return nil, false, errors.Wrap(err, "could not insert media into database")
|
||||
}
|
||||
|
||||
return &media, true, nil
|
||||
}
|
||||
|
||||
// ProcessSingleMedia processes a single media, might be used to reprocess media with corrupted cache
|
||||
// Function waits for processing to finish before returning.
|
||||
func ProcessSingleMedia(ctx context.Context, db *gorm.DB, media *models.Media) error {
|
||||
albumCache := scanner_cache.MakeAlbumCache()
|
||||
|
||||
var album models.Album
|
||||
if err := db.Model(media).Association("Album").Find(&album); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mediaData := media_encoding.NewEncodeMediaData(media)
|
||||
|
||||
taskContext := scanner_task.NewTaskContext(ctx, db, &album, albumCache)
|
||||
if err := scanMedia(taskContext, media, &mediaData, 0, 1); err != nil {
|
||||
return errors.Wrap(err, "single media scan")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package scanner_queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/notification"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_cache"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const globalScannerProgress = "global-scanner-progress"
|
||||
|
||||
// ScannerJob describes a job on the queue to be run by the scanner over a single album
|
||||
type ScannerJob struct {
|
||||
ctx scanner_task.TaskContext
|
||||
// album *models.Album
|
||||
// cache *scanner_cache.AlbumScannerCache
|
||||
}
|
||||
|
||||
func NewScannerJob(ctx scanner_task.TaskContext) ScannerJob {
|
||||
return ScannerJob{
|
||||
ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (job *ScannerJob) Run(db *gorm.DB) {
|
||||
err := scanner.ScanAlbum(job.ctx)
|
||||
if err != nil {
|
||||
scanner_utils.ScannerError(nil, "Failed to scan album: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type ScannerQueueSettings struct {
|
||||
max_concurrent_tasks int
|
||||
}
|
||||
|
||||
type ScannerQueue struct {
|
||||
mutex sync.Mutex
|
||||
idle_chan chan bool
|
||||
in_progress []ScannerJob
|
||||
up_next []ScannerJob
|
||||
db *gorm.DB
|
||||
settings ScannerQueueSettings
|
||||
close_chan *chan bool
|
||||
running bool
|
||||
}
|
||||
|
||||
var global_scanner_queue ScannerQueue
|
||||
|
||||
func InitializeScannerQueue(db *gorm.DB) error {
|
||||
|
||||
var concurrentWorkers int
|
||||
{
|
||||
site_info, err := models.GetSiteInfo(db)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "get current workers from database")
|
||||
}
|
||||
concurrentWorkers = site_info.ConcurrentWorkers
|
||||
}
|
||||
|
||||
log.Printf("Initializing scanner queue with %d workers", concurrentWorkers)
|
||||
|
||||
global_scanner_queue = ScannerQueue{
|
||||
idle_chan: make(chan bool, 1),
|
||||
in_progress: make([]ScannerJob, 0),
|
||||
up_next: make([]ScannerJob, 0),
|
||||
db: db,
|
||||
settings: ScannerQueueSettings{max_concurrent_tasks: concurrentWorkers},
|
||||
close_chan: nil,
|
||||
running: true,
|
||||
}
|
||||
|
||||
go global_scanner_queue.startBackgroundWorker()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseScannerQueue() {
|
||||
global_scanner_queue.CloseBackgroundWorker()
|
||||
}
|
||||
|
||||
func ChangeScannerConcurrentWorkers(newMaxWorkers int) {
|
||||
global_scanner_queue.mutex.Lock()
|
||||
defer global_scanner_queue.mutex.Unlock()
|
||||
|
||||
log.Printf("Scanner max concurrent workers changed to: %d", newMaxWorkers)
|
||||
global_scanner_queue.settings.max_concurrent_tasks = newMaxWorkers
|
||||
}
|
||||
|
||||
func (queue *ScannerQueue) startBackgroundWorker() {
|
||||
|
||||
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
|
||||
|
||||
for {
|
||||
log.Println("Queue waiting")
|
||||
<-queue.idle_chan
|
||||
|
||||
queue.mutex.Lock()
|
||||
shouldStop := queue.close_chan != nil && len(queue.in_progress) == 0 && len(queue.up_next) == 0
|
||||
queue.running = false
|
||||
queue.mutex.Unlock()
|
||||
|
||||
if shouldStop {
|
||||
*queue.close_chan <- true
|
||||
break
|
||||
}
|
||||
|
||||
queue.processQueue(¬ifyThrottle)
|
||||
}
|
||||
|
||||
log.Println("Scanner background worker stopped")
|
||||
}
|
||||
|
||||
func (queue *ScannerQueue) CloseBackgroundWorker() {
|
||||
queue.mutex.Lock()
|
||||
closeChan := make(chan bool)
|
||||
queue.close_chan = &closeChan
|
||||
queue.mutex.Unlock()
|
||||
|
||||
queue.notify()
|
||||
|
||||
log.Println("Waiting for scanner background worker to finish all jobs...")
|
||||
<-closeChan
|
||||
}
|
||||
|
||||
func (queue *ScannerQueue) processQueue(notifyThrottle *utils.Throttle) {
|
||||
log.Println("Queue waiting for lock")
|
||||
queue.mutex.Lock()
|
||||
maxJobs := queue.settings.max_concurrent_tasks
|
||||
log.Printf("Queue running: in_progress: %d, max_tasks: %d, queue_len: %d\n", len(queue.in_progress), maxJobs, len(queue.up_next))
|
||||
|
||||
for len(queue.in_progress) < maxJobs && len(queue.up_next) > 0 {
|
||||
log.Println("Queue starting job")
|
||||
nextJob := queue.up_next[0]
|
||||
queue.up_next = queue.up_next[1:]
|
||||
queue.in_progress = append(queue.in_progress, nextJob)
|
||||
jobNum := len(queue.in_progress)
|
||||
|
||||
go func() {
|
||||
log.Printf("Starting job %d/%d\n", jobNum, maxJobs)
|
||||
nextJob.Run(queue.db)
|
||||
log.Printf("Finished job %d/%d\n", jobNum, maxJobs)
|
||||
|
||||
// Delete finished job from queue
|
||||
queue.mutex.Lock()
|
||||
for i, x := range queue.in_progress {
|
||||
if x == nextJob {
|
||||
queue.in_progress[i] = queue.in_progress[len(queue.in_progress)-1]
|
||||
queue.in_progress = queue.in_progress[0 : len(queue.in_progress)-1]
|
||||
break
|
||||
}
|
||||
}
|
||||
queue.mutex.Unlock()
|
||||
|
||||
queue.notify()
|
||||
}()
|
||||
}
|
||||
|
||||
inProgressLength := len(global_scanner_queue.in_progress)
|
||||
upNextLength := len(global_scanner_queue.up_next)
|
||||
|
||||
queue.mutex.Unlock()
|
||||
|
||||
if inProgressLength+upNextLength == 0 {
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: globalScannerProgress,
|
||||
Type: models.NotificationTypeMessage,
|
||||
Header: "Scanner complete",
|
||||
Content: "All jobs have been scanned",
|
||||
Positive: true,
|
||||
})
|
||||
} else {
|
||||
notifyThrottle.Trigger(func() {
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: globalScannerProgress,
|
||||
Type: models.NotificationTypeMessage,
|
||||
Header: "Scanning media",
|
||||
Content: fmt.Sprintf("%d jobs in progress\n%d jobs waiting", inProgressLength, upNextLength),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Notifies the queue that the jobs has changed
|
||||
func (queue *ScannerQueue) notify() bool {
|
||||
select {
|
||||
case queue.idle_chan <- true:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func AddAllToQueue() error {
|
||||
|
||||
var users []*models.User
|
||||
result := global_scanner_queue.db.Find(&users)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(result.Error, "get all users from database")
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
if err := AddUserToQueue(user); err != nil {
|
||||
return errors.Wrapf(err, "failed to add user for scanning (%d)", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserToQueue finds all root albums owned by the given user and adds them to the scanner queue.
|
||||
// Function does not block.
|
||||
func AddUserToQueue(user *models.User) error {
|
||||
albumCache := scanner_cache.MakeAlbumCache()
|
||||
albums, album_errors := scanner.FindAlbumsForUser(global_scanner_queue.db, user, albumCache)
|
||||
for _, err := range album_errors {
|
||||
return errors.Wrapf(err, "find albums for user (user_id: %d)", user.ID)
|
||||
}
|
||||
|
||||
global_scanner_queue.mutex.Lock()
|
||||
for _, album := range albums {
|
||||
global_scanner_queue.addJob(&ScannerJob{
|
||||
ctx: scanner_task.NewTaskContext(context.Background(), global_scanner_queue.db, album, albumCache),
|
||||
})
|
||||
}
|
||||
global_scanner_queue.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Queue should be locked prior to calling this function
|
||||
func (queue *ScannerQueue) addJob(job *ScannerJob) error {
|
||||
if exists, err := queue.jobOnQueue(job); exists || err != nil {
|
||||
return err
|
||||
}
|
||||
queue.up_next = append(queue.up_next, *job)
|
||||
queue.notify()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Queue should be locked prior to calling this function
|
||||
func (queue *ScannerQueue) jobOnQueue(job *ScannerJob) (bool, error) {
|
||||
|
||||
scannerJobs := append(queue.in_progress, queue.up_next...)
|
||||
|
||||
for _, scannerJob := range scannerJobs {
|
||||
if scannerJob.ctx.GetAlbum().ID == job.ctx.GetAlbum().ID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package scanner_queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_cache"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
)
|
||||
|
||||
var _ = flag.Bool("database", false, "run database integration tests")
|
||||
var _ = flag.Bool("filesystem", false, "run filesystem integration tests")
|
||||
|
||||
func makeAlbumWithID(id int) *models.Album {
|
||||
var album models.Album
|
||||
album.ID = id
|
||||
|
||||
return &album
|
||||
}
|
||||
|
||||
func makeScannerJob(albumID int) ScannerJob {
|
||||
return NewScannerJob(scanner_task.NewTaskContext(context.Background(), nil, makeAlbumWithID(albumID), scanner_cache.MakeAlbumCache()))
|
||||
}
|
||||
|
||||
func TestScannerQueueAddJob(t *testing.T) {
|
||||
|
||||
scannerJobs := []ScannerJob{
|
||||
makeScannerJob(100),
|
||||
makeScannerJob(20),
|
||||
}
|
||||
|
||||
mockScannerQueue := ScannerQueue{
|
||||
idle_chan: make(chan bool, 1),
|
||||
in_progress: make([]ScannerJob, 0),
|
||||
up_next: scannerJobs,
|
||||
db: nil,
|
||||
}
|
||||
|
||||
t.Run("add new job to scanner queue", func(t *testing.T) {
|
||||
newJob := makeScannerJob(42)
|
||||
|
||||
startingJobs := len(mockScannerQueue.up_next)
|
||||
|
||||
err := mockScannerQueue.addJob(&newJob)
|
||||
if err != nil {
|
||||
t.Errorf(".AddJob() returned an unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if len(mockScannerQueue.up_next) != startingJobs+1 {
|
||||
t.Errorf("Expected scanner queue length to be %d but got %d", startingJobs+1, len(mockScannerQueue.up_next))
|
||||
} else if mockScannerQueue.up_next[len(mockScannerQueue.up_next)-1] != newJob {
|
||||
t.Errorf("Expected scanner queue to contain the job that was added: %+v", newJob)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("add existing job to scanner queue", func(t *testing.T) {
|
||||
startingJobs := len(mockScannerQueue.up_next)
|
||||
|
||||
job := makeScannerJob(20)
|
||||
err := mockScannerQueue.addJob(&job)
|
||||
if err != nil {
|
||||
t.Errorf(".AddJob() returned an unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if len(mockScannerQueue.up_next) != startingJobs {
|
||||
t.Errorf("Expected scanner queue length not to change: start length %d, new length %d", startingJobs, len(mockScannerQueue.up_next))
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func TestScannerQueueJobOnQueue(t *testing.T) {
|
||||
|
||||
scannerJobs := []ScannerJob{
|
||||
makeScannerJob(100),
|
||||
makeScannerJob(20),
|
||||
}
|
||||
|
||||
mockScannerQueue := ScannerQueue{
|
||||
idle_chan: make(chan bool, 1),
|
||||
in_progress: make([]ScannerJob, 0),
|
||||
up_next: scannerJobs,
|
||||
db: nil,
|
||||
}
|
||||
|
||||
onQueueTests := []struct {
|
||||
string
|
||||
bool
|
||||
ScannerJob
|
||||
}{
|
||||
{"album which owner is already on the queue", true, makeScannerJob(100)},
|
||||
{"album that is not on the queue", false, makeScannerJob(321)},
|
||||
}
|
||||
|
||||
for _, test := range onQueueTests {
|
||||
t.Run(test.string, func(t *testing.T) {
|
||||
onQueue, err := mockScannerQueue.jobOnQueue(&test.ScannerJob)
|
||||
if err != nil {
|
||||
t.Error("Expected jobOnQueue not to return an error")
|
||||
} else if onQueue != test.bool {
|
||||
t.Fail()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package scanner_task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"io/fs"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_cache"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ScannerTask is an interface for a task to be performed as a part of the scanner pipeline
|
||||
type ScannerTask interface {
|
||||
// BeforeScanAlbum will run at the beginning of the scan task.
|
||||
// New values can be stored in the returned TaskContext that will live throughout the lifetime of the task.
|
||||
BeforeScanAlbum(ctx TaskContext) (TaskContext, error)
|
||||
|
||||
// AfterScanAlbum will run at the end of the scan task.
|
||||
AfterScanAlbum(ctx TaskContext, changedMedia []*models.Media, albumMedia []*models.Media) error
|
||||
|
||||
// MediaFound will run for each media file found on the filesystem.
|
||||
// It will run even when the media is already present in the database.
|
||||
// If the returned skip value is true, the media will be skipped and further steps will not be executed for the given file.
|
||||
MediaFound(ctx TaskContext, fileInfo fs.FileInfo, mediaPath string) (skip bool, err error)
|
||||
|
||||
// AfterMediaFound will run each media file after is has been saved to the database, but not processed yet.
|
||||
// It will run even when the media is already present in the database, in that case `newMedia` will be true.
|
||||
AfterMediaFound(ctx TaskContext, media *models.Media, newMedia bool) error
|
||||
|
||||
BeforeProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData) (TaskContext, error)
|
||||
ProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) (updatedURLs []*models.MediaURL, err error)
|
||||
AfterProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData, updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error
|
||||
}
|
||||
|
||||
type TaskContext struct {
|
||||
context.Context
|
||||
}
|
||||
|
||||
func NewTaskContext(parent context.Context, db *gorm.DB, album *models.Album, cache *scanner_cache.AlbumScannerCache) TaskContext {
|
||||
ctx := TaskContext{Context: parent}
|
||||
ctx = ctx.WithValue(taskCtxKeyAlbum, album)
|
||||
ctx = ctx.WithValue(taskCtxKeyAlbumCache, cache)
|
||||
ctx = ctx.WithDB(db)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
type taskCtxKeyType string
|
||||
|
||||
const (
|
||||
taskCtxKeyAlbum taskCtxKeyType = "task_album"
|
||||
taskCtxKeyAlbumCache taskCtxKeyType = "task_album_cache"
|
||||
taskCtxKeyDatabase taskCtxKeyType = "task_database"
|
||||
)
|
||||
|
||||
func (c TaskContext) GetAlbum() *models.Album {
|
||||
return c.Context.Value(taskCtxKeyAlbum).(*models.Album)
|
||||
}
|
||||
|
||||
func (c TaskContext) GetCache() *scanner_cache.AlbumScannerCache {
|
||||
return c.Context.Value(taskCtxKeyAlbumCache).(*scanner_cache.AlbumScannerCache)
|
||||
}
|
||||
|
||||
func (c TaskContext) GetDB() *gorm.DB {
|
||||
return c.Context.Value(taskCtxKeyDatabase).(*gorm.DB)
|
||||
}
|
||||
|
||||
func (c TaskContext) DatabaseTransaction(transFunc func(ctx TaskContext) error, opts ...*sql.TxOptions) error {
|
||||
return c.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
return transFunc(c.WithDB(tx))
|
||||
}, opts...)
|
||||
}
|
||||
|
||||
func (c TaskContext) WithValue(key, val interface{}) TaskContext {
|
||||
return TaskContext{
|
||||
Context: context.WithValue(c.Context, key, val),
|
||||
}
|
||||
}
|
||||
|
||||
func (c TaskContext) WithDB(db *gorm.DB) TaskContext {
|
||||
// Allow db to be nil in tests
|
||||
if db == nil && flag.Lookup("test.v") != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
return c.WithValue(taskCtxKeyDatabase, db.WithContext(c.Context))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package scanner_task
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
)
|
||||
|
||||
// ScannerTaskBase provides a default "empty" implementation of ScannerTask,
|
||||
type ScannerTaskBase struct{}
|
||||
|
||||
func (t ScannerTaskBase) BeforeScanAlbum(ctx TaskContext) (TaskContext, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) AfterScanAlbum(ctx TaskContext, changedMedia []*models.Media, albumMedia []*models.Media) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) MediaFound(ctx TaskContext, fileInfo fs.FileInfo, mediaPath string) (skip bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) AfterMediaFound(ctx TaskContext, media *models.Media, newMedia bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) BeforeProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData) (TaskContext, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) ProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) (updatedURLs []*models.MediaURL, err error) {
|
||||
return []*models.MediaURL{}, nil
|
||||
}
|
||||
|
||||
func (t ScannerTaskBase) AfterProcessMedia(ctx TaskContext, mediaData *media_encoding.EncodeMediaData, updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"os"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
)
|
||||
|
||||
type BlurhashTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t BlurhashTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
|
||||
hasThumbnailUpdated := false
|
||||
for _, url := range updatedURLs {
|
||||
if url.Purpose == models.PhotoThumbnail || url.Purpose == models.VideoThumbnail {
|
||||
hasThumbnailUpdated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var media *models.Media
|
||||
if err := ctx.GetDB().Preload("MediaURL").Where("id = ?", mediaData.Media.ID).First(&media).Error; err != nil {
|
||||
return fmt.Errorf("failed to get media(id:%d): %w", mediaData.Media.ID, err)
|
||||
}
|
||||
|
||||
if media.Blurhash != nil && !hasThumbnailUpdated {
|
||||
log.Info(ctx, "No thumbnail updated, ignore generating blurhash", "media", media.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
thumbnail, err := media.GetThumbnail()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get thumbnail of image %q: %w", mediaData.Media.Path, err)
|
||||
}
|
||||
|
||||
hashStr, err := generateBlurhashFromThumbnail(thumbnail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate blurhash of image %q: %w", mediaData.Media.Path, err)
|
||||
}
|
||||
|
||||
media.Blurhash = &hashStr
|
||||
if err := ctx.GetDB().Select("blurhash").Save(media).Error; err != nil {
|
||||
return fmt.Errorf("failed to store blurhash of image %q: %w", mediaData.Media.Path, err)
|
||||
}
|
||||
|
||||
log.Info(ctx, "Generated blurhash of image", "media", mediaData.Media.Path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateBlurhashFromThumbnail generates a blurhash for a single media and stores it in the database
|
||||
func generateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) {
|
||||
path, err := thumbnail.CachedPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get path of media(id:%d) error: %w", thumbnail.MediaID, err)
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open %q error: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
imageData, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode %q error: %w", path, err)
|
||||
}
|
||||
|
||||
const (
|
||||
componentX = 4
|
||||
componentY = 3
|
||||
)
|
||||
hashStr, err := blurhash.Encode(componentX, componentY, imageData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode blurhash of %q error: %w", path, err)
|
||||
}
|
||||
|
||||
return hashStr, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package cleanup_tasks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_utils"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CleanupMedia removes media entries from the database that are no longer present on the filesystem
|
||||
func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error {
|
||||
albumMediaIds := make([]int, len(albumMedia))
|
||||
for i, media := range albumMedia {
|
||||
albumMediaIds[i] = media.ID
|
||||
}
|
||||
|
||||
// Will get from database
|
||||
var mediaList []models.Media
|
||||
|
||||
query := db.Where("album_id = ?", albumId)
|
||||
|
||||
// Select media from database that was not found on hard disk
|
||||
if len(albumMedia) > 0 {
|
||||
query = query.Where("NOT id IN (?)", albumMediaIds)
|
||||
}
|
||||
|
||||
if err := query.Find(&mediaList).Error; err != nil {
|
||||
return []error{errors.Wrap(err, "get media files to be deleted from database")}
|
||||
}
|
||||
|
||||
deleteErrors := make([]error, 0)
|
||||
|
||||
mediaIDs := make([]int, 0)
|
||||
for _, media := range mediaList {
|
||||
|
||||
mediaIDs = append(mediaIDs, media.ID)
|
||||
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
|
||||
err := os.RemoveAll(cachePath)
|
||||
if err != nil {
|
||||
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if len(mediaIDs) > 0 {
|
||||
if err := db.Where("id IN (?)", mediaIDs).Delete(models.Media{}).Error; err != nil {
|
||||
deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old media from database"))
|
||||
}
|
||||
|
||||
// Reload faces after deleting media
|
||||
if face_detection.GlobalFaceDetector != nil {
|
||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||
deleteErrors = append(deleteErrors, errors.Wrap(err, "reload faces from database"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleteErrors
|
||||
}
|
||||
|
||||
// DeleteOldUserAlbums finds and deletes old albums in the database and cache that does not exist on the filesystem anymore.
|
||||
func DeleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *models.User) []error {
|
||||
if len(scannedAlbums) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scannedAlbumIDs := make([]interface{}, len(scannedAlbums))
|
||||
for i, album := range scannedAlbums {
|
||||
scannedAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
// Old albums to be deleted
|
||||
var deleteAlbums []models.Album
|
||||
|
||||
// Find old albums in database
|
||||
query := db.
|
||||
Select("albums.*").
|
||||
Table("user_albums").
|
||||
Joins("JOIN albums ON user_albums.album_id = albums.id").
|
||||
Where("user_id = ?", user.ID).
|
||||
Where("album_id NOT IN (?)", scannedAlbumIDs)
|
||||
|
||||
if err := query.Find(&deleteAlbums).Error; err != nil {
|
||||
return []error{errors.Wrap(err, "get albums to be deleted from database")}
|
||||
}
|
||||
|
||||
if len(deleteAlbums) == 0 {
|
||||
return []error{}
|
||||
}
|
||||
|
||||
deleteErrors := make([]error, 0)
|
||||
|
||||
// Delete old albums from cache
|
||||
deleteAlbumIDs := make([]int, len(deleteAlbums))
|
||||
for i, album := range deleteAlbums {
|
||||
deleteAlbumIDs[i] = album.ID
|
||||
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(album.ID)))
|
||||
err := os.RemoveAll(cachePath)
|
||||
if err != nil {
|
||||
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old albums from database
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("album_id IN (?)", deleteAlbumIDs).Delete(&models.UserAlbums{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("id IN (?)", deleteAlbumIDs).Delete(models.Album{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
scanner_utils.ScannerError(nil, "Could not delete old albums from database:\n%s\n", err)
|
||||
deleteErrors = append(deleteErrors, err)
|
||||
}
|
||||
|
||||
// Reload faces after deleting albums
|
||||
if face_detection.GlobalFaceDetector != nil {
|
||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||
deleteErrors = append(deleteErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
return deleteErrors
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package cleanup_tasks_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/otiai10/copy"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
scanner_utils "github.com/photoview/photoview/api/test_utils/scanner"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
|
||||
func TestCleanupMedia(t *testing.T) {
|
||||
test_utils.FilesystemTest(t)
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
if !assert.NoError(t, face_detection.InitializeFaceDetector(db)) {
|
||||
return
|
||||
}
|
||||
|
||||
testDir := t.TempDir()
|
||||
assert.NoError(t, copy.Copy("../../test_media/library", testDir))
|
||||
|
||||
countAllMedia := func() int {
|
||||
var allMedia []*models.Media
|
||||
if !assert.NoError(t, db.Find(&allMedia).Error) {
|
||||
return -1
|
||||
}
|
||||
return len(allMedia)
|
||||
}
|
||||
|
||||
countAllMediaURLs := func() int {
|
||||
var allMediaURLs []*models.MediaURL
|
||||
if !assert.NoError(t, db.Find(&allMediaURLs).Error) {
|
||||
return -1
|
||||
}
|
||||
return len(allMediaURLs)
|
||||
}
|
||||
|
||||
pass := "1234"
|
||||
user1, err := models.RegisterUser(db, "user1", &pass, true)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
user2, err := models.RegisterUser(db, "user2", &pass, true)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root album",
|
||||
Path: testDir,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
err = db.Model(user1).Association("Albums").Append(&rootAlbum)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
err = db.Model(user2).Association("Albums").Append(&rootAlbum)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Modify albums", func(t *testing.T) {
|
||||
scanner_utils.RunScannerOnUser(t, db, user1)
|
||||
assert.Equal(t, 9, countAllMedia())
|
||||
assert.Equal(t, 18, countAllMediaURLs())
|
||||
|
||||
// move faces directory
|
||||
assert.NoError(t, os.Rename(path.Join(testDir, "faces"), path.Join(testDir, "faces_moved")))
|
||||
scanner_utils.RunScannerAll(t, db)
|
||||
assert.Equal(t, 9, countAllMedia())
|
||||
assert.Equal(t, 18, countAllMediaURLs())
|
||||
|
||||
// remove faces_moved directory
|
||||
assert.NoError(t, os.RemoveAll(path.Join(testDir, "faces_moved")))
|
||||
scanner_utils.RunScannerAll(t, db)
|
||||
assert.Equal(t, 3, countAllMedia())
|
||||
assert.Equal(t, 6, countAllMediaURLs())
|
||||
})
|
||||
|
||||
t.Run("Modify images", func(t *testing.T) {
|
||||
assert.NoError(t, os.Rename(path.Join(testDir, "buttercup_close_summer_yellow.jpg"), path.Join(testDir, "yellow-flower.jpg")))
|
||||
scanner_utils.RunScannerAll(t, db)
|
||||
assert.Equal(t, 3, countAllMedia())
|
||||
assert.Equal(t, 6, countAllMediaURLs())
|
||||
|
||||
assert.NoError(t, os.Remove(path.Join(testDir, "lilac_lilac_bush_lilac.jpg")))
|
||||
scanner_utils.RunScannerAll(t, db)
|
||||
assert.Equal(t, 2, countAllMedia())
|
||||
assert.Equal(t, 4, countAllMediaURLs())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cleanup_tasks
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_utils"
|
||||
)
|
||||
|
||||
type MediaCleanupTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t MediaCleanupTask) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
|
||||
albumMedia []*models.Media) error {
|
||||
|
||||
cleanupErrors := CleanupMedia(ctx.GetDB(), ctx.GetAlbum().ID, albumMedia)
|
||||
for _, err := range cleanupErrors {
|
||||
scanner_utils.ScannerError(ctx, "delete old media: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/externaltools/exif"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
)
|
||||
|
||||
type ExifTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t ExifTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
|
||||
if !newMedia {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := SaveEXIF(ctx.GetDB(), media); err != nil {
|
||||
log.Warn(ctx, "SaveEXIF failed", "title", media.Title, "error", err, "path", media.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveEXIF scans the media file for exif metadata and saves it in the database if found
|
||||
func SaveEXIF(tx *gorm.DB, media *models.Media) error {
|
||||
// Check if EXIF data already exists
|
||||
if media.ExifID != nil {
|
||||
var e models.MediaEXIF
|
||||
var err error
|
||||
if err = tx.First(&e, media.ExifID).Error; err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed to get EXIF for %q from database: %w", media.Path, err)
|
||||
}
|
||||
log.Warn(
|
||||
tx.Statement.Context,
|
||||
"EXIF metadata not found in database, will re-parse it",
|
||||
"path", media.Path,
|
||||
"error", err,
|
||||
)
|
||||
media.ExifID = nil
|
||||
}
|
||||
|
||||
exifData, err := exif.Parse(media.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse exif data: %w", err)
|
||||
}
|
||||
|
||||
if exifData == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add EXIF to database and link to media
|
||||
if err := tx.Model(media).Association("Exif").Replace(exifData); err != nil {
|
||||
return fmt.Errorf("failed to save media exif to database: %w", err)
|
||||
}
|
||||
|
||||
if exifData.DateShot != nil && !exifData.DateShot.Equal(media.DateShot) {
|
||||
if err := tx.Save(media).Error; err != nil {
|
||||
return fmt.Errorf("failed to update EXIF metadata for the media %s: %w", media.Path, err)
|
||||
}
|
||||
media.DateShot = *exifData.DateShot
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_utils"
|
||||
)
|
||||
|
||||
type FaceDetectionTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t FaceDetectionTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
|
||||
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
|
||||
|
||||
didProcess := len(updatedURLs) > 0
|
||||
|
||||
if didProcess && mediaData.Media.Type == models.MediaTypePhoto {
|
||||
media := mediaData.Media
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil
|
||||
}
|
||||
if err := face_detection.GlobalFaceDetector.DetectFaces(ctx.GetDB(), media); err != nil {
|
||||
scanner_utils.ScannerError(ctx, "Error detecting faces in image (%s): %s", media.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
)
|
||||
|
||||
type IgnorefileTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
type ignorefileTaskKey string
|
||||
|
||||
const albumIgnoreKey ignorefileTaskKey = "album_ignore_key"
|
||||
|
||||
func getAlbumIgnore(ctx scanner_task.TaskContext) *ignore.GitIgnore {
|
||||
return ctx.Value(albumIgnoreKey).(*ignore.GitIgnore)
|
||||
}
|
||||
|
||||
func (t IgnorefileTask) BeforeScanAlbum(ctx scanner_task.TaskContext) (scanner_task.TaskContext, error) {
|
||||
albumIgnore := ignore.CompileIgnoreLines(*ctx.GetCache().GetAlbumIgnore(ctx.GetAlbum().Path)...)
|
||||
return ctx.WithValue(albumIgnoreKey, albumIgnore), nil
|
||||
}
|
||||
|
||||
func (t IgnorefileTask) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (bool, error) {
|
||||
|
||||
// Match file against ignore data
|
||||
if getAlbumIgnore(ctx).MatchesPath(fileInfo.Name()) {
|
||||
log.Printf("File %s ignored\n", fileInfo.Name())
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/notification"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
type NotificationTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
throttle utils.Throttle
|
||||
albumKey string
|
||||
}
|
||||
|
||||
func NewNotificationTask() NotificationTask {
|
||||
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
|
||||
notifyThrottle.Trigger(nil)
|
||||
|
||||
return NotificationTask{
|
||||
albumKey: utils.GenerateToken(),
|
||||
throttle: notifyThrottle,
|
||||
}
|
||||
}
|
||||
|
||||
func (t NotificationTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
|
||||
if newMedia {
|
||||
t.throttle.Trigger(func() {
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: t.albumKey,
|
||||
Type: models.NotificationTypeMessage,
|
||||
Header: fmt.Sprintf("Found new media in album '%s'", ctx.GetAlbum().Title),
|
||||
Content: fmt.Sprintf("Found %s", media.Path),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t NotificationTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
|
||||
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
|
||||
|
||||
if len(updatedURLs) > 0 {
|
||||
progress := float64(mediaIndex) / float64(mediaTotal) * 100.0
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: t.albumKey,
|
||||
Type: models.NotificationTypeProgress,
|
||||
Header: fmt.Sprintf("Processing media for album '%s'", ctx.GetAlbum().Title),
|
||||
Content: fmt.Sprintf("Processed media at %s", mediaData.Media.Path),
|
||||
Progress: &progress,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t NotificationTask) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
|
||||
albumMedia []*models.Media) error {
|
||||
|
||||
if len(changedMedia) > 0 {
|
||||
timeoutDelay := 2000
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: t.albumKey,
|
||||
Type: models.NotificationTypeMessage,
|
||||
Positive: true,
|
||||
Header: fmt.Sprintf("Done processing media for album '%s'", ctx.GetAlbum().Title),
|
||||
Content: "All media have been processed",
|
||||
Timeout: &timeoutDelay,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"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/utils"
|
||||
)
|
||||
|
||||
type CounterpartFilesTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t CounterpartFilesTask) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (skip bool, err error) {
|
||||
fileType := media_type.GetMediaType(mediaPath)
|
||||
|
||||
if !fileType.IsSupported() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if utils.EnvDisableRawProcessing.GetBool() {
|
||||
if !fileType.IsWebCompatible() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Don't skip the JPEGs if raw processing is disabled. Treat them as standalone files.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if fileType.IsWebCompatible() {
|
||||
_, existed := media_type.FindRawCounterpart(mediaPath)
|
||||
if existed {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (t CounterpartFilesTask) BeforeProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData) (scanner_task.TaskContext, error) {
|
||||
|
||||
mediaType := ctx.GetCache().GetMediaType(mediaData.Media.Path)
|
||||
if mediaType == media_type.TypeUnknown {
|
||||
return ctx, fmt.Errorf("scan for counterpart file %s failed: media type is %s", mediaData.Media.Path, mediaType)
|
||||
}
|
||||
|
||||
if mediaType.IsWebCompatible() {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
counterpartFile, ok := media_type.FindWebCounterpart(mediaData.Media.Path)
|
||||
if !ok {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
mediaData.CounterpartPath = &counterpartFile
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
|
||||
func TestCounterpartFilesTaskMediaFound(t *testing.T) {
|
||||
mediaPath := test_utils.PathFromAPIRoot("scanner", "test_media", "real_media")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
disableRawProcessing bool
|
||||
wantSkip bool
|
||||
}{
|
||||
{
|
||||
name: "StandaloneProcessRaw",
|
||||
file: "standalone_jpg.jpg",
|
||||
disableRawProcessing: false,
|
||||
wantSkip: false,
|
||||
},
|
||||
{
|
||||
name: "StandaloneNotProcessRaw",
|
||||
file: "standalone_jpg.jpg",
|
||||
disableRawProcessing: true,
|
||||
wantSkip: false,
|
||||
},
|
||||
{
|
||||
name: "RawJpegProcessRaw",
|
||||
file: "raw_with_jpg.jpg",
|
||||
disableRawProcessing: false,
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "RawJpegNotProcessRaw",
|
||||
file: "raw_with_jpg.jpg",
|
||||
disableRawProcessing: true,
|
||||
wantSkip: false,
|
||||
},
|
||||
{
|
||||
name: "RawProcessRaw",
|
||||
file: "raw_with_jpg.tiff",
|
||||
disableRawProcessing: false,
|
||||
wantSkip: false,
|
||||
},
|
||||
{
|
||||
name: "RawNotProcessRaw",
|
||||
file: "raw_with_jpg.tiff",
|
||||
disableRawProcessing: true,
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownProcessRaw",
|
||||
file: "file.pdf",
|
||||
disableRawProcessing: false,
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownNotProcessRaw",
|
||||
file: "file.pdf",
|
||||
disableRawProcessing: true,
|
||||
wantSkip: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(string(utils.EnvDisableRawProcessing), fmt.Sprintf("%v", tc.disableRawProcessing))
|
||||
|
||||
ctx := scanner_task.NewTaskContext(context.Background(), nil, nil, nil)
|
||||
|
||||
fname := filepath.Join(mediaPath, tc.file)
|
||||
fi, err := os.Stat(fname)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(%q) error: %v", fname, err)
|
||||
}
|
||||
|
||||
var task CounterpartFilesTask
|
||||
got, err := task.MediaFound(ctx, fi, fname)
|
||||
if err != nil {
|
||||
t.Fatalf("task.MediaFound(ctx, %q) error: %v", fname, err)
|
||||
}
|
||||
|
||||
if got, want := got, tc.wantSkip; got != want {
|
||||
t.Errorf("task.MediaFound(ctx, %q) = (skip)%v, want skip: %v", fname, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ProcessPhotoTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) ([]*models.MediaURL, error) {
|
||||
if mediaData.Media.Type != models.MediaTypePhoto {
|
||||
return []*models.MediaURL{}, nil
|
||||
}
|
||||
|
||||
updatedURLs := make([]*models.MediaURL, 0)
|
||||
photo := mediaData.Media
|
||||
|
||||
log.Info(ctx, "Processing photo", "photo", photo.Path)
|
||||
|
||||
photoURLFromDB := makePhotoURLChecker(ctx.GetDB(), photo.ID)
|
||||
|
||||
// original photo url
|
||||
origURL, err := photoURLFromDB(models.MediaOriginal)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
// Thumbnail
|
||||
thumbURL, err := photoURLFromDB(models.PhotoThumbnail)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "error processing photo thumbnail")
|
||||
}
|
||||
|
||||
// Highres
|
||||
highResURL, err := photoURLFromDB(models.PhotoHighRes)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "error processing photo highres")
|
||||
}
|
||||
|
||||
var baseImagePath string = photo.Path
|
||||
|
||||
// Generate high res jpeg
|
||||
if highResURL == nil {
|
||||
|
||||
contentType, err := mediaData.ContentType()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
if !contentType.IsWebCompatible() {
|
||||
highresName := generateUniqueMediaNamePrefixed("highres", photo.Path, ".jpg")
|
||||
baseImagePath = path.Join(mediaCachePath, highresName)
|
||||
|
||||
highRes, err := generateSaveHighResJPEG(ctx.GetDB(), photo, mediaData, highresName, baseImagePath, nil)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, highRes)
|
||||
}
|
||||
} else {
|
||||
// Verify that highres photo still exists in cache
|
||||
baseImagePath = path.Join(mediaCachePath, highResURL.MediaName)
|
||||
|
||||
if _, err := os.Stat(baseImagePath); os.IsNotExist(err) {
|
||||
log.Info(ctx, "High-res photo found in database but not in cache, re-encoding photo to cache", "media_name", highResURL.MediaName)
|
||||
updatedURLs = append(updatedURLs, highResURL)
|
||||
|
||||
err = mediaData.EncodeHighRes(baseImagePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "creating high-res cached image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save original photo to database
|
||||
if origURL == nil {
|
||||
|
||||
// Make sure photo dimensions is set
|
||||
photoDimensions, err := media_encoding.GetPhotoDimensions(photo.Path)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
original, err := saveOriginalPhotoToDB(ctx.GetDB(), photo, mediaData, photoDimensions)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "saving original photo to database")
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, original)
|
||||
}
|
||||
|
||||
// Save thumbnail to cache
|
||||
if thumbURL == nil {
|
||||
thumbnailName := generateUniqueMediaNamePrefixed("thumbnail", photo.Path, ".jpg")
|
||||
thumbnail, err := generateSaveThumbnailJPEG(ctx.GetDB(), photo, thumbnailName, mediaCachePath, baseImagePath, nil)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, thumbnail)
|
||||
} else {
|
||||
// Verify that thumbnail photo still exists in cache
|
||||
thumbPath := path.Join(mediaCachePath, thumbURL.MediaName)
|
||||
|
||||
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
|
||||
updatedURLs = append(updatedURLs, thumbURL)
|
||||
log.Info(ctx, "Thumbnail photo found in database but not in cache, re-encoding photo to cache", "media_name", thumbURL.MediaName)
|
||||
|
||||
_, err := media_encoding.EncodeThumbnail(ctx.GetDB(), baseImagePath, thumbPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "could not create thumbnail cached image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedURLs, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
type ProcessVideoTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) ([]*models.MediaURL, error) {
|
||||
if mediaData.Media.Type != models.MediaTypeVideo {
|
||||
return []*models.MediaURL{}, nil
|
||||
}
|
||||
|
||||
updatedURLs := make([]*models.MediaURL, 0)
|
||||
video := mediaData.Media
|
||||
|
||||
log.Info(ctx, "Processing video", "video", video.Path)
|
||||
|
||||
mediaURLFromDB := makePhotoURLChecker(ctx.GetDB(), video.ID)
|
||||
|
||||
videoOriginalURL, err := mediaURLFromDB(models.MediaOriginal)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "error processing video original format")
|
||||
}
|
||||
|
||||
videoWebURL, err := mediaURLFromDB(models.VideoWeb)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "error processing video web-format")
|
||||
}
|
||||
|
||||
videoThumbnailURL, err := mediaURLFromDB(models.VideoThumbnail)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "error processing video thumbnail")
|
||||
}
|
||||
|
||||
videoType, err := mediaData.ContentType()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, fmt.Errorf("getting video content type error: %w", err)
|
||||
}
|
||||
|
||||
if videoOriginalURL == nil && videoType.IsWebCompatible() {
|
||||
origVideoPath := video.Path
|
||||
videoMediaName := generateUniqueMediaName(video.Path)
|
||||
|
||||
webMetadata, err := ReadVideoStreamMetadata(origVideoPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to read metadata for original video (%s)", video.Title)
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(origVideoPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of original video")
|
||||
}
|
||||
|
||||
mediaURL := models.MediaURL{
|
||||
MediaID: video.ID,
|
||||
MediaName: videoMediaName,
|
||||
Width: webMetadata.Width,
|
||||
Height: webMetadata.Height,
|
||||
Purpose: models.MediaOriginal,
|
||||
ContentType: videoType.String(),
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := ctx.GetDB().Create(&mediaURL).Error; err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "insert original video into database (%s)", video.Title)
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, &mediaURL)
|
||||
}
|
||||
|
||||
if videoWebURL == nil && !videoType.IsWebCompatible() {
|
||||
webVideoName := fmt.Sprintf("web_video_%s_%s", path.Base(video.Path), utils.GenerateToken())
|
||||
webVideoName = strings.ReplaceAll(webVideoName, ".", "_")
|
||||
webVideoName = strings.ReplaceAll(webVideoName, " ", "_")
|
||||
webVideoName = webVideoName + ".mp4"
|
||||
|
||||
webVideoPath := path.Join(mediaCachePath, webVideoName)
|
||||
|
||||
err = executable_worker.Ffmpeg.EncodeMp4(video.Path, webVideoPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "could not encode mp4 video (%s)", video.Path)
|
||||
}
|
||||
|
||||
webMetadata, err := ReadVideoStreamMetadata(webVideoPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to read metadata for encoded web-video (%s)", video.Title)
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(webVideoPath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of web-optimized video")
|
||||
}
|
||||
|
||||
mediaURL := models.MediaURL{
|
||||
MediaID: video.ID,
|
||||
MediaName: webVideoName,
|
||||
Width: webMetadata.Width,
|
||||
Height: webMetadata.Height,
|
||||
Purpose: models.VideoWeb,
|
||||
ContentType: "video/mp4",
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := ctx.GetDB().Create(&mediaURL).Error; err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to insert encoded web-video into database (%s)", video.Title)
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, &mediaURL)
|
||||
}
|
||||
|
||||
probeData, err := mediaData.VideoMetadata()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
if videoThumbnailURL == nil {
|
||||
videoThumbName := fmt.Sprintf("video_thumb_%s_%s", path.Base(video.Path), utils.GenerateToken())
|
||||
videoThumbName = strings.ReplaceAll(videoThumbName, ".", "_")
|
||||
videoThumbName = strings.ReplaceAll(videoThumbName, " ", "_")
|
||||
videoThumbName = videoThumbName + ".jpg"
|
||||
|
||||
thumbImagePath := path.Join(mediaCachePath, videoThumbName)
|
||||
|
||||
err = executable_worker.Ffmpeg.EncodeVideoThumbnail(video.Path, thumbImagePath, probeData)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
||||
}
|
||||
|
||||
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(thumbImagePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of video thumbnail")
|
||||
}
|
||||
|
||||
thumbMediaURL := models.MediaURL{
|
||||
MediaID: video.ID,
|
||||
MediaName: videoThumbName,
|
||||
Width: thumbDimensions.Width,
|
||||
Height: thumbDimensions.Height,
|
||||
Purpose: models.VideoThumbnail,
|
||||
ContentType: "image/jpeg",
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := ctx.GetDB().Create(&thumbMediaURL).Error; err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to insert video thumbnail image into database (%s)", video.Title)
|
||||
}
|
||||
|
||||
updatedURLs = append(updatedURLs, &thumbMediaURL)
|
||||
} else {
|
||||
// Verify that video thumbnail still exists in cache
|
||||
thumbImagePath := path.Join(mediaCachePath, videoThumbnailURL.MediaName)
|
||||
|
||||
if _, err := os.Stat(thumbImagePath); os.IsNotExist(err) {
|
||||
log.Info(ctx, "Video thumbnail found in database but not in cache, re-encoding video thumbnail to cache", "video", videoThumbnailURL.MediaName)
|
||||
updatedURLs = append(updatedURLs, videoThumbnailURL)
|
||||
|
||||
err = executable_worker.Ffmpeg.EncodeVideoThumbnail(video.Path, thumbImagePath, probeData)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
||||
}
|
||||
|
||||
thumbDimensions, err := media_encoding.GetPhotoDimensions(thumbImagePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "get dimensions of video thumbnail image")
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(thumbImagePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "reading file stats of video thumbnail")
|
||||
}
|
||||
|
||||
videoThumbnailURL.Width = thumbDimensions.Width
|
||||
videoThumbnailURL.Height = thumbDimensions.Height
|
||||
videoThumbnailURL.FileSize = fileStats.Size()
|
||||
|
||||
if err := ctx.GetDB().Save(videoThumbnailURL).Error; err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "updating video thumbnail url in database after re-encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedURLs, nil
|
||||
}
|
||||
|
||||
func ReadVideoMetadata(videoPath string) (*ffprobe.ProbeData, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), utils.MediaProbeTimeout())
|
||||
defer cancelFn()
|
||||
|
||||
data, err := ffprobe.ProbeURL(ctx, videoPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not read video metadata (%s)", path.Base(videoPath))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ReadVideoStreamMetadata(videoPath string) (*ffprobe.Stream, error) {
|
||||
data, err := ReadVideoMetadata(videoPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read video stream metadata")
|
||||
}
|
||||
|
||||
stream := data.FirstVideoStream()
|
||||
if stream == nil {
|
||||
return nil, fmt.Errorf("could not get stream from file metadata (%s)", path.Base(videoPath))
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *media_encoding.EncodeMediaData, highResName string, imagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
|
||||
|
||||
err := imageData.EncodeHighRes(imagePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "creating high-res cached image")
|
||||
}
|
||||
|
||||
photoDimensions, err := media_encoding.GetPhotoDimensions(imagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(imagePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "reading file stats of highres photo")
|
||||
}
|
||||
|
||||
if mediaURL == nil {
|
||||
|
||||
mediaURL = &models.MediaURL{
|
||||
MediaID: media.ID,
|
||||
MediaName: highResName,
|
||||
Width: photoDimensions.Width,
|
||||
Height: photoDimensions.Height,
|
||||
Purpose: models.PhotoHighRes,
|
||||
ContentType: "image/jpeg",
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&mediaURL).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "could not insert highres media url (%d, %s)", media.ID, highResName)
|
||||
}
|
||||
} else {
|
||||
mediaURL.Width = photoDimensions.Width
|
||||
mediaURL.Height = photoDimensions.Height
|
||||
mediaURL.FileSize = fileStats.Size()
|
||||
|
||||
if err := tx.Save(&mediaURL).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, highResName)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaURL, nil
|
||||
}
|
||||
|
||||
func generateSaveThumbnailJPEG(tx *gorm.DB, media *models.Media, thumbnailName string, photoCachePath string, baseImagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
|
||||
thumbOutputPath := path.Join(photoCachePath, thumbnailName)
|
||||
|
||||
thumbSize, err := media_encoding.EncodeThumbnail(tx, baseImagePath, thumbOutputPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create thumbnail cached image")
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(thumbOutputPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "reading file stats of thumbnail photo")
|
||||
}
|
||||
|
||||
if mediaURL == nil {
|
||||
|
||||
mediaURL = &models.MediaURL{
|
||||
MediaID: media.ID,
|
||||
MediaName: thumbnailName,
|
||||
Width: thumbSize.Width,
|
||||
Height: thumbSize.Height,
|
||||
Purpose: models.PhotoThumbnail,
|
||||
ContentType: "image/jpeg",
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&mediaURL).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "could not insert thumbnail media url (%d, %s)", media.ID, thumbnailName)
|
||||
}
|
||||
} else {
|
||||
mediaURL.Width = thumbSize.Width
|
||||
mediaURL.Height = thumbSize.Height
|
||||
mediaURL.FileSize = fileStats.Size()
|
||||
|
||||
if err := tx.Save(&mediaURL).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, thumbnailName)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaURL, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Higher order function used to check if MediaURL for a given MediaPurpose exists
|
||||
func makePhotoURLChecker(tx *gorm.DB, mediaID int) func(purpose models.MediaPurpose) (*models.MediaURL, error) {
|
||||
return func(purpose models.MediaPurpose) (*models.MediaURL, error) {
|
||||
var mediaURL []*models.MediaURL
|
||||
|
||||
result := tx.Where("purpose = ?", purpose).Where("media_id = ?", mediaID).Find(&mediaURL)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
return mediaURL[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func generateUniqueMediaNamePrefixed(prefix string, mediaPath string, extension string) string {
|
||||
mediaName := fmt.Sprintf("%s_%s_%s", prefix, path.Base(mediaPath), utils.GenerateToken())
|
||||
mediaName = models.SanitizeMediaName(mediaName)
|
||||
mediaName = mediaName + extension
|
||||
return mediaName
|
||||
}
|
||||
|
||||
func generateUniqueMediaName(mediaPath string) string {
|
||||
|
||||
filename := path.Base(mediaPath)
|
||||
baseName := filename[0 : len(filename)-len(path.Ext(filename))]
|
||||
baseExt := path.Ext(filename)
|
||||
|
||||
mediaName := fmt.Sprintf("%s_%s", baseName, utils.GenerateToken())
|
||||
mediaName = models.SanitizeMediaName(mediaName) + baseExt
|
||||
|
||||
return mediaName
|
||||
}
|
||||
|
||||
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *media_encoding.EncodeMediaData, photoDimensions media_encoding.Dimension) (*models.MediaURL, error) {
|
||||
originalImageName := generateUniqueMediaName(photo.Path)
|
||||
|
||||
contentType, err := imageData.ContentType()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileStats, err := os.Stat(photo.Path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "reading file stats of original photo")
|
||||
}
|
||||
|
||||
mediaURL := models.MediaURL{
|
||||
Media: photo,
|
||||
MediaName: originalImageName,
|
||||
Width: photoDimensions.Width,
|
||||
Height: photoDimensions.Height,
|
||||
Purpose: models.MediaOriginal,
|
||||
ContentType: contentType.String(),
|
||||
FileSize: fileStats.Size(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&mediaURL).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "inserting original photo url: %d, %s", photo.ID, photo.Title)
|
||||
}
|
||||
|
||||
return &mediaURL, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package processing_tasks
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"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/pkg/errors"
|
||||
)
|
||||
|
||||
type SidecarTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t SidecarTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
|
||||
if media.Type != models.MediaTypePhoto || !newMedia {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := ctx.GetCache().GetMediaType(media.Path)
|
||||
if mediaType == media_type.TypeUnknown {
|
||||
return fmt.Errorf("scan for sidecar file %s failed: media type is %s", media.Path, mediaType)
|
||||
}
|
||||
|
||||
if mediaType.IsWebCompatible() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sideCarPath *string = nil
|
||||
var sideCarHash *string = nil
|
||||
|
||||
sideCarPath = scanForSideCarFile(media.Path)
|
||||
if sideCarPath != nil {
|
||||
sideCarHash = hashSideCarFile(sideCarPath)
|
||||
}
|
||||
|
||||
// Add sidecar data to media
|
||||
media.SideCarPath = sideCarPath
|
||||
media.SideCarHash = sideCarHash
|
||||
if err := ctx.GetDB().Save(media).Error; err != nil {
|
||||
return errors.Wrapf(err, "update media sidecar info (%s)", *sideCarPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t SidecarTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, mediaCachePath string) (updatedURLs []*models.MediaURL, err error) {
|
||||
mediaType, err := mediaData.ContentType()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, process media")
|
||||
}
|
||||
|
||||
if mediaType.IsWebCompatible() {
|
||||
return []*models.MediaURL{}, nil
|
||||
}
|
||||
|
||||
photo := mediaData.Media
|
||||
|
||||
sideCarFileHasChanged := false
|
||||
var currentFileHash *string
|
||||
currentSideCarPath := scanForSideCarFile(photo.Path)
|
||||
|
||||
if currentSideCarPath != nil {
|
||||
currentFileHash = hashSideCarFile(currentSideCarPath)
|
||||
if photo.SideCarHash == nil || *photo.SideCarHash != *currentFileHash {
|
||||
sideCarFileHasChanged = true
|
||||
}
|
||||
} else if photo.SideCarPath != nil { // sidecar has been deleted since last scan
|
||||
sideCarFileHasChanged = true
|
||||
}
|
||||
|
||||
if !sideCarFileHasChanged {
|
||||
return []*models.MediaURL{}, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Detected changed sidecar file for %s recreating JPG's to reflect changes\n", photo.Path)
|
||||
|
||||
highResURL, err := photo.GetHighRes()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, get high-res media_url")
|
||||
}
|
||||
|
||||
thumbURL, err := photo.GetThumbnail()
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, get high-res media_url")
|
||||
}
|
||||
|
||||
// update high res image may be cropped so dimentions and file size can change
|
||||
baseImagePath := path.Join(mediaCachePath, highResURL.MediaName) // update base image path for thumbnail
|
||||
tempHighResPath := baseImagePath + ".hold"
|
||||
os.Rename(baseImagePath, tempHighResPath)
|
||||
updatedHighRes, err := generateSaveHighResJPEG(ctx.GetDB(), photo, mediaData, highResURL.MediaName, baseImagePath, highResURL)
|
||||
if err != nil {
|
||||
os.Rename(tempHighResPath, baseImagePath)
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "sidecar task, recreating high-res cached image")
|
||||
}
|
||||
os.Remove(tempHighResPath)
|
||||
|
||||
// update thumbnail image may be cropped so dimentions and file size can change
|
||||
thumbPath := path.Join(mediaCachePath, thumbURL.MediaName)
|
||||
tempThumbPath := thumbPath + ".hold" // hold onto the original image incase for some reason we fail to recreate one with the new settings
|
||||
os.Rename(thumbPath, tempThumbPath)
|
||||
updatedThumbnail, err := generateSaveThumbnailJPEG(ctx.GetDB(), photo, thumbURL.MediaName, mediaCachePath, baseImagePath, thumbURL)
|
||||
if err != nil {
|
||||
os.Rename(tempThumbPath, thumbPath)
|
||||
return []*models.MediaURL{}, errors.Wrap(err, "recreating thumbnail cached image")
|
||||
}
|
||||
os.Remove(tempThumbPath)
|
||||
|
||||
photo.SideCarHash = currentFileHash
|
||||
photo.SideCarPath = currentSideCarPath
|
||||
|
||||
// save new side car hash
|
||||
if err := ctx.GetDB().Save(&photo).Error; err != nil {
|
||||
return []*models.MediaURL{}, errors.Wrapf(err, "could not update side car hash for media: %s", photo.Path)
|
||||
}
|
||||
|
||||
return []*models.MediaURL{
|
||||
updatedThumbnail,
|
||||
updatedHighRes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scanForSideCarFile(path string) *string {
|
||||
testPath := path + ".xmp"
|
||||
|
||||
if scanner_utils.FileExists(testPath) {
|
||||
return &testPath
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashSideCarFile(path *string) *string {
|
||||
if path == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(*path)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
log.Printf("ERROR: %s", err)
|
||||
}
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
return &hash
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/media_encoding"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks/cleanup_tasks"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks/processing_tasks"
|
||||
)
|
||||
|
||||
var allTasks []scanner_task.ScannerTask = []scanner_task.ScannerTask{
|
||||
NotificationTask{},
|
||||
IgnorefileTask{},
|
||||
processing_tasks.CounterpartFilesTask{},
|
||||
processing_tasks.SidecarTask{},
|
||||
processing_tasks.ProcessPhotoTask{},
|
||||
processing_tasks.ProcessVideoTask{},
|
||||
FaceDetectionTask{},
|
||||
BlurhashTask{},
|
||||
ExifTask{},
|
||||
VideoMetadataTask{},
|
||||
cleanup_tasks.MediaCleanupTask{},
|
||||
}
|
||||
|
||||
type scannerTasks struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
var Tasks scannerTasks = scannerTasks{}
|
||||
|
||||
func simpleCombinedTasks(ctx scanner_task.TaskContext, doTask func(ctx scanner_task.TaskContext,
|
||||
task scanner_task.ScannerTask) error) error {
|
||||
|
||||
for _, task := range allTasks {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := doTask(ctx, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t scannerTasks) BeforeScanAlbum(ctx scanner_task.TaskContext) (scanner_task.TaskContext, error) {
|
||||
for _, task := range allTasks {
|
||||
var err error
|
||||
ctx, err = task.BeforeScanAlbum(ctx)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (t scannerTasks) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileInfo, mediaPath string) (bool, error) {
|
||||
for _, task := range allTasks {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
skip, err := task.MediaFound(ctx, fileInfo, mediaPath)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if skip {
|
||||
log.Info(ctx, "skip the media", "media_path", mediaPath, "by_task", fmt.Sprintf("%T", task))
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (t scannerTasks) AfterScanAlbum(ctx scanner_task.TaskContext, changedMedia []*models.Media,
|
||||
albumMedia []*models.Media) error {
|
||||
|
||||
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
|
||||
return task.AfterScanAlbum(ctx, changedMedia, albumMedia)
|
||||
})
|
||||
}
|
||||
|
||||
func (t scannerTasks) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
|
||||
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
|
||||
return task.AfterMediaFound(ctx, media, newMedia)
|
||||
})
|
||||
}
|
||||
|
||||
func (t scannerTasks) BeforeProcessMedia(ctx scanner_task.TaskContext,
|
||||
mediaData *media_encoding.EncodeMediaData) (scanner_task.TaskContext, error) {
|
||||
|
||||
for _, task := range allTasks {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var err error
|
||||
ctx, err = task.BeforeProcessMedia(ctx, mediaData)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (t scannerTasks) ProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
|
||||
mediaCachePath string) ([]*models.MediaURL, error) {
|
||||
|
||||
allNewMedia := make([]*models.MediaURL, 0)
|
||||
|
||||
for _, task := range allTasks {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
newMedia, err := task.ProcessMedia(ctx, mediaData, mediaCachePath)
|
||||
if err != nil {
|
||||
return []*models.MediaURL{}, err
|
||||
}
|
||||
|
||||
allNewMedia = append(allNewMedia, newMedia...)
|
||||
}
|
||||
|
||||
return allNewMedia, nil
|
||||
}
|
||||
|
||||
func (t scannerTasks) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData,
|
||||
updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error {
|
||||
|
||||
return simpleCombinedTasks(ctx, func(ctx scanner_task.TaskContext, task scanner_task.ScannerTask) error {
|
||||
return task.AfterProcessMedia(ctx, mediaData, updatedURLs, mediaIndex, mediaTotal)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package scanner_tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_task"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks/processing_tasks"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoMetadataTask struct {
|
||||
scanner_task.ScannerTaskBase
|
||||
}
|
||||
|
||||
func (t VideoMetadataTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
|
||||
|
||||
if !newMedia || media.Type != models.MediaTypeVideo {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := ScanVideoMetadata(ctx.GetDB(), media)
|
||||
if err != nil {
|
||||
log.Printf("WARN: ScanVideoMetadata for %s failed: %s\n", media.Title, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScanVideoMetadata(tx *gorm.DB, video *models.Media) error {
|
||||
|
||||
data, err := processing_tasks.ReadVideoMetadata(video.Path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "scan video metadata failed (%s)", video.Title)
|
||||
}
|
||||
|
||||
stream := data.FirstVideoStream()
|
||||
if stream == nil {
|
||||
return errors.New(fmt.Sprintf("could not get video stream from metadata (%s)", video.Path))
|
||||
}
|
||||
|
||||
audio := data.FirstAudioStream()
|
||||
var audioText string
|
||||
if audio == nil {
|
||||
audioText = "No audio"
|
||||
} else {
|
||||
switch audio.Channels {
|
||||
case 0:
|
||||
audioText = "No audio"
|
||||
case 1:
|
||||
audioText = "Mono audio"
|
||||
case 2:
|
||||
audioText = "Stereo audio"
|
||||
default:
|
||||
audioText = fmt.Sprintf("Audio (%d channels)", audio.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
framerate := getFrameRate(stream)
|
||||
|
||||
videoMetadata := models.VideoMetadata{
|
||||
Width: stream.Width,
|
||||
Height: stream.Height,
|
||||
Duration: data.Format.DurationSeconds,
|
||||
Codec: &stream.CodecLongName,
|
||||
Framerate: framerate,
|
||||
Bitrate: &stream.BitRate,
|
||||
ColorProfile: &stream.Profile,
|
||||
Audio: &audioText,
|
||||
}
|
||||
|
||||
video.VideoMetadata = &videoMetadata
|
||||
|
||||
if err := tx.Save(video).Error; err != nil {
|
||||
return errors.Wrapf(err, "failed to add video metadata to database (%s)", video.Title)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFrameRate(stream *ffprobe.Stream) *float64 {
|
||||
var framerate *float64 = nil
|
||||
if stream.AvgFrameRate != "" {
|
||||
parts := strings.Split(stream.AvgFrameRate, "/")
|
||||
if len(parts) == 2 {
|
||||
if numerator, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
||||
if denominator, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
|
||||
result := float64(numerator) / float64(denominator)
|
||||
framerate = &result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return framerate
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package scanner_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
scanner_utils "github.com/photoview/photoview/api/test_utils/scanner"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
|
||||
func TestFullScan(t *testing.T) {
|
||||
test_utils.FilesystemTest(t)
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
pass := "1234"
|
||||
user, err := models.RegisterUser(db, "test_user", &pass, true)
|
||||
if err != nil {
|
||||
t.Fatal("register user error:", err)
|
||||
}
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root album",
|
||||
Path: "./test_media",
|
||||
}
|
||||
|
||||
wantWebPhotos := []string{
|
||||
"bmp.bmp",
|
||||
"gif.gif",
|
||||
"jpeg.jpg",
|
||||
"png.png",
|
||||
"webp.webp",
|
||||
|
||||
"jpg_with_file.jpg",
|
||||
"recoverable_bad_rst_marker.jpg",
|
||||
"standalone_jpg.jpg",
|
||||
|
||||
"boy1.jpg",
|
||||
"boy2.jpg",
|
||||
"buttercup_close_summer_yellow.jpg",
|
||||
"girl_black_hair2.jpg",
|
||||
"girl_blond1.jpg",
|
||||
"girl_blond2.jpg",
|
||||
"girl_blond3.jpg",
|
||||
"lilac_lilac_bush_lilac.jpg",
|
||||
"mount_merapi_volcano_indonesia.jpg",
|
||||
|
||||
"left_arrow_normal_web.jpg",
|
||||
"up_arrow_90cw_web.jpg",
|
||||
}
|
||||
wantNonWebPhotos := []string{
|
||||
"heif.heif",
|
||||
"jpegxl.jxl",
|
||||
"jpg2000.jp2",
|
||||
"raw_with_file.tiff",
|
||||
"raw_with_jpg.tiff",
|
||||
"standalone_raw.tiff",
|
||||
"tiff.tiff",
|
||||
"cr3.cr3",
|
||||
|
||||
"left_arrow_normal_nonweb.tiff",
|
||||
"up_arrow_90cw_nonweb.tiff",
|
||||
}
|
||||
wantWebVideos := []string{
|
||||
"mp4.mp4",
|
||||
"mpeg.mpg",
|
||||
"ogg.ogg",
|
||||
"webm.webm",
|
||||
}
|
||||
wantNonWebVideos := []string{
|
||||
"avi.avi",
|
||||
"mkv.mkv",
|
||||
"quicktime.mov",
|
||||
"wmv.wmv",
|
||||
}
|
||||
|
||||
wantFaceGroups := [][]string{
|
||||
{"boy1.jpg", "boy2.jpg"},
|
||||
{"girl_black_hair2.jpg"},
|
||||
{"girl_blond1.jpg", "girl_blond2.jpg", "girl_blond3.jpg"},
|
||||
}
|
||||
|
||||
for i := range wantFaceGroups {
|
||||
slices.Sort(wantFaceGroups[i])
|
||||
}
|
||||
slices.SortFunc(wantFaceGroups, func(a, b []string) int {
|
||||
return strings.Compare(fmt.Sprint(a), fmt.Sprint(b))
|
||||
})
|
||||
|
||||
if err := db.Save(&rootAlbum).Error; err != nil {
|
||||
t.Fatal("create root album error:", err)
|
||||
}
|
||||
|
||||
if err := db.Model(user).Association("Albums").Append(&rootAlbum); err != nil {
|
||||
t.Fatal("bind root album error:", err)
|
||||
}
|
||||
|
||||
if err := face_detection.InitializeFaceDetector(db); err != nil {
|
||||
t.Fatal("initalize face detector error:", err)
|
||||
}
|
||||
|
||||
scanner_utils.RunScannerOnUser(t, db, user)
|
||||
|
||||
t.Run("CheckMedia", func(t *testing.T) {
|
||||
var allMedia []*models.Media
|
||||
if err := db.Find(&allMedia).Error; err != nil {
|
||||
t.Fatal("get all media error:", err)
|
||||
}
|
||||
|
||||
want := []string{}
|
||||
want = append(want, wantWebPhotos...)
|
||||
want = append(want, wantNonWebPhotos...)
|
||||
want = append(want, wantWebVideos...)
|
||||
want = append(want, wantNonWebVideos...)
|
||||
slices.Sort(want)
|
||||
|
||||
got := make([]string, len(allMedia))
|
||||
for i, media := range allMedia {
|
||||
got[i] = media.Title
|
||||
|
||||
if media.Blurhash == nil {
|
||||
t.Errorf("media %q(%s) doesn't have Blurhash, while it should have", media.Title, media.Type)
|
||||
}
|
||||
|
||||
if media.Date().IsZero() {
|
||||
t.Errorf("media %q(%s) doesn't have Date, while it should have", media.Title, media.Type)
|
||||
}
|
||||
}
|
||||
slices.Sort(got)
|
||||
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Errorf("all media diff (-got, +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CheckMediaURL", func(t *testing.T) {
|
||||
var allMediaURL []*models.MediaURL
|
||||
if err := db.Find(&allMediaURL).Error; err != nil {
|
||||
t.Fatal("get all media url error:", err)
|
||||
}
|
||||
|
||||
var want []string
|
||||
want = append(want, wantWebPhotos...)
|
||||
for _, name := range wantWebPhotos {
|
||||
want = append(want, "thumbnail_"+strings.ReplaceAll(name, ".", "_")+".jpg")
|
||||
}
|
||||
|
||||
want = append(want, wantNonWebPhotos...)
|
||||
for _, name := range wantNonWebPhotos {
|
||||
want = append(want, "thumbnail_"+strings.ReplaceAll(name, ".", "_")+".jpg")
|
||||
want = append(want, "highres_"+strings.ReplaceAll(name, ".", "_")+".jpg")
|
||||
}
|
||||
|
||||
want = append(want, wantWebVideos...)
|
||||
for _, name := range wantWebVideos {
|
||||
want = append(want, "video_thumb_"+strings.ReplaceAll(name, ".", "_")+".jpg")
|
||||
}
|
||||
|
||||
for _, name := range wantNonWebVideos {
|
||||
want = append(want, "video_thumb_"+strings.ReplaceAll(name, ".", "_")+".jpg")
|
||||
want = append(want, "web_video_"+strings.ReplaceAll(name, ".", "_")+".mp4")
|
||||
}
|
||||
|
||||
slices.Sort(want)
|
||||
|
||||
if got, want := len(allMediaURL), len(want); got != want {
|
||||
t.Errorf("got = %d, want: %v", got, want)
|
||||
}
|
||||
|
||||
got := make([]string, len(allMediaURL))
|
||||
for i, media := range allMediaURL {
|
||||
got[i] = media.MediaName
|
||||
}
|
||||
slices.Sort(got)
|
||||
|
||||
if diff := cmp.Diff(got, want, cmp.Comparer(equalNameWithoutSuffix)); diff != "" {
|
||||
t.Errorf("all media url diff (-got, +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CheckFaceGroup", func(t *testing.T) {
|
||||
var allFaceGroups []*models.FaceGroup
|
||||
if err := db.Find(&allFaceGroups).Error; err != nil {
|
||||
t.Fatal("get face groups error:", err)
|
||||
}
|
||||
|
||||
if got, want := len(allFaceGroups), len(wantFaceGroups); got != want {
|
||||
t.Errorf("len(allFaceGroups) = %d, want: %d", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CheckFaces", func(t *testing.T) {
|
||||
var allImageFaces []*models.ImageFace
|
||||
if err := db.Find(&allImageFaces).Error; err != nil {
|
||||
t.Fatal("get face images error:", err)
|
||||
}
|
||||
|
||||
for _, face := range allImageFaces {
|
||||
if err := face.FillMedia(db); err != nil {
|
||||
t.Fatalf("fill media for face %v error: %v", face, err)
|
||||
}
|
||||
}
|
||||
|
||||
got := groupMediaWithFaces(allImageFaces)
|
||||
|
||||
if diff := cmp.Diff(got, wantFaceGroups); diff != "" {
|
||||
t.Errorf("all media diff (-got, +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CheckPhotosOrientation", func(t *testing.T) {
|
||||
photoFiles := []string{
|
||||
"left_arrow_normal_web.jpg",
|
||||
"up_arrow_90cw_web.jpg",
|
||||
"left_arrow_normal_nonweb.tiff",
|
||||
"up_arrow_90cw_nonweb.tiff",
|
||||
}
|
||||
for _, filename := range photoFiles {
|
||||
var media models.Media
|
||||
if err := db.Preload("MediaURL").Where("title = ?", filename).Find(&media).Error; err != nil {
|
||||
t.Fatalf("can't find media with name %q: %v", filename, err)
|
||||
}
|
||||
|
||||
thumbnail, err := media.GetThumbnail()
|
||||
if err != nil {
|
||||
t.Fatalf("can't get thumbnail of media %q: %v", filename, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(filename, "up"):
|
||||
if thumbnail.Width >= thumbnail.Height {
|
||||
t.Errorf("media %q dimension: %dx%d, which should be a vertial photo", filename, thumbnail.Width, thumbnail.Height)
|
||||
}
|
||||
case strings.HasPrefix(filename, "left"):
|
||||
if thumbnail.Width <= thumbnail.Height {
|
||||
t.Errorf("media %q dimension: %dx%d, which should be a horizontal photo", filename, thumbnail.Width, thumbnail.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func equalNameWithoutSuffix(a, b string) bool {
|
||||
extA := filepath.Ext(a)
|
||||
mainA := strings.TrimSuffix(a, extA)
|
||||
extB := filepath.Ext(b)
|
||||
mainB := strings.TrimSuffix(b, extB)
|
||||
|
||||
// ext names are not same
|
||||
if extA != extB {
|
||||
return false
|
||||
}
|
||||
|
||||
// a is not prefix of b and b is not prefix of a
|
||||
if strings.HasPrefix(mainA, mainB) && strings.HasPrefix(mainB, mainA) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func groupMediaWithFaces(medias []*models.ImageFace) [][]string {
|
||||
grouped := make(map[int][]string)
|
||||
|
||||
for _, media := range medias {
|
||||
group := grouped[media.FaceGroupID]
|
||||
group = append(group, media.Media.Title)
|
||||
grouped[media.FaceGroupID] = group
|
||||
}
|
||||
|
||||
ret := make([][]string, 0, len(grouped))
|
||||
for _, medias := range grouped {
|
||||
slices.Sort(medias)
|
||||
ret = append(ret, medias)
|
||||
}
|
||||
|
||||
slices.SortFunc(ret, func(a, b []string) int {
|
||||
return strings.Compare(fmt.Sprint(a), fmt.Sprint(b))
|
||||
})
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func copyFilelistWithJpgExt(list []string) []string {
|
||||
ret := make([]string, 0, len(list))
|
||||
for _, f := range list {
|
||||
ext := filepath.Ext(f)
|
||||
main := strings.TrimSuffix(f, ext)
|
||||
|
||||
ret = append(ret, main+".jpg")
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"container/list"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_cache"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_tasks/cleanup_tasks"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_utils"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func getPhotoviewIgnore(ignorePath string) ([]string, error) {
|
||||
var photoviewIgnore []string
|
||||
|
||||
// Open .photoviewignore file, if exists
|
||||
photoviewIgnoreFile, err := os.Open(path.Join(ignorePath, ".photoviewignore"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return photoviewIgnore, nil
|
||||
}
|
||||
return photoviewIgnore, err
|
||||
}
|
||||
|
||||
// Close file on exit
|
||||
defer photoviewIgnoreFile.Close()
|
||||
|
||||
// Read and save .photoviewignore data
|
||||
scanner := bufio.NewScanner(photoviewIgnoreFile)
|
||||
for scanner.Scan() {
|
||||
photoviewIgnore = append(photoviewIgnore, scanner.Text())
|
||||
log.Printf("Ignore found: %s", scanner.Text())
|
||||
}
|
||||
|
||||
return photoviewIgnore, scanner.Err()
|
||||
}
|
||||
|
||||
func FindAlbumsForUser(db *gorm.DB, user *models.User, albumCache *scanner_cache.AlbumScannerCache) ([]*models.Album, []error) {
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
var userRootAlbums []*models.Album
|
||||
if err := db.
|
||||
Where("id IN (?)", userAlbumIDs).
|
||||
Where("parent_album_id IS NULL OR parent_album_id NOT IN (?)", userAlbumIDs).
|
||||
Order("path ASC").
|
||||
Find(&userRootAlbums).Error; err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
scanErrors := make([]error, 0)
|
||||
|
||||
type scanInfo struct {
|
||||
path string
|
||||
parent *models.Album
|
||||
ignore []string
|
||||
}
|
||||
|
||||
scanQueue := list.New()
|
||||
|
||||
for _, album := range userRootAlbums {
|
||||
// Check if user album directory exists on the file system
|
||||
if _, err := os.Stat(album.Path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
scanErrors = append(scanErrors, errors.Errorf("Album directory for user '%s' does not exist '%s'\n", user.Username, album.Path))
|
||||
} else {
|
||||
scanErrors = append(scanErrors, errors.Errorf("Could not read album directory for user '%s': %s\n", user.Username, album.Path))
|
||||
}
|
||||
} else {
|
||||
scanQueue.PushBack(scanInfo{
|
||||
path: album.Path,
|
||||
parent: nil,
|
||||
ignore: nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userAlbums := make([]*models.Album, 0)
|
||||
|
||||
for scanQueue.Front() != nil {
|
||||
albumInfo := scanQueue.Front().Value.(scanInfo)
|
||||
scanQueue.Remove(scanQueue.Front())
|
||||
|
||||
albumPath := albumInfo.path
|
||||
albumParent := albumInfo.parent
|
||||
albumIgnore := albumInfo.ignore
|
||||
|
||||
// Read path
|
||||
dirContent, err := os.ReadDir(albumPath)
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, errors.Wrapf(err, "read directory (%s)", albumPath))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip this dir if in ignore list
|
||||
ignorePaths := ignore.CompileIgnoreLines(albumIgnore...)
|
||||
if ignorePaths.MatchesPath(albumPath + "/") {
|
||||
log.Printf("Skip, directroy %s is in ignore file", albumPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update ignore dir list
|
||||
photoviewIgnore, err := getPhotoviewIgnore(albumPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get ignore file, err = %s", err)
|
||||
} else {
|
||||
albumIgnore = append(albumIgnore, photoviewIgnore...)
|
||||
}
|
||||
|
||||
// Will become new album or album from db
|
||||
var album *models.Album
|
||||
|
||||
transErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
log.Printf("Scanning directory: %s", albumPath)
|
||||
|
||||
// check if album already exists
|
||||
var albumResult []models.Album
|
||||
result := tx.Where("path_hash = ?", models.MD5Hash(albumPath)).Find(&albumResult)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// album does not exist, create new
|
||||
if len(albumResult) == 0 {
|
||||
albumTitle := path.Base(albumPath)
|
||||
|
||||
var albumParentID *int
|
||||
parentOwners := make([]models.User, 0)
|
||||
if albumParent != nil {
|
||||
albumParentID = &albumParent.ID
|
||||
|
||||
if err := tx.Model(&albumParent).Association("Owners").Find(&parentOwners); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
album = &models.Album{
|
||||
Title: albumTitle,
|
||||
ParentAlbumID: albumParentID,
|
||||
Path: albumPath,
|
||||
}
|
||||
|
||||
// Store album ignore
|
||||
albumCache.InsertAlbumIgnore(albumPath, albumIgnore)
|
||||
|
||||
if err := tx.Create(&album).Error; err != nil {
|
||||
return errors.Wrap(err, "insert album into database")
|
||||
}
|
||||
|
||||
if err := tx.Model(&album).Association("Owners").Append(parentOwners); err != nil {
|
||||
return errors.Wrap(err, "add owners to album")
|
||||
}
|
||||
} else {
|
||||
album = &albumResult[0]
|
||||
|
||||
// Add user as an owner of the album if not already
|
||||
var userAlbumOwner []models.User
|
||||
if err := tx.Model(&album).Association("Owners").Find(&userAlbumOwner, "user_albums.user_id = ?", user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userAlbumOwner) == 0 {
|
||||
newUser := models.User{}
|
||||
newUser.ID = user.ID
|
||||
if err := tx.Model(&album).Association("Owners").Append(&newUser); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update album ignore
|
||||
albumCache.InsertAlbumIgnore(albumPath, albumIgnore)
|
||||
}
|
||||
|
||||
userAlbums = append(userAlbums, album)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transErr != nil {
|
||||
scanErrors = append(scanErrors, errors.Wrap(transErr, "begin database transaction"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Scan for sub-albums
|
||||
for _, item := range dirContent {
|
||||
subalbumPath := path.Join(albumPath, item.Name())
|
||||
|
||||
// Skip if directory is hidden
|
||||
if path.Base(subalbumPath)[0:1] == "." {
|
||||
continue
|
||||
}
|
||||
|
||||
isDirSymlink, err := utils.IsDirSymlink(subalbumPath)
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, errors.Wrapf(err, "could not check for symlink target of %s", subalbumPath))
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.IsDir() || isDirSymlink) && directoryContainsPhotos(subalbumPath, albumCache, albumIgnore) {
|
||||
scanQueue.PushBack(scanInfo{
|
||||
path: subalbumPath,
|
||||
parent: album,
|
||||
ignore: albumIgnore,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleteErrors := cleanup_tasks.DeleteOldUserAlbums(db, userAlbums, user)
|
||||
scanErrors = append(scanErrors, deleteErrors...)
|
||||
|
||||
return userAlbums, scanErrors
|
||||
}
|
||||
|
||||
func directoryContainsPhotos(rootPath string, cache *scanner_cache.AlbumScannerCache, albumIgnore []string) bool {
|
||||
|
||||
if containsImage := cache.AlbumContainsPhotos(rootPath); containsImage != nil {
|
||||
return *containsImage
|
||||
}
|
||||
|
||||
scanQueue := list.New()
|
||||
scanQueue.PushBack(rootPath)
|
||||
|
||||
scannedDirectories := make([]string, 0)
|
||||
|
||||
for scanQueue.Front() != nil {
|
||||
|
||||
dirPath := scanQueue.Front().Value.(string)
|
||||
scanQueue.Remove(scanQueue.Front())
|
||||
|
||||
scannedDirectories = append(scannedDirectories, dirPath)
|
||||
|
||||
// Update ignore dir list
|
||||
photoviewIgnore, err := getPhotoviewIgnore(dirPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get ignore file, err = %s", err)
|
||||
} else {
|
||||
albumIgnore = append(albumIgnore, photoviewIgnore...)
|
||||
}
|
||||
ignoreEntries := ignore.CompileIgnoreLines(albumIgnore...)
|
||||
|
||||
dirContent, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
scanner_utils.ScannerError(nil, "Could not read directory (%s): %s\n", dirPath, err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
for _, fileInfo := range dirContent {
|
||||
filePath := path.Join(dirPath, fileInfo.Name())
|
||||
|
||||
isDirSymlink, err := utils.IsDirSymlink(filePath)
|
||||
if err != nil {
|
||||
log.Printf("Cannot detect whether %s is symlink to a directory. Pretending it is not", filePath)
|
||||
isDirSymlink = false
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() || isDirSymlink {
|
||||
scanQueue.PushBack(filePath)
|
||||
} else {
|
||||
if cache.IsPathMedia(filePath) {
|
||||
if ignoreEntries.MatchesPath(fileInfo.Name()) {
|
||||
log.Printf("Match found %s, continue search for media", fileInfo.Name())
|
||||
continue
|
||||
}
|
||||
log.Printf("Insert Album %s %s, contains photo is true", dirPath, rootPath)
|
||||
cache.InsertAlbumPaths(dirPath, rootPath, true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for _, scanned_path := range scannedDirectories {
|
||||
log.Printf("Insert Album %s, contains photo is false", scanned_path)
|
||||
cache.InsertAlbumPath(scanned_path, false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package scanner_utils
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func FileExists(testPath string) bool {
|
||||
_, err := os.Stat(testPath)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
} else if err != nil {
|
||||
// unexpected error logging
|
||||
log.Printf("Error: checking for file existence (%s): %s", testPath, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package scanner_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/notification"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
func ScannerError(ctx context.Context, format string, args ...any) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
|
||||
log.Error(ctx, message)
|
||||
notification.BroadcastNotification(&models.Notification{
|
||||
Key: utils.GenerateToken(),
|
||||
Type: models.NotificationTypeMessage,
|
||||
Header: "Scanner error",
|
||||
Content: message,
|
||||
Negative: true,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user