AI: Include NSFW flag & score when generating labels with Ollama #5232

Related issues: #5233 (reset command), #5234 (schedule for models)

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-05 04:23:36 +02:00
parent 79654170eb
commit e5dc335bcf
70 changed files with 2138 additions and 1082 deletions

View File

@@ -35,6 +35,7 @@ Note on specs repository availability
- Backend: Go (`internal/`, `pkg/`, `cmd/`) + MariaDB/SQLite
- Package boundaries: Code in `pkg/*` MUST NOT import from `internal/*`.
- If you need access to config/entity/DB, put new code in a package under `internal/` instead of `pkg/`.
- GORM field naming: When adding struct fields that include uppercase abbreviations (e.g., `LabelNSFW`), set an explicit `gorm:"column:<name>"` tag so column names stay consistent (`label_nsfw` instead of `label_n_s_f_w`).
- Frontend: Vue 3 + Vuetify 3 (`frontend/`)
- Docker/compose for dev/CI; Traefik is used for local TLS (`*.localssl.dev`)

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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},

View File

@@ -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
}

View File

@@ -0,0 +1,76 @@
package vision
import (
"bytes"
"strings"
"testing"
"github.com/sirupsen/logrus"
)
func TestApiRequestWriteLogRedactsBase64(t *testing.T) {
logger, ok := log.(*logrus.Logger)
if !ok {
t.Fatalf("unexpected logger type %T", log)
}
originalLevel := logger.GetLevel()
originalOutput := logger.Out
buffer := &bytes.Buffer{}
logger.SetLevel(logrus.TraceLevel)
logger.SetOutput(buffer)
defer func() {
logger.SetOutput(originalOutput)
logger.SetLevel(originalLevel)
}()
req := &ApiRequest{
Url: "data:image/jpeg;base64," + strings.Repeat("C", 40),
Images: Files{
"data:image/png;base64," + strings.Repeat("A", 40),
strings.Repeat("B", 48),
"https://example.test/image.jpg",
},
}
req.WriteLog()
output := buffer.String()
if output == "" {
t.Fatalf("expected trace log output")
}
if strings.Contains(output, strings.Repeat("A", 24)) {
t.Errorf("log contains unredacted data URL image payload: %s", output)
}
if strings.Contains(output, strings.Repeat("B", 24)) {
t.Errorf("log contains unredacted base64 image payload: %s", output)
}
if strings.Contains(output, strings.Repeat("C", 24)) {
t.Errorf("log contains unredacted data URL in url field: %s", output)
}
imagePreview := "data:image/png;base64," + strings.Repeat("A", logDataPreviewLength) + logDataTruncatedSuffix
if !strings.Contains(output, imagePreview) {
t.Errorf("missing truncated image data preview, got: %s", output)
}
base64Preview := strings.Repeat("B", logDataPreviewLength) + logDataTruncatedSuffix
if !strings.Contains(output, base64Preview) {
t.Errorf("missing truncated base64 preview, got: %s", output)
}
urlPreview := "data:image/jpeg;base64," + strings.Repeat("C", logDataPreviewLength) + logDataTruncatedSuffix
if !strings.Contains(output, urlPreview) {
t.Errorf("missing truncated url preview, got: %s", output)
}
if !strings.Contains(output, "https://example.test/image.jpg") {
t.Errorf("expected https url to remain unchanged: %s", output)
}
}

View File

@@ -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{

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -0,0 +1,9 @@
package vision
import (
"fmt"
)
var (
ErrInvalidModel = fmt.Errorf("vision: invalid model")
)

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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
}
}
}

View File

@@ -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)
})
}

View File

@@ -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"))
}
}
})

View File

@@ -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%
}
)

View File

@@ -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")

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -76,3 +76,4 @@ Models:
ResponseFormat: ollama
Thresholds:
Confidence: 10
NSFW: 75

View File

@@ -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
}

View File

@@ -0,0 +1,72 @@
package vision
import "testing"
func TestThresholds_GetConfidence(t *testing.T) {
t.Run("Negative", func(t *testing.T) {
th := Thresholds{Confidence: -5}
if got := th.GetConfidence(); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
})
t.Run("AboveMax", func(t *testing.T) {
th := Thresholds{Confidence: 150}
if got := th.GetConfidence(); got != 1 {
t.Fatalf("expected 1, got %d", got)
}
})
t.Run("Float", func(t *testing.T) {
th := Thresholds{Confidence: 25}
if got := th.GetConfidenceFloat32(); got != 0.25 {
t.Fatalf("expected 0.25, got %f", got)
}
})
}
func TestThresholds_GetTopicality(t *testing.T) {
t.Run("Negative", func(t *testing.T) {
th := Thresholds{Topicality: -10}
if got := th.GetTopicality(); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
})
t.Run("AboveMax", func(t *testing.T) {
th := Thresholds{Topicality: 300}
if got := th.GetTopicality(); got != 1 {
t.Fatalf("expected 1, got %d", got)
}
})
t.Run("Float", func(t *testing.T) {
th := Thresholds{Topicality: 45}
if got := th.GetTopicalityFloat32(); got != 0.45 {
t.Fatalf("expected 0.45, got %f", got)
}
})
}
func TestThresholds_GetNSFW(t *testing.T) {
t.Run("Default", func(t *testing.T) {
th := Thresholds{NSFW: 0}
if got := th.GetNSFW(); got != DefaultThresholds.NSFW {
t.Fatalf("expected default %d, got %d", DefaultThresholds.NSFW, got)
}
})
t.Run("AboveMax", func(t *testing.T) {
th := Thresholds{NSFW: 200}
if got := th.GetNSFW(); got != 1 {
t.Fatalf("expected 1, got %d", got)
}
})
t.Run("Float", func(t *testing.T) {
th := Thresholds{NSFW: 80}
if got := th.GetNSFWFloat32(); got != 0.8 {
t.Fatalf("expected 0.8, got %f", got)
}
})
}

View File

@@ -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)

View File

