From e5dc335bcf968149697ef4e5098768915525e33e Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 5 Oct 2025 04:23:36 +0200 Subject: [PATCH] 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 --- AGENTS.md | 1 + internal/ai/classify/label.go | 14 +- internal/ai/classify/labels.go | 91 ++++- internal/ai/classify/labels_test.go | 94 +++++ internal/ai/vision/api_request.go | 84 ++++- internal/ai/vision/api_request_test.go | 76 ++++ internal/ai/vision/api_response.go | 54 ++- internal/ai/vision/api_response_test.go | 13 + internal/ai/vision/caption.go | 4 +- internal/ai/vision/caption_test.go | 6 +- internal/ai/vision/config.go | 9 + internal/ai/vision/engine_ollama.go | 36 +- internal/ai/vision/engine_ollama_test.go | 6 +- internal/ai/vision/errors.go | 9 + internal/ai/vision/label_normalizer.go | 67 +++- internal/ai/vision/label_normalizer_test.go | 34 +- internal/ai/vision/labels.go | 11 +- internal/ai/vision/labels_test.go | 10 +- internal/ai/vision/model.go | 17 +- internal/ai/vision/models.go | 13 +- internal/ai/vision/nsfw.go | 20 +- internal/ai/vision/ollama/defaults.go | 26 +- internal/ai/vision/openai/defaults.go | 2 +- internal/ai/vision/schema/labels.go | 16 +- internal/ai/vision/testdata/vision.yml | 1 + internal/ai/vision/thresholds.go | 56 ++- internal/ai/vision/thresholds_test.go | 72 ++++ internal/api/labels_search.go | 5 + internal/api/swagger.json | 30 +- internal/api/users_upload.go | 2 +- internal/api/vision_caption.go | 2 +- internal/api/vision_labels.go | 2 +- internal/api/vision_nsfw.go | 2 +- internal/config/config.go | 1 + internal/config/config_vision.go | 48 +++ internal/entity/label.go | 5 +- internal/entity/migrate/dialect_mysql.go | 6 + internal/entity/migrate/dialect_sqlite3.go | 6 + .../entity/migrate/mysql/20251005-000001.sql | 3 + .../migrate/sqlite3/20251005-000001.sql | 3 + internal/entity/photo.go | 65 +++- internal/entity/photo_label.go | 27 +- internal/entity/photo_test.go | 101 ++++-- internal/entity/search/albums.go | 2 +- internal/entity/search/conditions.go | 46 ++- internal/entity/search/labels.go | 7 + internal/entity/search/labels_results.go | 7 +- internal/entity/search/photos.go | 13 +- internal/entity/search/photos_geo.go | 13 +- .../search/photos_geojson_result_test.go | 10 +- internal/entity/search/photos_results.go | 33 +- internal/entity/search/photos_results_test.go | 227 +++++------- internal/entity/search/photos_viewer.go | 20 +- internal/entity/search/photos_viewer_test.go | 332 +++++++++--------- internal/form/search_labels.go | 2 + internal/photoprism/index.go | 17 + internal/photoprism/index_caption.go | 52 --- internal/photoprism/index_labels.go | 75 ---- internal/photoprism/index_mediafile.go | 14 +- internal/photoprism/index_nsfw.go | 32 -- internal/photoprism/index_vision_test.go | 14 +- internal/photoprism/mediafile.go | 88 +++-- .../mediafile_copy_move_force_test.go | 208 ----------- internal/photoprism/mediafile_fs_test.go | 237 +++++++++++++ internal/photoprism/mediafile_meta.go | 26 +- internal/photoprism/mediafile_test.go | 238 ++++++------- internal/photoprism/mediafile_vision.go | 146 ++++++++ internal/photoprism/mediafile_vision_test.go | 140 ++++++++ internal/workers/meta.go | 31 +- internal/workers/vision.go | 40 ++- 70 files changed, 2138 insertions(+), 1082 deletions(-) create mode 100644 internal/ai/vision/api_request_test.go create mode 100644 internal/ai/vision/errors.go create mode 100644 internal/ai/vision/thresholds_test.go create mode 100644 internal/entity/migrate/mysql/20251005-000001.sql create mode 100644 internal/entity/migrate/sqlite3/20251005-000001.sql delete mode 100644 internal/photoprism/index_caption.go delete mode 100644 internal/photoprism/index_labels.go delete mode 100644 internal/photoprism/index_nsfw.go delete mode 100644 internal/photoprism/mediafile_copy_move_force_test.go create mode 100644 internal/photoprism/mediafile_fs_test.go create mode 100644 internal/photoprism/mediafile_vision.go create mode 100644 internal/photoprism/mediafile_vision_test.go diff --git a/AGENTS.md b/AGENTS.md index 2295ee5e8..7c6df7920 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:"` 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`) diff --git a/internal/ai/classify/label.go b/internal/ai/classify/label.go index 149da8f53..97341ac5b 100644 --- a/internal/ai/classify/label.go +++ b/internal/ai/classify/label.go @@ -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. diff --git a/internal/ai/classify/labels.go b/internal/ai/classify/labels.go index 2b6052ac6..86f290b2e 100644 --- a/internal/ai/classify/labels.go +++ b/internal/ai/classify/labels.go @@ -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 +} diff --git a/internal/ai/classify/labels_test.go b/internal/ai/classify/labels_test.go index ccb04ab25..f0ef28ace 100644 --- a/internal/ai/classify/labels_test.go +++ b/internal/ai/classify/labels_test.go @@ -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}, diff --git a/internal/ai/vision/api_request.go b/internal/ai/vision/api_request.go index db5c66415..8565003b5 100644 --- a/internal/ai/vision/api_request.go +++ b/internal/ai/vision/api_request.go @@ -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 +} diff --git a/internal/ai/vision/api_request_test.go b/internal/ai/vision/api_request_test.go new file mode 100644 index 000000000..3b3993090 --- /dev/null +++ b/internal/ai/vision/api_request_test.go @@ -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) + } +} diff --git a/internal/ai/vision/api_response.go b/internal/ai/vision/api_response.go index cbe5d7e43..a1a4c5536 100644 --- a/internal/ai/vision/api_response.go +++ b/internal/ai/vision/api_response.go @@ -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{ diff --git a/internal/ai/vision/api_response_test.go b/internal/ai/vision/api_response_test.go index 095be4934..4cb225e39 100644 --- a/internal/ai/vision/api_response_test.go +++ b/internal/ai/vision/api_response_test.go @@ -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) + } +} diff --git a/internal/ai/vision/caption.go b/internal/ai/vision/caption.go index 46f7ae903..585795f7b 100644 --- a/internal/ai/vision/caption.go +++ b/internal/ai/vision/caption.go @@ -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) } diff --git a/internal/ai/vision/caption_test.go b/internal/ai/vision/caption_test.go index 09c022b45..00e290908 100644 --- a/internal/ai/vision/caption_test.go +++ b/internal/ai/vision/caption_test.go @@ -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) diff --git a/internal/ai/vision/config.go b/internal/ai/vision/config.go index da228ba1f..4161bb4b6 100644 --- a/internal/ai/vision/config.go +++ b/internal/ai/vision/config.go @@ -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 } diff --git a/internal/ai/vision/engine_ollama.go b/internal/ai/vision/engine_ollama.go index 237415440..0cd1dcfda 100644 --- a/internal/ai/vision/engine_ollama.go +++ b/internal/ai/vision/engine_ollama.go @@ -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") diff --git a/internal/ai/vision/engine_ollama_test.go b/internal/ai/vision/engine_ollama_test.go index 6b2fe21ef..dffc6fe7d 100644 --- a/internal/ai/vision/engine_ollama_test.go +++ b/internal/ai/vision/engine_ollama_test.go @@ -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) } } diff --git a/internal/ai/vision/errors.go b/internal/ai/vision/errors.go new file mode 100644 index 000000000..384fc3d14 --- /dev/null +++ b/internal/ai/vision/errors.go @@ -0,0 +1,9 @@ +package vision + +import ( + "fmt" +) + +var ( + ErrInvalidModel = fmt.Errorf("vision: invalid model") +) diff --git a/internal/ai/vision/label_normalizer.go b/internal/ai/vision/label_normalizer.go index 34766b6ca..a27bcee73 100644 --- a/internal/ai/vision/label_normalizer.go +++ b/internal/ai/vision/label_normalizer.go @@ -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. diff --git a/internal/ai/vision/label_normalizer_test.go b/internal/ai/vision/label_normalizer_test.go index e9ff5bfb5..7cdb9c045 100644 --- a/internal/ai/vision/label_normalizer_test.go +++ b/internal/ai/vision/label_normalizer_test.go @@ -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) { diff --git a/internal/ai/vision/labels.go b/internal/ai/vision/labels.go index 2b36d1a10..e27767e07 100644 --- a/internal/ai/vision/labels.go +++ b/internal/ai/vision/labels.go @@ -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 + } } } diff --git a/internal/ai/vision/labels_test.go b/internal/ai/vision/labels_test.go index 540018607..7ff10b727 100644 --- a/internal/ai/vision/labels_test.go +++ b/internal/ai/vision/labels_test.go @@ -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) }) } diff --git a/internal/ai/vision/model.go b/internal/ai/vision/model.go index 3d4a4394e..cabb67170 100644 --- a/internal/ai/vision/model.go +++ b/internal/ai/vision/model.go @@ -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")) + } } }) diff --git a/internal/ai/vision/models.go b/internal/ai/vision/models.go index e594c086a..71083e05c 100644 --- a/internal/ai/vision/models.go +++ b/internal/ai/vision/models.go @@ -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% + } ) diff --git a/internal/ai/vision/nsfw.go b/internal/ai/vision/nsfw.go index 41a15ae57..ed8244544 100644 --- a/internal/ai/vision/nsfw.go +++ b/internal/ai/vision/nsfw.go @@ -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") diff --git a/internal/ai/vision/ollama/defaults.go b/internal/ai/vision/ollama/defaults.go index 71b3179f8..145e710eb 100644 --- a/internal/ai/vision/ollama/defaults.go +++ b/internal/ai/vision/ollama/defaults.go @@ -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 + } } diff --git a/internal/ai/vision/openai/defaults.go b/internal/ai/vision/openai/defaults.go index f579ba99c..b29b44bea 100644 --- a/internal/ai/vision/openai/defaults.go +++ b/internal/ai/vision/openai/defaults.go @@ -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 } diff --git a/internal/ai/vision/schema/labels.go b/internal/ai/vision/schema/labels.go index 0afc1e1ba..735a70cb9 100644 --- a/internal/ai/vision/schema/labels.go +++ b/internal/ai/vision/schema/labels.go @@ -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 + } +} diff --git a/internal/ai/vision/testdata/vision.yml b/internal/ai/vision/testdata/vision.yml index 3159c75a5..6529e4f29 100644 --- a/internal/ai/vision/testdata/vision.yml +++ b/internal/ai/vision/testdata/vision.yml @@ -76,3 +76,4 @@ Models: ResponseFormat: ollama Thresholds: Confidence: 10 + NSFW: 75 diff --git a/internal/ai/vision/thresholds.go b/internal/ai/vision/thresholds.go index 9e795b2c4..d21b9bf0d 100644 --- a/internal/ai/vision/thresholds.go +++ b/internal/ai/vision/thresholds.go @@ -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 } diff --git a/internal/ai/vision/thresholds_test.go b/internal/ai/vision/thresholds_test.go new file mode 100644 index 000000000..c50cbdef0 --- /dev/null +++ b/internal/ai/vision/thresholds_test.go @@ -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) + } + }) +} diff --git a/internal/api/labels_search.go b/internal/api/labels_search.go index d073c3c6a..a5e640221 100644 --- a/internal/api/labels_search.go +++ b/internal/api/labels_search.go @@ -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) diff --git a/internal/api/swagger.json b/internal/api/swagger.json index f4ffaf4bb..55e867351 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -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" }, diff --git a/internal/api/users_upload.go b/internal/api/users_upload.go index 9f690e83b..df13410db 100644 --- a/internal/api/users_upload.go +++ b/internal/api/users_upload.go @@ -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) diff --git a/internal/api/vision_caption.go b/internal/api/vision_caption.go index 175e9b455..58cc048c2 100644 --- a/internal/api/vision_caption.go +++ b/internal/api/vision_caption.go @@ -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) diff --git a/internal/api/vision_labels.go b/internal/api/vision_labels.go index 9e4ad708a..3cffcda27 100644 --- a/internal/api/vision_labels.go +++ b/internal/api/vision_labels.go @@ -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) diff --git a/internal/api/vision_nsfw.go b/internal/api/vision_nsfw.go index 18cba157f..c2a252806 100644 --- a/internal/api/vision_nsfw.go +++ b/internal/api/vision_nsfw.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 43864fd2c..dafb3785f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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{ diff --git a/internal/config/config_vision.go b/internal/config/config_vision.go index eaa0ba676..18f3dee3e 100644 --- a/internal/config/config_vision.go +++ b/internal/config/config_vision.go @@ -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 } diff --git a/internal/entity/label.go b/internal/entity/label.go index cbaa062ea..79575c6f6 100644 --- a/internal/entity/label.go +++ b/internal/entity/label.go @@ -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:"-"` diff --git a/internal/entity/migrate/dialect_mysql.go b/internal/entity/migrate/dialect_mysql.go index fca093ae6..f20e0493f 100644 --- a/internal/entity/migrate/dialect_mysql.go +++ b/internal/entity/migrate/dialect_mysql.go @@ -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;"}, + }, } diff --git a/internal/entity/migrate/dialect_sqlite3.go b/internal/entity/migrate/dialect_sqlite3.go index 2b4d20e93..90abdd7e7 100644 --- a/internal/entity/migrate/dialect_sqlite3.go +++ b/internal/entity/migrate/dialect_sqlite3.go @@ -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;"}, + }, } diff --git a/internal/entity/migrate/mysql/20251005-000001.sql b/internal/entity/migrate/mysql/20251005-000001.sql new file mode 100644 index 000000000..e0061c616 --- /dev/null +++ b/internal/entity/migrate/mysql/20251005-000001.sql @@ -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; \ No newline at end of file diff --git a/internal/entity/migrate/sqlite3/20251005-000001.sql b/internal/entity/migrate/sqlite3/20251005-000001.sql new file mode 100644 index 000000000..e0061c616 --- /dev/null +++ b/internal/entity/migrate/sqlite3/20251005-000001.sql @@ -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; \ No newline at end of file diff --git a/internal/entity/photo.go b/internal/entity/photo.go index a38dd6bd9..5b4255d86 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -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" } - 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) diff --git a/internal/entity/photo_label.go b/internal/entity/photo_label.go index ae8131693..d9ad88905 100644 --- a/internal/entity/photo_label.go +++ b/internal/entity/photo_label.go @@ -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 diff --git a/internal/entity/photo_test.go b/internal/entity/photo_test.go index e6f704072..6ac1c4452 100644 --- a/internal/entity/photo_test.go +++ b/internal/entity/photo_test.go @@ -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", m.String()) - assert.Equal(t, "Photo", 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", + 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) diff --git a/internal/entity/search/albums.go b/internal/entity/search/albums.go index 40521b934..97c436db7 100644 --- a/internal/entity/search/albums.go +++ b/internal/entity/search/albums.go @@ -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 { diff --git a/internal/entity/search/conditions.go b/internal/entity/search/conditions.go index d2fdb0d74..e4db13ab1 100644 --- a/internal/entity/search/conditions.go +++ b/internal/entity/search/conditions.go @@ -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) } diff --git a/internal/entity/search/labels.go b/internal/entity/search/labels.go index 066ebcfab..3e544f1d8 100644 --- a/internal/entity/search/labels.go +++ b/internal/entity/search/labels.go @@ -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) diff --git a/internal/entity/search/labels_results.go b/internal/entity/search/labels_results.go index 35efefbb6..9f8848ccd 100644 --- a/internal/entity/search/labels_results.go +++ b/internal/entity/search/labels_results.go @@ -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"` diff --git a/internal/entity/search/photos.go b/internal/entity/search/photos.go index aa5e7da0d..b83ee6acb 100644 --- a/internal/entity/search/photos.go +++ b/internal/entity/search/photos.go @@ -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)) diff --git a/internal/entity/search/photos_geo.go b/internal/entity/search/photos_geo.go index 3e4bd0152..40676af41 100644 --- a/internal/entity/search/photos_geo.go +++ b/internal/entity/search/photos_geo.go @@ -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. diff --git a/internal/entity/search/photos_geojson_result_test.go b/internal/entity/search/photos_geojson_result_test.go index 7b9a2057a..45e39353a 100644 --- a/internal/entity/search/photos_geojson_result_test.go +++ b/internal/entity/search/photos_geojson_result_test.go @@ -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, diff --git a/internal/entity/search/photos_results.go b/internal/entity/search/photos_results.go index 6b4f0f863..2a0e5c979 100644 --- a/internal/entity/search/photos_results.go +++ b/internal/entity/search/photos_results.go @@ -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" + } + + 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) diff --git a/internal/entity/search/photos_results_test.go b/internal/entity/search/photos_results_test.go index 2b4a2e9f0..848442c77 100644 --- a/internal/entity/search/photos_results_test.go +++ b/internal/entity/search/photos_results_test.go @@ -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", + }, + { + 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", diff --git a/internal/entity/search/photos_viewer.go b/internal/entity/search/photos_viewer.go index a7a313890..fa20dabca 100644 --- a/internal/entity/search/photos_viewer.go +++ b/internal/entity/search/photos_viewer.go @@ -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)) diff --git a/internal/entity/search/photos_viewer_test.go b/internal/entity/search/photos_viewer_test.go index 938ff8a78..61415967c 100644 --- a/internal/entity/search/photos_viewer_test.go +++ b/internal/entity/search/photos_viewer_test.go @@ -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) + } } diff --git a/internal/form/search_labels.go b/internal/form/search_labels.go index 3a9d79dfe..02c0f8ba9 100644 --- a/internal/form/search_labels.go +++ b/internal/form/search_labels.go @@ -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:"-"` diff --git a/internal/photoprism/index.go b/internal/photoprism/index.go index 1cee8ac26..5f00e9e39 100644 --- a/internal/photoprism/index.go +++ b/internal/photoprism/index.go @@ -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() } diff --git a/internal/photoprism/index_caption.go b/internal/photoprism/index_caption.go deleted file mode 100644 index 65960f37b..000000000 --- a/internal/photoprism/index_caption.go +++ /dev/null @@ -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 -} diff --git a/internal/photoprism/index_labels.go b/internal/photoprism/index_labels.go deleted file mode 100644 index a97569c09..000000000 --- a/internal/photoprism/index_labels.go +++ /dev/null @@ -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 -} diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index f5b28a285..610cb70ad 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -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. diff --git a/internal/photoprism/index_nsfw.go b/internal/photoprism/index_nsfw.go deleted file mode 100644 index 4bda3a9d7..000000000 --- a/internal/photoprism/index_nsfw.go +++ /dev/null @@ -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 -} diff --git a/internal/photoprism/index_vision_test.go b/internal/photoprism/index_vision_test.go index 09fbaa63e..0e8bcb746 100644 --- a/internal/photoprism/index_vision_test.go +++ b/internal/photoprism/index_vision_test.go @@ -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) }) diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index a60f9f6c4..0afba08bf 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -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 diff --git a/internal/photoprism/mediafile_copy_move_force_test.go b/internal/photoprism/mediafile_copy_move_force_test.go deleted file mode 100644 index c4a3b973f..000000000 --- a/internal/photoprism/mediafile_copy_move_force_test.go +++ /dev/null @@ -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) -} diff --git a/internal/photoprism/mediafile_fs_test.go b/internal/photoprism/mediafile_fs_test.go new file mode 100644 index 000000000..c54106c06 --- /dev/null +++ b/internal/photoprism/mediafile_fs_test.go @@ -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) + } + }) + } +} diff --git a/internal/photoprism/mediafile_meta.go b/internal/photoprism/mediafile_meta.go index 68e4d96bb..10fce06f4 100644 --- a/internal/photoprism/mediafile_meta.go +++ b/internal/photoprism/mediafile_meta.go @@ -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. diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 8b6f8297c..0e916ccd0 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -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") diff --git a/internal/photoprism/mediafile_vision.go b/internal/photoprism/mediafile_vision.go new file mode 100644 index 000000000..7fc8acdd7 --- /dev/null +++ b/internal/photoprism/mediafile_vision.go @@ -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 +} diff --git a/internal/photoprism/mediafile_vision_test.go b/internal/photoprism/mediafile_vision_test.go new file mode 100644 index 000000000..ffe23be9a --- /dev/null +++ b/internal/photoprism/mediafile_vision_test.go @@ -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()) + }) +} diff --git a/internal/workers/meta.go b/internal/workers/meta.go index f89c3b0d2..fef06dd55 100644 --- a/internal/workers/meta.go +++ b/internal/workers/meta.go @@ -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 + } } } diff --git a/internal/workers/vision.go b/internal/workers/vision.go index 2ce53824c..16d0fe8d8 100644 --- a/internal/workers/vision.go +++ b/internal/workers/vision.go @@ -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) } }