精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+115
View File
@@ -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
}
+141
View File
@@ -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)
}
}
+5
View File
@@ -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
}
+170
View File
@@ -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 $@
+40
View File
@@ -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
}
+45
View File
@@ -0,0 +1,45 @@
package media_type
import (
"path"
"path/filepath"
"strings"
)
// FindWebCounterpart returns the filename if the file `imagePath` has a counterpart file competible with the browser.
func FindWebCounterpart(imagePath string) (string, bool) {
return findCounterpart(imagePath, func(filename string) bool {
mt := GetMediaType(filename)
return mt.IsImage() && mt.IsWebCompatible()
})
}
// FindRawCounterpart returns the filename if the file `imagePath` has a counterpart file which needs to be processed before showing in the browser.
func FindRawCounterpart(imagePath string) (string, bool) {
return findCounterpart(imagePath, func(filename string) bool {
mt := GetMediaType(filename)
return mt.IsImage() && !mt.IsWebCompatible()
})
}
func findCounterpart(filename string, acceptFn func(filepath string) bool) (string, bool) {
ext := path.Ext(filename)
filenamePattern := strings.TrimSuffix(filename, ext) + ".*"
filenames, err := filepath.Glob(filenamePattern)
if err != nil {
return "", false
}
for _, f := range filenames {
if f == filename {
continue
}
if acceptFn(f) {
return f, true
}
}
return "", false
}
@@ -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)
}
}
}
+136
View File
@@ -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()
}
+144
View File
@@ -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")
}
}
+77
View File
@@ -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
})
}
+183
View File
@@ -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)
}
+74
View File
@@ -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)
})
}
+128
View File
@@ -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
}
+93
View File
@@ -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
}
+264
View File
@@ -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(&notifyThrottle)
}
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
}
+109
View File
@@ -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()
}
})
}
}
+90
View File
@@ -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
}
+74
View File
@@ -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
}
+157
View File
@@ -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
}
+303
View File
@@ -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
}
+292
View File
@@ -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
}
+19
View File
@@ -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,
})
}