@@ -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"
},

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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:"-"`

View File

@@ -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;"},
},
}

View File

@@ -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;"},
},
}

View File

@@ -0,0 +1,3 @@
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;

View File

@@ -0,0 +1,3 @@
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;

View File

@@ -310,23 +310,29 @@ func (m *Photo) SetMediaType(newType media.Type, typeSrc string) {
return
}
// String returns the id or name as string.
// PhotoLogString returns a sanitized identifier for logging that prefers
// photo name, falling back to original name, UID, or numeric ID.
func PhotoLogString(photoPath, photoName, originalName, photoUID string, id uint) string {
if photoName != "" {
return clean.Log(path.Join(photoPath, photoName))
} else if originalName != "" {
return clean.Log(originalName)
} else if photoUID != "" {
return "uid " + clean.Log(photoUID)
} else if id > 0 {
return fmt.Sprintf("id %d", id)
}
return "*Photo"
}
// String returns the id or name as string for logging purposes.
func (m *Photo) String() string {
if m == nil {
return "Photo<nil>"
}
if m.PhotoName != "" {
return clean.Log(path.Join(m.PhotoPath, m.PhotoName))
} else if m.OriginalName != "" {
return clean.Log(m.OriginalName)
} else if m.PhotoUID != "" {
return "uid " + clean.Log(m.PhotoUID)
} else if m.ID > 0 {
return fmt.Sprintf("id %d", m.ID)
}
return "*Photo"
return PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
}
// FirstOrCreate inserts the Photo if it does not exist and otherwise reloads the persisted row with its associations.
@@ -784,7 +790,6 @@ func (m *Photo) ShouldGenerateLabels(force bool) bool {
// never receives invalid input from upstream detectors.
func (m *Photo) AddLabels(labels classify.Labels) {
for _, classifyLabel := range labels {
title := classifyLabel.Title()
if title == "" || txt.Slug(title) == "" {
@@ -823,6 +828,21 @@ func (m *Photo) AddLabels(labels classify.Labels) {
template := NewPhotoLabel(m.ID, labelEntity.ID, classifyLabel.Uncertainty, labelSrc)
template.Topicality = classifyLabel.Topicality
score := 0
if classifyLabel.NSFWConfidence > 0 {
score = classifyLabel.NSFWConfidence
}
if classifyLabel.NSFW && score == 0 {
score = 100
}
if score > 100 {
score = 100
}
template.NSFW = score
photoLabel := FirstOrCreatePhotoLabel(template)
if photoLabel == nil {
@@ -832,13 +852,32 @@ func (m *Photo) AddLabels(labels classify.Labels) {
if photoLabel.HasID() {
updates := Values{}
if photoLabel.Uncertainty > classifyLabel.Uncertainty && photoLabel.Uncertainty < 100 {
updates["Uncertainty"] = classifyLabel.Uncertainty
updates["LabelSrc"] = labelSrc
}
if classifyLabel.Topicality > 0 && photoLabel.Topicality != classifyLabel.Topicality {
updates["Topicality"] = classifyLabel.Topicality
}
if classifyLabel.NSFWConfidence > 0 || classifyLabel.NSFW {
nsfwScore := 0
if classifyLabel.NSFWConfidence > 0 {
nsfwScore = classifyLabel.NSFWConfidence
}
if classifyLabel.NSFW && nsfwScore == 0 {
nsfwScore = 100
}
if nsfwScore > 100 {
nsfwScore = 100
}
if photoLabel.NSFW != nsfwScore {
updates["NSFW"] = nsfwScore
}
}
if len(updates) > 0 {
if err := photoLabel.Updates(updates); err != nil {
log.Errorf("index: %s", err)

View File

@@ -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

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
)
@@ -597,37 +598,85 @@ func TestPhoto_Delete(t *testing.T) {
func TestPhotos_UIDs(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo1 := &Photo{PhotoUID: "abc123"}
photo2 := &Photo{PhotoUID: "abc456"}
uid1 := rnd.GenerateUID(PhotoUID)
uid2 := rnd.GenerateUID(PhotoUID)
photo1 := &Photo{PhotoUID: uid1}
photo2 := &Photo{PhotoUID: uid2}
photos := Photos{photo1, photo2}
assert.Equal(t, []string{"abc123", "abc456"}, photos.UIDs())
assert.Equal(t, []string{uid1, uid2}, photos.UIDs())
})
}
func TestPhoto_String(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var m *Photo
assert.Equal(t, "Photo<nil>", m.String())
assert.Equal(t, "Photo<nil>", fmt.Sprintf("%s", m))
})
t.Run("New", func(t *testing.T) {
m := &Photo{PhotoUID: "", PhotoName: "", OriginalName: ""}
assert.Equal(t, "*Photo", m.String())
assert.Equal(t, "*Photo", fmt.Sprintf("%s", m))
})
t.Run("Original", func(t *testing.T) {
m := Photo{PhotoUID: "", PhotoName: "", OriginalName: "holidayOriginal"}
assert.Equal(t, "holidayOriginal", m.String())
})
t.Run("UID", func(t *testing.T) {
m := Photo{PhotoUID: "ps6sg6be2lvl0k53", PhotoName: "", OriginalName: ""}
assert.Equal(t, "uid ps6sg6be2lvl0k53", m.String())
})
generatedUID := rnd.GenerateUID(PhotoUID)
testcases := []struct {
name string
photo *Photo
want string
checkFmt bool
}{
{
name: "Nil",
photo: nil,
want: "Photo<nil>",
checkFmt: true,
},
{
name: "PhotoNameWithPath",
photo: &Photo{PhotoPath: "albums/test", PhotoName: "my photo.jpg"},
want: "'albums/test/my photo.jpg'",
checkFmt: true,
},
{
name: "PhotoNameOnly",
photo: &Photo{PhotoName: "photo.jpg"},
want: "photo.jpg",
},
{
name: "OriginalName",
photo: &Photo{OriginalName: "orig name.dng"},
want: "'orig name.dng'",
},
{
name: "UID",
photo: &Photo{PhotoUID: generatedUID},
want: fmt.Sprintf("uid %s", generatedUID),
},
{
name: "ID",
photo: &Photo{ID: 42},
want: "id 42",
},
{
name: "Fallback",
photo: &Photo{},
want: "*Photo",
checkFmt: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
if tc.photo == nil {
var p *Photo
assert.Equal(t, tc.want, p.String())
if tc.checkFmt {
assert.Equal(t, tc.want, fmt.Sprintf("%s", p))
}
return
}
assert.Equal(t, tc.want, tc.photo.String())
if tc.checkFmt {
assert.Equal(t, tc.want, fmt.Sprintf("%s", tc.photo))
}
})
}
}
func TestPhoto_Create(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Create()
if err != nil {
t.Fatal(err)
@@ -637,7 +686,7 @@ func TestPhoto_Create(t *testing.T) {
func TestPhoto_Save(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Save()
if err != nil {
t.Fatal(err)
@@ -893,7 +942,7 @@ func TestPhoto_UpdateKeywordLabels(t *testing.T) {
func TestPhoto_LocationLoaded(t *testing.T) {
t.Run("Photo", func(t *testing.T) {
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
assert.False(t, photo.LocationLoaded())
})
t.Run("PhotoWithCell", func(t *testing.T) {
@@ -924,7 +973,7 @@ func TestPhoto_LoadLocation(t *testing.T) {
func TestPhoto_PlaceLoaded(t *testing.T) {
t.Run("False", func(t *testing.T) {
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
assert.False(t, photo.PlaceLoaded())
})
}
@@ -1129,7 +1178,7 @@ func TestPhoto_SetPrimary(t *testing.T) {
assert.Error(t, err)
})
t.Run("NoPreviewImage", func(t *testing.T) {
m := Photo{PhotoUID: "1245678"}
m := Photo{PhotoUID: rnd.GenerateUID(PhotoUID)}
err := m.SetPrimary("")
assert.Error(t, err)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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))

View File

@@ -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.

View File

@@ -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,

View File

@@ -15,7 +15,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// Photo represents a photo search result.
// Photo represents a photo search result row joined with its primary file and
// related metadata that we surface in the UI and API responses.
type Photo struct {
ID uint `json:"-" select:"photos.id"`
CompositeID string `json:"ID" select:"files.photo_id AS composite_id"`
@@ -132,7 +133,17 @@ func (m *Photo) GetUID() string {
return m.PhotoUID
}
// Approve approves the photo if it is in review.
// String returns the id or name as string for logging purposes.
func (m *Photo) String() string {
if m == nil {
return "Photo<nil>"
}
return entity.PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
}
// Approve promotes the photo to quality level 3 and clears review flags if it
// currently sits in review state.
func (m *Photo) Approve() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
@@ -172,7 +183,7 @@ func (m *Photo) Approve() error {
return nil
}
// Restore removes the photo from the archive (reverses soft delete).
// Restore removes the photo from the archive by clearing the soft-delete flag.
func (m *Photo) Restore() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
@@ -202,7 +213,8 @@ func (m *Photo) IsPlayable() bool {
}
}
// MediaInfo returns the media file hash and codec depending on the media type.
// MediaInfo returns the best available media hash, codec, mime type, and
// dimensions for the photo based on its media type and merged files.
func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, height int) {
switch m.PhotoType {
case entity.MediaVideo, entity.MediaLive:
@@ -247,7 +259,8 @@ func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, hei
return m.FileHash, "", m.FileMime, m.FileWidth, m.FileHeight
}
// ShareBase returns a meaningful file name for sharing.
// ShareBase returns a deterministic, human friendly file name stem for sharing
// downloads generated from the photo's timestamp and title.
func (m *Photo) ShareBase(seq int) string {
var name string
@@ -266,9 +279,12 @@ func (m *Photo) ShareBase(seq int) string {
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
}
// PhotoResults represents a list of photo search results that can be post
// processed (for example merged by file).
type PhotoResults []Photo
// Photos returns the result as a slice of Photo.
// Photos returns the results as a slice of the generic PhotoInterface type so
// callers can interact with shared entity helpers.
func (m PhotoResults) Photos() []entity.PhotoInterface {
result := make([]entity.PhotoInterface, len(m))
@@ -279,7 +295,7 @@ func (m PhotoResults) Photos() []entity.PhotoInterface {
return result
}
// UIDs returns a slice of photo UIDs.
// UIDs returns the photo UIDs for all results in order.
func (m PhotoResults) UIDs() []string {
result := make([]string, len(m))
@@ -290,7 +306,8 @@ func (m PhotoResults) UIDs() []string {
return result
}
// Merge consecutive file results that belong to the same photo.
// Merge collapses consecutive rows that reference the same photo into a single
// item with an aggregated Files slice.
func (m PhotoResults) Merge() (merged PhotoResults, count int, err error) {
count = len(m)
merged = make(PhotoResults, 0, count)

View File

@@ -1,6 +1,7 @@
package search
import (
"fmt"
"testing"
"time"
@@ -9,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -28,6 +30,65 @@ func TestPhoto_Ids(t *testing.T) {
assert.Equal(t, "ps6sg6be2lvl0o98", r.GetUID())
}
func TestPhoto_String(t *testing.T) {
testcases := []struct {
name string
photo *Photo
want string
}{
{
name: "Nil",
photo: nil,
want: "Photo<nil>",
},
{
name: "PhotoName",
photo: &Photo{
PhotoPath: "albums/test",
PhotoName: "my photo.jpg",
},
want: "'albums/test/my photo.jpg'",
},
{
name: "OriginalName",
photo: &Photo{
OriginalName: "orig name.dng",
},
want: "'orig name.dng'",
},
{
name: "UID",
photo: &Photo{
PhotoUID: "ps123",
},
want: "uid ps123",
},
{
name: "ID",
photo: &Photo{
ID: 42,
},
want: "id 42",
},
{
name: "Fallback",
photo: &Photo{},
want: "*Photo",
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
if tc.photo == nil {
var p *Photo
assert.Equal(t, tc.want, p.String())
} else {
assert.Equal(t, tc.want, tc.photo.String())
}
})
}
}
func TestPhoto_Approve(t *testing.T) {
t.Run("EmptyPhoto", func(t *testing.T) {
r := Photo{}
@@ -395,137 +456,38 @@ func TestPhotoResults_Photos(t *testing.T) {
}
func TestPhotosResults_Merged(t *testing.T) {
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo1",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
}
fileUIDA := rnd.GenerateUID(entity.FileUID)
fileUIDB := rnd.GenerateUID(entity.FileUID)
fileUIDC := rnd.GenerateUID(entity.FileUID)
result2 := Photo{
ID: 22222,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo2",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
results := PhotoResults{
{ID: 1, FileID: 10, FileUID: fileUIDA, FileName: "a.jpg"},
{ID: 1, FileID: 11, FileUID: fileUIDB, FileName: "b.jpg"},
{ID: 2, FileID: 20, FileUID: fileUIDC, FileName: "c.jpg"},
}
results := PhotoResults{result1, result2}
merged, count, err := results.Merge()
assert.NoError(t, err)
assert.Equal(t, 3, count)
assert.Len(t, merged, 2)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 2, count)
t.Log(merged)
first := merged[0]
assert.Equal(t, "1-10", first.CompositeID)
assert.True(t, first.Merged)
assert.Len(t, first.Files, 2)
assert.Equal(t, uint(10), first.Files[0].ID)
assert.Equal(t, uint(11), first.Files[1].ID)
second := merged[1]
assert.Equal(t, "2-20", second.CompositeID)
assert.False(t, second.Merged)
assert.Len(t, second.Files, 1)
assert.Equal(t, uint(20), second.Files[0].ID)
}
func TestPhotosResults_UIDs(t *testing.T) {
uid1 := rnd.GenerateUID(entity.PhotoUID)
uid2 := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@@ -535,7 +497,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "123",
PhotoUID: uid1,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo1",
@@ -595,7 +557,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "456",
PhotoUID: uid2,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo2",
@@ -649,11 +611,12 @@ func TestPhotosResults_UIDs(t *testing.T) {
results := PhotoResults{result1, result2}
result := results.UIDs()
assert.Equal(t, []string{"123", "456"}, result)
assert.Equal(t, []string{uid1, uid2}, result)
}
func TestPhotosResult_ShareFileName(t *testing.T) {
t.Run("WithTitle", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@@ -663,7 +626,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "PhotoTitle123",
@@ -718,6 +681,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
assert.Contains(t, r, "20131111-090718-Phototitle123")
})
t.Run("NoTitle", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@@ -727,7 +691,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "",
@@ -779,9 +743,10 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
}
r := result1.ShareBase(0)
assert.Contains(t, r, "20151111-090718-uid123")
assert.Contains(t, r, fmt.Sprintf("20151111-090718-%s", uid))
})
t.Run("SeqGreater0", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@@ -791,7 +756,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2022, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "PhotoTitle123",

View File

@@ -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))

View File

@@ -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)
}
}

View File

@@ -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:"-"`

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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)
}

