Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 40 additions & 49 deletions internal/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -261,12 +228,36 @@ 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)
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
Comment on lines 230 to +249
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()
decoder := json.NewDecoder(res.Body)

var response struct {
Expand Down
6 changes: 2 additions & 4 deletions internal/image/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ type Source struct {
thumbnailGenerators io.Sources
thumbnailSink *sqlite.Source

Clip ai.AI
Clip *ai.AI
Geo *geo.Geo
}

Expand Down Expand Up @@ -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 = config.AI
}

return &source
Expand Down
15 changes: 9 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading