mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
AI: Include NSFW flag & score when generating labels with Ollama #5232
Related issues: #5233 (reset command), #5234 (schedule for models) Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -35,6 +35,7 @@ Note on specs repository availability
|
||||
- Backend: Go (`internal/`, `pkg/`, `cmd/`) + MariaDB/SQLite
|
||||
- Package boundaries: Code in `pkg/*` MUST NOT import from `internal/*`.
|
||||
- If you need access to config/entity/DB, put new code in a package under `internal/` instead of `pkg/`.
|
||||
- GORM field naming: When adding struct fields that include uppercase abbreviations (e.g., `LabelNSFW`), set an explicit `gorm:"column:<name>"` tag so column names stay consistent (`label_nsfw` instead of `label_n_s_f_w`).
|
||||
- Frontend: Vue 3 + Vuetify 3 (`frontend/`)
|
||||
- Docker/compose for dev/CI; Traefik is used for local TLS (`*.localssl.dev`)
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
|
||||
// Label represents a MediaFile label (automatically created).
|
||||
type Label struct {
|
||||
Name string `json:"label"` // Label name
|
||||
Source string `json:"source"` // Where was this label found / detected?
|
||||
Uncertainty int `json:"uncertainty"` // >= 0
|
||||
Topicality int `json:"topicality"` // >= 0
|
||||
Priority int `json:"priority"` // >= 0
|
||||
Categories []string `json:"categories"` // List of similar labels
|
||||
Name string `json:"label"` // Label name
|
||||
Source string `json:"source"` // Where was this label found / detected?
|
||||
Uncertainty int `json:"uncertainty"` // >= 0
|
||||
Topicality int `json:"topicality"` // >= 0
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
NSFWConfidence int `json:"nsfw_confidence,omitempty"`
|
||||
Priority int `json:"priority"` // >= 0
|
||||
Categories []string `json:"categories"` // List of similar labels
|
||||
}
|
||||
|
||||
// LocationLabel returns a new location label.
|
||||
|
||||
@@ -6,13 +6,19 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// Labels is list of MediaFile labels.
|
||||
// Labels represents a sortable collection of Label values returned by vision
|
||||
// models, Exif metadata, or user input.
|
||||
type Labels []Label
|
||||
|
||||
// Labels implement the sort interface to sort by priority and uncertainty.
|
||||
// Len implements sort.Interface for Labels.
|
||||
func (l Labels) Len() int { return len(l) }
|
||||
|
||||
func (l Labels) Len() int { return len(l) }
|
||||
// Swap implements sort.Interface for Labels.
|
||||
func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
|
||||
|
||||
// Less implements sort.Interface for Labels. Higher-priority labels come first;
|
||||
// for equal priority the lower-uncertainty label wins. Labels with an
|
||||
// uncertainty >= 100 are considered unusable and are ordered last.
|
||||
func (l Labels) Less(i, j int) bool {
|
||||
if l[i].Uncertainty >= 100 {
|
||||
return false
|
||||
@@ -25,7 +31,8 @@ func (l Labels) Less(i, j int) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// AppendLabel extends append func by not appending empty label
|
||||
// AppendLabel mirrors append but discards labels with an empty name so callers
|
||||
// do not need to check for that guard condition.
|
||||
func (l Labels) AppendLabel(label Label) Labels {
|
||||
if label.Name == "" {
|
||||
return l
|
||||
@@ -34,7 +41,9 @@ func (l Labels) AppendLabel(label Label) Labels {
|
||||
return append(l, label)
|
||||
}
|
||||
|
||||
// Keywords returns all keywords contained in Labels and their categories.
|
||||
// Keywords maps label names and categories to their keyword tokens (using the
|
||||
// txt.Keywords helper) while skipping low-confidence labels and those sourced
|
||||
// from plain text fields (title/caption/keyword).
|
||||
func (l Labels) Keywords() (result []string) {
|
||||
for _, label := range l {
|
||||
if label.Uncertainty >= 100 ||
|
||||
@@ -55,7 +64,58 @@ func (l Labels) Keywords() (result []string) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Title gets the best label out a list of labels or fallback to compute a meaningful default title.
|
||||
// Count returns the number of labels that have a non-empty name and an
|
||||
// uncertainty below 100 (0% confidence cut-off).
|
||||
func (l Labels) Count() (count int) {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, label := range l {
|
||||
if label.Name == "" || label.Uncertainty >= 100 {
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// Names returns label names whose uncertainty is less than 100 (0% confidence
|
||||
// cut-off). The order matches the underlying slice.
|
||||
func (l Labels) Names() (s []string) {
|
||||
if l == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
s = make([]string, 0, l.Count())
|
||||
|
||||
for _, label := range l {
|
||||
if label.Name == "" || label.Uncertainty >= 100 {
|
||||
continue
|
||||
}
|
||||
|
||||
s = append(s, label.Name)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// String returns a human-readable list of label names joined with commas and an
|
||||
// "and" before the final element. When no names are available "none" is
|
||||
// returned to communicate the absence of labels.
|
||||
func (l Labels) String() string {
|
||||
if l == nil {
|
||||
return "none"
|
||||
}
|
||||
|
||||
return txt.JoinAnd(l.Names())
|
||||
}
|
||||
|
||||
// Title selects a suitable title from the labels slice using priority and
|
||||
// uncertainty thresholds. When titles are not available or fail the confidence
|
||||
// checks the provided fallback string is returned instead.
|
||||
func (l Labels) Title(fallback string) string {
|
||||
fallbackRunes := len([]rune(fallback))
|
||||
|
||||
@@ -89,3 +149,22 @@ func (l Labels) Title(fallback string) string {
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// IsNSFW reports whether any label marks the asset as "not safe for work"
|
||||
// (NSFW). The threshold is clamped to [0,100] and checked against
|
||||
// NSFWConfidence; explicit NSFW flags always trigger a positive result.
|
||||
func (l Labels) IsNSFW(threshold int) bool {
|
||||
if l == nil || threshold < 0 {
|
||||
return false
|
||||
} else if threshold > 100 {
|
||||
threshold = 100
|
||||
}
|
||||
|
||||
for _, label := range l {
|
||||
if label.NSFW || label.NSFWConfidence >= threshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -89,6 +89,100 @@ func TestLabels_Keywords(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabels_Names(t *testing.T) {
|
||||
t.Run("FiltersEmptyAndUncertain", func(t *testing.T) {
|
||||
labels := Labels{
|
||||
{Name: "cat", Uncertainty: 20},
|
||||
{Name: "", Uncertainty: 10},
|
||||
{Name: "dog", Uncertainty: 100},
|
||||
{Name: "bird", Uncertainty: 99},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"cat", "bird"}, labels.Names())
|
||||
})
|
||||
|
||||
t.Run("NilLabels", func(t *testing.T) {
|
||||
var labels Labels
|
||||
assert.Nil(t, labels.Names())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabels_Count(t *testing.T) {
|
||||
t.Run("CountsEligible", func(t *testing.T) {
|
||||
labels := Labels{
|
||||
{Name: "cat", Uncertainty: 20},
|
||||
{Name: "", Uncertainty: 10},
|
||||
{Name: "dog", Uncertainty: 100},
|
||||
{Name: "bird", Uncertainty: 99},
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, labels.Count())
|
||||
})
|
||||
|
||||
t.Run("NilLabels", func(t *testing.T) {
|
||||
var labels Labels
|
||||
assert.Equal(t, 0, labels.Count())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabels_String(t *testing.T) {
|
||||
t.Run("JoinWithAnd", func(t *testing.T) {
|
||||
labels := Labels{{Name: "cat"}, {Name: "dog"}, {Name: "bird"}}
|
||||
assert.Equal(t, "cat, dog, and bird", labels.String())
|
||||
})
|
||||
|
||||
t.Run("NoneForNil", func(t *testing.T) {
|
||||
var labels Labels
|
||||
assert.Equal(t, "none", labels.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabels_IsNSFW(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels Labels
|
||||
threshold int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ExplicitFlag",
|
||||
threshold: 80,
|
||||
labels: Labels{{Name: "cat", NSFW: true}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ConfidenceAboveThreshold",
|
||||
threshold: 80,
|
||||
labels: Labels{{Name: "cat", NSFWConfidence: 85}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ThresholdClamped",
|
||||
threshold: 150,
|
||||
labels: Labels{{Name: "cat", NSFWConfidence: 90}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "BelowThreshold",
|
||||
threshold: 80,
|
||||
labels: Labels{{Name: "cat", NSFWConfidence: 40}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "NegativeThreshold",
|
||||
threshold: -10,
|
||||
labels: Labels{{Name: "cat", NSFWConfidence: 100}},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.labels.IsNSFW(tc.threshold))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel_Sort(t *testing.T) {
|
||||
labels := Labels{
|
||||
{Name: "label 0", Source: "location", Uncertainty: 100, Priority: 10},
|
||||
|
||||
@@ -23,6 +23,9 @@ type Files = []string
|
||||
|
||||
const (
|
||||
FormatJSON = "json"
|
||||
|
||||
logDataPreviewLength = 16
|
||||
logDataTruncatedSuffix = "... (truncated)"
|
||||
)
|
||||
|
||||
// ApiRequestOptions represents additional model parameters listed in the documentation.
|
||||
@@ -201,7 +204,86 @@ func (r *ApiRequest) WriteLog() {
|
||||
return
|
||||
}
|
||||
|
||||
if data, _ := r.JSON(); len(data) > 0 {
|
||||
sanitized := r.sanitizedForLog()
|
||||
|
||||
if data, _ := json.Marshal(sanitized); len(data) > 0 {
|
||||
log.Tracef("vision: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizedForLog returns a shallow copy of the request with large base64 payloads shortened.
|
||||
func (r *ApiRequest) sanitizedForLog() ApiRequest {
|
||||
if r == nil {
|
||||
return ApiRequest{}
|
||||
}
|
||||
|
||||
sanitized := *r
|
||||
|
||||
if len(r.Images) > 0 {
|
||||
sanitized.Images = make(Files, len(r.Images))
|
||||
|
||||
for i := range r.Images {
|
||||
sanitized.Images[i] = sanitizeLogPayload(r.Images[i])
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.Url = sanitizeLogPayload(r.Url)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// sanitizeLogPayload shortens base64-encoded data so trace logs remain readable.
|
||||
func sanitizeLogPayload(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "data:") {
|
||||
if prefix, encoded, found := strings.Cut(value, ","); found {
|
||||
sanitized := truncateBase64ForLog(encoded)
|
||||
|
||||
if sanitized != encoded {
|
||||
return prefix + "," + sanitized
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
if isLikelyBase64(value) {
|
||||
return truncateBase64ForLog(value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func truncateBase64ForLog(value string) string {
|
||||
if len(value) <= logDataPreviewLength {
|
||||
return value
|
||||
}
|
||||
|
||||
return value[:logDataPreviewLength] + logDataTruncatedSuffix
|
||||
}
|
||||
|
||||
func isLikelyBase64(value string) bool {
|
||||
if len(value) < logDataPreviewLength {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '+', c == '/', c == '=', c == '-', c == '_':
|
||||
case c == '\n' || c == '\r':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
76
internal/ai/vision/api_request_test.go
Normal file
76
internal/ai/vision/api_request_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestApiRequestWriteLogRedactsBase64(t *testing.T) {
|
||||
logger, ok := log.(*logrus.Logger)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected logger type %T", log)
|
||||
}
|
||||
|
||||
originalLevel := logger.GetLevel()
|
||||
originalOutput := logger.Out
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
logger.SetLevel(logrus.TraceLevel)
|
||||
logger.SetOutput(buffer)
|
||||
|
||||
defer func() {
|
||||
logger.SetOutput(originalOutput)
|
||||
logger.SetLevel(originalLevel)
|
||||
}()
|
||||
|
||||
req := &ApiRequest{
|
||||
Url: "data:image/jpeg;base64," + strings.Repeat("C", 40),
|
||||
Images: Files{
|
||||
"data:image/png;base64," + strings.Repeat("A", 40),
|
||||
strings.Repeat("B", 48),
|
||||
"https://example.test/image.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
req.WriteLog()
|
||||
|
||||
output := buffer.String()
|
||||
|
||||
if output == "" {
|
||||
t.Fatalf("expected trace log output")
|
||||
}
|
||||
|
||||
if strings.Contains(output, strings.Repeat("A", 24)) {
|
||||
t.Errorf("log contains unredacted data URL image payload: %s", output)
|
||||
}
|
||||
|
||||
if strings.Contains(output, strings.Repeat("B", 24)) {
|
||||
t.Errorf("log contains unredacted base64 image payload: %s", output)
|
||||
}
|
||||
|
||||
if strings.Contains(output, strings.Repeat("C", 24)) {
|
||||
t.Errorf("log contains unredacted data URL in url field: %s", output)
|
||||
}
|
||||
|
||||
imagePreview := "data:image/png;base64," + strings.Repeat("A", logDataPreviewLength) + logDataTruncatedSuffix
|
||||
if !strings.Contains(output, imagePreview) {
|
||||
t.Errorf("missing truncated image data preview, got: %s", output)
|
||||
}
|
||||
|
||||
base64Preview := strings.Repeat("B", logDataPreviewLength) + logDataTruncatedSuffix
|
||||
if !strings.Contains(output, base64Preview) {
|
||||
t.Errorf("missing truncated base64 preview, got: %s", output)
|
||||
}
|
||||
|
||||
urlPreview := "data:image/jpeg;base64," + strings.Repeat("C", logDataPreviewLength) + logDataTruncatedSuffix
|
||||
if !strings.Contains(output, urlPreview) {
|
||||
t.Errorf("missing truncated url preview, got: %s", output)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "https://example.test/image.jpg") {
|
||||
t.Errorf("expected https url to remain unchanged: %s", output)
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,14 @@ type CaptionResult struct {
|
||||
|
||||
// LabelResult represents a label generated by an image classification model.
|
||||
type LabelResult struct {
|
||||
Name string `yaml:"Name,omitempty" json:"name"`
|
||||
Source string `yaml:"Source,omitempty" json:"source"`
|
||||
Priority int `yaml:"Priority,omitempty" json:"priority,omitempty"`
|
||||
Confidence float32 `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
|
||||
Topicality float32 `yaml:"Topicality,omitempty" json:"topicality,omitempty"`
|
||||
Categories []string `yaml:"Categories,omitempty" json:"categories,omitempty"`
|
||||
Name string `yaml:"Name,omitempty" json:"name"`
|
||||
Source string `yaml:"Source,omitempty" json:"source"`
|
||||
Priority int `yaml:"Priority,omitempty" json:"priority,omitempty"`
|
||||
Confidence float32 `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
|
||||
Topicality float32 `yaml:"Topicality,omitempty" json:"topicality,omitempty"`
|
||||
Categories []string `yaml:"Categories,omitempty" json:"categories,omitempty"`
|
||||
NSFW bool `yaml:"Nsfw,omitempty" json:"nsfw,omitempty"`
|
||||
NSFWConfidence float32 `yaml:"NsfwConfidence,omitempty" json:"nsfw_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ToClassify returns the label results as classify.Label.
|
||||
@@ -113,13 +115,26 @@ func (r LabelResult) ToClassify(labelSrc string) classify.Label {
|
||||
}
|
||||
|
||||
// Return label.
|
||||
confidenceScaled := int(math.RoundToEven(float64(r.NSFWConfidence * 100)))
|
||||
if confidenceScaled < 0 {
|
||||
confidenceScaled = 0
|
||||
} else if confidenceScaled > 100 {
|
||||
confidenceScaled = 100
|
||||
}
|
||||
if r.NSFW && confidenceScaled == 0 {
|
||||
confidenceScaled = 100
|
||||
}
|
||||
|
||||
return classify.Label{
|
||||
Name: r.Name,
|
||||
Source: labelSrc,
|
||||
Priority: r.Priority,
|
||||
Uncertainty: uncertainty,
|
||||
Topicality: topicality,
|
||||
Categories: r.Categories}
|
||||
Name: r.Name,
|
||||
Source: labelSrc,
|
||||
Priority: r.Priority,
|
||||
Uncertainty: uncertainty,
|
||||
Topicality: topicality,
|
||||
Categories: r.Categories,
|
||||
NSFW: r.NSFW,
|
||||
NSFWConfidence: confidenceScaled,
|
||||
}
|
||||
}
|
||||
|
||||
// NewApiError generates a Vision API error response based on the specified HTTP status code.
|
||||
@@ -142,12 +157,15 @@ func NewLabelsResponse(id string, model *Model, results classify.Labels) ApiResp
|
||||
for _, label := range results {
|
||||
|
||||
labels = append(labels, LabelResult{
|
||||
Name: label.Name,
|
||||
Source: label.Source,
|
||||
Priority: label.Priority,
|
||||
Confidence: label.Confidence(),
|
||||
Topicality: float32(label.Topicality) / 100,
|
||||
Categories: label.Categories})
|
||||
Name: label.Name,
|
||||
Source: label.Source,
|
||||
Priority: label.Priority,
|
||||
Confidence: label.Confidence(),
|
||||
Topicality: float32(label.Topicality) / 100,
|
||||
Categories: label.Categories,
|
||||
NSFW: label.NSFW,
|
||||
NSFWConfidence: float32(label.NSFWConfidence) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return ApiResponse{
|
||||
|
||||
@@ -18,3 +18,16 @@ func TestLabelResultToClassifyTopicality(t *testing.T) {
|
||||
t.Fatalf("expected uncertainty less than 30, got %d", label.Uncertainty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelResultToClassifyNSFW(t *testing.T) {
|
||||
r := LabelResult{Name: "lingerie", Confidence: 0.9, Topicality: 0.8, NSFW: true, NSFWConfidence: 0.65}
|
||||
label := r.ToClassify(entity.SrcAuto)
|
||||
|
||||
if !label.NSFW {
|
||||
t.Fatalf("expected NSFW true")
|
||||
}
|
||||
|
||||
if label.NSFWConfidence != 65 {
|
||||
t.Fatalf("expected NSFW confidence 65, got %d", label.NSFWConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ func SetCaptionFunc(fn func(Files, media.Src) (*CaptionResult, *Model, error)) {
|
||||
captionFunc = fn
|
||||
}
|
||||
|
||||
// Caption returns generated captions for the specified images.
|
||||
func Caption(images Files, mediaSrc media.Src) (*CaptionResult, *Model, error) {
|
||||
// GenerateCaption returns generated captions for the specified images.
|
||||
func GenerateCaption(images Files, mediaSrc media.Src) (*CaptionResult, *Model, error) {
|
||||
return captionFunc(images, mediaSrc)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
func TestCaption(t *testing.T) {
|
||||
func TestGenerateCaption(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
} else if _, err := net.DialTimeout("tcp", "photoprism-vision:5000", 10*time.Second); err != nil {
|
||||
@@ -20,7 +20,7 @@ func TestCaption(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
expectedText := "An image of sound waves"
|
||||
|
||||
result, model, err := Caption(Files{"https://dl.photoprism.app/img/artwork/colorwaves-400.jpg"}, media.SrcRemote)
|
||||
result, model, err := GenerateCaption(Files{"https://dl.photoprism.app/img/artwork/colorwaves-400.jpg"}, media.SrcRemote)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, model)
|
||||
@@ -32,7 +32,7 @@ func TestCaption(t *testing.T) {
|
||||
assert.Equal(t, expectedText, result.Text)
|
||||
})
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
result, model, err := Caption(nil, media.SrcLocal)
|
||||
result, model, err := GenerateCaption(nil, media.SrcLocal)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, model)
|
||||
|
||||
@@ -30,6 +30,7 @@ var (
|
||||
DefaultTemperature = 0.1
|
||||
MaxTemperature = 2.0
|
||||
DefaultSrc = entity.SrcImage
|
||||
DetectNSFWLabels = false
|
||||
)
|
||||
|
||||
// Config reference the current configuration options.
|
||||
@@ -104,6 +105,14 @@ func (c *ConfigValues) Load(fileName string) error {
|
||||
c.Thresholds.Confidence = DefaultThresholds.Confidence
|
||||
}
|
||||
|
||||
if c.Thresholds.Topicality <= 0 || c.Thresholds.Topicality > 100 {
|
||||
c.Thresholds.Topicality = DefaultThresholds.Topicality
|
||||
}
|
||||
|
||||
if c.Thresholds.NSFW <= 0 || c.Thresholds.NSFW > 100 {
|
||||
c.Thresholds.NSFW = DefaultThresholds.NSFW
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/vision/ollama"
|
||||
@@ -37,6 +36,7 @@ func init() {
|
||||
CaptionModel.ApplyEngineDefaults()
|
||||
}
|
||||
|
||||
// SystemPrompt returns the Ollama system prompt for the specified model type.
|
||||
func (ollamaDefaults) SystemPrompt(model *Model) string {
|
||||
if model == nil || model.Type != ModelTypeLabels {
|
||||
return ""
|
||||
@@ -44,6 +44,7 @@ func (ollamaDefaults) SystemPrompt(model *Model) string {
|
||||
return ollama.LabelSystem
|
||||
}
|
||||
|
||||
// UserPrompt returns the Ollama user prompt for the specified model type.
|
||||
func (ollamaDefaults) UserPrompt(model *Model) string {
|
||||
if model == nil {
|
||||
return ""
|
||||
@@ -53,12 +54,17 @@ func (ollamaDefaults) UserPrompt(model *Model) string {
|
||||
case ModelTypeCaption:
|
||||
return ollama.CaptionPrompt
|
||||
case ModelTypeLabels:
|
||||
return ollama.LabelPrompt
|
||||
if DetectNSFWLabels {
|
||||
return ollama.LabelPromptNSFW
|
||||
} else {
|
||||
return ollama.LabelPromptDefault
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SchemaTemplate returns the Ollama JSON schema template.
|
||||
func (ollamaDefaults) SchemaTemplate(model *Model) string {
|
||||
if model == nil {
|
||||
return ""
|
||||
@@ -66,12 +72,13 @@ func (ollamaDefaults) SchemaTemplate(model *Model) string {
|
||||
|
||||
switch model.Type {
|
||||
case ModelTypeLabels:
|
||||
return ollama.LabelsSchema()
|
||||
return ollama.LabelsSchema(model.PromptContains("nsfw"))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Options returns the Ollama service request options.
|
||||
func (ollamaDefaults) Options(model *Model) *ApiRequestOptions {
|
||||
if model == nil {
|
||||
return nil
|
||||
@@ -93,6 +100,7 @@ func (ollamaDefaults) Options(model *Model) *ApiRequestOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// Build builds the Ollama service request.
|
||||
func (ollamaBuilder) Build(ctx context.Context, model *Model, files Files) (*ApiRequest, error) {
|
||||
if model == nil {
|
||||
return nil, ErrInvalidModel
|
||||
@@ -118,8 +126,10 @@ func (ollamaBuilder) Build(ctx context.Context, model *Model, files Files) (*Api
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Parse processes the Ollama service response.
|
||||
func (ollamaParser) Parse(ctx context.Context, req *ApiRequest, raw []byte, status int) (*ApiResponse, error) {
|
||||
ollamaResp, err := decodeOllamaResponse(raw)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -155,31 +165,33 @@ func (ollamaParser) Parse(ctx context.Context, req *ApiRequest, raw []byte, stat
|
||||
filtered := result.Result.Labels[:0]
|
||||
for i := range result.Result.Labels {
|
||||
if result.Result.Labels[i].Confidence <= 0 {
|
||||
result.Result.Labels[i].Confidence = ollama.DefaultLabelConfidence
|
||||
result.Result.Labels[i].Confidence = ollama.LabelConfidenceDefault
|
||||
}
|
||||
|
||||
if result.Result.Labels[i].Topicality <= 0 {
|
||||
result.Result.Labels[i].Topicality = result.Result.Labels[i].Confidence
|
||||
}
|
||||
|
||||
// Apply thresholds and canonicalize the name.
|
||||
normalizeLabelResult(&result.Result.Labels[i])
|
||||
|
||||
if result.Result.Labels[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Result.Labels[i].Source == "" {
|
||||
result.Result.Labels[i].Source = entity.SrcOllama
|
||||
}
|
||||
|
||||
filtered = append(filtered, result.Result.Labels[i])
|
||||
}
|
||||
result.Result.Labels = filtered
|
||||
} else {
|
||||
if caption := strings.TrimSpace(ollamaResp.Response); caption != "" {
|
||||
result.Result.Caption = &CaptionResult{
|
||||
Text: caption,
|
||||
Source: entity.SrcOllama,
|
||||
}
|
||||
} else if caption := strings.TrimSpace(ollamaResp.Response); caption != "" {
|
||||
result.Result.Caption = &CaptionResult{
|
||||
Text: caption,
|
||||
Source: entity.SrcOllama,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var ErrInvalidModel = fmt.Errorf("vision: invalid model")
|
||||
|
||||
@@ -30,10 +30,10 @@ func TestOllamaDefaultConfidenceApplied(t *testing.T) {
|
||||
t.Fatalf("expected one label, got %d", len(resp.Result.Labels))
|
||||
}
|
||||
|
||||
if resp.Result.Labels[0].Confidence != ollama.DefaultLabelConfidence {
|
||||
t.Fatalf("expected default confidence %.2f, got %.2f", ollama.DefaultLabelConfidence, resp.Result.Labels[0].Confidence)
|
||||
if resp.Result.Labels[0].Confidence != ollama.LabelConfidenceDefault {
|
||||
t.Fatalf("expected default confidence %.2f, got %.2f", ollama.LabelConfidenceDefault, resp.Result.Labels[0].Confidence)
|
||||
}
|
||||
if resp.Result.Labels[0].Topicality != ollama.DefaultLabelConfidence {
|
||||
if resp.Result.Labels[0].Topicality != ollama.LabelConfidenceDefault {
|
||||
t.Fatalf("expected topicality to default to confidence, got %.2f", resp.Result.Labels[0].Topicality)
|
||||
}
|
||||
}
|
||||
|
||||
9
internal/ai/vision/errors.go
Normal file
9
internal/ai/vision/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidModel = fmt.Errorf("vision: invalid model")
|
||||
)
|
||||
@@ -40,21 +40,58 @@ func normalizeLabelResult(result *LabelResult) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get canonical label name and metadata,
|
||||
name, meta := resolveLabelName(result.Name)
|
||||
|
||||
// Use canonical name from rules.
|
||||
if name != "" {
|
||||
result.Name = name
|
||||
}
|
||||
|
||||
threshold := meta.Threshold
|
||||
if threshold <= 0 {
|
||||
threshold = float32(Config.Thresholds.Confidence) / 100
|
||||
// Apply Confidence threshold if configured and the label has a Confidence score.
|
||||
if result.Confidence > 0 || meta.Threshold == 1 {
|
||||
// Cap Confidence at 100%.
|
||||
if result.Confidence > 1 {
|
||||
result.Confidence = 1
|
||||
}
|
||||
|
||||
// Get Confidence threshold from label rules.
|
||||
threshold := meta.Threshold
|
||||
|
||||
// Get global Confidence threshold, if label has no rule,
|
||||
if threshold <= 0 {
|
||||
threshold = Config.Thresholds.GetConfidenceFloat32()
|
||||
}
|
||||
|
||||
// Compare Confidence threshold.
|
||||
if threshold > 0 && result.Confidence < threshold {
|
||||
result.Name = ""
|
||||
result.Categories = nil
|
||||
result.Priority = 0
|
||||
return
|
||||
}
|
||||
} else if result.Confidence < 0 {
|
||||
// Confidence cannot be negative.
|
||||
result.Confidence = 0
|
||||
}
|
||||
|
||||
if threshold > 0 && result.Confidence < threshold {
|
||||
result.Name = ""
|
||||
result.Categories = nil
|
||||
result.Priority = 0
|
||||
return
|
||||
// Apply Topicality threshold if it is configured and the label has a Topicality score.
|
||||
if result.Topicality > 0 || Config.Thresholds.Topicality == 100 {
|
||||
// Cap Topicality at 100%.
|
||||
if result.Topicality > 1 {
|
||||
result.Topicality = 1
|
||||
}
|
||||
|
||||
// Compare Topicality threshold.
|
||||
if t := Config.Thresholds.GetTopicalityFloat32(); t > 0 && result.Topicality < t {
|
||||
result.Name = ""
|
||||
result.Categories = nil
|
||||
result.Priority = 0
|
||||
return
|
||||
}
|
||||
} else if result.Topicality < 0 {
|
||||
// Topicality cannot be negative.
|
||||
result.Topicality = 0
|
||||
}
|
||||
|
||||
if len(meta.Categories) > 0 {
|
||||
@@ -68,6 +105,20 @@ func normalizeLabelResult(result *LabelResult) {
|
||||
if result.Priority == 0 {
|
||||
result.Priority = PriorityFromTopicality(result.Topicality)
|
||||
}
|
||||
|
||||
// NSFWConfidence cannot be less than 0%, or more than 100%.
|
||||
if result.NSFWConfidence < 0 {
|
||||
result.NSFWConfidence = 0
|
||||
} else if result.NSFWConfidence > 1 {
|
||||
result.NSFWConfidence = 1
|
||||
result.NSFW = true
|
||||
}
|
||||
|
||||
// Set NSFWConfidence to 100% if result.NSFW
|
||||
// is set without a numeric score.
|
||||
if result.NSFW && result.NSFWConfidence <= 0 {
|
||||
result.NSFWConfidence = 1
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLabelName returns the canonical label name and metadata, preferring (1) TensorFlow rules, (2) existing PhotoPrism labels, (3) sanitized tokens, then (4) a Title-case fallback.
|
||||
|
||||
@@ -70,7 +70,39 @@ func TestNormalizeLabelResult(t *testing.T) {
|
||||
normalizeLabelResult(&label)
|
||||
|
||||
if label.Name != "" {
|
||||
t.Fatalf("expected label to be dropped due to global threshold, got %q", label.Name)
|
||||
t.Fatalf("expected label to be dropped due to global Confidence threshold, got %q", label.Name)
|
||||
}
|
||||
})
|
||||
t.Run("TopicalityThreshold", func(t *testing.T) {
|
||||
prev := Config.Thresholds
|
||||
Config.Thresholds.Topicality = 80
|
||||
defer func() { Config.Thresholds = prev }()
|
||||
|
||||
label := LabelResult{Name: "low topicality", Confidence: 0.9, Topicality: 0.5}
|
||||
normalizeLabelResult(&label)
|
||||
|
||||
if label.Name != "" {
|
||||
t.Fatalf("expected label to be dropped due to Topicality threshold, got %q", label.Name)
|
||||
}
|
||||
})
|
||||
t.Run("NSFWConfidenceClamp", func(t *testing.T) {
|
||||
label := LabelResult{Name: "nsfw-high", Confidence: 0.9, Topicality: 0.9, NSFW: true, NSFWConfidence: 2.5}
|
||||
normalizeLabelResult(&label)
|
||||
|
||||
if !label.NSFW {
|
||||
t.Fatalf("expected label to remain NSFW")
|
||||
}
|
||||
|
||||
if label.NSFWConfidence != 1 {
|
||||
t.Fatalf("expected NSFW confidence to be clamped to 1, got %f", label.NSFWConfidence)
|
||||
}
|
||||
})
|
||||
t.Run("NSFWBooleanWithoutConfidence", func(t *testing.T) {
|
||||
label := LabelResult{Name: "nsfw-bool", Confidence: 0.9, Topicality: 0.9, NSFW: true}
|
||||
normalizeLabelResult(&label)
|
||||
|
||||
if label.NSFWConfidence != 1 {
|
||||
t.Fatalf("expected NSFW confidence to default to 1 when NSFW is true, got %f", label.NSFWConfidence)
|
||||
}
|
||||
})
|
||||
t.Run("Apostrophe", func(t *testing.T) {
|
||||
|
||||
@@ -25,10 +25,10 @@ func SetLabelsFunc(fn func(Files, media.Src, string) (classify.Labels, error)) {
|
||||
labelsFunc = fn
|
||||
}
|
||||
|
||||
// Labels finds matching labels for the specified image.
|
||||
// GenerateLabels finds matching labels for the specified image.
|
||||
// Caller must pass the appropriate metadata source string (e.g., entity.SrcOllama, entity.SrcOpenAI)
|
||||
// so that downstream indexing can record where the labels originated.
|
||||
func Labels(images Files, mediaSrc media.Src, labelSrc string) (classify.Labels, error) {
|
||||
func GenerateLabels(images Files, mediaSrc media.Src, labelSrc string) (classify.Labels, error) {
|
||||
return labelsFunc(images, mediaSrc, labelSrc)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ func mergeLabels(result, labels classify.Labels) classify.Labels {
|
||||
if labels[j].Priority > result[k].Priority {
|
||||
result[k].Priority = labels[j].Priority
|
||||
}
|
||||
|
||||
if labels[j].NSFW && !result[k].NSFW {
|
||||
result[k].NSFW = true
|
||||
result[k].NSFWConfidence = labels[j].NSFWConfidence
|
||||
} else if labels[j].NSFWConfidence > result[k].NSFWConfidence {
|
||||
result[k].NSFWConfidence = labels[j].NSFWConfidence
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
func TestLabels(t *testing.T) {
|
||||
func TestGenerateLabels(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
result, err := Labels(Files{examplesPath + "/chameleon_lime.jpg"}, media.SrcLocal, entity.SrcAuto)
|
||||
result, err := GenerateLabels(Files{examplesPath + "/chameleon_lime.jpg"}, media.SrcLocal, entity.SrcAuto)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, classify.Labels{}, result)
|
||||
@@ -24,7 +24,7 @@ func TestLabels(t *testing.T) {
|
||||
assert.Equal(t, 7, result[0].Uncertainty)
|
||||
})
|
||||
t.Run("Cat224", func(t *testing.T) {
|
||||
result, err := Labels(Files{examplesPath + "/cat_224.jpeg"}, media.SrcLocal, entity.SrcAuto)
|
||||
result, err := GenerateLabels(Files{examplesPath + "/cat_224.jpeg"}, media.SrcLocal, entity.SrcAuto)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, classify.Labels{}, result)
|
||||
@@ -37,7 +37,7 @@ func TestLabels(t *testing.T) {
|
||||
assert.InDelta(t, float32(0.41), result[0].Confidence(), 0.1)
|
||||
})
|
||||
t.Run("Cat720", func(t *testing.T) {
|
||||
result, err := Labels(Files{examplesPath + "/cat_720.jpeg"}, media.SrcLocal, entity.SrcAuto)
|
||||
result, err := GenerateLabels(Files{examplesPath + "/cat_720.jpeg"}, media.SrcLocal, entity.SrcAuto)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, classify.Labels{}, result)
|
||||
@@ -50,7 +50,7 @@ func TestLabels(t *testing.T) {
|
||||
assert.InDelta(t, float32(0.4), result[0].Confidence(), 0.1)
|
||||
})
|
||||
t.Run("InvalidFile", func(t *testing.T) {
|
||||
_, err := Labels(Files{examplesPath + "/notexisting.jpg"}, media.SrcLocal, entity.SrcAuto)
|
||||
_, err := GenerateLabels(Files{examplesPath + "/notexisting.jpg"}, media.SrcLocal, entity.SrcAuto)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,12 +222,21 @@ func (m *Model) GetPrompt() string {
|
||||
case ModelTypeCaption:
|
||||
return ollama.CaptionPrompt
|
||||
case ModelTypeLabels:
|
||||
return ollama.LabelPrompt
|
||||
return ollama.LabelPromptDefault
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// PromptContains returns true if the prompt contains the specified substring.
|
||||
func (m *Model) PromptContains(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(m.GetSystemPrompt()+m.GetPrompt(), s)
|
||||
}
|
||||
|
||||
// GetSystemPrompt returns the configured system prompt, falling back to
|
||||
// engine defaults when none is specified. Nil receivers return an empty
|
||||
// string.
|
||||
@@ -479,10 +488,10 @@ func (m *Model) SchemaTemplate() string {
|
||||
if defaults := m.engineDefaults(); defaults != nil {
|
||||
m.schema = strings.TrimSpace(defaults.SchemaTemplate(m))
|
||||
}
|
||||
}
|
||||
|
||||
if m.schema == "" && m.Type == ModelTypeLabels {
|
||||
m.schema = visionschema.LabelsDefaultV1
|
||||
if m.schema == "" {
|
||||
m.schema = visionschema.Labels(m.PromptContains("nsfw"))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -97,6 +97,15 @@ var (
|
||||
Uri: "http://ollama:11434/api/generate",
|
||||
},
|
||||
}
|
||||
DefaultModels = Models{NasnetModel, NsfwModel, FacenetModel, CaptionModel}
|
||||
DefaultThresholds = Thresholds{Confidence: 10}
|
||||
DefaultModels = Models{
|
||||
NasnetModel,
|
||||
NsfwModel,
|
||||
FacenetModel,
|
||||
CaptionModel,
|
||||
}
|
||||
DefaultThresholds = Thresholds{
|
||||
Confidence: 10, // 0-100%
|
||||
Topicality: 0, // 0-100%
|
||||
NSFW: 75, // 1-100%
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,8 +9,24 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// Nsfw checks the specified images for inappropriate content.
|
||||
func Nsfw(images Files, mediaSrc media.Src) (result []nsfw.Result, err error) {
|
||||
var nsfwFunc = nsfwInternal
|
||||
|
||||
// SetNSFWFunc overrides the Vision NSFW detector. Intended for tests.
|
||||
func SetNSFWFunc(fn func(Files, media.Src) ([]nsfw.Result, error)) {
|
||||
if fn == nil {
|
||||
nsfwFunc = nsfwInternal
|
||||
return
|
||||
}
|
||||
|
||||
nsfwFunc = fn
|
||||
}
|
||||
|
||||
// DetectNSFW checks images for inappropriate content and generates probability scores grouped by category.
|
||||
func DetectNSFW(images Files, mediaSrc media.Src) (result []nsfw.Result, err error) {
|
||||
return nsfwFunc(images, mediaSrc)
|
||||
}
|
||||
|
||||
func nsfwInternal(images Files, mediaSrc media.Src) (result []nsfw.Result, err error) {
|
||||
// Return if no thumbnail filenames were given.
|
||||
if len(images) == 0 {
|
||||
return result, errors.New("at least one image required")
|
||||
|
||||
@@ -7,21 +7,27 @@ const (
|
||||
CaptionPrompt = "Create a caption with exactly one sentence in the active voice that describes the main visual content. Begin with the main subject and clear action. Avoid text formatting, meta-language, and filler words."
|
||||
// CaptionModel names the default caption model bundled with our adapter defaults.
|
||||
CaptionModel = "gemma3"
|
||||
// DefaultLabelConfidence is used when the model omits the confidence field.
|
||||
DefaultLabelConfidence = 0.5
|
||||
// LabelSystemSimple defines a simple system prompt for Ollama label models.
|
||||
LabelSystemSimple = "You are a PhotoPrism vision model. Output concise JSON that matches the schema."
|
||||
// LabelPromptSimple defines a simple user prompt for Ollama label models.
|
||||
LabelPromptSimple = "Analyze the image and return label objects with name, confidence (0-1), and topicality (0-1)."
|
||||
// LabelConfidenceDefault is used when the model omits the confidence field.
|
||||
LabelConfidenceDefault = 0.5
|
||||
// LabelSystem defines the system prompt shared by Ollama label models. It aims to ensure that single-word nouns are returned.
|
||||
LabelSystem = "You are a PhotoPrism vision model. Output concise JSON that matches the schema. Each label name MUST be a single-word noun in its canonical singular form. Avoid spaces, punctuation, emoji, or descriptive phrases."
|
||||
// LabelPrompt asks the model to return scored labels for the provided image. It aims to ensure that single-word nouns are returned.
|
||||
LabelPrompt = "Analyze the image and return label objects with name (single-word noun), confidence (0-1), and topicality (0-1). Respond with JSON exactly like {\"labels\":[{\"name\":\"sunset\",\"confidence\":0.72,\"topicality\":0.64}]} and adjust the values for this photo."
|
||||
// LabelSystemSimple defines a simple system prompt for Ollama label models that does not strictly require names to be single-word nouns.
|
||||
LabelSystemSimple = "You are a PhotoPrism vision model. Output concise JSON that matches the schema."
|
||||
// LabelPromptDefault defines a simple user prompt for Ollama label models.
|
||||
LabelPromptDefault = "Analyze the image and return label objects with name, confidence (0-1), and topicality (0-1)."
|
||||
// LabelPromptStrict asks the model to return scored labels for the provided image. It aims to ensure that single-word nouns are returned.
|
||||
LabelPromptStrict = "Analyze the image and return label objects with name (single-word noun), confidence (0-1), and topicality (0-1). Respond with JSON exactly like {\"labels\":[{\"name\":\"sunset\",\"confidence\":0.72,\"topicality\":0.64}]} and adjust the values for this image."
|
||||
// LabelPromptNSFW asks the model to return scored labels for the provided image that includes a NSFW flag and score. It aims to ensure that single-word nouns are returned.
|
||||
LabelPromptNSFW = "Analyze the image and return label objects with name (single-word noun), confidence (0-1), topicality (0-1), nsfw (true when the label describes sensitive or adult content), and nsfw_confidence (0-1). Respond with JSON exactly like {\"labels\":[{\"name\":\"sunset\",\"confidence\":0.72,\"topicality\":0.64,\"nsfw\":false,\"nsfw_confidence\":0.02}]} and adjust the values for this image."
|
||||
// DefaultResolution is the default thumbnail size submitted to Ollama models.
|
||||
DefaultResolution = 720
|
||||
)
|
||||
|
||||
// LabelsSchema returns the canonical label schema string consumed by Ollama models.
|
||||
func LabelsSchema() string {
|
||||
return schema.LabelsDefaultV1
|
||||
func LabelsSchema(nsfw bool) string {
|
||||
if nsfw {
|
||||
return schema.LabelsNSFW
|
||||
} else {
|
||||
return schema.LabelsDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ var (
|
||||
|
||||
// LabelsSchema returns the canonical label schema string consumed by OpenAI models.
|
||||
func LabelsSchema() string {
|
||||
return schema.LabelsDefaultV1
|
||||
return schema.LabelsDefault
|
||||
}
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
package schema
|
||||
|
||||
// LabelsDefaultV1 provides the minimal JSON schema for label responses used across engines.
|
||||
const LabelsDefaultV1 = "{\n \"labels\": [{\n \"name\": \"\",\n \"confidence\": 0,\n \"topicality\": 0\n }]\n}"
|
||||
// LabelsDefault provides the minimal JSON schema for label responses used across engines.
|
||||
const (
|
||||
LabelsDefault = "{\n \"labels\": [{\n \"name\": \"\",\n \"confidence\": 0,\n \"topicality\": 0 }]\n}"
|
||||
LabelsNSFW = "{\n \"labels\": [{\n \"name\": \"\",\n \"confidence\": 0,\n \"topicality\": 0,\n \"nsfw\": false,\n \"nsfw_confidence\": 0\n }]\n}"
|
||||
)
|
||||
|
||||
// Labels returns the canonical label schema string.
|
||||
func Labels(nsfw bool) string {
|
||||
if nsfw {
|
||||
return LabelsNSFW
|
||||
} else {
|
||||
return LabelsDefault
|
||||
}
|
||||
}
|
||||
|
||||
1
internal/ai/vision/testdata/vision.yml
vendored
1
internal/ai/vision/testdata/vision.yml
vendored
@@ -76,3 +76,4 @@ Models:
|
||||
ResponseFormat: ollama
|
||||
Thresholds:
|
||||
Confidence: 10
|
||||
NSFW: 75
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
package vision
|
||||
|
||||
// Thresholds are percentages, e.g. to determine the minimum confidence level
|
||||
// a model must have for a prediction to be considered valid or "accepted".
|
||||
// Thresholds are expressed as percentages (0-100) and gate label acceptance,
|
||||
// topicality, and NSFW handling for the configured vision models.
|
||||
type Thresholds struct {
|
||||
Confidence int `yaml:"Confidence" json:"confidence"`
|
||||
Confidence int `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
|
||||
Topicality int `yaml:"Topicality,omitempty" json:"topicality,omitempty"`
|
||||
NSFW int `yaml:"NSFW,omitempty" json:"nsfw,omitempty"`
|
||||
}
|
||||
|
||||
// GetConfidence returns the Confidence threshold in percent from 0 to 100.
|
||||
func (t *Thresholds) GetConfidence() int {
|
||||
if t.Confidence < 0 {
|
||||
return 0
|
||||
} else if t.Confidence > 100 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return t.Confidence
|
||||
}
|
||||
|
||||
// GetConfidenceFloat32 returns the Confidence threshold as float32 for comparison.
|
||||
func (t *Thresholds) GetConfidenceFloat32() float32 {
|
||||
return float32(t.GetConfidence()) / 100
|
||||
}
|
||||
|
||||
// GetTopicality returns the Topicality threshold in percent from 0 to 100.
|
||||
func (t *Thresholds) GetTopicality() int {
|
||||
if t.Topicality < 0 {
|
||||
return 0
|
||||
} else if t.Topicality > 100 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return t.Topicality
|
||||
}
|
||||
|
||||
// GetTopicalityFloat32 returns the Topicality threshold as float32 for comparison.
|
||||
func (t *Thresholds) GetTopicalityFloat32() float32 {
|
||||
return float32(t.GetTopicality()) / 100
|
||||
}
|
||||
|
||||
// GetNSFW returns the NSFW threshold in percent from 0 to 100.
|
||||
func (t *Thresholds) GetNSFW() int {
|
||||
if t.NSFW <= 0 {
|
||||
return DefaultThresholds.NSFW
|
||||
} else if t.NSFW > 100 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return t.NSFW
|
||||
}
|
||||
|
||||
// GetNSFWFloat32 returns the NSFW threshold as float32 for comparison.
|
||||
func (t *Thresholds) GetNSFWFloat32() float32 {
|
||||
return float32(t.GetNSFW()) / 100
|
||||
}
|
||||
|
||||
72
internal/ai/vision/thresholds_test.go
Normal file
72
internal/ai/vision/thresholds_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package vision
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThresholds_GetConfidence(t *testing.T) {
|
||||
t.Run("Negative", func(t *testing.T) {
|
||||
th := Thresholds{Confidence: -5}
|
||||
if got := th.GetConfidence(); got != 0 {
|
||||
t.Fatalf("expected 0, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AboveMax", func(t *testing.T) {
|
||||
th := Thresholds{Confidence: 150}
|
||||
if got := th.GetConfidence(); got != 1 {
|
||||
t.Fatalf("expected 1, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Float", func(t *testing.T) {
|
||||
th := Thresholds{Confidence: 25}
|
||||
if got := th.GetConfidenceFloat32(); got != 0.25 {
|
||||
t.Fatalf("expected 0.25, got %f", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestThresholds_GetTopicality(t *testing.T) {
|
||||
t.Run("Negative", func(t *testing.T) {
|
||||
th := Thresholds{Topicality: -10}
|
||||
if got := th.GetTopicality(); got != 0 {
|
||||
t.Fatalf("expected 0, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AboveMax", func(t *testing.T) {
|
||||
th := Thresholds{Topicality: 300}
|
||||
if got := th.GetTopicality(); got != 1 {
|
||||
t.Fatalf("expected 1, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Float", func(t *testing.T) {
|
||||
th := Thresholds{Topicality: 45}
|
||||
if got := th.GetTopicalityFloat32(); got != 0.45 {
|
||||
t.Fatalf("expected 0.45, got %f", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestThresholds_GetNSFW(t *testing.T) {
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
th := Thresholds{NSFW: 0}
|
||||
if got := th.GetNSFW(); got != DefaultThresholds.NSFW {
|
||||
t.Fatalf("expected default %d, got %d", DefaultThresholds.NSFW, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AboveMax", func(t *testing.T) {
|
||||
th := Thresholds{NSFW: 200}
|
||||
if got := th.GetNSFW(); got != 1 {
|
||||
t.Fatalf("expected 1, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Float", func(t *testing.T) {
|
||||
th := Thresholds{NSFW: 80}
|
||||
if got := th.GetNSFWFloat32(); got != 0.8 {
|
||||
t.Fatalf("expected 0.8, got %f", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,11 @@ func SearchLabels(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
if acl.Rules.Deny(acl.ResourceLabels, s.GetUserRole(), acl.AccessPrivate) {
|
||||
frm.NSFW = false
|
||||
frm.Public = true
|
||||
}
|
||||
|
||||
// Update precalculated photo counts if needed.
|
||||
if err = entity.UpdateLabelCountsIfNeeded(); err != nil {
|
||||
log.Warnf("labels: could not update photo counts (%s)", err)
|
||||
|
||||
@@ -1586,6 +1586,9 @@
|
||||
"ID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"NSFW": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1991,25 +1994,25 @@
|
||||
},
|
||||
"entity.PhotoLabel": {
|
||||
"properties": {
|
||||
"label": {
|
||||
"Label": {
|
||||
"$ref": "#/definitions/entity.Label"
|
||||
},
|
||||
"labelID": {
|
||||
"LabelID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"labelSrc": {
|
||||
"LabelSrc": {
|
||||
"type": "string"
|
||||
},
|
||||
"photo": {
|
||||
"$ref": "#/definitions/entity.Photo"
|
||||
},
|
||||
"photoID": {
|
||||
"NSFW": {
|
||||
"type": "integer"
|
||||
},
|
||||
"topicality": {
|
||||
"PhotoID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uncertainty": {
|
||||
"Topicality": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Uncertainty": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -4007,6 +4010,9 @@
|
||||
"ID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"NSFW": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4701,6 +4707,12 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nsfw_confidence": {
|
||||
"type": "number"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
@@ -199,7 +199,7 @@ func UploadUserFiles(router *gin.RouterGroup) {
|
||||
containsNSFW := false
|
||||
|
||||
for _, filename := range uploads {
|
||||
labels, nsfwErr := vision.Nsfw([]string{filename}, media.SrcLocal)
|
||||
labels, nsfwErr := vision.DetectNSFW([]string{filename}, media.SrcLocal)
|
||||
|
||||
if nsfwErr != nil {
|
||||
log.Debug(nsfwErr)
|
||||
|
||||
@@ -53,7 +53,7 @@ func PostVisionCaption(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Run inference to generate a caption.
|
||||
result, model, err := vision.Caption(request.Images, media.SrcRemote)
|
||||
result, model, err := vision.GenerateCaption(request.Images, media.SrcRemote)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("vision: %s (caption)", err)
|
||||
|
||||
@@ -55,7 +55,7 @@ func PostVisionLabels(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Run inference to find matching labels.
|
||||
labels, err := vision.Labels(request.Images, media.SrcRemote, entity.SrcAuto)
|
||||
labels, err := vision.GenerateLabels(request.Images, media.SrcRemote, entity.SrcAuto)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("vision: %s (run labels)", err)
|
||||
|
||||
@@ -54,7 +54,7 @@ func PostVisionNsfw(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Run inference to check the specified images for inappropriate content.
|
||||
results, err := vision.Nsfw(request.Images, media.SrcRemote)
|
||||
results, err := vision.DetectNSFW(request.Images, media.SrcRemote)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("vision: %s (run nsfw)", err)
|
||||
|
||||
@@ -310,6 +310,7 @@ func (c *Config) Propagate() {
|
||||
vision.ServiceUri = c.VisionUri()
|
||||
vision.ServiceKey = c.VisionKey()
|
||||
vision.DownloadUrl = c.DownloadUrl()
|
||||
vision.DetectNSFWLabels = c.DetectNSFW() && c.Experimental()
|
||||
|
||||
// Set allowed path in download package.
|
||||
download.AllowedPaths = []string{
|
||||
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
//
|
||||
// return fs.YamlFilePath("vision", c.ConfigPath(), c.options.VisionYaml)
|
||||
func (c *Config) VisionYaml() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if c.options.VisionYaml != "" {
|
||||
return fs.Abs(c.options.VisionYaml)
|
||||
} else {
|
||||
@@ -25,16 +29,28 @@ func (c *Config) VisionYaml() string {
|
||||
|
||||
// VisionSchedule returns the cron schedule configured for the vision worker, or "" if disabled.
|
||||
func (c *Config) VisionSchedule() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return Schedule(c.options.VisionSchedule)
|
||||
}
|
||||
|
||||
// VisionFilter returns the search filter to use for scheduled vision runs.
|
||||
func (c *Config) VisionFilter() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(c.options.VisionFilter)
|
||||
}
|
||||
|
||||
// VisionModelShouldRun checks when the specified model type should run.
|
||||
func (c *Config) VisionModelShouldRun(t vision.ModelType, when vision.RunType) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if t == vision.ModelTypeLabels && c.DisableClassification() {
|
||||
return false
|
||||
}
|
||||
@@ -52,16 +68,28 @@ func (c *Config) VisionModelShouldRun(t vision.ModelType, when vision.RunType) b
|
||||
|
||||
// VisionApi checks whether the Computer Vision API endpoints should be enabled.
|
||||
func (c *Config) VisionApi() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.options.VisionApi && !c.options.Demo
|
||||
}
|
||||
|
||||
// VisionUri returns the remote computer vision service URI, e.g. https://example.com/api/v1/vision.
|
||||
func (c *Config) VisionUri() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return clean.Uri(c.options.VisionUri)
|
||||
}
|
||||
|
||||
// VisionKey returns the remote computer vision service access token, if any.
|
||||
func (c *Config) VisionKey() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to read access token from file if c.options.VisionKey is not set.
|
||||
if c.options.VisionKey != "" {
|
||||
return clean.Password(c.options.VisionKey)
|
||||
@@ -78,6 +106,10 @@ func (c *Config) VisionKey() string {
|
||||
|
||||
// ModelsPath returns the path where the machine learning models are located.
|
||||
func (c *Config) ModelsPath() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if c.options.ModelsPath != "" {
|
||||
return fs.Abs(c.options.ModelsPath)
|
||||
}
|
||||
@@ -94,20 +126,36 @@ func (c *Config) ModelsPath() string {
|
||||
|
||||
// NasnetModelPath returns the TensorFlow model path.
|
||||
func (c *Config) NasnetModelPath() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(c.ModelsPath(), "nasnet")
|
||||
}
|
||||
|
||||
// FacenetModelPath returns the FaceNet model path.
|
||||
func (c *Config) FacenetModelPath() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(c.ModelsPath(), "facenet")
|
||||
}
|
||||
|
||||
// NsfwModelPath returns the "not safe for work" TensorFlow model path.
|
||||
func (c *Config) NsfwModelPath() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(c.ModelsPath(), "nsfw")
|
||||
}
|
||||
|
||||
// DetectNSFW checks if NSFW photos should be detected and flagged.
|
||||
func (c *Config) DetectNSFW() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.options.DetectNSFW
|
||||
}
|
||||
|
||||
@@ -33,8 +33,9 @@ type Label struct {
|
||||
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
||||
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
|
||||
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
||||
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
|
||||
LabelFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
|
||||
LabelFavorite bool `gorm:"default:0;" json:"Favorite" yaml:"Favorite,omitempty"`
|
||||
LabelPriority int `gorm:"default:0;" json:"Priority" yaml:"Priority,omitempty"`
|
||||
LabelNSFW bool `gorm:"column:label_nsfw;default:0;" json:"NSFW,omitempty" yaml:"NSFW,omitempty"`
|
||||
LabelDescription string `gorm:"type:VARCHAR(2048);" json:"Description" yaml:"Description,omitempty"`
|
||||
LabelNotes string `gorm:"type:VARCHAR(1024);" json:"Notes" yaml:"Notes,omitempty"`
|
||||
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id" json:"-" yaml:"-"`
|
||||
|
||||
@@ -219,4 +219,10 @@ var DialectMySQL = Migrations{
|
||||
Stage: "main",
|
||||
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
|
||||
},
|
||||
{
|
||||
ID: "20251005-000001",
|
||||
Dialect: "mysql",
|
||||
Stage: "main",
|
||||
Statements: []string{"UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;", "UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;", "UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -135,4 +135,10 @@ var DialectSQLite3 = Migrations{
|
||||
Stage: "main",
|
||||
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
|
||||
},
|
||||
{
|
||||
ID: "20251005-000001",
|
||||
Dialect: "sqlite3",
|
||||
Stage: "main",
|
||||
Statements: []string{"UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;", "UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;", "UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;"},
|
||||
},
|
||||
}
|
||||
|
||||
3
internal/entity/migrate/mysql/20251005-000001.sql
Normal file
3
internal/entity/migrate/mysql/20251005-000001.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
|
||||
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
|
||||
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;
|
||||
3
internal/entity/migrate/sqlite3/20251005-000001.sql
Normal file
3
internal/entity/migrate/sqlite3/20251005-000001.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
|
||||
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
|
||||
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;
|
||||
@@ -310,23 +310,29 @@ func (m *Photo) SetMediaType(newType media.Type, typeSrc string) {
|
||||
return
|
||||
}
|
||||
|
||||
// String returns the id or name as string.
|
||||
// PhotoLogString returns a sanitized identifier for logging that prefers
|
||||
// photo name, falling back to original name, UID, or numeric ID.
|
||||
func PhotoLogString(photoPath, photoName, originalName, photoUID string, id uint) string {
|
||||
if photoName != "" {
|
||||
return clean.Log(path.Join(photoPath, photoName))
|
||||
} else if originalName != "" {
|
||||
return clean.Log(originalName)
|
||||
} else if photoUID != "" {
|
||||
return "uid " + clean.Log(photoUID)
|
||||
} else if id > 0 {
|
||||
return fmt.Sprintf("id %d", id)
|
||||
}
|
||||
|
||||
return "*Photo"
|
||||
}
|
||||
|
||||
// String returns the id or name as string for logging purposes.
|
||||
func (m *Photo) String() string {
|
||||
if m == nil {
|
||||
return "Photo<nil>"
|
||||
}
|
||||
|
||||
if m.PhotoName != "" {
|
||||
return clean.Log(path.Join(m.PhotoPath, m.PhotoName))
|
||||
} else if m.OriginalName != "" {
|
||||
return clean.Log(m.OriginalName)
|
||||
} else if m.PhotoUID != "" {
|
||||
return "uid " + clean.Log(m.PhotoUID)
|
||||
} else if m.ID > 0 {
|
||||
return fmt.Sprintf("id %d", m.ID)
|
||||
}
|
||||
|
||||
return "*Photo"
|
||||
return PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
|
||||
}
|
||||
|
||||
// FirstOrCreate inserts the Photo if it does not exist and otherwise reloads the persisted row with its associations.
|
||||
@@ -784,7 +790,6 @@ func (m *Photo) ShouldGenerateLabels(force bool) bool {
|
||||
// never receives invalid input from upstream detectors.
|
||||
func (m *Photo) AddLabels(labels classify.Labels) {
|
||||
for _, classifyLabel := range labels {
|
||||
|
||||
title := classifyLabel.Title()
|
||||
|
||||
if title == "" || txt.Slug(title) == "" {
|
||||
@@ -823,6 +828,21 @@ func (m *Photo) AddLabels(labels classify.Labels) {
|
||||
|
||||
template := NewPhotoLabel(m.ID, labelEntity.ID, classifyLabel.Uncertainty, labelSrc)
|
||||
template.Topicality = classifyLabel.Topicality
|
||||
score := 0
|
||||
|
||||
if classifyLabel.NSFWConfidence > 0 {
|
||||
score = classifyLabel.NSFWConfidence
|
||||
}
|
||||
|
||||
if classifyLabel.NSFW && score == 0 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
template.NSFW = score
|
||||
photoLabel := FirstOrCreatePhotoLabel(template)
|
||||
|
||||
if photoLabel == nil {
|
||||
@@ -832,13 +852,32 @@ func (m *Photo) AddLabels(labels classify.Labels) {
|
||||
|
||||
if photoLabel.HasID() {
|
||||
updates := Values{}
|
||||
|
||||
if photoLabel.Uncertainty > classifyLabel.Uncertainty && photoLabel.Uncertainty < 100 {
|
||||
updates["Uncertainty"] = classifyLabel.Uncertainty
|
||||
updates["LabelSrc"] = labelSrc
|
||||
}
|
||||
|
||||
if classifyLabel.Topicality > 0 && photoLabel.Topicality != classifyLabel.Topicality {
|
||||
updates["Topicality"] = classifyLabel.Topicality
|
||||
}
|
||||
|
||||
if classifyLabel.NSFWConfidence > 0 || classifyLabel.NSFW {
|
||||
nsfwScore := 0
|
||||
if classifyLabel.NSFWConfidence > 0 {
|
||||
nsfwScore = classifyLabel.NSFWConfidence
|
||||
}
|
||||
if classifyLabel.NSFW && nsfwScore == 0 {
|
||||
nsfwScore = 100
|
||||
}
|
||||
if nsfwScore > 100 {
|
||||
nsfwScore = 100
|
||||
}
|
||||
if photoLabel.NSFW != nsfwScore {
|
||||
updates["NSFW"] = nsfwScore
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := photoLabel.Updates(updates); err != nil {
|
||||
log.Errorf("index: %s", err)
|
||||
|
||||
@@ -12,13 +12,14 @@ type PhotoLabels []PhotoLabel
|
||||
// PhotoLabel represents the many-to-many relation between Photo and Label.
|
||||
// Labels are weighted by uncertainty (100 - confidence).
|
||||
type PhotoLabel struct {
|
||||
PhotoID uint `gorm:"primary_key;auto_increment:false"`
|
||||
LabelID uint `gorm:"primary_key;auto_increment:false;index"`
|
||||
LabelSrc string `gorm:"type:VARBINARY(8);"`
|
||||
Uncertainty int `gorm:"type:SMALLINT"`
|
||||
Topicality int `gorm:"type:SMALLINT"`
|
||||
Photo *Photo `gorm:"PRELOAD:false"`
|
||||
Label *Label `gorm:"PRELOAD:true"`
|
||||
PhotoID uint `gorm:"primary_key;auto_increment:false" json:"PhotoID,omitempty" yaml:"PhotoID"`
|
||||
LabelID uint `gorm:"primary_key;auto_increment:false;index" json:"LabelID,omitempty" yaml:"LabelID"`
|
||||
LabelSrc string `gorm:"type:VARBINARY(8);" json:"LabelSrc,omitempty" yaml:"LabelSrc,omitempty"`
|
||||
Uncertainty int `gorm:"type:SMALLINT" json:"Uncertainty" yaml:"Uncertainty"`
|
||||
Topicality int `gorm:"type:SMALLINT;default:0;" json:"Topicality" yaml:"Topicality,omitempty"`
|
||||
NSFW int `gorm:"type:SMALLINT;column:nsfw;default:0;" json:"NSFW,omitempty" yaml:"NSFW,omitempty"`
|
||||
Photo *Photo `gorm:"PRELOAD:false" json:"-" yaml:"-"`
|
||||
Label *Label `gorm:"PRELOAD:true" json:"Label,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the database table name for PhotoLabel.
|
||||
@@ -145,11 +146,13 @@ func (m *PhotoLabel) ClassifyLabel() classify.Label {
|
||||
}
|
||||
|
||||
result := classify.Label{
|
||||
Name: m.Label.LabelName,
|
||||
Source: m.LabelSrc,
|
||||
Uncertainty: m.Uncertainty,
|
||||
Topicality: m.Topicality,
|
||||
Priority: m.Label.LabelPriority,
|
||||
Name: m.Label.LabelName,
|
||||
Source: m.LabelSrc,
|
||||
Uncertainty: m.Uncertainty,
|
||||
Topicality: m.Topicality,
|
||||
Priority: m.Label.LabelPriority,
|
||||
NSFW: m.Label.LabelNSFW,
|
||||
NSFWConfidence: m.NSFW,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
@@ -597,37 +598,85 @@ func TestPhoto_Delete(t *testing.T) {
|
||||
|
||||
func TestPhotos_UIDs(t *testing.T) {
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
photo1 := &Photo{PhotoUID: "abc123"}
|
||||
photo2 := &Photo{PhotoUID: "abc456"}
|
||||
uid1 := rnd.GenerateUID(PhotoUID)
|
||||
uid2 := rnd.GenerateUID(PhotoUID)
|
||||
photo1 := &Photo{PhotoUID: uid1}
|
||||
photo2 := &Photo{PhotoUID: uid2}
|
||||
photos := Photos{photo1, photo2}
|
||||
assert.Equal(t, []string{"abc123", "abc456"}, photos.UIDs())
|
||||
assert.Equal(t, []string{uid1, uid2}, photos.UIDs())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhoto_String(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var m *Photo
|
||||
assert.Equal(t, "Photo<nil>", m.String())
|
||||
assert.Equal(t, "Photo<nil>", fmt.Sprintf("%s", m))
|
||||
})
|
||||
t.Run("New", func(t *testing.T) {
|
||||
m := &Photo{PhotoUID: "", PhotoName: "", OriginalName: ""}
|
||||
assert.Equal(t, "*Photo", m.String())
|
||||
assert.Equal(t, "*Photo", fmt.Sprintf("%s", m))
|
||||
})
|
||||
t.Run("Original", func(t *testing.T) {
|
||||
m := Photo{PhotoUID: "", PhotoName: "", OriginalName: "holidayOriginal"}
|
||||
assert.Equal(t, "holidayOriginal", m.String())
|
||||
})
|
||||
t.Run("UID", func(t *testing.T) {
|
||||
m := Photo{PhotoUID: "ps6sg6be2lvl0k53", PhotoName: "", OriginalName: ""}
|
||||
assert.Equal(t, "uid ps6sg6be2lvl0k53", m.String())
|
||||
})
|
||||
generatedUID := rnd.GenerateUID(PhotoUID)
|
||||
testcases := []struct {
|
||||
name string
|
||||
photo *Photo
|
||||
want string
|
||||
checkFmt bool
|
||||
}{
|
||||
{
|
||||
name: "Nil",
|
||||
photo: nil,
|
||||
want: "Photo<nil>",
|
||||
checkFmt: true,
|
||||
},
|
||||
{
|
||||
name: "PhotoNameWithPath",
|
||||
photo: &Photo{PhotoPath: "albums/test", PhotoName: "my photo.jpg"},
|
||||
want: "'albums/test/my photo.jpg'",
|
||||
checkFmt: true,
|
||||
},
|
||||
{
|
||||
name: "PhotoNameOnly",
|
||||
photo: &Photo{PhotoName: "photo.jpg"},
|
||||
want: "photo.jpg",
|
||||
},
|
||||
{
|
||||
name: "OriginalName",
|
||||
photo: &Photo{OriginalName: "orig name.dng"},
|
||||
want: "'orig name.dng'",
|
||||
},
|
||||
{
|
||||
name: "UID",
|
||||
photo: &Photo{PhotoUID: generatedUID},
|
||||
want: fmt.Sprintf("uid %s", generatedUID),
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
photo: &Photo{ID: 42},
|
||||
want: "id 42",
|
||||
},
|
||||
{
|
||||
name: "Fallback",
|
||||
photo: &Photo{},
|
||||
want: "*Photo",
|
||||
checkFmt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.photo == nil {
|
||||
var p *Photo
|
||||
assert.Equal(t, tc.want, p.String())
|
||||
if tc.checkFmt {
|
||||
assert.Equal(t, tc.want, fmt.Sprintf("%s", p))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.want, tc.photo.String())
|
||||
if tc.checkFmt {
|
||||
assert.Equal(t, tc.want, fmt.Sprintf("%s", tc.photo))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoto_Create(t *testing.T) {
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
err := photo.Create()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -637,7 +686,7 @@ func TestPhoto_Create(t *testing.T) {
|
||||
|
||||
func TestPhoto_Save(t *testing.T) {
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
err := photo.Save()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -893,7 +942,7 @@ func TestPhoto_UpdateKeywordLabels(t *testing.T) {
|
||||
|
||||
func TestPhoto_LocationLoaded(t *testing.T) {
|
||||
t.Run("Photo", func(t *testing.T) {
|
||||
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
assert.False(t, photo.LocationLoaded())
|
||||
})
|
||||
t.Run("PhotoWithCell", func(t *testing.T) {
|
||||
@@ -924,7 +973,7 @@ func TestPhoto_LoadLocation(t *testing.T) {
|
||||
|
||||
func TestPhoto_PlaceLoaded(t *testing.T) {
|
||||
t.Run("False", func(t *testing.T) {
|
||||
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
|
||||
assert.False(t, photo.PlaceLoaded())
|
||||
})
|
||||
}
|
||||
@@ -1129,7 +1178,7 @@ func TestPhoto_SetPrimary(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
})
|
||||
t.Run("NoPreviewImage", func(t *testing.T) {
|
||||
m := Photo{PhotoUID: "1245678"}
|
||||
m := Photo{PhotoUID: rnd.GenerateUID(PhotoUID)}
|
||||
|
||||
err := m.SetPrimary("")
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -151,7 +151,7 @@ func UserAlbums(frm form.SearchAlbums, sess *entity.Session) (results AlbumResul
|
||||
}
|
||||
}
|
||||
|
||||
// Albums with public pictures only?
|
||||
// Filter private albums.
|
||||
if frm.Public {
|
||||
s = s.Where("albums.album_private = 0 AND (albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL))")
|
||||
} else {
|
||||
|
||||
@@ -10,12 +10,16 @@ import (
|
||||
"github.com/jinzhu/inflection"
|
||||
)
|
||||
|
||||
// Like escapes a string for use in a query.
|
||||
// Like sanitizes user input so it can be safely interpolated into SQL LIKE
|
||||
// expressions. It strips operators that we don't expect to persist in the
|
||||
// statement and lets callers provide their own surrounding wildcards.
|
||||
func Like(s string) string {
|
||||
return strings.Trim(clean.SqlString(s), " |&*%")
|
||||
}
|
||||
|
||||
// LikeAny returns a single where condition matching the search words.
|
||||
// LikeAny builds OR-chained LIKE predicates for a text column. The input string
|
||||
// may contain AND / OR separators; keywords trigger stemming and plural
|
||||
// normalization while exact mode disables wildcard suffixes.
|
||||
func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
|
||||
if s == "" {
|
||||
return wheres
|
||||
@@ -73,17 +77,20 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
|
||||
return wheres
|
||||
}
|
||||
|
||||
// LikeAnyKeyword returns a single where condition matching the search keywords.
|
||||
// LikeAnyKeyword is a keyword-optimized wrapper around LikeAny.
|
||||
func LikeAnyKeyword(col, s string) (wheres []string) {
|
||||
return LikeAny(col, s, true, false)
|
||||
}
|
||||
|
||||
// LikeAnyWord returns a single where condition matching the search word.
|
||||
// LikeAnyWord matches whole words and keeps wildcard thresholds tuned for
|
||||
// free-form text search instead of keyword lists.
|
||||
func LikeAnyWord(col, s string) (wheres []string) {
|
||||
return LikeAny(col, s, false, false)
|
||||
}
|
||||
|
||||
// LikeAll returns a list of where conditions matching all search words.
|
||||
// LikeAll produces AND-chained LIKE predicates for every significant token in
|
||||
// the search string. When exact is false, longer words receive a suffix
|
||||
// wildcard to support prefix matches.
|
||||
func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
|
||||
if s == "" {
|
||||
return wheres
|
||||
@@ -117,17 +124,19 @@ func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
|
||||
return wheres
|
||||
}
|
||||
|
||||
// LikeAllKeywords returns a list of where conditions matching all search keywords.
|
||||
// LikeAllKeywords is LikeAll specialized for keyword search.
|
||||
func LikeAllKeywords(col, s string) (wheres []string) {
|
||||
return LikeAll(col, s, true, false)
|
||||
}
|
||||
|
||||
// LikeAllWords returns a list of where conditions matching all search words.
|
||||
// LikeAllWords is LikeAll specialized for general word search.
|
||||
func LikeAllWords(col, s string) (wheres []string) {
|
||||
return LikeAll(col, s, false, false)
|
||||
}
|
||||
|
||||
// LikeAllNames returns a list of where conditions matching all names.
|
||||
// LikeAllNames splits a name query into AND-separated groups and generates
|
||||
// prefix or substring matches against each provided column, keeping multi-word
|
||||
// tokens intact so "John Doe" still matches full-name columns.
|
||||
func LikeAllNames(cols Cols, s string) (wheres []string) {
|
||||
if len(cols) == 0 || len(s) < 1 {
|
||||
return wheres
|
||||
@@ -160,7 +169,9 @@ func LikeAllNames(cols Cols, s string) (wheres []string) {
|
||||
return wheres
|
||||
}
|
||||
|
||||
// AnySlug returns a where condition that matches any slug in search.
|
||||
// AnySlug converts human-friendly search terms into slugs and matches them
|
||||
// against the provided slug column, including the singularized variant for
|
||||
// plural words (e.g. "Cats" -> "cat").
|
||||
func AnySlug(col, search, sep string) (where string) {
|
||||
if search == "" {
|
||||
return ""
|
||||
@@ -200,7 +211,8 @@ func AnySlug(col, search, sep string) (where string) {
|
||||
return strings.Join(wheres, " OR ")
|
||||
}
|
||||
|
||||
// AnyInt returns a where condition that matches any integer within a range.
|
||||
// AnyInt filters user-specified integers through an allowed range and returns
|
||||
// an OR-chained equality predicate for the values that remain.
|
||||
func AnyInt(col, numbers, sep string, min, max int) (where string) {
|
||||
if numbers == "" {
|
||||
return ""
|
||||
@@ -234,7 +246,9 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
|
||||
return strings.Join(wheres, " OR ")
|
||||
}
|
||||
|
||||
// OrLike returns a where condition and values for finding multiple terms combined with OR.
|
||||
// OrLike prepares a parameterised OR/LIKE clause for a single column. Star (* )
|
||||
// wildcards are mapped to SQL percent wildcards before returning the query and
|
||||
// bind values.
|
||||
func OrLike(col, s string) (where string, values []interface{}) {
|
||||
if txt.Empty(col) || txt.Empty(s) {
|
||||
return "", []interface{}{}
|
||||
@@ -262,7 +276,9 @@ func OrLike(col, s string) (where string, values []interface{}) {
|
||||
return where, values
|
||||
}
|
||||
|
||||
// OrLikeCols returns a where condition and values for finding multiple terms combined with OR.
|
||||
// OrLikeCols behaves like OrLike but fans out the same search terms across
|
||||
// multiple columns, preserving the order of values so callers can feed them to
|
||||
// database/sql.
|
||||
func OrLikeCols(cols []string, s string) (where string, values []interface{}) {
|
||||
if len(cols) == 0 || txt.Empty(s) {
|
||||
return "", []interface{}{}
|
||||
@@ -299,12 +315,14 @@ func OrLikeCols(cols []string, s string) (where string, values []interface{}) {
|
||||
return strings.Join(wheres, " OR "), values
|
||||
}
|
||||
|
||||
// SplitOr splits a search string into separate OR values for an IN condition.
|
||||
// SplitOr splits a search string on OR separators (|) while respecting escape
|
||||
// sequences so literals like "\|" survive unchanged.
|
||||
func SplitOr(s string) (values []string) {
|
||||
return txt.TrimmedSplitWithEscape(s, txt.OrRune, txt.EscapeRune)
|
||||
}
|
||||
|
||||
// SplitAnd splits a search string into separate AND values.
|
||||
// SplitAnd splits a search string on AND separators (&) while honouring escape
|
||||
// sequences.
|
||||
func SplitAnd(s string) (values []string) {
|
||||
return txt.TrimmedSplitWithEscape(s, txt.AndRune, txt.EscapeRune)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ func Labels(frm form.SearchLabels) (results []Label, err error) {
|
||||
Where("labels.photo_count > 0").
|
||||
Group("labels.id")
|
||||
|
||||
// Filter private labels.
|
||||
if frm.Public {
|
||||
s = s.Where("labels.label_nsfw = 0")
|
||||
} else if frm.NSFW {
|
||||
s = s.Where("labels.label_nsfw = 1")
|
||||
}
|
||||
|
||||
// Limit result count.
|
||||
if frm.Count > 0 && frm.Count <= MaxResults {
|
||||
s = s.Limit(frm.Count).Offset(frm.Offset)
|
||||
|
||||
@@ -8,16 +8,17 @@ import (
|
||||
type Label struct {
|
||||
ID uint `json:"ID"`
|
||||
LabelUID string `json:"UID"`
|
||||
Thumb string `json:"Thumb"`
|
||||
ThumbSrc string `json:"ThumbSrc,omitempty"`
|
||||
LabelSlug string `json:"Slug"`
|
||||
CustomSlug string `json:"CustomSlug"`
|
||||
LabelName string `json:"Name"`
|
||||
LabelPriority int `json:"Priority"`
|
||||
LabelFavorite bool `json:"Favorite"`
|
||||
LabelPriority int `json:"Priority"`
|
||||
LabelNSFW bool `json:"NSFW,omitempty"`
|
||||
LabelDescription string `json:"Description"`
|
||||
LabelNotes string `json:"Notes"`
|
||||
PhotoCount int `json:"PhotoCount"`
|
||||
Thumb string `json:"Thumb"`
|
||||
ThumbSrc string `json:"ThumbSrc,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
DeletedAt time.Time `json:"DeletedAt,omitempty"`
|
||||
|
||||
@@ -500,12 +500,6 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
|
||||
} else {
|
||||
s = s.Where("photos.deleted_at IS NULL")
|
||||
|
||||
if frm.Private {
|
||||
s = s.Where("photos.photo_private = 1")
|
||||
} else if frm.Public {
|
||||
s = s.Where("photos.photo_private = 0")
|
||||
}
|
||||
|
||||
if frm.Review {
|
||||
s = s.Where("photos.photo_quality < 3")
|
||||
} else if frm.Quality != 0 && frm.Private == false {
|
||||
@@ -513,6 +507,13 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
|
||||
}
|
||||
}
|
||||
|
||||
// Filter private pictures.
|
||||
if frm.Public {
|
||||
s = s.Where("photos.photo_private = 0")
|
||||
} else if frm.Private {
|
||||
s = s.Where("photos.photo_private = 1")
|
||||
}
|
||||
|
||||
// Filter by camera id or name.
|
||||
if txt.IsPosInt(frm.Camera) {
|
||||
s = s.Where("photos.camera_id = ?", txt.UInt(frm.Camera))
|
||||
|
||||
@@ -624,12 +624,6 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
|
||||
} else {
|
||||
s = s.Where("photos.deleted_at IS NULL")
|
||||
|
||||
if frm.Private {
|
||||
s = s.Where("photos.photo_private = 1")
|
||||
} else if frm.Public {
|
||||
s = s.Where("photos.photo_private = 0")
|
||||
}
|
||||
|
||||
if frm.Review {
|
||||
s = s.Where("photos.photo_quality < 3")
|
||||
} else if frm.Quality != 0 && frm.Private == false {
|
||||
@@ -637,6 +631,13 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
|
||||
}
|
||||
}
|
||||
|
||||
// Filter private pictures.
|
||||
if frm.Public {
|
||||
s = s.Where("photos.photo_private = 0")
|
||||
} else if frm.Private {
|
||||
s = s.Where("photos.photo_private = 1")
|
||||
}
|
||||
|
||||
// Filter by location code.
|
||||
if txt.NotEmpty(frm.S2) {
|
||||
// S2 Cell ID.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestGeoResult_Lat(t *testing.T) {
|
||||
@@ -46,12 +47,15 @@ func TestGeoResult_Lng(t *testing.T) {
|
||||
|
||||
func TestGeoResults_GeoJSON(t *testing.T) {
|
||||
taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Truncate(time.Second)
|
||||
uid1 := rnd.GenerateUID(entity.PhotoUID)
|
||||
uid2 := rnd.GenerateUID(entity.PhotoUID)
|
||||
uid3 := rnd.GenerateUID(entity.PhotoUID)
|
||||
items := GeoResults{
|
||||
GeoResult{
|
||||
ID: "1",
|
||||
PhotoLat: 7.775,
|
||||
PhotoLng: 8.775,
|
||||
PhotoUID: "p1",
|
||||
PhotoUID: uid1,
|
||||
PhotoTitle: "Title 1",
|
||||
PhotoCaption: "Description 1",
|
||||
PhotoFavorite: false,
|
||||
@@ -65,7 +69,7 @@ func TestGeoResults_GeoJSON(t *testing.T) {
|
||||
ID: "2",
|
||||
PhotoLat: 1.775,
|
||||
PhotoLng: -5.775,
|
||||
PhotoUID: "p2",
|
||||
PhotoUID: uid2,
|
||||
PhotoTitle: "Title 2",
|
||||
PhotoCaption: "Description 2",
|
||||
PhotoFavorite: true,
|
||||
@@ -79,7 +83,7 @@ func TestGeoResults_GeoJSON(t *testing.T) {
|
||||
ID: "3",
|
||||
PhotoLat: -1.775,
|
||||
PhotoLng: 100.775,
|
||||
PhotoUID: "p3",
|
||||
PhotoUID: uid3,
|
||||
PhotoTitle: "Title 3",
|
||||
PhotoCaption: "Description 3",
|
||||
PhotoFavorite: false,
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// Photo represents a photo search result.
|
||||
// Photo represents a photo search result row joined with its primary file and
|
||||
// related metadata that we surface in the UI and API responses.
|
||||
type Photo struct {
|
||||
ID uint `json:"-" select:"photos.id"`
|
||||
CompositeID string `json:"ID" select:"files.photo_id AS composite_id"`
|
||||
@@ -132,7 +133,17 @@ func (m *Photo) GetUID() string {
|
||||
return m.PhotoUID
|
||||
}
|
||||
|
||||
// Approve approves the photo if it is in review.
|
||||
// String returns the id or name as string for logging purposes.
|
||||
func (m *Photo) String() string {
|
||||
if m == nil {
|
||||
return "Photo<nil>"
|
||||
}
|
||||
|
||||
return entity.PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
|
||||
}
|
||||
|
||||
// Approve promotes the photo to quality level 3 and clears review flags if it
|
||||
// currently sits in review state.
|
||||
func (m *Photo) Approve() error {
|
||||
if !m.HasID() {
|
||||
return fmt.Errorf("photo has no id")
|
||||
@@ -172,7 +183,7 @@ func (m *Photo) Approve() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore removes the photo from the archive (reverses soft delete).
|
||||
// Restore removes the photo from the archive by clearing the soft-delete flag.
|
||||
func (m *Photo) Restore() error {
|
||||
if !m.HasID() {
|
||||
return fmt.Errorf("photo has no id")
|
||||
@@ -202,7 +213,8 @@ func (m *Photo) IsPlayable() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// MediaInfo returns the media file hash and codec depending on the media type.
|
||||
// MediaInfo returns the best available media hash, codec, mime type, and
|
||||
// dimensions for the photo based on its media type and merged files.
|
||||
func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, height int) {
|
||||
switch m.PhotoType {
|
||||
case entity.MediaVideo, entity.MediaLive:
|
||||
@@ -247,7 +259,8 @@ func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, hei
|
||||
return m.FileHash, "", m.FileMime, m.FileWidth, m.FileHeight
|
||||
}
|
||||
|
||||
// ShareBase returns a meaningful file name for sharing.
|
||||
// ShareBase returns a deterministic, human friendly file name stem for sharing
|
||||
// downloads generated from the photo's timestamp and title.
|
||||
func (m *Photo) ShareBase(seq int) string {
|
||||
var name string
|
||||
|
||||
@@ -266,9 +279,12 @@ func (m *Photo) ShareBase(seq int) string {
|
||||
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
|
||||
}
|
||||
|
||||
// PhotoResults represents a list of photo search results that can be post
|
||||
// processed (for example merged by file).
|
||||
type PhotoResults []Photo
|
||||
|
||||
// Photos returns the result as a slice of Photo.
|
||||
// Photos returns the results as a slice of the generic PhotoInterface type so
|
||||
// callers can interact with shared entity helpers.
|
||||
func (m PhotoResults) Photos() []entity.PhotoInterface {
|
||||
result := make([]entity.PhotoInterface, len(m))
|
||||
|
||||
@@ -279,7 +295,7 @@ func (m PhotoResults) Photos() []entity.PhotoInterface {
|
||||
return result
|
||||
}
|
||||
|
||||
// UIDs returns a slice of photo UIDs.
|
||||
// UIDs returns the photo UIDs for all results in order.
|
||||
func (m PhotoResults) UIDs() []string {
|
||||
result := make([]string, len(m))
|
||||
|
||||
@@ -290,7 +306,8 @@ func (m PhotoResults) UIDs() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// Merge consecutive file results that belong to the same photo.
|
||||
// Merge collapses consecutive rows that reference the same photo into a single
|
||||
// item with an aggregated Files slice.
|
||||
func (m PhotoResults) Merge() (merged PhotoResults, count int, err error) {
|
||||
count = len(m)
|
||||
merged = make(PhotoResults, 0, count)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,65 @@ func TestPhoto_Ids(t *testing.T) {
|
||||
assert.Equal(t, "ps6sg6be2lvl0o98", r.GetUID())
|
||||
}
|
||||
|
||||
func TestPhoto_String(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
photo *Photo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Nil",
|
||||
photo: nil,
|
||||
want: "Photo<nil>",
|
||||
},
|
||||
{
|
||||
name: "PhotoName",
|
||||
photo: &Photo{
|
||||
PhotoPath: "albums/test",
|
||||
PhotoName: "my photo.jpg",
|
||||
},
|
||||
want: "'albums/test/my photo.jpg'",
|
||||
},
|
||||
{
|
||||
name: "OriginalName",
|
||||
photo: &Photo{
|
||||
OriginalName: "orig name.dng",
|
||||
},
|
||||
want: "'orig name.dng'",
|
||||
},
|
||||
{
|
||||
name: "UID",
|
||||
photo: &Photo{
|
||||
PhotoUID: "ps123",
|
||||
},
|
||||
want: "uid ps123",
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
photo: &Photo{
|
||||
ID: 42,
|
||||
},
|
||||
want: "id 42",
|
||||
},
|
||||
{
|
||||
name: "Fallback",
|
||||
photo: &Photo{},
|
||||
want: "*Photo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.photo == nil {
|
||||
var p *Photo
|
||||
assert.Equal(t, tc.want, p.String())
|
||||
} else {
|
||||
assert.Equal(t, tc.want, tc.photo.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoto_Approve(t *testing.T) {
|
||||
t.Run("EmptyPhoto", func(t *testing.T) {
|
||||
r := Photo{}
|
||||
@@ -395,137 +456,38 @@ func TestPhotoResults_Photos(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPhotosResults_Merged(t *testing.T) {
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
DeletedAt: &time.Time{},
|
||||
TakenAt: time.Time{},
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "",
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo1",
|
||||
PhotoYear: 0,
|
||||
PhotoMonth: 0,
|
||||
PhotoCountry: "",
|
||||
PhotoFavorite: false,
|
||||
PhotoPrivate: false,
|
||||
PhotoLat: 0,
|
||||
PhotoLng: 0,
|
||||
PhotoAltitude: 0,
|
||||
PhotoIso: 0,
|
||||
PhotoFocalLength: 0,
|
||||
PhotoFNumber: 0,
|
||||
PhotoExposure: "",
|
||||
PhotoQuality: 0,
|
||||
PhotoResolution: 0,
|
||||
Merged: false,
|
||||
CameraID: 0,
|
||||
CameraModel: "",
|
||||
CameraMake: "",
|
||||
CameraType: "",
|
||||
LensID: 0,
|
||||
LensModel: "",
|
||||
LensMake: "",
|
||||
CellID: "",
|
||||
PlaceID: "",
|
||||
PlaceLabel: "",
|
||||
PlaceCity: "",
|
||||
PlaceState: "",
|
||||
PlaceCountry: "",
|
||||
FileID: 0,
|
||||
FileUID: "",
|
||||
FilePrimary: false,
|
||||
FileMissing: false,
|
||||
FileName: "",
|
||||
FileHash: "",
|
||||
FileType: "",
|
||||
FileMime: "",
|
||||
FileWidth: 0,
|
||||
FileHeight: 0,
|
||||
FileOrientation: 0,
|
||||
FileAspectRatio: 0,
|
||||
FileColors: "",
|
||||
FileChroma: 0,
|
||||
FileLuminance: "",
|
||||
FileDiff: 0,
|
||||
Files: nil,
|
||||
}
|
||||
fileUIDA := rnd.GenerateUID(entity.FileUID)
|
||||
fileUIDB := rnd.GenerateUID(entity.FileUID)
|
||||
fileUIDC := rnd.GenerateUID(entity.FileUID)
|
||||
|
||||
result2 := Photo{
|
||||
ID: 22222,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
DeletedAt: &time.Time{},
|
||||
TakenAt: time.Time{},
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "",
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo2",
|
||||
PhotoYear: 0,
|
||||
PhotoMonth: 0,
|
||||
PhotoCountry: "",
|
||||
PhotoFavorite: false,
|
||||
PhotoPrivate: false,
|
||||
PhotoLat: 0,
|
||||
PhotoLng: 0,
|
||||
PhotoAltitude: 0,
|
||||
PhotoIso: 0,
|
||||
PhotoFocalLength: 0,
|
||||
PhotoFNumber: 0,
|
||||
PhotoExposure: "",
|
||||
PhotoQuality: 0,
|
||||
PhotoResolution: 0,
|
||||
Merged: false,
|
||||
CameraID: 0,
|
||||
CameraModel: "",
|
||||
CameraMake: "",
|
||||
CameraType: "",
|
||||
LensID: 0,
|
||||
LensModel: "",
|
||||
LensMake: "",
|
||||
CellID: "",
|
||||
PlaceID: "",
|
||||
PlaceLabel: "",
|
||||
PlaceCity: "",
|
||||
PlaceState: "",
|
||||
PlaceCountry: "",
|
||||
FileID: 0,
|
||||
FileUID: "",
|
||||
FilePrimary: false,
|
||||
FileMissing: false,
|
||||
FileName: "",
|
||||
FileHash: "",
|
||||
FileType: "",
|
||||
FileMime: "",
|
||||
FileWidth: 0,
|
||||
FileHeight: 0,
|
||||
FileOrientation: 0,
|
||||
FileAspectRatio: 0,
|
||||
FileColors: "",
|
||||
FileChroma: 0,
|
||||
FileLuminance: "",
|
||||
FileDiff: 0,
|
||||
Files: nil,
|
||||
results := PhotoResults{
|
||||
{ID: 1, FileID: 10, FileUID: fileUIDA, FileName: "a.jpg"},
|
||||
{ID: 1, FileID: 11, FileUID: fileUIDB, FileName: "b.jpg"},
|
||||
{ID: 2, FileID: 20, FileUID: fileUIDC, FileName: "c.jpg"},
|
||||
}
|
||||
|
||||
results := PhotoResults{result1, result2}
|
||||
|
||||
merged, count, err := results.Merge()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, count)
|
||||
assert.Len(t, merged, 2)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, 2, count)
|
||||
t.Log(merged)
|
||||
first := merged[0]
|
||||
assert.Equal(t, "1-10", first.CompositeID)
|
||||
assert.True(t, first.Merged)
|
||||
assert.Len(t, first.Files, 2)
|
||||
assert.Equal(t, uint(10), first.Files[0].ID)
|
||||
assert.Equal(t, uint(11), first.Files[1].ID)
|
||||
|
||||
second := merged[1]
|
||||
assert.Equal(t, "2-20", second.CompositeID)
|
||||
assert.False(t, second.Merged)
|
||||
assert.Len(t, second.Files, 1)
|
||||
assert.Equal(t, uint(20), second.Files[0].ID)
|
||||
}
|
||||
func TestPhotosResults_UIDs(t *testing.T) {
|
||||
uid1 := rnd.GenerateUID(entity.PhotoUID)
|
||||
uid2 := rnd.GenerateUID(entity.PhotoUID)
|
||||
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
@@ -535,7 +497,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "123",
|
||||
PhotoUID: uid1,
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo1",
|
||||
@@ -595,7 +557,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "456",
|
||||
PhotoUID: uid2,
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo2",
|
||||
@@ -649,11 +611,12 @@ func TestPhotosResults_UIDs(t *testing.T) {
|
||||
results := PhotoResults{result1, result2}
|
||||
|
||||
result := results.UIDs()
|
||||
assert.Equal(t, []string{"123", "456"}, result)
|
||||
assert.Equal(t, []string{uid1, uid2}, result)
|
||||
}
|
||||
|
||||
func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
t.Run("WithTitle", func(t *testing.T) {
|
||||
uid := rnd.GenerateUID(entity.PhotoUID)
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
@@ -663,7 +626,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
TakenAtLocal: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC),
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "uid123",
|
||||
PhotoUID: uid,
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "PhotoTitle123",
|
||||
@@ -718,6 +681,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
assert.Contains(t, r, "20131111-090718-Phototitle123")
|
||||
})
|
||||
t.Run("NoTitle", func(t *testing.T) {
|
||||
uid := rnd.GenerateUID(entity.PhotoUID)
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
@@ -727,7 +691,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
TakenAtLocal: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC),
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "uid123",
|
||||
PhotoUID: uid,
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "",
|
||||
@@ -779,9 +743,10 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
}
|
||||
|
||||
r := result1.ShareBase(0)
|
||||
assert.Contains(t, r, "20151111-090718-uid123")
|
||||
assert.Contains(t, r, fmt.Sprintf("20151111-090718-%s", uid))
|
||||
})
|
||||
t.Run("SeqGreater0", func(t *testing.T) {
|
||||
uid := rnd.GenerateUID(entity.PhotoUID)
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
@@ -791,7 +756,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
|
||||
TakenAtLocal: time.Date(2022, 11, 11, 9, 7, 18, 0, time.UTC),
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "uid123",
|
||||
PhotoUID: uid,
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "PhotoTitle123",
|
||||
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
)
|
||||
|
||||
// PhotosViewerResults finds photos based on the search form provided and returns them as viewer.Results.
|
||||
// PhotosViewerResults searches public photos using the provided form and returns
|
||||
// them in the lightweight viewer format that powers the slideshow endpoints.
|
||||
func PhotosViewerResults(frm form.SearchPhotos, contentUri, apiUri, previewToken, downloadToken string) (viewer.Results, int, error) {
|
||||
return UserPhotosViewerResults(frm, nil, contentUri, apiUri, previewToken, downloadToken)
|
||||
}
|
||||
|
||||
// UserPhotosViewerResults finds photos based on the search form and user session and returns them as viewer.Results.
|
||||
// UserPhotosViewerResults behaves like PhotosViewerResults but also applies the
|
||||
// permissions encoded in the session (for example shared albums and private
|
||||
// visibility) before returning viewer-formatted results.
|
||||
func UserPhotosViewerResults(frm form.SearchPhotos, sess *entity.Session, contentUri, apiUri, previewToken, downloadToken string) (viewer.Results, int, error) {
|
||||
if results, count, err := searchPhotos(frm, sess, PhotosColsView); err != nil {
|
||||
return viewer.Results{}, count, err
|
||||
@@ -23,7 +26,9 @@ func UserPhotosViewerResults(frm form.SearchPhotos, sess *entity.Session, conten
|
||||
}
|
||||
}
|
||||
|
||||
// ViewerResult returns a new photo viewer result.
|
||||
// ViewerResult converts a photo search result into the DTO consumed by the
|
||||
// frontend viewer, including derived metadata such as thumbnails and download
|
||||
// URLs.
|
||||
func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken string) viewer.Result {
|
||||
mediaHash, mediaCodec, mediaMime, width, height := m.MediaInfo()
|
||||
return viewer.Result{
|
||||
@@ -48,12 +53,12 @@ func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken str
|
||||
}
|
||||
}
|
||||
|
||||
// ViewerJSON returns the results as photo viewer JSON.
|
||||
// ViewerJSON marshals the current result set to the viewer JSON structure.
|
||||
func (m PhotoResults) ViewerJSON(contentUri, apiUri, previewToken, downloadToken string) ([]byte, error) {
|
||||
return json.Marshal(m.ViewerResults(contentUri, apiUri, previewToken, downloadToken))
|
||||
}
|
||||
|
||||
// ViewerResults returns the results photo viewer formatted.
|
||||
// ViewerResults maps every photo into the viewer DTO while preserving order.
|
||||
func (m PhotoResults) ViewerResults(contentUri, apiUri, previewToken, downloadToken string) (results viewer.Results) {
|
||||
results = make(viewer.Results, 0, len(m))
|
||||
|
||||
@@ -64,7 +69,8 @@ func (m PhotoResults) ViewerResults(contentUri, apiUri, previewToken, downloadTo
|
||||
return results
|
||||
}
|
||||
|
||||
// ViewerResult creates a new photo viewer result.
|
||||
// ViewerResult converts a geographic search hit into the viewer DTO, reusing
|
||||
// the thumbnail and download helpers so photos and map results stay aligned.
|
||||
func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken string) viewer.Result {
|
||||
return viewer.Result{
|
||||
UID: m.PhotoUID,
|
||||
@@ -88,7 +94,7 @@ func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken
|
||||
}
|
||||
}
|
||||
|
||||
// ViewerJSON returns the results as photo viewer JSON.
|
||||
// ViewerJSON marshals geo search hits to the viewer JSON structure.
|
||||
func (photos GeoResults) ViewerJSON(contentUri, apiUri, previewToken, downloadToken string) ([]byte, error) {
|
||||
results := make(viewer.Results, 0, len(photos))
|
||||
|
||||
|
||||
@@ -1,196 +1,190 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/search/viewer"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
func TestPhotoResults_ViewerJSON(t *testing.T) {
|
||||
result1 := Photo{
|
||||
ID: 111111,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
DeletedAt: &time.Time{},
|
||||
TakenAt: time.Time{},
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "123",
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo1",
|
||||
PhotoYear: 0,
|
||||
PhotoMonth: 0,
|
||||
PhotoCountry: "",
|
||||
PhotoFavorite: false,
|
||||
PhotoPrivate: false,
|
||||
PhotoLat: 0,
|
||||
PhotoLng: 0,
|
||||
PhotoAltitude: 0,
|
||||
PhotoIso: 0,
|
||||
PhotoFocalLength: 0,
|
||||
PhotoFNumber: 0,
|
||||
PhotoExposure: "",
|
||||
PhotoQuality: 0,
|
||||
PhotoResolution: 0,
|
||||
Merged: false,
|
||||
CameraID: 0,
|
||||
CameraModel: "",
|
||||
CameraMake: "",
|
||||
CameraType: "",
|
||||
LensID: 0,
|
||||
LensModel: "",
|
||||
LensMake: "",
|
||||
CellID: "",
|
||||
PlaceID: "",
|
||||
PlaceLabel: "",
|
||||
PlaceCity: "",
|
||||
PlaceState: "",
|
||||
PlaceCountry: "",
|
||||
FileID: 0,
|
||||
FileUID: "",
|
||||
FilePrimary: false,
|
||||
FileMissing: false,
|
||||
FileName: "",
|
||||
FileHash: "",
|
||||
FileType: "",
|
||||
FileMime: "",
|
||||
FileWidth: 0,
|
||||
FileHeight: 0,
|
||||
FileOrientation: 0,
|
||||
FileAspectRatio: 0,
|
||||
FileColors: "",
|
||||
FileChroma: 0,
|
||||
FileLuminance: "",
|
||||
FileDiff: 0,
|
||||
Files: nil,
|
||||
func TestPhoto_ViewerResult(t *testing.T) {
|
||||
uid := rnd.GenerateUID(entity.PhotoUID)
|
||||
imgHash := "img-hash"
|
||||
videoHash := "video-hash"
|
||||
taken := time.Date(2024, 5, 1, 15, 4, 5, 0, time.UTC)
|
||||
|
||||
photo := Photo{
|
||||
PhotoUID: uid,
|
||||
PhotoType: entity.MediaVideo,
|
||||
PhotoTitle: "Sunset",
|
||||
PhotoCaption: "Golden hour",
|
||||
PhotoLat: 12.34,
|
||||
PhotoLng: 56.78,
|
||||
TakenAtLocal: taken,
|
||||
TimeZone: "UTC",
|
||||
PhotoFavorite: true,
|
||||
PhotoDuration: 5 * time.Second,
|
||||
FileHash: imgHash,
|
||||
FileWidth: 800,
|
||||
FileHeight: 600,
|
||||
Files: []entity.File{
|
||||
{
|
||||
FileVideo: true,
|
||||
MediaType: entity.MediaVideo,
|
||||
FileHash: videoHash,
|
||||
FileCodec: "avc1",
|
||||
FileMime: header.ContentTypeMp4AvcMain,
|
||||
FileWidth: 1920,
|
||||
FileHeight: 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result2 := Photo{
|
||||
ID: 22222,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
DeletedAt: &time.Time{},
|
||||
TakenAt: time.Time{},
|
||||
TakenAtLocal: time.Time{},
|
||||
TakenSrc: "",
|
||||
TimeZone: "Local",
|
||||
PhotoUID: "456",
|
||||
PhotoPath: "",
|
||||
PhotoName: "",
|
||||
PhotoTitle: "Photo2",
|
||||
PhotoYear: 0,
|
||||
PhotoMonth: 0,
|
||||
PhotoCountry: "",
|
||||
PhotoFavorite: false,
|
||||
PhotoPrivate: false,
|
||||
PhotoLat: 0,
|
||||
PhotoLng: 0,
|
||||
PhotoAltitude: 0,
|
||||
PhotoIso: 0,
|
||||
PhotoFocalLength: 0,
|
||||
PhotoFNumber: 0,
|
||||
PhotoExposure: "",
|
||||
PhotoQuality: 0,
|
||||
PhotoResolution: 0,
|
||||
Merged: false,
|
||||
CameraID: 0,
|
||||
CameraModel: "",
|
||||
CameraMake: "",
|
||||
CameraType: "",
|
||||
LensID: 0,
|
||||
LensModel: "",
|
||||
LensMake: "",
|
||||
CellID: "",
|
||||
PlaceID: "",
|
||||
PlaceLabel: "",
|
||||
PlaceCity: "",
|
||||
PlaceState: "",
|
||||
PlaceCountry: "",
|
||||
FileID: 0,
|
||||
FileUID: "",
|
||||
FilePrimary: false,
|
||||
FileMissing: false,
|
||||
FileName: "",
|
||||
FileHash: "",
|
||||
FileType: "",
|
||||
FileMime: "",
|
||||
FileWidth: 0,
|
||||
FileHeight: 0,
|
||||
FileOrientation: 0,
|
||||
FileAspectRatio: 0,
|
||||
FileColors: "",
|
||||
FileChroma: 0,
|
||||
FileLuminance: "",
|
||||
FileDiff: 0,
|
||||
Files: nil,
|
||||
result := photo.ViewerResult("/content", "/api/v1", "preview-token", "download-token")
|
||||
|
||||
assert.Equal(t, uid, result.UID)
|
||||
assert.Equal(t, entity.MediaVideo, result.Type)
|
||||
assert.Equal(t, "Sunset", result.Title)
|
||||
assert.Equal(t, "Golden hour", result.Caption)
|
||||
assert.Equal(t, 12.34, result.Lat)
|
||||
assert.Equal(t, 56.78, result.Lng)
|
||||
assert.Equal(t, taken, result.TakenAtLocal)
|
||||
assert.Equal(t, "UTC", result.TimeZone)
|
||||
assert.True(t, result.Favorite)
|
||||
assert.True(t, result.Playable)
|
||||
assert.Equal(t, 5*time.Second, result.Duration)
|
||||
assert.Equal(t, videoHash, result.Hash)
|
||||
assert.Equal(t, "avc1", result.Codec)
|
||||
assert.Equal(t, header.ContentTypeMp4AvcMain, result.Mime)
|
||||
assert.Equal(t, 1920, result.Width)
|
||||
assert.Equal(t, 1080, result.Height)
|
||||
if assert.NotNil(t, result.Thumbs) {
|
||||
assert.NotNil(t, result.Thumbs.Fit720)
|
||||
}
|
||||
assert.Equal(t, "/api/v1/dl/img-hash?t=download-token", result.DownloadUrl)
|
||||
}
|
||||
|
||||
func TestPhotoResults_ViewerFormatting(t *testing.T) {
|
||||
uid1 := rnd.GenerateUID(entity.PhotoUID)
|
||||
uid2 := rnd.GenerateUID(entity.PhotoUID)
|
||||
|
||||
photos := PhotoResults{
|
||||
{PhotoUID: uid1},
|
||||
{PhotoUID: uid2},
|
||||
}
|
||||
|
||||
results := PhotoResults{result1, result2}
|
||||
|
||||
b, err := results.ViewerJSON("/content", "/api/v1", "preview-token", "download-token")
|
||||
results := photos.ViewerResults("/content", "/api", "preview", "download")
|
||||
assert.Len(t, results, 2)
|
||||
assert.Equal(t, uid1, results[0].UID)
|
||||
assert.Equal(t, uid2, results[1].UID)
|
||||
|
||||
data, err := photos.ViewerJSON("/content", "/api", "preview", "download")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("result: %s", b)
|
||||
var parsed viewer.Results
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal viewer json: %v", err)
|
||||
}
|
||||
|
||||
assert.Len(t, parsed, 2)
|
||||
assert.Equal(t, uid1, parsed[0].UID)
|
||||
assert.Equal(t, uid2, parsed[1].UID)
|
||||
}
|
||||
|
||||
func TestGeoResult_ViewerResult(t *testing.T) {
|
||||
uid := rnd.GenerateUID(entity.PhotoUID)
|
||||
taken := time.Date(2023, 3, 14, 9, 26, 53, 0, time.UTC)
|
||||
|
||||
geo := GeoResult{
|
||||
PhotoUID: uid,
|
||||
PhotoType: entity.MediaImage,
|
||||
PhotoTitle: "Mountains",
|
||||
PhotoCaption: "Snow peaks",
|
||||
PhotoLat: -12.34,
|
||||
PhotoLng: 78.9,
|
||||
TakenAtLocal: taken,
|
||||
TimeZone: "Europe/Berlin",
|
||||
PhotoFavorite: false,
|
||||
PhotoDuration: 0,
|
||||
FileHash: "img-hash",
|
||||
FileCodec: "jpeg",
|
||||
FileMime: header.ContentTypeJpeg,
|
||||
FileWidth: 1024,
|
||||
FileHeight: 768,
|
||||
}
|
||||
|
||||
result := geo.ViewerResult("/content", "/api", "preview", "download")
|
||||
|
||||
assert.Equal(t, uid, result.UID)
|
||||
assert.Equal(t, entity.MediaImage, result.Type)
|
||||
assert.Equal(t, "Mountains", result.Title)
|
||||
assert.Equal(t, "Snow peaks", result.Caption)
|
||||
assert.Equal(t, -12.34, result.Lat)
|
||||
assert.Equal(t, 78.9, result.Lng)
|
||||
assert.Equal(t, taken, result.TakenAtLocal)
|
||||
assert.Equal(t, "Europe/Berlin", result.TimeZone)
|
||||
assert.False(t, result.Favorite)
|
||||
assert.False(t, result.Playable)
|
||||
assert.Equal(t, "img-hash", result.Hash)
|
||||
assert.Equal(t, "jpeg", result.Codec)
|
||||
assert.Equal(t, header.ContentTypeJpeg, result.Mime)
|
||||
assert.Equal(t, 1024, result.Width)
|
||||
assert.Equal(t, 768, result.Height)
|
||||
if assert.NotNil(t, result.Thumbs) {
|
||||
assert.NotNil(t, result.Thumbs.Fit720)
|
||||
}
|
||||
assert.Equal(t, "/api/dl/img-hash?t=download", result.DownloadUrl)
|
||||
}
|
||||
|
||||
func TestGeoResults_ViewerJSON(t *testing.T) {
|
||||
taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Truncate(time.Second)
|
||||
uid1 := rnd.GenerateUID(entity.PhotoUID)
|
||||
uid2 := rnd.GenerateUID(entity.PhotoUID)
|
||||
|
||||
items := GeoResults{
|
||||
GeoResult{
|
||||
ID: "1",
|
||||
PhotoLat: 7.775,
|
||||
PhotoLng: 8.775,
|
||||
PhotoUID: "p1",
|
||||
PhotoTitle: "Title 1",
|
||||
PhotoCaption: "Description 1",
|
||||
PhotoFavorite: false,
|
||||
PhotoType: entity.MediaVideo,
|
||||
FileHash: "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2",
|
||||
FileWidth: 1920,
|
||||
FileHeight: 1080,
|
||||
TakenAtLocal: taken,
|
||||
},
|
||||
GeoResult{
|
||||
ID: "2",
|
||||
PhotoLat: 1.775,
|
||||
PhotoLng: -5.775,
|
||||
PhotoUID: "p2",
|
||||
PhotoTitle: "Title 2",
|
||||
PhotoCaption: "Description 2",
|
||||
PhotoFavorite: true,
|
||||
PhotoType: entity.MediaImage,
|
||||
FileHash: "da639e836dfa9179e66c619499b0a5e592f72fc1",
|
||||
FileWidth: 3024,
|
||||
FileHeight: 3024,
|
||||
TakenAtLocal: taken,
|
||||
},
|
||||
GeoResult{
|
||||
ID: "3",
|
||||
PhotoLat: -1.775,
|
||||
PhotoLng: 100.775,
|
||||
PhotoUID: "p3",
|
||||
PhotoTitle: "Title 3",
|
||||
PhotoCaption: "Description 3",
|
||||
PhotoFavorite: false,
|
||||
PhotoType: entity.MediaRaw,
|
||||
FileHash: "412fe4c157a82b636efebc5bc4bc4a15c321aad1",
|
||||
FileWidth: 5000,
|
||||
FileHeight: 10000,
|
||||
TakenAtLocal: taken,
|
||||
},
|
||||
{PhotoUID: uid1, FileHash: "hash1"},
|
||||
{PhotoUID: uid2, FileHash: "hash2"},
|
||||
}
|
||||
|
||||
b, err := items.ViewerJSON("/content", "/api/v1", "preview-token", "download-token")
|
||||
|
||||
data, err := items.ViewerJSON("/content", "/api", "preview", "download")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("result: %s", b)
|
||||
var parsed viewer.Results
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal viewer json: %v", err)
|
||||
}
|
||||
|
||||
assert.Len(t, parsed, 2)
|
||||
assert.Equal(t, uid1, parsed[0].UID)
|
||||
assert.Equal(t, uid2, parsed[1].UID)
|
||||
}
|
||||
|
||||
func TestPhotosViewerResults(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("19800101_000002_D640C559")
|
||||
form := form.SearchPhotos{
|
||||
UID: fixture.PhotoUID,
|
||||
Count: 1,
|
||||
Primary: true,
|
||||
}
|
||||
|
||||
results, count, err := PhotosViewerResults(form, "/content", "/api", "preview", "download")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assert.Greater(t, count, 0)
|
||||
if assert.NotEmpty(t, results) {
|
||||
assert.Equal(t, fixture.PhotoUID, results[0].UID)
|
||||
assert.NotNil(t, results[0].Thumbs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ type SearchLabels struct {
|
||||
Name string `form:"name"`
|
||||
All bool `form:"all"`
|
||||
Favorite bool `form:"favorite"`
|
||||
NSFW bool `form:"nsfw"`
|
||||
Public bool `form:"public"`
|
||||
Count int `form:"count" binding:"required" serialize:"-"`
|
||||
Offset int `form:"offset" serialize:"-"`
|
||||
Order string `form:"order" serialize:"-"`
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/karrick/godirwalk"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
@@ -56,6 +57,22 @@ func NewIndex(conf *config.Config, convert *Convert, files *Files, photos *Photo
|
||||
return i
|
||||
}
|
||||
|
||||
func (ind *Index) shouldFlagPrivate(labels classify.Labels) bool {
|
||||
if ind == nil || ind.conf == nil || !ind.conf.DetectNSFW() {
|
||||
return false
|
||||
}
|
||||
|
||||
threshold := vision.Config.Thresholds.GetNSFW()
|
||||
|
||||
for _, label := range labels {
|
||||
if label.NSFW || label.NSFWConfidence >= threshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (ind *Index) originalsPath() string {
|
||||
return ind.conf.OriginalsPath()
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// Caption generates a caption for the provided media file using the active
|
||||
// vision model. When captionSrc is SrcAuto the model's declared source is used;
|
||||
// otherwise the explicit source is recorded on the returned caption.
|
||||
func (ind *Index) Caption(file *MediaFile, captionSrc entity.Src) (caption *vision.CaptionResult, err error) {
|
||||
start := time.Now()
|
||||
|
||||
model := vision.Config.Model(vision.ModelTypeCaption)
|
||||
|
||||
// No caption generation model configured or usable.
|
||||
if model == nil {
|
||||
return caption, errors.New("no caption model configured")
|
||||
}
|
||||
|
||||
if captionSrc == entity.SrcAuto {
|
||||
captionSrc = model.GetSource()
|
||||
}
|
||||
|
||||
size := vision.Thumb(vision.ModelTypeCaption)
|
||||
|
||||
// Get thumbnail filenames for the selected sizes.
|
||||
fileName, fileErr := file.Thumbnail(Config().ThumbCachePath(), size.Name)
|
||||
|
||||
if fileErr != nil {
|
||||
return caption, err
|
||||
}
|
||||
|
||||
// Get matching labels from computer vision model.
|
||||
// Generate a caption using the configured vision model.
|
||||
if caption, _, err = vision.Caption(vision.Files{fileName}, media.SrcLocal); err != nil {
|
||||
// Failed.
|
||||
} else if caption.Text != "" {
|
||||
if captionSrc != entity.SrcAuto {
|
||||
caption.Source = captionSrc
|
||||
}
|
||||
|
||||
log.Infof("vision: generated caption for %s [%s]", clean.Log(file.BaseName()), time.Since(start))
|
||||
}
|
||||
|
||||
return caption, err
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// Labels classifies the media file and returns matching labels. When labelSrc
|
||||
// is SrcAuto the model's declared source is used; otherwise the provided source
|
||||
// is applied to every returned label.
|
||||
func (ind *Index) Labels(file *MediaFile, labelSrc entity.Src) (labels classify.Labels) {
|
||||
start := time.Now()
|
||||
|
||||
var err error
|
||||
var sizes []thumb.Name
|
||||
var thumbnails []string
|
||||
|
||||
model := vision.Config.Model(vision.ModelTypeLabels)
|
||||
|
||||
// No label generation model configured or usable.
|
||||
if model == nil {
|
||||
return labels
|
||||
}
|
||||
|
||||
if labelSrc == entity.SrcAuto {
|
||||
labelSrc = model.GetSource()
|
||||
}
|
||||
|
||||
size := vision.Thumb(vision.ModelTypeLabels)
|
||||
|
||||
// The thumbnail size may need to be adjusted to use other models.
|
||||
if size.Name != "" && size.Name != thumb.Tile224 {
|
||||
sizes = []thumb.Name{size.Name}
|
||||
thumbnails = make([]string, 0, 1)
|
||||
} else if file.Square() {
|
||||
// Only one thumbnail is required for square images.
|
||||
sizes = []thumb.Name{thumb.Tile224}
|
||||
thumbnails = make([]string, 0, 1)
|
||||
} else {
|
||||
// Use three thumbnails otherwise (center, left, right).
|
||||
sizes = []thumb.Name{thumb.Tile224, thumb.Left224, thumb.Right224}
|
||||
thumbnails = make([]string, 0, 3)
|
||||
}
|
||||
|
||||
// Get thumbnail filenames for the selected sizes.
|
||||
for _, s := range sizes {
|
||||
if thumbnail, fileErr := file.Thumbnail(Config().ThumbCachePath(), s); fileErr != nil {
|
||||
log.Debugf("index: %s in %s", err, clean.Log(file.BaseName()))
|
||||
continue
|
||||
} else {
|
||||
thumbnails = append(thumbnails, thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the configured vision model to obtain labels for the generated thumbnails.
|
||||
if labels, err = vision.Labels(thumbnails, media.SrcLocal, labelSrc); err != nil {
|
||||
log.Debugf("labels: %s in %s", err, clean.Log(file.BaseName()))
|
||||
return labels
|
||||
}
|
||||
|
||||
// Log number of labels found and return results.
|
||||
if n := len(labels); n > 0 {
|
||||
log.Infof("index: found %s for %s [%s]", english.Plural(n, "label", "labels"), clean.Log(file.BaseName()), time.Since(start))
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
@@ -58,6 +59,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
||||
photo := entity.NewUserPhoto(o.Stack, userUID)
|
||||
metaData := meta.NewData()
|
||||
labels := classify.Labels{}
|
||||
isNSFW := false
|
||||
stripSequence := Config().Settings().StackSequences() && o.Stack
|
||||
|
||||
fileRoot, fileBase, filePath, fileName := m.PathNameInfo(stripSequence)
|
||||
@@ -816,17 +818,23 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
||||
|
||||
// Classify images with TensorFlow?
|
||||
if ind.findLabels {
|
||||
labels = ind.Labels(m, entity.SrcAuto)
|
||||
labels = m.GenerateLabels(entity.SrcAuto)
|
||||
|
||||
// Append labels from other sources such as face detection.
|
||||
if len(extraLabels) > 0 {
|
||||
labels = append(labels, extraLabels...)
|
||||
}
|
||||
|
||||
isNSFW = labels.IsNSFW(vision.Config.Thresholds.GetNSFW())
|
||||
}
|
||||
|
||||
// Decouple NSFW detection from label generation.
|
||||
if !photoExists && ind.detectNsfw {
|
||||
photo.PhotoPrivate = ind.IsNsfw(m)
|
||||
if !photoExists {
|
||||
if isNSFW {
|
||||
photo.PhotoPrivate = true
|
||||
} else if ind.detectNsfw {
|
||||
photo.PhotoPrivate = m.DetectNSFW()
|
||||
}
|
||||
}
|
||||
|
||||
// Read metadata from embedded Exif and JSON sidecar file, if exists.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/ai/nsfw"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// IsNsfw returns true if media file might be offensive and detection is enabled.
|
||||
func (ind *Index) IsNsfw(m *MediaFile) bool {
|
||||
filename, err := m.Thumbnail(Config().ThumbCachePath(), thumb.Fit720)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return false
|
||||
}
|
||||
|
||||
if results, modelErr := vision.Nsfw([]string{filename}, media.SrcLocal); modelErr != nil {
|
||||
log.Errorf("vision: %s in %s (detect nsfw)", modelErr, m.RootRelName())
|
||||
return false
|
||||
} else if len(results) < 1 {
|
||||
log.Errorf("vision: nsfw model returned no result for %s", m.RootRelName())
|
||||
return false
|
||||
} else if results[0].IsNsfw(nsfw.ThresholdHigh) {
|
||||
log.Warnf("vision: %s might contain offensive content", clean.Log(m.RelName(Config().OriginalsPath())))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -21,7 +21,6 @@ func TestIndexCaptionSource(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
require.NoError(t, cfg.InitializeTestData())
|
||||
|
||||
ind := NewIndex(cfg, NewConvert(cfg), NewFiles(), NewPhotos())
|
||||
mediaFile, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -41,8 +40,8 @@ func TestIndexCaptionSource(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { vision.SetCaptionFunc(nil) })
|
||||
|
||||
caption, err := ind.Caption(mediaFile, entity.SrcAuto)
|
||||
require.NoError(t, err)
|
||||
caption, captionErr := mediaFile.GenerateCaption(entity.SrcAuto)
|
||||
require.NoError(t, captionErr)
|
||||
require.NotNil(t, caption)
|
||||
assert.Equal(t, captionModel.GetSource(), caption.Source)
|
||||
})
|
||||
@@ -54,8 +53,8 @@ func TestIndexCaptionSource(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { vision.SetCaptionFunc(nil) })
|
||||
|
||||
caption, err := ind.Caption(mediaFile, entity.SrcManual)
|
||||
require.NoError(t, err)
|
||||
caption, captionErr := mediaFile.GenerateCaption(entity.SrcManual)
|
||||
require.NoError(t, captionErr)
|
||||
require.NotNil(t, caption)
|
||||
assert.Equal(t, entity.SrcManual, caption.Source)
|
||||
})
|
||||
@@ -69,7 +68,6 @@ func TestIndexLabelsSource(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
require.NoError(t, cfg.InitializeTestData())
|
||||
|
||||
ind := NewIndex(cfg, NewConvert(cfg), NewFiles(), NewPhotos())
|
||||
mediaFile, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -91,7 +89,7 @@ func TestIndexLabelsSource(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { vision.SetLabelsFunc(nil) })
|
||||
|
||||
labels := ind.Labels(mediaFile, entity.SrcAuto)
|
||||
labels := mediaFile.GenerateLabels(entity.SrcAuto)
|
||||
assert.NotEmpty(t, labels)
|
||||
assert.Equal(t, labelModel.GetSource(), captured)
|
||||
})
|
||||
@@ -104,7 +102,7 @@ func TestIndexLabelsSource(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { vision.SetLabelsFunc(nil) })
|
||||
|
||||
labels := ind.Labels(mediaFile, entity.SrcManual)
|
||||
labels := mediaFile.GenerateLabels(entity.SrcManual)
|
||||
assert.NotEmpty(t, labels)
|
||||
assert.Equal(t, entity.SrcManual, captured)
|
||||
})
|
||||
|
||||
@@ -68,7 +68,9 @@ type MediaFile struct {
|
||||
imageConfig *image.Config
|
||||
}
|
||||
|
||||
// NewMediaFile returns a new media file and automatically resolves any symlinks.
|
||||
// NewMediaFile resolves fileName (following symlinks) and initialises a MediaFile
|
||||
// instance. The returned instance is never nil; callers must check the error to
|
||||
// learn whether the path existed or was readable.
|
||||
func NewMediaFile(fileName string) (*MediaFile, error) {
|
||||
if fileNameResolved, err := fs.Resolve(fileName); err != nil {
|
||||
// Don't return nil on error, as this would change the previous behavior.
|
||||
@@ -78,8 +80,9 @@ func NewMediaFile(fileName string) (*MediaFile, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// NewMediaFileSkipResolve returns a new media file without resolving symlinks.
|
||||
// This is useful because if it is known that the filename is fully resolved, it is much faster.
|
||||
// NewMediaFileSkipResolve behaves like NewMediaFile but assumes fileNameResolved
|
||||
// already points to the canonical location. This avoids an extra filesystem
|
||||
// lookup when the caller has already resolved the path.
|
||||
func NewMediaFileSkipResolve(fileName string, fileNameResolved string) (*MediaFile, error) {
|
||||
// Create and initialize the new media file.
|
||||
m := &MediaFile{
|
||||
@@ -105,18 +108,21 @@ func NewMediaFileSkipResolve(fileName string, fileNameResolved string) (*MediaFi
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Ok checks if the file has a name, exists and is not empty.
|
||||
// Ok reports whether the file name is set, Stat succeeded and the file is not empty.
|
||||
// It relies on cached metadata populated by Stat.
|
||||
func (m *MediaFile) Ok() bool {
|
||||
return m.FileName() != "" && m.statErr == nil && !m.Empty()
|
||||
}
|
||||
|
||||
// Empty checks if the file is empty.
|
||||
// Empty reports whether Stat determined that the file has zero (or negative when
|
||||
// stat failed) length.
|
||||
func (m *MediaFile) Empty() bool {
|
||||
return m.FileSize() <= 0
|
||||
}
|
||||
|
||||
// Stat calls os.Stat() to return the file size and modification time,
|
||||
// or an error if this failed.
|
||||
// Stat populates cached file size / modification time information (respecting
|
||||
// second precision) and returns the cached values. Subsequent calls reuse the
|
||||
// cached details unless the size has not yet been determined.
|
||||
func (m *MediaFile) Stat() (size int64, mod time.Time, err error) {
|
||||
if m.fileSize > 0 {
|
||||
return m.fileSize, m.modTime, m.statErr
|
||||
@@ -136,14 +142,16 @@ func (m *MediaFile) Stat() (size int64, mod time.Time, err error) {
|
||||
return m.fileSize, m.modTime, m.statErr
|
||||
}
|
||||
|
||||
// ModTime returns the file modification time.
|
||||
// ModTime returns the cached modification timestamp in UTC, fetching it via Stat
|
||||
// if necessary.
|
||||
func (m *MediaFile) ModTime() time.Time {
|
||||
_, modTime, _ := m.Stat()
|
||||
|
||||
return modTime
|
||||
}
|
||||
|
||||
// SetModTime sets the file modification time.
|
||||
// SetModTime updates the on-disk modification time and caches the new value on
|
||||
// success. The receiver is returned so callers can chain additional method calls.
|
||||
func (m *MediaFile) SetModTime(modTime time.Time) *MediaFile {
|
||||
modTime = modTime.UTC()
|
||||
|
||||
@@ -163,14 +171,19 @@ func (m *MediaFile) FileSize() int64 {
|
||||
return fileSize
|
||||
}
|
||||
|
||||
// DateCreated returns the media creation time in UTC.
|
||||
// DateCreated returns the best-known creation timestamp in UTC. It is a thin
|
||||
// wrapper around TakenAt() that discards the local time / source metadata.
|
||||
func (m *MediaFile) DateCreated() time.Time {
|
||||
takenAt, _, _ := m.TakenAt()
|
||||
|
||||
return takenAt
|
||||
}
|
||||
|
||||
// TakenAt returns the media creation time in UTC and the source from which it originates.
|
||||
// TakenAt returns the UTC creation timestamp, the local timestamp and the source
|
||||
// used to derive it. The value is cached so repeated calls avoid re-reading
|
||||
// metadata. Extraction order: EXIF metadata, filename parsing, file modification
|
||||
// time; if none of those succeed the timestamps remain set to the current time
|
||||
// captured when the method first ran.
|
||||
func (m *MediaFile) TakenAt() (utc time.Time, local time.Time, source string) {
|
||||
// Check if creation time has been cached.
|
||||
if !m.takenAt.IsZero() {
|
||||
@@ -330,7 +343,9 @@ func (m *MediaFile) Checksum() string {
|
||||
return m.checksum
|
||||
}
|
||||
|
||||
// PathNameInfo returns file name infos for indexing.
|
||||
// PathNameInfo resolves the file root (originals/import/sidecar/etc) and returns
|
||||
// the root identifier, file base prefix, relative directory and relative name
|
||||
// for indexing / metadata persistence.
|
||||
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
|
||||
fileRoot = m.Root()
|
||||
|
||||
@@ -356,17 +371,18 @@ func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relati
|
||||
return fileRoot, fileBase, relativePath, relativeName
|
||||
}
|
||||
|
||||
// FileName returns the filename.
|
||||
// FileName returns the absolute file name recorded for this media file.
|
||||
func (m *MediaFile) FileName() string {
|
||||
return m.fileName
|
||||
}
|
||||
|
||||
// BaseName returns the filename without path.
|
||||
// BaseName returns just the final path component of the file.
|
||||
func (m *MediaFile) BaseName() string {
|
||||
return filepath.Base(m.fileName)
|
||||
}
|
||||
|
||||
// SetFileName sets the filename to the given string.
|
||||
// SetFileName updates the stored file name and resets the cached root hint so
|
||||
// it will be recalculated on next access.
|
||||
func (m *MediaFile) SetFileName(fileName string) {
|
||||
if m == nil {
|
||||
log.Errorf("media: file %s is nil - you may have found a bug", clean.Log(fileName))
|
||||
@@ -377,17 +393,19 @@ func (m *MediaFile) SetFileName(fileName string) {
|
||||
m.fileRoot = entity.RootUnknown
|
||||
}
|
||||
|
||||
// RootRelName returns the relative filename, and automatically detects the root path.
|
||||
// RootRelName returns the path of the file relative to the detected root (e.g.
|
||||
// Originals, Import, Sidecar).
|
||||
func (m *MediaFile) RootRelName() string {
|
||||
return m.RelName(m.RootPath())
|
||||
}
|
||||
|
||||
// RelName returns the relative filename.
|
||||
// RelName returns the file name relative to directory, sanitising the result for logging.
|
||||
func (m *MediaFile) RelName(directory string) string {
|
||||
return fs.RelName(m.fileName, directory)
|
||||
}
|
||||
|
||||
// RelPath returns the relative path without filename.
|
||||
// RelPath returns the relative directory (without filename) by trimming the
|
||||
// provided base directory from the stored file path.
|
||||
func (m *MediaFile) RelPath(directory string) string {
|
||||
pathname := m.fileName
|
||||
|
||||
@@ -418,7 +436,9 @@ func (m *MediaFile) RelPath(directory string) string {
|
||||
return pathname
|
||||
}
|
||||
|
||||
// RootPath returns the file root path based on the configuration.
|
||||
// RootPath returns the absolute root directory for the media file (Originals,
|
||||
// Import, Sidecar, Examples) based on its detected storage location and the
|
||||
// current configuration.
|
||||
func (m *MediaFile) RootPath() string {
|
||||
switch m.Root() {
|
||||
case entity.RootSidecar:
|
||||
@@ -432,12 +452,14 @@ func (m *MediaFile) RootPath() string {
|
||||
}
|
||||
}
|
||||
|
||||
// RootRelPath returns the relative path and automatically detects the root path.
|
||||
// RootRelPath returns the file path relative to the detected root directory.
|
||||
func (m *MediaFile) RootRelPath() string {
|
||||
return m.RelPath(m.RootPath())
|
||||
}
|
||||
|
||||
// RelPrefix returns the relative path and file name prefix.
|
||||
// RelPrefix builds a relative path (without extension) suitable for deriving
|
||||
// related files such as sidecars. When stripSequence is true the sequence
|
||||
// suffix is removed from the filename prefix.
|
||||
func (m *MediaFile) RelPrefix(directory string, stripSequence bool) string {
|
||||
if relativePath := m.RelPath(directory); relativePath != "" {
|
||||
return filepath.Join(relativePath, m.BasePrefix(stripSequence))
|
||||
@@ -446,27 +468,31 @@ func (m *MediaFile) RelPrefix(directory string, stripSequence bool) string {
|
||||
return m.BasePrefix(stripSequence)
|
||||
}
|
||||
|
||||
// Dir returns the file path.
|
||||
// Dir returns the directory containing the media file.
|
||||
func (m *MediaFile) Dir() string {
|
||||
return filepath.Dir(m.fileName)
|
||||
}
|
||||
|
||||
// SubDir returns a sub directory name.
|
||||
// SubDir joins the media file's directory with the provided sub directory name.
|
||||
func (m *MediaFile) SubDir(dir string) string {
|
||||
return filepath.Join(filepath.Dir(m.fileName), dir)
|
||||
}
|
||||
|
||||
// AbsPrefix returns the directory and base filename without any extensions.
|
||||
// AbsPrefix returns the absolute path (directory + filename) without any
|
||||
// extensions, optionally stripping numeric sequence suffixes.
|
||||
func (m *MediaFile) AbsPrefix(stripSequence bool) string {
|
||||
return fs.AbsPrefix(m.FileName(), stripSequence)
|
||||
}
|
||||
|
||||
// BasePrefix returns the filename base without any extensions and path.
|
||||
// BasePrefix returns the filename (without directory) stripped of all
|
||||
// extensions; stripSequence removes trailing sequence tokens such as "_01".
|
||||
func (m *MediaFile) BasePrefix(stripSequence bool) string {
|
||||
return fs.BasePrefix(m.FileName(), stripSequence)
|
||||
}
|
||||
|
||||
// EditedName returns the corresponding edited image file name as used by Apple (e.g. IMG_E12345.JPG).
|
||||
// EditedName returns the alternate filename used by Apple Photos for edited
|
||||
// JPEGs (e.g. IMG_E12345.JPG). An empty string indicates no edited companion is
|
||||
// present.
|
||||
func (m *MediaFile) EditedName() string {
|
||||
basename := filepath.Base(m.fileName)
|
||||
|
||||
@@ -479,7 +505,8 @@ func (m *MediaFile) EditedName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Root returns the file root directory.
|
||||
// Root identifies which configured root the media file resides in (originals,
|
||||
// import, sidecar, examples). The result is cached so repeated calls are cheap.
|
||||
func (m *MediaFile) Root() string {
|
||||
if m.fileRoot != entity.RootUnknown {
|
||||
return m.fileRoot
|
||||
@@ -1201,7 +1228,9 @@ func (m *MediaFile) IsMedia() bool {
|
||||
return !m.IsThumb() && (m.IsImage() || m.IsRaw() || m.IsVideo() || m.IsVector() || m.IsDocument())
|
||||
}
|
||||
|
||||
// PreviewImage returns a PNG or JPEG version of the media file, if exists.
|
||||
// PreviewImage returns the media file itself if it is already a JPEG/PNG, or
|
||||
// locates a matching preview image (JPEG/PNG) stored alongside the file. The
|
||||
// helper returns an error when no preview can be found.
|
||||
func (m *MediaFile) PreviewImage() (*MediaFile, error) {
|
||||
if m.IsJpeg() {
|
||||
if !fs.FileExists(m.FileName()) {
|
||||
@@ -1230,7 +1259,8 @@ func (m *MediaFile) PreviewImage() (*MediaFile, error) {
|
||||
return nil, fmt.Errorf("no preview image found for %s", m.RootRelName())
|
||||
}
|
||||
|
||||
// HasPreviewImage returns true if the file has or is a JPEG or PNG image.
|
||||
// HasPreviewImage reports whether a JPEG/PNG preview exists. The result is
|
||||
// cached, so expensive lookups only happen once per MediaFile instance.
|
||||
func (m *MediaFile) HasPreviewImage() bool {
|
||||
if m.hasPreviewImage {
|
||||
return true
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, p string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), fs.ModeDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, data, fs.ModeFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, p string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_Existing_NoForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Copy(dst, false)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "LONGER_DEST_CONTENT", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
// Create an empty destination file.
|
||||
writeFile(t, dst, []byte{})
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Copy(dst, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_Existing_Force_TruncatesAndOverwrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set a known mod time via MediaFile to update cache and file mtime.
|
||||
known := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
|
||||
_ = m.SetModTime(known)
|
||||
|
||||
if err = m.Copy(dst, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
// Check mtime propagated to destination (second resolution).
|
||||
if st, err := os.Stat(dst); err == nil {
|
||||
assert.Equal(t, known, st.ModTime().UTC().Truncate(time.Second))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = m.Copy(src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMediaFile_Copy_InvalidDestPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = m.Copy(".", true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_Existing_NoForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Move(dst, false)
|
||||
assert.Error(t, err)
|
||||
// Verify no changes
|
||||
assert.FileExists(t, src)
|
||||
assert.Equal(t, "BBB", string(readFile(t, dst)))
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_ExistingEmpty_NoForce_AllowsReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
// Pre-create empty destination file
|
||||
writeFile(t, dst, []byte{})
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Move(dst, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Source removed, destination replaced.
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, m.FileName())
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_Existing_Force(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = m.Move(dst, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Source removed, destination replaced.
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, m.FileName())
|
||||
}
|
||||
|
||||
func TestMediaFile_Move_SamePath_Error(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
|
||||
m, err := NewMediaFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.Move(src, true)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
237
internal/photoprism/mediafile_fs_test.go
Normal file
237
internal/photoprism/mediafile_fs_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, p string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), fs.ModeDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, data, fs.ModeFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, p string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMediaFileCopy(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
force bool
|
||||
expectErr bool
|
||||
setup func(t *testing.T, dir string) (src, dst string)
|
||||
before func(t *testing.T, mf *MediaFile)
|
||||
destFn func(src, dst string) string
|
||||
assertFn func(t *testing.T, src, dst string)
|
||||
}{
|
||||
{
|
||||
name: "existing destination without force",
|
||||
force: false,
|
||||
expectErr: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
return src, dst
|
||||
},
|
||||
assertFn: func(t *testing.T, _, dst string) {
|
||||
assert.Equal(t, "LONGER_DEST_CONTENT", string(readFile(t, dst)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "existing empty destination without force",
|
||||
force: false,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte{})
|
||||
return src, dst
|
||||
},
|
||||
assertFn: func(t *testing.T, _, dst string) {
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "force overwrites destination and propagates mtime",
|
||||
force: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("ABC"))
|
||||
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
|
||||
return src, dst
|
||||
},
|
||||
before: func(t *testing.T, mf *MediaFile) {
|
||||
reference := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
|
||||
mf.SetModTime(reference)
|
||||
},
|
||||
assertFn: func(t *testing.T, _, dst string) {
|
||||
assert.Equal(t, "ABC", string(readFile(t, dst)))
|
||||
st, err := os.Stat(dst)
|
||||
require.NoError(t, err)
|
||||
expected := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
|
||||
assert.Equal(t, expected, st.ModTime().UTC().Truncate(time.Second))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same path returns error",
|
||||
force: true,
|
||||
expectErr: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
return src, src
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid destination path",
|
||||
force: true,
|
||||
expectErr: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
return src, filepath.Join(dir, "unused")
|
||||
},
|
||||
destFn: func(string, string) string { return "." },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src, dst := tc.setup(t, dir)
|
||||
mf, err := NewMediaFile(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.before != nil {
|
||||
tc.before(t, mf)
|
||||
}
|
||||
|
||||
target := dst
|
||||
if tc.destFn != nil {
|
||||
target = tc.destFn(src, dst)
|
||||
}
|
||||
|
||||
err = mf.Copy(target, tc.force)
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.assertFn != nil {
|
||||
tc.assertFn(t, src, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFileMove(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
force bool
|
||||
expectErr bool
|
||||
setup func(t *testing.T, dir string) (src, dst string)
|
||||
assertFn func(t *testing.T, src, dst string, mf *MediaFile)
|
||||
}{
|
||||
{
|
||||
name: "existing destination without force",
|
||||
force: false,
|
||||
expectErr: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
return src, dst
|
||||
},
|
||||
assertFn: func(t *testing.T, src, dst string, _ *MediaFile) {
|
||||
assert.FileExists(t, src)
|
||||
assert.Equal(t, "BBB", string(readFile(t, dst)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "existing empty destination without force",
|
||||
force: false,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte{})
|
||||
return src, dst
|
||||
},
|
||||
assertFn: func(t *testing.T, src, dst string, mf *MediaFile) {
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, mf.FileName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "force overwrites destination",
|
||||
force: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "src.bin")
|
||||
dst := filepath.Join(dir, "dst.bin")
|
||||
writeFile(t, src, []byte("AAA"))
|
||||
writeFile(t, dst, []byte("BBB"))
|
||||
return src, dst
|
||||
},
|
||||
assertFn: func(t *testing.T, src, dst string, mf *MediaFile) {
|
||||
_, srcErr := os.Stat(src)
|
||||
assert.True(t, os.IsNotExist(srcErr))
|
||||
assert.Equal(t, "AAA", string(readFile(t, dst)))
|
||||
assert.Equal(t, dst, mf.FileName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same path returns error",
|
||||
force: true,
|
||||
expectErr: true,
|
||||
setup: func(t *testing.T, dir string) (string, string) {
|
||||
src := filepath.Join(dir, "file.bin")
|
||||
writeFile(t, src, []byte("DATA"))
|
||||
return src, src
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src, dst := tc.setup(t, dir)
|
||||
mf, err := NewMediaFile(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = mf.Move(dst, tc.force)
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.assertFn != nil {
|
||||
tc.assertFn(t, src, dst, mf)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
// HasSidecarJson returns true if this file has or is a json sidecar file.
|
||||
// HasSidecarJson reports whether the media file already has a JSON sidecar in
|
||||
// any of the configured lookup paths (or is itself a JSON sidecar).
|
||||
func (m *MediaFile) HasSidecarJson() bool {
|
||||
if m.IsJSON() {
|
||||
return true
|
||||
@@ -20,7 +21,8 @@ func (m *MediaFile) HasSidecarJson() bool {
|
||||
return fs.SidecarJson.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false) != ""
|
||||
}
|
||||
|
||||
// SidecarJsonName returns the corresponding JSON sidecar file name as used by Google Photos (and potentially other apps).
|
||||
// SidecarJsonName returns the Google Photos style JSON sidecar path if it exists
|
||||
// alongside the media file; otherwise it returns an empty string.
|
||||
func (m *MediaFile) SidecarJsonName() string {
|
||||
jsonName := m.fileName + ".json"
|
||||
|
||||
@@ -31,7 +33,8 @@ func (m *MediaFile) SidecarJsonName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExifToolJsonName returns the cached ExifTool metadata file name.
|
||||
// ExifToolJsonName returns the path to the cached ExifTool JSON metadata file or
|
||||
// an error when ExifTool integration is disabled.
|
||||
func (m *MediaFile) ExifToolJsonName() (string, error) {
|
||||
if Config().DisableExifTool() {
|
||||
return "", fmt.Errorf("media: exiftool json files disabled")
|
||||
@@ -40,7 +43,8 @@ func (m *MediaFile) ExifToolJsonName() (string, error) {
|
||||
return ExifToolCacheName(m.Hash())
|
||||
}
|
||||
|
||||
// NeedsExifToolJson tests if an ExifTool JSON file needs to be created.
|
||||
// NeedsExifToolJson indicates whether a new ExifTool JSON export should be
|
||||
// generated for this media file.
|
||||
func (m *MediaFile) NeedsExifToolJson() bool {
|
||||
if m.InSidecar() && m.IsImage() || !m.IsMedia() || m.Empty() {
|
||||
return false
|
||||
@@ -55,7 +59,9 @@ func (m *MediaFile) NeedsExifToolJson() bool {
|
||||
return !fs.FileExists(jsonName)
|
||||
}
|
||||
|
||||
// CreateExifToolJson extracts metadata to a JSON file using Exiftool.
|
||||
// CreateExifToolJson runs ExifTool via the provided Convert helper and merges
|
||||
// its JSON output into the cached metadata. When nothing needs to be generated
|
||||
// the call is a no-op.
|
||||
func (m *MediaFile) CreateExifToolJson(convert *Convert) error {
|
||||
if !m.NeedsExifToolJson() {
|
||||
return nil
|
||||
@@ -69,7 +75,8 @@ func (m *MediaFile) CreateExifToolJson(convert *Convert) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadExifToolJson reads metadata from a cached ExifTool JSON file.
|
||||
// ReadExifToolJson loads cached ExifTool JSON metadata into the MediaFile
|
||||
// metadata cache.
|
||||
func (m *MediaFile) ReadExifToolJson() error {
|
||||
jsonName, err := m.ExifToolJsonName()
|
||||
|
||||
@@ -80,7 +87,9 @@ func (m *MediaFile) ReadExifToolJson() error {
|
||||
return m.metaData.JSON(jsonName, "")
|
||||
}
|
||||
|
||||
// MetaData returns exif meta data of a media file.
|
||||
// MetaData returns cached EXIF/sidecar metadata. On first access it probes the
|
||||
// underlying file, merges JSON sidecars (including ExifTool exports) and
|
||||
// normalises the time zone field.
|
||||
func (m *MediaFile) MetaData() (result meta.Data) {
|
||||
if !m.Ok() || !m.IsMedia() {
|
||||
// Not a main media file.
|
||||
@@ -133,7 +142,8 @@ func (m *MediaFile) MetaData() (result meta.Data) {
|
||||
return m.metaData
|
||||
}
|
||||
|
||||
// VideoInfo returns video information if this is a video file or has a video embedded.
|
||||
// VideoInfo probes the file with a built-in parser to retrieve video
|
||||
// metadata; results are cached after the first successful call.
|
||||
func (m *MediaFile) VideoInfo() video.Info {
|
||||
if !m.Ok() || !m.IsMedia() {
|
||||
// Not a main media file.
|
||||
|
||||
@@ -17,47 +17,64 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
func TestMediaFile_Ok(t *testing.T) {
|
||||
func TestMediaFileOk(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
exists, err := NewMediaFile(c.ExamplesPath() + "/cat_black.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
expectErr bool
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "existing file", path: c.ExamplesPath() + "/cat_black.jpg", wantOk: true},
|
||||
{name: "missing file", path: c.ExamplesPath() + "/xxz.jpg", expectErr: true, wantOk: false},
|
||||
}
|
||||
|
||||
assert.True(t, exists.Ok())
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mf, err := NewMediaFile(tc.path)
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, mf)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
missing, err := NewMediaFile(c.ExamplesPath() + "/xxz.jpg")
|
||||
|
||||
assert.NotNil(t, missing)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, missing.Ok())
|
||||
assert.Equal(t, tc.wantOk, mf.Ok())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_Empty(t *testing.T) {
|
||||
func TestMediaFileEmpty(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
exists, err := NewMediaFile(c.ExamplesPath() + "/cat_black.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
expectErr bool
|
||||
wantEmpty bool
|
||||
}{
|
||||
{name: "existing file", path: c.ExamplesPath() + "/cat_black.jpg", wantEmpty: false},
|
||||
{name: "missing file", path: c.ExamplesPath() + "/xxz.jpg", expectErr: true, wantEmpty: true},
|
||||
}
|
||||
|
||||
assert.False(t, exists.Empty())
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mf, err := NewMediaFile(tc.path)
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, mf)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
missing, err := NewMediaFile(c.ExamplesPath() + "/xxz.jpg")
|
||||
|
||||
assert.NotNil(t, missing)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, missing.Empty())
|
||||
assert.Equal(t, tc.wantEmpty, mf.Empty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_DateCreated(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
c := config.TestConfig()
|
||||
t.Run("TelegramNum2020Num01Num30Num09Num57EighteenJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -65,7 +82,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2020-01-30 09:57:18 +0000 UTC", date.String())
|
||||
})
|
||||
t.Run("ScreenshotNum2019Num05Num21AtTenNum45Num52Png", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -73,7 +90,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2019-05-21 10:45:52 +0000 UTC", date.String())
|
||||
})
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,7 +98,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2018-09-10 03:16:13 +0000 UTC", date.String())
|
||||
})
|
||||
t.Run("IphoneFifteenProHeic", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_15_pro.heic")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_15_pro.heic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -90,7 +107,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2023-10-31 10:44:43 +0000 UTC", mediaFile.DateCreated().String())
|
||||
})
|
||||
t.Run("CanonEosSixDDng", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,7 +115,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", date.String())
|
||||
})
|
||||
t.Run("ElephantsJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,7 +123,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", date.String())
|
||||
})
|
||||
t.Run("DogCreatedNum1919Jpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/dog_created_1919.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/dog_created_1919.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,7 +133,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_TakenAt(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
t.Run("TestdataNum2018Num04TwelveNineteenNum24Num49Gif", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile("testdata/2018-04-12 19_24_49.gif")
|
||||
if err != nil {
|
||||
@@ -140,7 +157,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcName, src)
|
||||
})
|
||||
t.Run("TelegramNum2020Num01Num30Num09Num57EighteenJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -151,7 +168,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcName, src)
|
||||
})
|
||||
t.Run("ScreenshotNum2019Num05Num21AtTenNum45Num52Png", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -162,7 +179,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcName, src)
|
||||
})
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -173,7 +190,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcMeta, src)
|
||||
})
|
||||
t.Run("CanonEosSixDDng", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -184,7 +201,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcMeta, src)
|
||||
})
|
||||
t.Run("ElephantsJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -195,7 +212,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
assert.Equal(t, entity.SrcMeta, src)
|
||||
})
|
||||
t.Run("DogCreatedNum1919Jpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/dog_created_1919.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/dog_created_1919.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -208,39 +225,36 @@ func TestMediaFile_TakenAt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_HasTimeAndPlace(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.True(t, mediaFile.HasTimeAndPlace())
|
||||
})
|
||||
t.Run("PeacockBlueJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/peacock_blue.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/peacock_blue.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.False(t, mediaFile.HasTimeAndPlace())
|
||||
})
|
||||
}
|
||||
func TestMediaFile_CameraModel(t *testing.T) {
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
|
||||
func TestMediaFile_CameraModel(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "iPhone SE", mediaFile.CameraModel())
|
||||
})
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -249,19 +263,17 @@ func TestMediaFile_CameraModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_CameraMake(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "Apple", mediaFile.CameraMake())
|
||||
})
|
||||
t.Run("PeacockBlueJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/peacock_blue.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/peacock_blue.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -270,19 +282,17 @@ func TestMediaFile_CameraMake(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_LensModel(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", mediaFile.LensModel())
|
||||
})
|
||||
t.Run("CanonEosSixDDng", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -292,19 +302,16 @@ func TestMediaFile_LensModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_LensMake(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("CatBrownJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "Apple", mediaFile.LensMake())
|
||||
})
|
||||
t.Run("ElephantsJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -314,7 +321,6 @@ func TestMediaFile_LensMake(t *testing.T) {
|
||||
|
||||
func TestMediaFile_FocalLength(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("CatBrownJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
|
||||
if err != nil {
|
||||
@@ -333,7 +339,6 @@ func TestMediaFile_FocalLength(t *testing.T) {
|
||||
|
||||
func TestMediaFile_FNumber(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("CatBrownJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
|
||||
if err != nil {
|
||||
@@ -352,7 +357,6 @@ func TestMediaFile_FNumber(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Iso(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("CatBrownJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
|
||||
if err != nil {
|
||||
@@ -371,7 +375,6 @@ func TestMediaFile_Iso(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Exposure(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("CatBrownJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
|
||||
|
||||
@@ -409,7 +412,6 @@ func TestMediaFileCanonicalName(t *testing.T) {
|
||||
|
||||
func TestMediaFileCanonicalNameFromFile(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("BeachWoodJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
|
||||
|
||||
@@ -442,7 +444,6 @@ func TestMediaFile_CanonicalNameFromFileWithDirectory(t *testing.T) {
|
||||
|
||||
func TestMediaFile_EditedFilename(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("ImgNum4120Jpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.JPG")
|
||||
if err != nil {
|
||||
@@ -473,9 +474,9 @@ func TestMediaFile_SetFilename(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_RootRelName(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -488,24 +489,25 @@ func TestMediaFile_RootRelName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_RootRelPath(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile.fileRoot = entity.RootImport
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("ExamplesPath", func(t *testing.T) {
|
||||
path := mediaFile.RootRelPath()
|
||||
assert.Equal(t, conf.ExamplesPath(), path)
|
||||
assert.Equal(t, c.ExamplesPath(), path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_RootPath(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -519,117 +521,114 @@ func TestMediaFile_RootPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMediaFile_RelName(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
|
||||
filename := mediaFile.RelName(conf.AssetsPath())
|
||||
filename := mediaFile.RelName(c.AssetsPath())
|
||||
assert.Equal(t, "examples/tree_white.jpg", filename)
|
||||
})
|
||||
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
|
||||
filename := mediaFile.RelName(conf.AssetsPath())
|
||||
filename := mediaFile.RelName(c.AssetsPath())
|
||||
assert.Equal(t, "examples/tree_white.jpg", filename)
|
||||
})
|
||||
t.Run("DirectoryNotPartOfFilename", func(t *testing.T) {
|
||||
filename := mediaFile.RelName("xxx/")
|
||||
assert.Equal(t, conf.ExamplesPath()+"/tree_white.jpg", filename)
|
||||
assert.Equal(t, c.ExamplesPath()+"/tree_white.jpg", filename)
|
||||
})
|
||||
t.Run("DirectoryEqualsExamplePath", func(t *testing.T) {
|
||||
filename := mediaFile.RelName(conf.ExamplesPath())
|
||||
filename := mediaFile.RelName(c.ExamplesPath())
|
||||
assert.Equal(t, "tree_white.jpg", filename)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_RelativePath(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
|
||||
path := mediaFile.RelPath(conf.AssetsPath() + "/")
|
||||
path := mediaFile.RelPath(c.AssetsPath() + "/")
|
||||
assert.Equal(t, "examples", path)
|
||||
})
|
||||
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
|
||||
path := mediaFile.RelPath(conf.AssetsPath())
|
||||
path := mediaFile.RelPath(c.AssetsPath())
|
||||
assert.Equal(t, "examples", path)
|
||||
})
|
||||
t.Run("DirectoryEqualsFilepath", func(t *testing.T) {
|
||||
path := mediaFile.RelPath(conf.ExamplesPath())
|
||||
path := mediaFile.RelPath(c.ExamplesPath())
|
||||
assert.Equal(t, "", path)
|
||||
})
|
||||
t.Run("DirectoryDoesNotMatchFilepath", func(t *testing.T) {
|
||||
path := mediaFile.RelPath("xxx")
|
||||
assert.Equal(t, conf.ExamplesPath(), path)
|
||||
assert.Equal(t, c.ExamplesPath(), path)
|
||||
})
|
||||
|
||||
mediaFile, err = NewMediaFile(conf.ExamplesPath() + "/.photoprism/example.jpg")
|
||||
mediaFile, err = NewMediaFile(c.ExamplesPath() + "/.photoprism/example.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("Hidden", func(t *testing.T) {
|
||||
path := mediaFile.RelPath(conf.ExamplesPath())
|
||||
path := mediaFile.RelPath(c.ExamplesPath())
|
||||
assert.Equal(t, "", path)
|
||||
})
|
||||
t.Run("HiddenEmpty", func(t *testing.T) {
|
||||
path := mediaFile.RelPath("")
|
||||
assert.Equal(t, conf.ExamplesPath(), path)
|
||||
assert.Equal(t, c.ExamplesPath(), path)
|
||||
})
|
||||
t.Run("HiddenRoot", func(t *testing.T) {
|
||||
path := mediaFile.RelPath(filepath.Join(conf.ExamplesPath(), fs.PPHiddenPathname))
|
||||
path := mediaFile.RelPath(filepath.Join(c.ExamplesPath(), fs.PPHiddenPathname))
|
||||
assert.Equal(t, "", path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_RelativeBasename(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
c := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
|
||||
basename := mediaFile.RelPrefix(conf.AssetsPath()+"/", true)
|
||||
basename := mediaFile.RelPrefix(c.AssetsPath()+"/", true)
|
||||
assert.Equal(t, "examples/tree_white", basename)
|
||||
})
|
||||
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
|
||||
basename := mediaFile.RelPrefix(conf.AssetsPath(), true)
|
||||
basename := mediaFile.RelPrefix(c.AssetsPath(), true)
|
||||
assert.Equal(t, "examples/tree_white", basename)
|
||||
})
|
||||
t.Run("DirectoryEqualsExamplePath", func(t *testing.T) {
|
||||
basename := mediaFile.RelPrefix(conf.ExamplesPath(), true)
|
||||
basename := mediaFile.RelPrefix(c.ExamplesPath(), true)
|
||||
assert.Equal(t, "tree_white", basename)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestMediaFile_Directory(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("LimesJpg", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/limes.jpg")
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, conf.ExamplesPath(), mediaFile.Dir())
|
||||
assert.Equal(t, c.ExamplesPath(), mediaFile.Dir())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_AbsPrefix(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("LimesJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
|
||||
if err != nil {
|
||||
@@ -661,7 +660,6 @@ func TestMediaFile_AbsPrefix(t *testing.T) {
|
||||
|
||||
func TestMediaFile_BasePrefix(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("LimesJpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
|
||||
if err != nil {
|
||||
@@ -687,7 +685,6 @@ func TestMediaFile_BasePrefix(t *testing.T) {
|
||||
|
||||
func TestMediaFile_MimeType(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("ElephantsJpg", func(t *testing.T) {
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
|
||||
if err != nil {
|
||||
@@ -875,7 +872,6 @@ func TestMediaFile_SetModTime(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Move(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Move"
|
||||
origName := tmpPath + "/original.jpg"
|
||||
@@ -915,7 +911,6 @@ func TestMediaFile_Move(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Copy(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Copy"
|
||||
|
||||
@@ -941,7 +936,6 @@ func TestMediaFile_Copy(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Extension(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -974,7 +968,6 @@ func TestMediaFile_Extension(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsJpeg(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1007,7 +1000,6 @@ func TestMediaFile_IsJpeg(t *testing.T) {
|
||||
|
||||
func TestMediaFile_HasType(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
if err != nil {
|
||||
@@ -1040,7 +1032,6 @@ func TestMediaFile_HasType(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsHeic(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1073,7 +1064,6 @@ func TestMediaFile_IsHeic(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsRaw(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1107,7 +1097,6 @@ func TestMediaFile_IsRaw(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsPng(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1130,7 +1119,6 @@ func TestMediaFile_IsPng(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsTiff(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1162,7 +1150,6 @@ func TestMediaFile_IsTiff(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsImageOther(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1251,7 +1238,6 @@ func TestMediaFile_IsImageOther(t *testing.T) {
|
||||
|
||||
func TestMediaFile_CheckType(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("JPEG", func(t *testing.T) {
|
||||
if f, err := NewMediaFile("testdata/flash.jpg"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1449,7 +1435,6 @@ func TestMediaFile_IsArchive(t *testing.T) {
|
||||
}
|
||||
func TestMediaFile_IsImage(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
|
||||
if err != nil {
|
||||
@@ -1497,7 +1482,6 @@ func TestMediaFile_IsImage(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsVideo(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("ChristmasMp4", func(t *testing.T) {
|
||||
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "christmas.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1535,7 +1519,6 @@ func TestMediaFile_IsVideo(t *testing.T) {
|
||||
|
||||
func TestMediaFile_IsLive(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Num2018Num04TwelveNineteenNum24Num49Jpg", func(t *testing.T) {
|
||||
fileName := fs.Abs("testdata/2018-04-12 19_24_49.jpg")
|
||||
if f, err := NewMediaFile(fileName); err != nil {
|
||||
@@ -2194,7 +2177,6 @@ func TestMediaFile_ExceedsResolution(t *testing.T) {
|
||||
|
||||
func TestMediaFile_AspectRatio(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
@@ -2233,7 +2215,6 @@ func TestMediaFile_AspectRatio(t *testing.T) {
|
||||
|
||||
func TestMediaFile_Orientation(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("IphoneSevenHeic", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
|
||||
|
||||
@@ -2359,7 +2340,6 @@ func TestMediaFile_JsonName(t *testing.T) {
|
||||
|
||||
func TestMediaFile_PathNameInfo(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("BlueGoVideoMp4", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
|
||||
|
||||
@@ -2433,7 +2413,6 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
|
||||
|
||||
func TestMediaFile_SubDirectory(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("BlueGoVideoMp4", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
|
||||
|
||||
@@ -2448,7 +2427,6 @@ func TestMediaFile_SubDirectory(t *testing.T) {
|
||||
|
||||
func TestMediaFile_HasSameName(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("False", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
|
||||
|
||||
@@ -2495,7 +2473,6 @@ func TestMediaFile_IsJson(t *testing.T) {
|
||||
|
||||
func TestMediaFile_NeedsTranscoding(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Json", func(t *testing.T) {
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.json")
|
||||
|
||||
@@ -2536,7 +2513,6 @@ func TestMediaFile_NeedsTranscoding(t *testing.T) {
|
||||
|
||||
func TestMediaFile_SkipTranscoding(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Json", func(t *testing.T) {
|
||||
f, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.json")
|
||||
|
||||
|
||||
146
internal/photoprism/mediafile_vision.go
Normal file
146
internal/photoprism/mediafile_vision.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/ai/nsfw"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// GenerateCaption generates a caption for the provided media file using the active
|
||||
// vision model. When captionSrc is SrcAuto the model's declared source is used;
|
||||
// otherwise the explicit source is recorded on the returned caption.
|
||||
func (m *MediaFile) GenerateCaption(captionSrc entity.Src) (caption *vision.CaptionResult, err error) {
|
||||
start := time.Now()
|
||||
|
||||
model := vision.Config.Model(vision.ModelTypeCaption)
|
||||
|
||||
// No caption generation model configured or usable.
|
||||
if model == nil {
|
||||
return caption, errors.New("no caption model configured")
|
||||
}
|
||||
|
||||
if captionSrc == entity.SrcAuto {
|
||||
captionSrc = model.GetSource()
|
||||
}
|
||||
|
||||
size := vision.Thumb(vision.ModelTypeCaption)
|
||||
|
||||
// Get thumbnail filenames for the selected sizes.
|
||||
fileName, fileErr := m.Thumbnail(Config().ThumbCachePath(), size.Name)
|
||||
|
||||
if fileErr != nil {
|
||||
return caption, err
|
||||
}
|
||||
|
||||
// Get matching labels from computer vision model.
|
||||
// Generate a caption using the configured vision model.
|
||||
if caption, _, err = vision.GenerateCaption(vision.Files{fileName}, media.SrcLocal); err != nil {
|
||||
// Failed.
|
||||
} else if caption.Text != "" {
|
||||
if captionSrc != entity.SrcAuto {
|
||||
caption.Source = captionSrc
|
||||
}
|
||||
|
||||
log.Infof("vision: generated caption for %s [%s]", clean.Log(m.RootRelName()), time.Since(start))
|
||||
}
|
||||
|
||||
return caption, err
|
||||
}
|
||||
|
||||
// GenerateLabels classifies the media file and returns matching labels. When labelSrc
|
||||
// is SrcAuto the model's declared source is used; otherwise the provided source
|
||||
// is applied to every returned label.
|
||||
func (m *MediaFile) GenerateLabels(labelSrc entity.Src) (labels classify.Labels) {
|
||||
if m == nil {
|
||||
return labels
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var err error
|
||||
var sizes []thumb.Name
|
||||
var thumbnails []string
|
||||
|
||||
model := vision.Config.Model(vision.ModelTypeLabels)
|
||||
|
||||
// No label generation model configured or usable.
|
||||
if model == nil {
|
||||
return labels
|
||||
}
|
||||
|
||||
if labelSrc == entity.SrcAuto {
|
||||
labelSrc = model.GetSource()
|
||||
}
|
||||
|
||||
size := vision.Thumb(vision.ModelTypeLabels)
|
||||
|
||||
// The thumbnail size may need to be adjusted to use other models.
|
||||
if size.Name != "" && size.Name != thumb.Tile224 {
|
||||
sizes = []thumb.Name{size.Name}
|
||||
thumbnails = make([]string, 0, 1)
|
||||
} else if m.Square() {
|
||||
// Only one thumbnail is required for square images.
|
||||
sizes = []thumb.Name{thumb.Tile224}
|
||||
thumbnails = make([]string, 0, 1)
|
||||
} else {
|
||||
// Use three thumbnails otherwise (center, left, right).
|
||||
sizes = []thumb.Name{thumb.Tile224, thumb.Left224, thumb.Right224}
|
||||
thumbnails = make([]string, 0, 3)
|
||||
}
|
||||
|
||||
// Get thumbnail filenames for the selected sizes.
|
||||
for _, s := range sizes {
|
||||
if thumbnail, fileErr := m.Thumbnail(Config().ThumbCachePath(), s); fileErr != nil {
|
||||
log.Debugf("index: %s in %s", err, clean.Log(m.RootRelName()))
|
||||
continue
|
||||
} else {
|
||||
thumbnails = append(thumbnails, thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the configured vision model to obtain labels for the generated thumbnails.
|
||||
if labels, err = vision.GenerateLabels(thumbnails, media.SrcLocal, labelSrc); err != nil {
|
||||
log.Debugf("labels: %s in %s", err, clean.Log(m.RootRelName()))
|
||||
return labels
|
||||
}
|
||||
|
||||
// Log number and names of generated labels.
|
||||
if n := labels.Count(); n > 0 {
|
||||
log.Debugf("vision: %#v", labels)
|
||||
log.Infof("vision: generated %s for %s [%s]", english.Plural(n, "label", "labels"), clean.Log(m.RootRelName()), time.Since(start))
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
// DetectNSFW returns true if media file might be offensive and detection is enabled.
|
||||
func (m *MediaFile) DetectNSFW() bool {
|
||||
filename, err := m.Thumbnail(Config().ThumbCachePath(), thumb.Fit720)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return false
|
||||
}
|
||||
|
||||
if results, modelErr := vision.DetectNSFW([]string{filename}, media.SrcLocal); modelErr != nil {
|
||||
log.Errorf("vision: %s in %s (detect nsfw)", modelErr, clean.Log(m.RootRelName()))
|
||||
return false
|
||||
} else if len(results) < 1 {
|
||||
log.Errorf("vision: nsfw model returned no result for %s", clean.Log(m.RootRelName()))
|
||||
return false
|
||||
} else if results[0].IsNsfw(nsfw.ThresholdHigh) {
|
||||
log.Warnf("vision: detected offensive content in %s", clean.Log(m.RootRelName()))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
140
internal/photoprism/mediafile_vision_test.go
Normal file
140
internal/photoprism/mediafile_vision_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package photoprism
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/ai/nsfw"
|
||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
func setupVisionMediaFile(t *testing.T) *MediaFile {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.TestConfig()
|
||||
require.NoError(t, cfg.InitializeTestData())
|
||||
|
||||
mediaFile, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
return mediaFile
|
||||
}
|
||||
|
||||
func TestMediaFile_GenerateCaption(t *testing.T) {
|
||||
mediaFile := setupVisionMediaFile(t)
|
||||
|
||||
originalConfig := vision.Config
|
||||
t.Cleanup(func() {
|
||||
vision.Config = originalConfig
|
||||
vision.SetCaptionFunc(nil)
|
||||
})
|
||||
|
||||
captionModel := &vision.Model{Type: vision.ModelTypeCaption, Engine: vision.ApiFormatOpenAI}
|
||||
captionModel.ApplyEngineDefaults()
|
||||
vision.Config = &vision.ConfigValues{Models: vision.Models{captionModel}}
|
||||
|
||||
t.Run("AutoUsesModelSource", func(t *testing.T) {
|
||||
vision.SetCaptionFunc(func(files vision.Files, mediaSrc media.Src) (*vision.CaptionResult, *vision.Model, error) {
|
||||
return &vision.CaptionResult{Text: "stub", Source: captionModel.GetSource()}, captionModel, nil
|
||||
})
|
||||
|
||||
caption, err := mediaFile.GenerateCaption(entity.SrcAuto)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, caption)
|
||||
assert.Equal(t, captionModel.GetSource(), caption.Source)
|
||||
})
|
||||
|
||||
t.Run("CustomSourceOverrides", func(t *testing.T) {
|
||||
vision.SetCaptionFunc(func(files vision.Files, mediaSrc media.Src) (*vision.CaptionResult, *vision.Model, error) {
|
||||
return &vision.CaptionResult{Text: "stub", Source: captionModel.GetSource()}, captionModel, nil
|
||||
})
|
||||
|
||||
caption, err := mediaFile.GenerateCaption(entity.SrcManual)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, caption)
|
||||
assert.Equal(t, entity.SrcManual, caption.Source)
|
||||
})
|
||||
|
||||
t.Run("MissingModelReturnsError", func(t *testing.T) {
|
||||
vision.Config = &vision.ConfigValues{}
|
||||
vision.SetCaptionFunc(nil)
|
||||
|
||||
caption, err := mediaFile.GenerateCaption(entity.SrcAuto)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, caption)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_GenerateLabels(t *testing.T) {
|
||||
mediaFile := setupVisionMediaFile(t)
|
||||
|
||||
originalConfig := vision.Config
|
||||
t.Cleanup(func() {
|
||||
vision.Config = originalConfig
|
||||
vision.SetLabelsFunc(nil)
|
||||
})
|
||||
|
||||
labelModel := &vision.Model{Type: vision.ModelTypeLabels, Engine: vision.ApiFormatOllama}
|
||||
labelModel.ApplyEngineDefaults()
|
||||
vision.Config = &vision.ConfigValues{Models: vision.Models{labelModel}}
|
||||
|
||||
t.Run("AutoUsesModelSource", func(t *testing.T) {
|
||||
var captured string
|
||||
vision.SetLabelsFunc(func(files vision.Files, mediaSrc media.Src, src string) (classify.Labels, error) {
|
||||
captured = src
|
||||
return classify.Labels{{Name: "stub", Source: src}}, nil
|
||||
})
|
||||
|
||||
labels := mediaFile.GenerateLabels(entity.SrcAuto)
|
||||
assert.NotEmpty(t, labels)
|
||||
assert.Equal(t, labelModel.GetSource(), captured)
|
||||
})
|
||||
|
||||
t.Run("CustomSourceOverrides", func(t *testing.T) {
|
||||
var captured string
|
||||
vision.SetLabelsFunc(func(files vision.Files, mediaSrc media.Src, src string) (classify.Labels, error) {
|
||||
captured = src
|
||||
return classify.Labels{{Name: "stub", Source: src}}, nil
|
||||
})
|
||||
|
||||
labels := mediaFile.GenerateLabels(entity.SrcManual)
|
||||
assert.NotEmpty(t, labels)
|
||||
assert.Equal(t, entity.SrcManual, captured)
|
||||
})
|
||||
|
||||
t.Run("MissingModel", func(t *testing.T) {
|
||||
vision.Config = &vision.ConfigValues{}
|
||||
vision.SetLabelsFunc(nil)
|
||||
|
||||
labels := mediaFile.GenerateLabels(entity.SrcAuto)
|
||||
assert.Empty(t, labels)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_DetectNSFW(t *testing.T) {
|
||||
mediaFile := setupVisionMediaFile(t)
|
||||
|
||||
t.Run("FlagsHighConfidence", func(t *testing.T) {
|
||||
vision.SetNSFWFunc(func(files vision.Files, mediaSrc media.Src) ([]nsfw.Result, error) {
|
||||
return []nsfw.Result{{Porn: nsfw.ThresholdHigh + 0.01}}, nil
|
||||
})
|
||||
t.Cleanup(func() { vision.SetNSFWFunc(nil) })
|
||||
|
||||
assert.True(t, mediaFile.DetectNSFW())
|
||||
})
|
||||
|
||||
t.Run("SafeContent", func(t *testing.T) {
|
||||
vision.SetNSFWFunc(func(files vision.Files, mediaSrc media.Src) ([]nsfw.Result, error) {
|
||||
return []nsfw.Result{{Neutral: 0.9}}, nil
|
||||
})
|
||||
t.Cleanup(func() { vision.SetNSFWFunc(nil) })
|
||||
|
||||
assert.False(t, mediaFile.DetectNSFW())
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/mutex"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
@@ -72,11 +71,8 @@ func (w *Meta) Start(delay, interval time.Duration, force bool) (err error) {
|
||||
offset := 0
|
||||
optimized := 0
|
||||
|
||||
ind := get.Index()
|
||||
|
||||
labelsModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeLabels, vision.RunNewlyIndexed)
|
||||
captionModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeCaption, vision.RunNewlyIndexed)
|
||||
|
||||
nsfwModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeNsfw, vision.RunNewlyIndexed)
|
||||
|
||||
if nsfwModelShouldRun {
|
||||
@@ -105,15 +101,18 @@ func (w *Meta) Start(delay, interval time.Duration, force bool) (err error) {
|
||||
|
||||
done[photo.PhotoUID] = true
|
||||
|
||||
logName := photo.String()
|
||||
|
||||
generateLabels := labelsModelShouldRun && photo.ShouldGenerateLabels(false)
|
||||
generateCaption := captionModelShouldRun && photo.ShouldGenerateCaption(entity.SrcAuto, false)
|
||||
detectNsfw := w.conf.DetectNSFW() && !photo.PhotoPrivate
|
||||
|
||||
// If configured, generate metadata for newly indexed photos using external vision services.
|
||||
if photo.IsNewlyIndexed() && (generateLabels || generateCaption) {
|
||||
primaryFile, fileErr := photo.PrimaryFile()
|
||||
|
||||
if fileErr != nil {
|
||||
log.Debugf("index: photo %s has invalid primary file (%s)", photo.PhotoUID, clean.Error(fileErr))
|
||||
log.Debugf("index: photo %s has invalid primary file (%s)", logName, clean.Error(fileErr))
|
||||
} else {
|
||||
fileName := photoprism.FileName(primaryFile.FileRoot, primaryFile.FileName)
|
||||
|
||||
@@ -127,19 +126,25 @@ func (w *Meta) Start(delay, interval time.Duration, force bool) (err error) {
|
||||
} else {
|
||||
// Generate photo labels if needed.
|
||||
if generateLabels {
|
||||
if labels := ind.Labels(mediaFile, entity.SrcAuto); len(labels) > 0 {
|
||||
if labels := mediaFile.GenerateLabels(entity.SrcAuto); len(labels) > 0 {
|
||||
if detectNsfw {
|
||||
if labels.IsNSFW(vision.Config.Thresholds.GetNSFW()) {
|
||||
photo.PhotoPrivate = true
|
||||
log.Infof("vision: changed private flag of %s to %t (labels)", logName, photo.PhotoPrivate)
|
||||
}
|
||||
}
|
||||
photo.AddLabels(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate photo caption if needed.
|
||||
if generateCaption {
|
||||
if caption, captionErr := ind.Caption(mediaFile, entity.SrcAuto); captionErr != nil {
|
||||
log.Debugf("index: %s (generate caption for %s)", clean.Error(captionErr), photo.PhotoUID)
|
||||
if caption, captionErr := mediaFile.GenerateCaption(entity.SrcAuto); captionErr != nil {
|
||||
log.Debugf("index: failed to generate caption for %s (%s)", logName, clean.Error(captionErr))
|
||||
} else if text := strings.TrimSpace(caption.Text); text != "" {
|
||||
photo.SetCaption(text, caption.Source)
|
||||
if updateErr := photo.UpdateCaptionLabels(); updateErr != nil {
|
||||
log.Warnf("index: %s (update caption labels for %s)", clean.Error(updateErr), photo.PhotoUID)
|
||||
log.Warnf("index: failed to update caption labels for %s (%s)", logName, clean.Error(updateErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,12 +163,14 @@ func (w *Meta) Start(delay, interval time.Duration, force bool) (err error) {
|
||||
log.Errorf("index: %s in optimization worker", optimizeErr)
|
||||
} else if updated {
|
||||
optimized++
|
||||
log.Debugf("index: updated photo %s", photo.String())
|
||||
log.Debugf("index: updated photo %s", logName)
|
||||
}
|
||||
|
||||
for _, p := range merged {
|
||||
log.Infof("index: merged %s", p.PhotoUID)
|
||||
done[p.PhotoUID] = true
|
||||
if p != nil {
|
||||
log.Infof("index: merged %s", p.String())
|
||||
done[p.PhotoUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package workers
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/mutex"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
@@ -131,8 +129,6 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
|
||||
count = search.MaxResults
|
||||
}
|
||||
|
||||
ind := get.Index()
|
||||
|
||||
frm := form.SearchPhotos{
|
||||
Query: filter,
|
||||
Primary: true,
|
||||
@@ -172,20 +168,20 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
|
||||
|
||||
done[photo.PhotoUID] = true
|
||||
|
||||
photoName := path.Join(photo.PhotoPath, photo.PhotoName)
|
||||
logName := photo.String()
|
||||
|
||||
m, loadErr := query.PhotoByUID(photo.PhotoUID)
|
||||
|
||||
if loadErr != nil {
|
||||
log.Errorf("vision: failed to load %s (%s)", photoName, loadErr)
|
||||
log.Errorf("vision: failed to load %s (%s)", logName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
generateLabels := updateLabels && m.ShouldGenerateLabels(force)
|
||||
generateCaptions := updateCaptions && m.ShouldGenerateCaption(customSrc, force)
|
||||
generateNsfw := updateNsfw && (!photo.PhotoPrivate || force)
|
||||
detectNsfw := updateNsfw && (!photo.PhotoPrivate || force)
|
||||
|
||||
if !(generateLabels || generateCaptions || generateNsfw) {
|
||||
if !(generateLabels || generateCaptions || detectNsfw) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -193,7 +189,7 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
|
||||
file, fileErr := photoprism.NewMediaFile(fileName)
|
||||
|
||||
if fileErr != nil {
|
||||
log.Errorf("vision: failed to open %s (%s)", photoName, fileErr)
|
||||
log.Errorf("vision: failed to open %s (%s)", logName, fileErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -201,42 +197,48 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
|
||||
|
||||
// Generate labels.
|
||||
if generateLabels {
|
||||
if labels := ind.Labels(file, customSrc); len(labels) > 0 {
|
||||
if labels := file.GenerateLabels(customSrc); len(labels) > 0 {
|
||||
if w.conf.DetectNSFW() && !m.PhotoPrivate {
|
||||
if labels.IsNSFW(vision.Config.Thresholds.GetNSFW()) {
|
||||
m.PhotoPrivate = true
|
||||
log.Infof("vision: changed private flag of %s to %t (labels)", logName, m.PhotoPrivate)
|
||||
}
|
||||
}
|
||||
m.AddLabels(labels)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Detect NSFW content.
|
||||
if generateNsfw {
|
||||
if isNsfw := ind.IsNsfw(file); m.PhotoPrivate != isNsfw {
|
||||
if detectNsfw {
|
||||
if isNsfw := file.DetectNSFW(); m.PhotoPrivate != isNsfw {
|
||||
m.PhotoPrivate = isNsfw
|
||||
changed = true
|
||||
log.Infof("vision: changed private flag of %s to %t", photoName, m.PhotoPrivate)
|
||||
log.Infof("vision: changed private flag of %s to %t", logName, m.PhotoPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a caption if none exists or the force flag is used,
|
||||
// and only if no caption was set or removed by a higher-priority source.
|
||||
if generateCaptions {
|
||||
if caption, captionErr := ind.Caption(file, customSrc); captionErr != nil {
|
||||
log.Warnf("vision: %s in %s (generate caption)", clean.Error(captionErr), photoName)
|
||||
if caption, captionErr := file.GenerateCaption(customSrc); captionErr != nil {
|
||||
log.Warnf("vision: %s in %s (generate caption)", clean.Error(captionErr), logName)
|
||||
} else if text := strings.TrimSpace(caption.Text); text != "" {
|
||||
m.SetCaption(text, caption.Source)
|
||||
if updateErr := m.UpdateCaptionLabels(); updateErr != nil {
|
||||
log.Warnf("vision: %s in %s (update caption labels)", clean.Error(updateErr), photoName)
|
||||
log.Warnf("vision: %s in %s (update caption labels)", clean.Error(updateErr), logName)
|
||||
}
|
||||
changed = true
|
||||
log.Infof("vision: changed caption of %s to %s", photoName, clean.Log(m.PhotoCaption))
|
||||
log.Infof("vision: changed caption of %s to %s", logName, clean.Log(m.PhotoCaption))
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if saveErr := m.GenerateAndSaveTitle(); saveErr != nil {
|
||||
log.Infof("vision: failed to updated %s (%s)", photoName, clean.Error(saveErr))
|
||||
log.Errorf("vision: failed to update %s (%s)", logName, clean.Error(saveErr))
|
||||
} else {
|
||||
updated++
|
||||
log.Debugf("vision: updated %s", photoName)
|
||||
log.Infof("vision: updated %s", logName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user