View File

@@ -0,0 +1,237 @@
package photoprism
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/pkg/fs"
)
func writeFile(t *testing.T, p string, data []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), fs.ModeDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, data, fs.ModeFile); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, p string) []byte {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return b
}
func TestMediaFileCopy(t *testing.T) {
testCases := []struct {
name string
force bool
expectErr bool
setup func(t *testing.T, dir string) (src, dst string)
before func(t *testing.T, mf *MediaFile)
destFn func(src, dst string) string
assertFn func(t *testing.T, src, dst string)
}{
{
name: "existing destination without force",
force: false,
expectErr: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("ABC"))
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
return src, dst
},
assertFn: func(t *testing.T, _, dst string) {
assert.Equal(t, "LONGER_DEST_CONTENT", string(readFile(t, dst)))
},
},
{
name: "existing empty destination without force",
force: false,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("ABC"))
writeFile(t, dst, []byte{})
return src, dst
},
assertFn: func(t *testing.T, _, dst string) {
assert.Equal(t, "ABC", string(readFile(t, dst)))
},
},
{
name: "force overwrites destination and propagates mtime",
force: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("ABC"))
writeFile(t, dst, []byte("LONGER_DEST_CONTENT"))
return src, dst
},
before: func(t *testing.T, mf *MediaFile) {
reference := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
mf.SetModTime(reference)
},
assertFn: func(t *testing.T, _, dst string) {
assert.Equal(t, "ABC", string(readFile(t, dst)))
st, err := os.Stat(dst)
require.NoError(t, err)
expected := time.Date(2020, 5, 4, 3, 2, 1, 0, time.UTC)
assert.Equal(t, expected, st.ModTime().UTC().Truncate(time.Second))
},
},
{
name: "same path returns error",
force: true,
expectErr: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "file.bin")
writeFile(t, src, []byte("DATA"))
return src, src
},
},
{
name: "invalid destination path",
force: true,
expectErr: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "file.bin")
writeFile(t, src, []byte("DATA"))
return src, filepath.Join(dir, "unused")
},
destFn: func(string, string) string { return "." },
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src, dst := tc.setup(t, dir)
mf, err := NewMediaFile(src)
require.NoError(t, err)
if tc.before != nil {
tc.before(t, mf)
}
target := dst
if tc.destFn != nil {
target = tc.destFn(src, dst)
}
err = mf.Copy(target, tc.force)
if tc.expectErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
if tc.assertFn != nil {
tc.assertFn(t, src, target)
}
})
}
}
func TestMediaFileMove(t *testing.T) {
testCases := []struct {
name string
force bool
expectErr bool
setup func(t *testing.T, dir string) (src, dst string)
assertFn func(t *testing.T, src, dst string, mf *MediaFile)
}{
{
name: "existing destination without force",
force: false,
expectErr: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("AAA"))
writeFile(t, dst, []byte("BBB"))
return src, dst
},
assertFn: func(t *testing.T, src, dst string, _ *MediaFile) {
assert.FileExists(t, src)
assert.Equal(t, "BBB", string(readFile(t, dst)))
},
},
{
name: "existing empty destination without force",
force: false,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("AAA"))
writeFile(t, dst, []byte{})
return src, dst
},
assertFn: func(t *testing.T, src, dst string, mf *MediaFile) {
_, srcErr := os.Stat(src)
assert.True(t, os.IsNotExist(srcErr))
assert.Equal(t, "AAA", string(readFile(t, dst)))
assert.Equal(t, dst, mf.FileName())
},
},
{
name: "force overwrites destination",
force: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
writeFile(t, src, []byte("AAA"))
writeFile(t, dst, []byte("BBB"))
return src, dst
},
assertFn: func(t *testing.T, src, dst string, mf *MediaFile) {
_, srcErr := os.Stat(src)
assert.True(t, os.IsNotExist(srcErr))
assert.Equal(t, "AAA", string(readFile(t, dst)))
assert.Equal(t, dst, mf.FileName())
},
},
{
name: "same path returns error",
force: true,
expectErr: true,
setup: func(t *testing.T, dir string) (string, string) {
src := filepath.Join(dir, "file.bin")
writeFile(t, src, []byte("DATA"))
return src, src
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src, dst := tc.setup(t, dir)
mf, err := NewMediaFile(src)
require.NoError(t, err)
err = mf.Move(dst, tc.force)
if tc.expectErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
if tc.assertFn != nil {
tc.assertFn(t, src, dst, mf)
}
})
}
}

