From 40a807940428764b18cfccc4891e20acf240607a Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Mon, 8 Jun 2026 00:55:53 +0200 Subject: [PATCH 1/2] Better face support detection --- internal/ai/client.go | 73 ++++++++++++++-------------------------- internal/image/source.go | 4 +-- main.go | 15 +++++---- 3 files changed, 36 insertions(+), 56 deletions(-) diff --git a/internal/ai/client.go b/internal/ai/client.go index 2b7610a..6a2d3b2 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -32,77 +32,44 @@ type AI struct { Textual Model `json:"textual"` Faces Model `json:"faces"` - facesAvailable bool - facesChecked bool - facesMu sync.RWMutex + facesAvailable bool + facesAvailableUntil time.Time + facesMu sync.RWMutex } -func (a AI) Available() bool { +func (a *AI) Available() bool { return a.TextualHost() != "" } -func (a *AI) CheckFacesAvailable() { - if !a.Available() || a.FaceHost() == "" { - return - } - - url := fmt.Sprintf("%s/faces", a.FaceHost()) - req, err := http.NewRequest(http.MethodHead, url, nil) - if err != nil { - return - } - - client := &http.Client{Timeout: 5 * time.Second} - res, err := client.Do(req) - if err != nil { - a.facesMu.Lock() - a.facesAvailable = false - a.facesChecked = true - a.facesMu.Unlock() - return - } - defer res.Body.Close() - - a.facesMu.Lock() - a.facesAvailable = res.StatusCode == http.StatusOK - a.facesChecked = true - a.facesMu.Unlock() -} - func (a *AI) FacesAvailable() bool { a.facesMu.RLock() - checked := a.facesChecked - available := a.facesAvailable - a.facesMu.RUnlock() - - if !checked { - return false - } - return available + defer a.facesMu.RUnlock() + now := time.Now() + return now.After(a.facesAvailableUntil) || a.facesAvailable } -func (a AI) VisualHost() string { +func (a *AI) VisualHost() string { if a.Visual.Host != "" { return a.Visual.Host } return a.Host } -func (a AI) TextualHost() string { +func (a *AI) TextualHost() string { if a.Textual.Host != "" { return a.Textual.Host } return a.Host } -func (a AI) FaceHost() string { +func (a *AI) FaceHost() string { if a.Faces.Host != "" { return a.Faces.Host } return a.Host } -func (a AI) EmbedImagePath(path string) (Embedding, error) { +func (a *AI) EmbedImagePath(path string) (Embedding, error) { if !a.Available() || a.TextualHost() == "" { return nil, ErrNotAvailable } @@ -122,7 +89,7 @@ func (a AI) EmbedImagePath(path string) (Embedding, error) { return a.EmbedImageReader(f) } -func (a AI) EmbedImageReader(r io.Reader) (Embedding, error) { +func (a *AI) EmbedImageReader(r io.Reader) (Embedding, error) { if !a.Available() || a.VisualHost() == "" { return nil, ErrNotAvailable } @@ -179,7 +146,7 @@ func (a AI) EmbedImageReader(r io.Reader) (Embedding, error) { }, nil } -func (a AI) EmbedText(text string) (Embedding, error) { +func (a *AI) EmbedText(text string) (Embedding, error) { if !a.Available() { return nil, ErrNotAvailable } @@ -240,7 +207,7 @@ type Face struct { Embedding []byte // Normalized face embedding } -func (a AI) DetectFaces(r io.Reader) ([]Face, error) { +func (a *AI) DetectFaces(r io.Reader) ([]Face, error) { if !a.Available() || a.FaceHost() == "" || !a.FacesAvailable() { return nil, ErrNotAvailable } @@ -262,9 +229,19 @@ func (a AI) DetectFaces(r io.Reader) ([]Face, error) { url := fmt.Sprintf("%s/faces", a.FaceHost()) res, err := http.Post(url, w.FormDataContentType(), &b) - if err != nil { + if err != nil || res.StatusCode != http.StatusOK { + a.facesMu.Lock() + a.facesAvailable = false + dur := 1 * time.Hour + a.facesAvailableUntil = time.Now().Add(dur) + a.facesMu.Unlock() + fmt.Printf("face detection failed, retrying in %v: %v\n", dur, err) return nil, err } + a.facesMu.Lock() + a.facesAvailable = true + a.facesAvailableUntil = time.Time{} + a.facesMu.Unlock() defer res.Body.Close() decoder := json.NewDecoder(res.Body) diff --git a/internal/image/source.go b/internal/image/source.go index 368bb3f..e1aa077 100644 --- a/internal/image/source.go +++ b/internal/image/source.go @@ -154,7 +154,7 @@ type Source struct { thumbnailGenerators io.Sources thumbnailSink *sqlite.Source - Clip ai.AI + Clip *ai.AI Geo *geo.Geo } @@ -262,7 +262,7 @@ func NewSource(config Config, migrations embed.FS, geo *geo.Geo) *Source { log.Printf("skipping load info") } else { - source.Clip = config.AI + source.Clip = &source.Config.AI } return &source diff --git a/main.go b/main.go index 713c768..db95a1a 100644 --- a/main.go +++ b/main.go @@ -1812,13 +1812,16 @@ func applyConfig(appConfig *AppConfig) { oldSource.Close() } + if appConfig.AI.TextualHost() != "" { + log.Printf("ai textual (search) host: %s", appConfig.AI.TextualHost()) + } + + if appConfig.AI.VisualHost() != "" { + log.Printf("ai visual (indexing) host: %s", appConfig.AI.VisualHost()) + } + if appConfig.AI.FaceHost() != "" { - imageSource.Clip.CheckFacesAvailable() - if imageSource.Clip.FacesAvailable() { - log.Printf("face detection enabled (AI server supports /faces)") - } else { - log.Printf("face detection disabled (AI server does not support /faces)") - } + log.Printf("ai face (indexing) host: %s", appConfig.AI.FaceHost()) } // Initialize pipeline coordinator From 73556b12ee7df3032b432f5c02ce843d1afb309c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:32:42 +0000 Subject: [PATCH 2/2] Fix AI face detection error handling and Clip initialization Co-authored-by: SmilyOrg <1451391+SmilyOrg@users.noreply.github.com> --- internal/ai/client.go | 22 ++++++++++++++++++---- internal/image/source.go | 4 +--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/internal/ai/client.go b/internal/ai/client.go index 6a2d3b2..fa40ba1 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -228,8 +228,23 @@ func (a *AI) DetectFaces(r io.Reader) ([]Face, error) { w.Close() url := fmt.Sprintf("%s/faces", a.FaceHost()) - res, err := http.Post(url, w.FormDataContentType(), &b) - if err != nil || res.StatusCode != http.StatusOK { + client := &http.Client{ + Timeout: 30 * time.Second, + } + res, err := client.Post(url, w.FormDataContentType(), &b) + if err != nil { + a.facesMu.Lock() + a.facesAvailable = false + dur := 1 * time.Hour + a.facesAvailableUntil = time.Now().Add(dur) + a.facesMu.Unlock() + fmt.Printf("face detection failed, retrying in %v: %v\n", dur, err) + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + err = fmt.Errorf("face request failed with status %s", res.Status) a.facesMu.Lock() a.facesAvailable = false dur := 1 * time.Hour @@ -238,12 +253,11 @@ func (a *AI) DetectFaces(r io.Reader) ([]Face, error) { fmt.Printf("face detection failed, retrying in %v: %v\n", dur, err) return nil, err } + a.facesMu.Lock() a.facesAvailable = true a.facesAvailableUntil = time.Time{} a.facesMu.Unlock() - - defer res.Body.Close() decoder := json.NewDecoder(res.Body) var response struct { diff --git a/internal/image/source.go b/internal/image/source.go index e1aa077..403013f 100644 --- a/internal/image/source.go +++ b/internal/image/source.go @@ -258,11 +258,9 @@ func NewSource(config Config, migrations embed.FS, geo *geo.Geo) *Source { } source.thumbnailSink = sqliteSink + source.Clip = &source.Config.AI if config.SkipLoadInfo { log.Printf("skipping load info") - } else { - - source.Clip = &source.Config.AI } return &source