View File

@@ -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.

View File

@@ -17,47 +17,64 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
func TestMediaFile_Ok(t *testing.T) {
func TestMediaFileOk(t *testing.T) {
c := config.TestConfig()
exists, err := NewMediaFile(c.ExamplesPath() + "/cat_black.jpg")
if err != nil {
t.Fatal(err)
cases := []struct {
name string
path string
expectErr bool
wantOk bool
}{
{name: "existing file", path: c.ExamplesPath() + "/cat_black.jpg", wantOk: true},
{name: "missing file", path: c.ExamplesPath() + "/xxz.jpg", expectErr: true, wantOk: false},
}
assert.True(t, exists.Ok())
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mf, err := NewMediaFile(tc.path)
if tc.expectErr {
assert.Error(t, err)
assert.NotNil(t, mf)
} else {
assert.NoError(t, err)
}
missing, err := NewMediaFile(c.ExamplesPath() + "/xxz.jpg")
assert.NotNil(t, missing)
assert.Error(t, err)
assert.False(t, missing.Ok())
assert.Equal(t, tc.wantOk, mf.Ok())
})
}
}
func TestMediaFile_Empty(t *testing.T) {
func TestMediaFileEmpty(t *testing.T) {
c := config.TestConfig()
exists, err := NewMediaFile(c.ExamplesPath() + "/cat_black.jpg")
if err != nil {
t.Fatal(err)
cases := []struct {
name string
path string
expectErr bool
wantEmpty bool
}{
{name: "existing file", path: c.ExamplesPath() + "/cat_black.jpg", wantEmpty: false},
{name: "missing file", path: c.ExamplesPath() + "/xxz.jpg", expectErr: true, wantEmpty: true},
}
assert.False(t, exists.Empty())
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mf, err := NewMediaFile(tc.path)
if tc.expectErr {
assert.Error(t, err)
assert.NotNil(t, mf)
} else {
assert.NoError(t, err)
}
missing, err := NewMediaFile(c.ExamplesPath() + "/xxz.jpg")
assert.NotNil(t, missing)
assert.Error(t, err)
assert.True(t, missing.Empty())
assert.Equal(t, tc.wantEmpty, mf.Empty())
})
}
}
func TestMediaFile_DateCreated(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
t.Run("TelegramNum2020Num01Num30Num09Num57EighteenJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
if err != nil {
t.Fatal(err)
}
@@ -65,7 +82,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2020-01-30 09:57:18 +0000 UTC", date.String())
})
t.Run("ScreenshotNum2019Num05Num21AtTenNum45Num52Png", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
if err != nil {
t.Fatal(err)
}
@@ -73,7 +90,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2019-05-21 10:45:52 +0000 UTC", date.String())
})
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
@@ -81,7 +98,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2018-09-10 03:16:13 +0000 UTC", date.String())
})
t.Run("IphoneFifteenProHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_15_pro.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_15_pro.heic")
if err != nil {
t.Fatal(err)
}
@@ -90,7 +107,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2023-10-31 10:44:43 +0000 UTC", mediaFile.DateCreated().String())
})
t.Run("CanonEosSixDDng", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
@@ -98,7 +115,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", date.String())
})
t.Run("ElephantsJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
@@ -106,7 +123,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", date.String())
})
t.Run("DogCreatedNum1919Jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/dog_created_1919.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/dog_created_1919.jpg")
if err != nil {
t.Fatal(err)
}
@@ -116,7 +133,7 @@ func TestMediaFile_DateCreated(t *testing.T) {
}
func TestMediaFile_TakenAt(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
t.Run("TestdataNum2018Num04TwelveNineteenNum24Num49Gif", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2018-04-12 19_24_49.gif")
if err != nil {
@@ -140,7 +157,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcName, src)
})
t.Run("TelegramNum2020Num01Num30Num09Num57EighteenJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg")
if err != nil {
t.Fatal(err)
}
@@ -151,7 +168,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcName, src)
})
t.Run("ScreenshotNum2019Num05Num21AtTenNum45Num52Png", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/Screenshot 2019-05-21 at 10.45.52.png")
if err != nil {
t.Fatal(err)
}
@@ -162,7 +179,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcName, src)
})
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
@@ -173,7 +190,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcMeta, src)
})
t.Run("CanonEosSixDDng", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
@@ -184,7 +201,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcMeta, src)
})
t.Run("ElephantsJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
@@ -195,7 +212,7 @@ func TestMediaFile_TakenAt(t *testing.T) {
assert.Equal(t, entity.SrcMeta, src)
})
t.Run("DogCreatedNum1919Jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/dog_created_1919.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/dog_created_1919.jpg")
if err != nil {
t.Fatal(err)
}
@@ -208,39 +225,36 @@ func TestMediaFile_TakenAt(t *testing.T) {
}
func TestMediaFile_HasTimeAndPlace(t *testing.T) {
c := config.TestConfig()
t.Run("BeachWoodJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil {
t.Fatal(err)
}
assert.True(t, mediaFile.HasTimeAndPlace())
})
t.Run("PeacockBlueJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/peacock_blue.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/peacock_blue.jpg")
if err != nil {
t.Fatal(err)
}
assert.False(t, mediaFile.HasTimeAndPlace())
})
}
func TestMediaFile_CameraModel(t *testing.T) {
t.Run("BeachWoodJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
func TestMediaFile_CameraModel(t *testing.T) {
c := config.TestConfig()
t.Run("BeachWoodJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "iPhone SE", mediaFile.CameraModel())
})
t.Run("IphoneSevenHeic", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
@@ -249,19 +263,17 @@ func TestMediaFile_CameraModel(t *testing.T) {
}
func TestMediaFile_CameraMake(t *testing.T) {
c := config.TestConfig()
t.Run("BeachWoodJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Apple", mediaFile.CameraMake())
})
t.Run("PeacockBlueJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/peacock_blue.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/peacock_blue.jpg")
if err != nil {
t.Fatal(err)
}
@@ -270,19 +282,17 @@ func TestMediaFile_CameraMake(t *testing.T) {
}
func TestMediaFile_LensModel(t *testing.T) {
c := config.TestConfig()
t.Run("BeachWoodJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", mediaFile.LensModel())
})
t.Run("CanonEosSixDDng", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
@@ -292,19 +302,16 @@ func TestMediaFile_LensModel(t *testing.T) {
}
func TestMediaFile_LensMake(t *testing.T) {
c := config.TestConfig()
t.Run("CatBrownJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Apple", mediaFile.LensMake())
})
t.Run("ElephantsJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
}
@@ -314,7 +321,6 @@ func TestMediaFile_LensMake(t *testing.T) {
func TestMediaFile_FocalLength(t *testing.T) {
c := config.TestConfig()
t.Run("CatBrownJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil {
@@ -333,7 +339,6 @@ func TestMediaFile_FocalLength(t *testing.T) {
func TestMediaFile_FNumber(t *testing.T) {
c := config.TestConfig()
t.Run("CatBrownJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil {
@@ -352,7 +357,6 @@ func TestMediaFile_FNumber(t *testing.T) {
func TestMediaFile_Iso(t *testing.T) {
c := config.TestConfig()
t.Run("CatBrownJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil {
@@ -371,7 +375,6 @@ func TestMediaFile_Iso(t *testing.T) {
func TestMediaFile_Exposure(t *testing.T) {
c := config.TestConfig()
t.Run("CatBrownJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
@@ -409,7 +412,6 @@ func TestMediaFileCanonicalName(t *testing.T) {
func TestMediaFileCanonicalNameFromFile(t *testing.T) {
c := config.TestConfig()
t.Run("BeachWoodJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
@@ -442,7 +444,6 @@ func TestMediaFile_CanonicalNameFromFileWithDirectory(t *testing.T) {
func TestMediaFile_EditedFilename(t *testing.T) {
c := config.TestConfig()
t.Run("ImgNum4120Jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.JPG")
if err != nil {
@@ -473,9 +474,9 @@ func TestMediaFile_SetFilename(t *testing.T) {
}
func TestMediaFile_RootRelName(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
if err != nil {
t.Fatal(err)
@@ -488,24 +489,25 @@ func TestMediaFile_RootRelName(t *testing.T) {
}
func TestMediaFile_RootRelPath(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
mediaFile.fileRoot = entity.RootImport
if err != nil {
t.Fatal(err)
}
t.Run("ExamplesPath", func(t *testing.T) {
path := mediaFile.RootRelPath()
assert.Equal(t, conf.ExamplesPath(), path)
assert.Equal(t, c.ExamplesPath(), path)
})
}
func TestMediaFile_RootPath(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
if err != nil {
t.Fatal(err)
@@ -519,117 +521,114 @@ func TestMediaFile_RootPath(t *testing.T) {
}
func TestMediaFile_RelName(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
if err != nil {
t.Fatal(err)
}
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
filename := mediaFile.RelName(conf.AssetsPath())
filename := mediaFile.RelName(c.AssetsPath())
assert.Equal(t, "examples/tree_white.jpg", filename)
})
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
filename := mediaFile.RelName(conf.AssetsPath())
filename := mediaFile.RelName(c.AssetsPath())
assert.Equal(t, "examples/tree_white.jpg", filename)
})
t.Run("DirectoryNotPartOfFilename", func(t *testing.T) {
filename := mediaFile.RelName("xxx/")
assert.Equal(t, conf.ExamplesPath()+"/tree_white.jpg", filename)
assert.Equal(t, c.ExamplesPath()+"/tree_white.jpg", filename)
})
t.Run("DirectoryEqualsExamplePath", func(t *testing.T) {
filename := mediaFile.RelName(conf.ExamplesPath())
filename := mediaFile.RelName(c.ExamplesPath())
assert.Equal(t, "tree_white.jpg", filename)
})
}
func TestMediaFile_RelativePath(t *testing.T) {
c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
if err != nil {
t.Fatal(err)
}
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
path := mediaFile.RelPath(conf.AssetsPath() + "/")
path := mediaFile.RelPath(c.AssetsPath() + "/")
assert.Equal(t, "examples", path)
})
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
path := mediaFile.RelPath(conf.AssetsPath())
path := mediaFile.RelPath(c.AssetsPath())
assert.Equal(t, "examples", path)
})
t.Run("DirectoryEqualsFilepath", func(t *testing.T) {
path := mediaFile.RelPath(conf.ExamplesPath())
path := mediaFile.RelPath(c.ExamplesPath())
assert.Equal(t, "", path)
})
t.Run("DirectoryDoesNotMatchFilepath", func(t *testing.T) {
path := mediaFile.RelPath("xxx")
assert.Equal(t, conf.ExamplesPath(), path)
assert.Equal(t, c.ExamplesPath(), path)
})
mediaFile, err = NewMediaFile(conf.ExamplesPath() + "/.photoprism/example.jpg")
mediaFile, err = NewMediaFile(c.ExamplesPath() + "/.photoprism/example.jpg")
if err != nil {
t.Fatal(err)
}
t.Run("Hidden", func(t *testing.T) {
path := mediaFile.RelPath(conf.ExamplesPath())
path := mediaFile.RelPath(c.ExamplesPath())
assert.Equal(t, "", path)
})
t.Run("HiddenEmpty", func(t *testing.T) {
path := mediaFile.RelPath("")
assert.Equal(t, conf.ExamplesPath(), path)
assert.Equal(t, c.ExamplesPath(), path)
})
t.Run("HiddenRoot", func(t *testing.T) {
path := mediaFile.RelPath(filepath.Join(conf.ExamplesPath(), fs.PPHiddenPathname))
path := mediaFile.RelPath(filepath.Join(c.ExamplesPath(), fs.PPHiddenPathname))
assert.Equal(t, "", path)
})
}
func TestMediaFile_RelativeBasename(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/tree_white.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/tree_white.jpg")
if err != nil {
t.Fatal(err)
}
t.Run("DirectoryWithEndSlash", func(t *testing.T) {
basename := mediaFile.RelPrefix(conf.AssetsPath()+"/", true)
basename := mediaFile.RelPrefix(c.AssetsPath()+"/", true)
assert.Equal(t, "examples/tree_white", basename)
})
t.Run("DirectoryWithoutEndSlash", func(t *testing.T) {
basename := mediaFile.RelPrefix(conf.AssetsPath(), true)
basename := mediaFile.RelPrefix(c.AssetsPath(), true)
assert.Equal(t, "examples/tree_white", basename)
})
t.Run("DirectoryEqualsExamplePath", func(t *testing.T) {
basename := mediaFile.RelPrefix(conf.ExamplesPath(), true)
basename := mediaFile.RelPrefix(c.ExamplesPath(), true)
assert.Equal(t, "tree_white", basename)
})
}
func TestMediaFile_Directory(t *testing.T) {
c := config.TestConfig()
t.Run("LimesJpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/limes.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, conf.ExamplesPath(), mediaFile.Dir())
assert.Equal(t, c.ExamplesPath(), mediaFile.Dir())
})
}
func TestMediaFile_AbsPrefix(t *testing.T) {
c := config.TestConfig()
t.Run("LimesJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
if err != nil {
@@ -661,7 +660,6 @@ func TestMediaFile_AbsPrefix(t *testing.T) {
func TestMediaFile_BasePrefix(t *testing.T) {
c := config.TestConfig()
t.Run("LimesJpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
if err != nil {
@@ -687,7 +685,6 @@ func TestMediaFile_BasePrefix(t *testing.T) {
func TestMediaFile_MimeType(t *testing.T) {
c := config.TestConfig()
t.Run("ElephantsJpg", func(t *testing.T) {
f, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
@@ -875,7 +872,6 @@ func TestMediaFile_SetModTime(t *testing.T) {
func TestMediaFile_Move(t *testing.T) {
c := config.TestConfig()
t.Run("Success", func(t *testing.T) {
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Move"
origName := tmpPath + "/original.jpg"
@@ -915,7 +911,6 @@ func TestMediaFile_Move(t *testing.T) {
func TestMediaFile_Copy(t *testing.T) {
c := config.TestConfig()
t.Run("Success", func(t *testing.T) {
tmpPath := c.CachePath() + "/_tmp/TestMediaFile_Copy"
@@ -941,7 +936,6 @@ func TestMediaFile_Copy(t *testing.T) {
func TestMediaFile_Extension(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -974,7 +968,6 @@ func TestMediaFile_Extension(t *testing.T) {
func TestMediaFile_IsJpeg(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1007,7 +1000,6 @@ func TestMediaFile_IsJpeg(t *testing.T) {
func TestMediaFile_HasType(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
@@ -1040,7 +1032,6 @@ func TestMediaFile_HasType(t *testing.T) {
func TestMediaFile_IsHeic(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1073,7 +1064,6 @@ func TestMediaFile_IsHeic(t *testing.T) {
func TestMediaFile_IsRaw(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1107,7 +1097,6 @@ func TestMediaFile_IsRaw(t *testing.T) {
func TestMediaFile_IsPng(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1130,7 +1119,6 @@ func TestMediaFile_IsPng(t *testing.T) {
func TestMediaFile_IsTiff(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1162,7 +1150,6 @@ func TestMediaFile_IsTiff(t *testing.T) {
func TestMediaFile_IsImageOther(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1251,7 +1238,6 @@ func TestMediaFile_IsImageOther(t *testing.T) {
func TestMediaFile_CheckType(t *testing.T) {
c := config.TestConfig()
t.Run("JPEG", func(t *testing.T) {
if f, err := NewMediaFile("testdata/flash.jpg"); err != nil {
t.Fatal(err)
@@ -1449,7 +1435,6 @@ func TestMediaFile_IsArchive(t *testing.T) {
}
func TestMediaFile_IsImage(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenJson", func(t *testing.T) {
f, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.json")
if err != nil {
@@ -1497,7 +1482,6 @@ func TestMediaFile_IsImage(t *testing.T) {
func TestMediaFile_IsVideo(t *testing.T) {
c := config.TestConfig()
t.Run("ChristmasMp4", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "christmas.mp4")); err != nil {
t.Fatal(err)
@@ -1535,7 +1519,6 @@ func TestMediaFile_IsVideo(t *testing.T) {
func TestMediaFile_IsLive(t *testing.T) {
c := config.TestConfig()
t.Run("Num2018Num04TwelveNineteenNum24Num49Jpg", func(t *testing.T) {
fileName := fs.Abs("testdata/2018-04-12 19_24_49.jpg")
if f, err := NewMediaFile(fileName); err != nil {
@@ -2194,7 +2177,6 @@ func TestMediaFile_ExceedsResolution(t *testing.T) {
func TestMediaFile_AspectRatio(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
@@ -2233,7 +2215,6 @@ func TestMediaFile_AspectRatio(t *testing.T) {
func TestMediaFile_Orientation(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
@@ -2359,7 +2340,6 @@ func TestMediaFile_JsonName(t *testing.T) {
func TestMediaFile_PathNameInfo(t *testing.T) {
c := config.TestConfig()
t.Run("BlueGoVideoMp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
@@ -2433,7 +2413,6 @@ func TestMediaFile_PathNameInfo(t *testing.T) {
func TestMediaFile_SubDirectory(t *testing.T) {
c := config.TestConfig()
t.Run("BlueGoVideoMp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
@@ -2448,7 +2427,6 @@ func TestMediaFile_SubDirectory(t *testing.T) {
func TestMediaFile_HasSameName(t *testing.T) {
c := config.TestConfig()
t.Run("False", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
@@ -2495,7 +2473,6 @@ func TestMediaFile_IsJson(t *testing.T) {
func TestMediaFile_NeedsTranscoding(t *testing.T) {
c := config.TestConfig()
t.Run("Json", func(t *testing.T) {
f, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.json")
@@ -2536,7 +2513,6 @@ func TestMediaFile_NeedsTranscoding(t *testing.T) {
func TestMediaFile_SkipTranscoding(t *testing.T) {
c := config.TestConfig()
t.Run("Json", func(t *testing.T) {
f, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.json")

View File

@@ -0,0 +1,146 @@
package photoprism
import (
"errors"
"time"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media"
)
// GenerateCaption generates a caption for the provided media file using the active
// vision model. When captionSrc is SrcAuto the model's declared source is used;
// otherwise the explicit source is recorded on the returned caption.
func (m *MediaFile) GenerateCaption(captionSrc entity.Src) (caption *vision.CaptionResult, err error) {
start := time.Now()
model := vision.Config.Model(vision.ModelTypeCaption)
// No caption generation model configured or usable.
if model == nil {
return caption, errors.New("no caption model configured")
}
if captionSrc == entity.SrcAuto {
captionSrc = model.GetSource()
}
size := vision.Thumb(vision.ModelTypeCaption)
// Get thumbnail filenames for the selected sizes.
fileName, fileErr := m.Thumbnail(Config().ThumbCachePath(), size.Name)
if fileErr != nil {
return caption, err
}
// Get matching labels from computer vision model.
// Generate a caption using the configured vision model.
if caption, _, err = vision.GenerateCaption(vision.Files{fileName}, media.SrcLocal); err != nil {
// Failed.
} else if caption.Text != "" {
if captionSrc != entity.SrcAuto {
caption.Source = captionSrc
}
log.Infof("vision: generated caption for %s [%s]", clean.Log(m.RootRelName()), time.Since(start))
}
return caption, err
}
// GenerateLabels classifies the media file and returns matching labels. When labelSrc
// is SrcAuto the model's declared source is used; otherwise the provided source
// is applied to every returned label.
func (m *MediaFile) GenerateLabels(labelSrc entity.Src) (labels classify.Labels) {
if m == nil {
return labels
}
start := time.Now()
var err error
var sizes []thumb.Name
var thumbnails []string
model := vision.Config.Model(vision.ModelTypeLabels)
// No label generation model configured or usable.
if model == nil {
return labels
}
if labelSrc == entity.SrcAuto {
labelSrc = model.GetSource()
}
size := vision.Thumb(vision.ModelTypeLabels)
// The thumbnail size may need to be adjusted to use other models.
if size.Name != "" && size.Name != thumb.Tile224 {
sizes = []thumb.Name{size.Name}
thumbnails = make([]string, 0, 1)
} else if m.Square() {
// Only one thumbnail is required for square images.
sizes = []thumb.Name{thumb.Tile224}
thumbnails = make([]string, 0, 1)
} else {
// Use three thumbnails otherwise (center, left, right).
sizes = []thumb.Name{thumb.Tile224, thumb.Left224, thumb.Right224}
thumbnails = make([]string, 0, 3)
}
// Get thumbnail filenames for the selected sizes.
for _, s := range sizes {
if thumbnail, fileErr := m.Thumbnail(Config().ThumbCachePath(), s); fileErr != nil {
log.Debugf("index: %s in %s", err, clean.Log(m.RootRelName()))
continue
} else {
thumbnails = append(thumbnails, thumbnail)
}
}
// Run the configured vision model to obtain labels for the generated thumbnails.
if labels, err = vision.GenerateLabels(thumbnails, media.SrcLocal, labelSrc); err != nil {
log.Debugf("labels: %s in %s", err, clean.Log(m.RootRelName()))
return labels
}
// Log number and names of generated labels.
if n := labels.Count(); n > 0 {
log.Debugf("vision: %#v", labels)
log.Infof("vision: generated %s for %s [%s]", english.Plural(n, "label", "labels"), clean.Log(m.RootRelName()), time.Since(start))
}
return labels
}
// DetectNSFW returns true if media file might be offensive and detection is enabled.
func (m *MediaFile) DetectNSFW() bool {
filename, err := m.Thumbnail(Config().ThumbCachePath(), thumb.Fit720)
if err != nil {
log.Error(err)
return false
}
if results, modelErr := vision.DetectNSFW([]string{filename}, media.SrcLocal); modelErr != nil {
log.Errorf("vision: %s in %s (detect nsfw)", modelErr, clean.Log(m.RootRelName()))
return false
} else if len(results) < 1 {
log.Errorf("vision: nsfw model returned no result for %s", clean.Log(m.RootRelName()))
return false
} else if results[0].IsNsfw(nsfw.ThresholdHigh) {
log.Warnf("vision: detected offensive content in %s", clean.Log(m.RootRelName()))
return true
}
return false
}

View File

@@ -0,0 +1,140 @@
package photoprism
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/media"
)
func setupVisionMediaFile(t *testing.T) *MediaFile {
t.Helper()
cfg := config.TestConfig()
require.NoError(t, cfg.InitializeTestData())
mediaFile, err := NewMediaFile("testdata/flash.jpg")
require.NoError(t, err)
return mediaFile
}
func TestMediaFile_GenerateCaption(t *testing.T) {
mediaFile := setupVisionMediaFile(t)
originalConfig := vision.Config
t.Cleanup(func() {
vision.Config = originalConfig
vision.SetCaptionFunc(nil)
})
captionModel := &vision.Model{Type: vision.ModelTypeCaption, Engine: vision.ApiFormatOpenAI}
captionModel.ApplyEngineDefaults()
vision.Config = &vision.ConfigValues{Models: vision.Models{captionModel}}
t.Run("AutoUsesModelSource", func(t *testing.T) {
vision.SetCaptionFunc(func(files vision.Files, mediaSrc media.Src) (*vision.CaptionResult, *vision.Model, error) {
return &vision.CaptionResult{Text: "stub", Source: captionModel.GetSource()}, captionModel, nil
})
caption, err := mediaFile.GenerateCaption(entity.SrcAuto)
require.NoError(t, err)
require.NotNil(t, caption)
assert.Equal(t, captionModel.GetSource(), caption.Source)
})
t.Run("CustomSourceOverrides", func(t *testing.T) {
vision.SetCaptionFunc(func(files vision.Files, mediaSrc media.Src) (*vision.CaptionResult, *vision.Model, error) {
return &vision.CaptionResult{Text: "stub", Source: captionModel.GetSource()}, captionModel, nil
})
caption, err := mediaFile.GenerateCaption(entity.SrcManual)
require.NoError(t, err)
require.NotNil(t, caption)
assert.Equal(t, entity.SrcManual, caption.Source)
})
t.Run("MissingModelReturnsError", func(t *testing.T) {
vision.Config = &vision.ConfigValues{}
vision.SetCaptionFunc(nil)
caption, err := mediaFile.GenerateCaption(entity.SrcAuto)
assert.Error(t, err)
assert.Nil(t, caption)
})
}
func TestMediaFile_GenerateLabels(t *testing.T) {
mediaFile := setupVisionMediaFile(t)
originalConfig := vision.Config
t.Cleanup(func() {
vision.Config = originalConfig
vision.SetLabelsFunc(nil)
})
labelModel := &vision.Model{Type: vision.ModelTypeLabels, Engine: vision.ApiFormatOllama}
labelModel.ApplyEngineDefaults()
vision.Config = &vision.ConfigValues{Models: vision.Models{labelModel}}
t.Run("AutoUsesModelSource", func(t *testing.T) {
var captured string
vision.SetLabelsFunc(func(files vision.Files, mediaSrc media.Src, src string) (classify.Labels, error) {
captured = src
return classify.Labels{{Name: "stub", Source: src}}, nil
})
labels := mediaFile.GenerateLabels(entity.SrcAuto)
assert.NotEmpty(t, labels)
assert.Equal(t, labelModel.GetSource(), captured)
})
t.Run("CustomSourceOverrides", func(t *testing.T) {
var captured string
vision.SetLabelsFunc(func(files vision.Files, mediaSrc media.Src, src string) (classify.Labels, error) {
captured = src
return classify.Labels{{Name: "stub", Source: src}}, nil
})
labels := mediaFile.GenerateLabels(entity.SrcManual)
assert.NotEmpty(t, labels)
assert.Equal(t, entity.SrcManual, captured)
})
t.Run("MissingModel", func(t *testing.T) {
vision.Config = &vision.ConfigValues{}
vision.SetLabelsFunc(nil)
labels := mediaFile.GenerateLabels(entity.SrcAuto)
assert.Empty(t, labels)
})
}
func TestMediaFile_DetectNSFW(t *testing.T) {
mediaFile := setupVisionMediaFile(t)
t.Run("FlagsHighConfidence", func(t *testing.T) {
vision.SetNSFWFunc(func(files vision.Files, mediaSrc media.Src) ([]nsfw.Result, error) {
return []nsfw.Result{{Porn: nsfw.ThresholdHigh + 0.01}}, nil
})
t.Cleanup(func() { vision.SetNSFWFunc(nil) })
assert.True(t, mediaFile.DetectNSFW())
})
t.Run("SafeContent", func(t *testing.T) {
vision.SetNSFWFunc(func(files vision.Files, mediaSrc media.Src) ([]nsfw.Result, error) {
return []nsfw.Result{{Neutral: 0.9}}, nil
})
t.Cleanup(func() { vision.SetNSFWFunc(nil) })
assert.False(t, mediaFile.DetectNSFW())
})
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}