From fafdeba27e0f9880e76201fb89709fdec51ecbd8 Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Sun, 17 Mar 2024 22:26:30 +0100 Subject: [PATCH 1/6] Add birdweather thumbnail support Makes use of the birdweather graphql api to fetch a given bird's thumbnail. --- go.mod | 1 + go.sum | 2 + internal/datastore/interfaces.go | 2 +- internal/httpcontroller/routes.go | 1 + internal/httpcontroller/utils.go | 61 +++++++++++++++++++++++++++ views/fragments/birdsTableHTML.html | 2 + views/fragments/recentDetections.html | 24 +++++++---- 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 05b564b0..9f411ffe 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect diff --git a/go.sum b/go.sum index 00d550c5..bf844176 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e h1:KHiRfgBfn0d3lv2kXs4iayASb6TdInNNIHe75zX0sqg= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e/go.mod h1:mXcZNMJHswhQDDJZIjdtJoG97JIwIa/HdcHNM3w15T0= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index 371b091d..3d1f8f44 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -178,7 +178,7 @@ func (ds *DataStore) GetTopBirdsData(selectedDate string, minConfidenceNormalize const reportCount = 30 // Consider making this a configurable parameter err := ds.DB.Table("notes"). - Select("common_name, COUNT(*) as count"). + Select("common_name", "scientific_name", "COUNT(*) as count"). Where("date = ? AND confidence >= ?", selectedDate, minConfidenceNormalized). Group("common_name"). //Having("COUNT(*) > ?", 1). diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index 32e30611..9b5a99a6 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -40,6 +40,7 @@ func (s *Server) initRoutes() { "title": cases.Title(language.English).String, "confidence": confidence, "confidenceColor": confidenceColor, + "thumbnail": thumbnail, "RenderContent": s.RenderContent, "sub": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, diff --git a/internal/httpcontroller/utils.go b/internal/httpcontroller/utils.go index e618383b..b51d1043 100644 --- a/internal/httpcontroller/utils.go +++ b/internal/httpcontroller/utils.go @@ -2,6 +2,7 @@ package httpcontroller import ( + "context" "fmt" "log" "os" @@ -10,8 +11,10 @@ import ( "runtime" "strconv" "strings" + "sync" "time" + "github.com/shurcooL/graphql" "github.com/tphakala/birdnet-go/internal/datastore" ) @@ -237,3 +240,61 @@ func parseOffset(offsetStr string, defaultOffset int) int { } return offset } + +var ( + client = graphql.NewClient("https://app.birdweather.com/graphql", nil) + thumbnailMap sync.Map + thumbnailMutexMap sync.Map +) + +func queryGraphQL(scientificName string) (string, error) { + log.Printf("Fetching thumbnail for bird: %s\n", scientificName) + + var query struct { + Species struct { + ThumbnailUrl graphql.String + } `graphql:"species(scientificName: $scientificName)"` + } + + variables := map[string]interface{}{ + "scientificName": graphql.String(scientificName), + } + + err := client.Query(context.Background(), &query, variables) + if err != nil { + return "", err + } + + return string(query.Species.ThumbnailUrl), nil +} + +// thumbnail returns the url of a given bird's thumbnail +func thumbnail(scientificName string) (string, error) { + // Check if thumbnail is already cached + if thumbnail, ok := thumbnailMap.Load(scientificName); ok { + log.Printf("Bird: %s, Thumbnail (cached): %s\n", scientificName, thumbnail) + return thumbnail.(string), nil + } + + // Use a per-item mutex to ensure only one GraphQL query is performed per item + mu, _ := thumbnailMutexMap.LoadOrStore(scientificName, &sync.Mutex{}) + mutex := mu.(*sync.Mutex) + + mutex.Lock() + defer mutex.Unlock() + + // Check again if thumbnail is cached after acquiring the lock + if thumbnail, ok := thumbnailMap.Load(scientificName); ok { + log.Printf("Bird: %s, Thumbnail (cached): %s\n", scientificName, thumbnail) + return thumbnail.(string), nil + } + + thumbn, err := queryGraphQL(scientificName) + if err != nil { + return "", fmt.Errorf("error querying GraphQL endpoint: %v", err) + } + + thumbnailMap.Store(scientificName, thumbn) + log.Printf("Bird: %s, Thumbnail (fetched): %s\n", scientificName, thumbn) + return thumbn, nil +} diff --git a/views/fragments/birdsTableHTML.html b/views/fragments/birdsTableHTML.html index f26fc379..d7bca286 100644 --- a/views/fragments/birdsTableHTML.html +++ b/views/fragments/birdsTableHTML.html @@ -24,6 +24,8 @@ hx-trigger="click" hx-push-url="true">{{title .Note.CommonName}} + + Bird Image diff --git a/views/fragments/recentDetections.html b/views/fragments/recentDetections.html index ff976de5..e875e4b9 100644 --- a/views/fragments/recentDetections.html +++ b/views/fragments/recentDetections.html @@ -24,6 +24,8 @@ hx-trigger="click" hx-push-url="true"> {{ .CommonName}} + + Bird Image
@@ -54,14 +56,20 @@
{{.Time}}
- - {{title .CommonName}} - + + +
+ + {{title .CommonName}} + + + Bird Image +
{{confidence .Confidence}}
From fdd07d1638d4afd8a5ad9fd6b8b9f66c1eac3e78 Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Sat, 23 Mar 2024 13:36:35 +0100 Subject: [PATCH 2/6] Add pre-fetching of thumbnails on server start Pre-fetching the thumbnails during server boot makes it very responsive when the user accesses the webpage for the first time. Since all the birds' thumbnails have already been fetched. --- internal/datastore/interfaces.go | 13 +++++++++++++ internal/httpcontroller/routes.go | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index 3d1f8f44..4f10e055 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -24,6 +24,7 @@ type Interface interface { GetHourlyOccurrences(date, commonName string, minConfidenceNormalized float64) ([24]int, error) SpeciesDetections(species, date, hour string, sortAscending bool, limit int, offset int) ([]Note, error) GetLastDetections(numDetections int) ([]Note, error) + GetAllDetectedSpecies() ([]Note, error) SearchNotes(query string, sortAscending bool, limit int, offset int) ([]Note, error) GetNoteClipPath(noteID string) (string, error) DeleteNoteClipPath(noteID string) error @@ -311,6 +312,18 @@ func (ds *DataStore) GetLastDetections(numDetections int) ([]Note, error) { return notes, nil } +// GetLastDetections retrieves all detected species. +func (ds *DataStore) GetAllDetectedSpecies() ([]Note, error) { + var results []Note + + err := ds.DB.Table("notes"). + Select("scientific_name"). + Group("scientific_name"). + Scan(&results).Error + + return results, err +} + // SearchNotes performs a search on notes with optional sorting, pagination, and limits. func (ds *DataStore) SearchNotes(query string, sortAscending bool, limit int, offset int) ([]Note, error) { var notes []Note diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index 9b5a99a6..a45bb44b 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -5,6 +5,7 @@ import ( "embed" "html/template" "io/fs" + "sync" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -30,6 +31,26 @@ var routes = []routeConfig{ {Path: "/settings", TemplateName: "settings", Title: "General Settings"}, } +func (s *Server) initThumbnailCache() { + notes, err := s.ds.GetAllDetectedSpecies() + if err != nil { + s.Echo.Logger.Fatal(err) + } + + var wg sync.WaitGroup + + for _, note := range notes { + wg.Add(1) + + go func(note string) { + defer wg.Done() + thumbnail(note) + }(note.ScientificName) + } + + wg.Wait() +} + // initRoutes initializes the routes for the server. func (s *Server) initRoutes() { // Define function map for templates. @@ -83,6 +104,9 @@ func (s *Server) initRoutes() { s.Echo.POST("/update-settings", s.updateSettingsHandler) + // Initialize thumbnail cache + go s.initThumbnailCache() + // Specific handler for settings route //s.Echo.GET("/settings", s.settingsHandler) } From 295913f048c2a2c13653a23719571889d044d1ee Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Thu, 16 May 2024 22:05:48 +0200 Subject: [PATCH 3/6] Add error management to initThumbnailCache --- internal/httpcontroller/routes.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index a45bb44b..ffdd34ca 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -5,6 +5,7 @@ import ( "embed" "html/template" "io/fs" + "log" "sync" "golang.org/x/text/cases" @@ -44,7 +45,9 @@ func (s *Server) initThumbnailCache() { go func(note string) { defer wg.Done() - thumbnail(note) + if _, err := thumbnail(note); err != nil { + log.Printf("Error fetching thumbnail for %s: %v", note, err) + } }(note.ScientificName) } @@ -97,7 +100,6 @@ func (s *Server) initRoutes() { s.Echo.GET("/search", s.searchHandler) s.Echo.GET("/spectrogram", s.serveSpectrogramHandler) - // Handle both GET and DELETE requests for the /note route s.Echo.Add("GET", "/note", s.getNoteHandler) s.Echo.Add("DELETE", "/note", s.deleteNoteHandler) From dbf7717bae75790b022ff60ed995db41f51754ac Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Fri, 17 May 2024 21:18:40 +0200 Subject: [PATCH 4/6] Use wikimedia to fetch images --- go.mod | 4 ++- internal/httpcontroller/utils.go | 44 +++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 9f411ffe..7a39b849 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,8 @@ require ( ) require ( + cgt.name/pkg/go-mwclient v1.3.0 // indirect + github.com/antonholmquist/jason v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -43,6 +45,7 @@ require ( github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.5.0 // indirect @@ -51,7 +54,6 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect diff --git a/internal/httpcontroller/utils.go b/internal/httpcontroller/utils.go index b51d1043..35cbddce 100644 --- a/internal/httpcontroller/utils.go +++ b/internal/httpcontroller/utils.go @@ -2,7 +2,6 @@ package httpcontroller import ( - "context" "fmt" "log" "os" @@ -14,7 +13,7 @@ import ( "sync" "time" - "github.com/shurcooL/graphql" + "cgt.name/pkg/go-mwclient" "github.com/tphakala/birdnet-go/internal/datastore" ) @@ -242,30 +241,45 @@ func parseOffset(offsetStr string, defaultOffset int) int { } var ( - client = graphql.NewClient("https://app.birdweather.com/graphql", nil) thumbnailMap sync.Map thumbnailMutexMap sync.Map ) -func queryGraphQL(scientificName string) (string, error) { - log.Printf("Fetching thumbnail for bird: %s\n", scientificName) +func queryWikiMedia(scientificName string) (string, error) { + w, err := mwclient.New("https://wikipedia.org/w/api.php", "Birdnet-Go") + if err != nil { + return "", err + } - var query struct { - Species struct { - ThumbnailUrl graphql.String - } `graphql:"species(scientificName: $scientificName)"` + // Specify parameters to send. + parameters := map[string]string{ + "action": "query", + "prop": "pageimages", + "piprop": "thumbnail", + "pilicense": "free", + "titles": scientificName, + "pithumbsize": "400", + "redirects": "", } - variables := map[string]interface{}{ - "scientificName": graphql.String(scientificName), + // Make the request. + resp, err := w.Get(parameters) + if err != nil { + return "", err + } + + // Print the *jason.Object + pages, err := resp.GetObjectArray("query", "pages") + if err != nil { + return "", err } - err := client.Query(context.Background(), &query, variables) + thumbnail, err := pages[0].GetString("thumbnail", "source") if err != nil { return "", err } - return string(query.Species.ThumbnailUrl), nil + return string(thumbnail), nil } // thumbnail returns the url of a given bird's thumbnail @@ -289,9 +303,9 @@ func thumbnail(scientificName string) (string, error) { return thumbnail.(string), nil } - thumbn, err := queryGraphQL(scientificName) + thumbn, err := queryWikiMedia(scientificName) if err != nil { - return "", fmt.Errorf("error querying GraphQL endpoint: %v", err) + log.Printf("error querying wikimedia endpoint: %v", err) } thumbnailMap.Store(scientificName, thumbn) From a151d6fd822f7cd6ee33133f5bf49b75b257dd98 Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Fri, 17 May 2024 21:21:43 +0200 Subject: [PATCH 5/6] Add missing go.sum change --- go.sum | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index bf844176..8d99fe5a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +cgt.name/pkg/go-mwclient v1.3.0 h1:8PtOxq+aL4wtyLZXHPImpNtUioFq/DxBwae2C44j2gE= +cgt.name/pkg/go-mwclient v1.3.0/go.mod h1:X1auRhzIA0Bz5Yx7Yei29vUqr/Ju+r8IWudnJmmAG30= +github.com/antonholmquist/jason v1.0.0 h1:Ytg94Bcf1Bfi965K2q0s22mig/n4eGqEij/atENBhA0= +github.com/antonholmquist/jason v1.0.0/go.mod h1:+GxMEKI0Va2U8h3os6oiUAetHAlGMvxjdpAH/9uvUMA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -64,6 +68,8 @@ github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6 github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= +github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -92,8 +98,6 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= -github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e h1:KHiRfgBfn0d3lv2kXs4iayASb6TdInNNIHe75zX0sqg= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e/go.mod h1:mXcZNMJHswhQDDJZIjdtJoG97JIwIa/HdcHNM3w15T0= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= From f68715d97dddf310e05c525d4eba281f52f06dec Mon Sep 17 00:00:00 2001 From: Hampus Carlsson Date: Tue, 11 Jun 2024 17:44:20 +0200 Subject: [PATCH 6/6] Add credits to image authors and lots of cleanup --- go.mod | 2 +- go.sum | 11 + .../imageprovider/imageprovider.go | 109 +++++++ .../imageprovider/imageprovider_test.go | 54 ++++ .../httpcontroller/imageprovider/wikipedia.go | 281 ++++++++++++++++++ internal/httpcontroller/init.go | 47 ++- internal/httpcontroller/routes.go | 48 +-- internal/httpcontroller/utils.go | 83 ++---- views/fragments/birdsTableHTML.html | 9 +- views/fragments/recentDetections.html | 19 +- 10 files changed, 551 insertions(+), 112 deletions(-) create mode 100644 internal/httpcontroller/imageprovider/imageprovider.go create mode 100644 internal/httpcontroller/imageprovider/imageprovider_test.go create mode 100644 internal/httpcontroller/imageprovider/wikipedia.go diff --git a/go.mod b/go.mod index ea505acb..8ca82499 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/k3a/html2text v1.2.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -45,7 +46,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/go.sum b/go.sum index b85f71be..72efe857 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,7 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -44,6 +45,9 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= +github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -99,6 +103,8 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e h1:KHiRfgBfn0d3lv2kXs4iayASb6TdInNNIHe75zX0sqg= github.com/smallnest/ringbuffer v0.0.0-20230728150354-35801fa39d0e/go.mod h1:mXcZNMJHswhQDDJZIjdtJoG97JIwIa/HdcHNM3w15T0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -139,14 +145,17 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -155,10 +164,12 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/internal/httpcontroller/imageprovider/imageprovider.go b/internal/httpcontroller/imageprovider/imageprovider.go new file mode 100644 index 00000000..f0a65c88 --- /dev/null +++ b/internal/httpcontroller/imageprovider/imageprovider.go @@ -0,0 +1,109 @@ +package imageprovider + +import ( + "slices" + "sync" +) + +type ImageProvider interface { + fetch(scientificName string) (BirdImage, error) +} + +type BirdImage struct { + Url string + LicenseName string + LicenseUrl string + AuthorName string + AuthorUrl string +} + +type BirdImageCache struct { + dataMap sync.Map + dataMutexMap sync.Map + birdImageProvider ImageProvider + nonBirdImageProvider ImageProvider +} + +type emptyImageProvider struct { +} + +func (l *emptyImageProvider) fetch(scientificName string) (BirdImage, error) { + return BirdImage{}, nil +} + +func initCache(e ImageProvider) *BirdImageCache { + return &BirdImageCache{ + birdImageProvider: e, + nonBirdImageProvider: &emptyImageProvider{}, // TODO: Use a real image provider for non-birds + } +} + +func CreateDefaultCache() (*BirdImageCache, error) { + provider, err := NewWikiMediaProvider() + if err != nil { + return nil, err + } + return initCache(provider), nil +} + +func (c *BirdImageCache) Get(scientificName string) (info BirdImage, err error) { + // Check if the bird image is already in the cache + birdImage, ok := c.dataMap.Load(scientificName) + if ok { + return birdImage.(BirdImage), nil + } + + // Use a per-item mutex to ensure only one query is performed per item + mu, _ := c.dataMutexMap.LoadOrStore(scientificName, &sync.Mutex{}) + mutex := mu.(*sync.Mutex) + + mutex.Lock() + defer mutex.Unlock() + + // Check again if bird image is cached after acquiring the lock + birdImage, ok = c.dataMap.Load(scientificName) + if ok { + return birdImage.(BirdImage), nil + } + + // Fetch the bird image from the image provider + fetchedBirdImage, err := c.fetch(scientificName) + if err != nil { + // TODO for now store a empty result in the cache to avoid future queries that would fail. + // In the future, look at the error and decide if it was caused by networking and is recoverable. + // And if it was, do not store the empty result in the cache. + c.dataMap.Store(scientificName, BirdImage{}) + return BirdImage{}, err + } + + // Store the fetched image information in the cache + c.dataMap.Store(scientificName, fetchedBirdImage) + + return fetchedBirdImage, nil +} + +func (c *BirdImageCache) fetch(scientificName string) (info BirdImage, err error) { + var imageProviderToUse ImageProvider + + // Determine the image provider based on the scientific name + if slices.Contains([]string{ + "Dog", + "Engine", + "Environmental", + "Fireworks", + "Gun", + "Human non-vocal", + "Human vocal", + "Human whistle", + "Noise", + "Power tools", + "Siren", + }, scientificName) { + imageProviderToUse = c.nonBirdImageProvider + } else { + imageProviderToUse = c.birdImageProvider + } + + // Fetch the image from the image provider + return imageProviderToUse.fetch(scientificName) +} diff --git a/internal/httpcontroller/imageprovider/imageprovider_test.go b/internal/httpcontroller/imageprovider/imageprovider_test.go new file mode 100644 index 00000000..05b8b9c2 --- /dev/null +++ b/internal/httpcontroller/imageprovider/imageprovider_test.go @@ -0,0 +1,54 @@ +package imageprovider + +import ( + "testing" +) + +type mockProvider struct { + fetchCounter int +} + +func (l *mockProvider) fetch(scientificName string) (BirdImage, error) { + l.fetchCounter++ + return BirdImage{}, nil +} + +// TestBirdImageCache ensures the bird image cache behaves as expected. +// It tests whether the cache correctly handles duplicate requests. +func TestBirdImageCache(t *testing.T) { + // Create a mock image provider and initialize the cache. + mockBirdProvider := &mockProvider{} + mockNonBirdProvider := &mockProvider{} + cache := &BirdImageCache{ + birdImageProvider: mockBirdProvider, + nonBirdImageProvider: mockNonBirdProvider, + } + + entriesToTest := []string{ + "a", + "b", + "a", // Duplicate request + "Human non-vocal", // Non-bird request + } + + for _, entry := range entriesToTest { + _, err := cache.Get(entry) + if err != nil { + t.Errorf("Unexpected error for entry %s: %v", entry, err) + } + } + + // Verify that the bird provider's fetch method was called exactly twice. + expectedBirdFetchCalls := 2 + if mockBirdProvider.fetchCounter != expectedBirdFetchCalls { + t.Errorf("Expected %d calls to bird provider, got %d", + expectedBirdFetchCalls, mockBirdProvider.fetchCounter) + } + + // Verify that the non-bird provider's fetch method was called exactly once. + expectedNonBirdFetchCalls := 1 + if mockNonBirdProvider.fetchCounter != expectedNonBirdFetchCalls { + t.Errorf("Expected %d calls to non-bird provider, got %d", + expectedNonBirdFetchCalls, mockNonBirdProvider.fetchCounter) + } +} diff --git a/internal/httpcontroller/imageprovider/wikipedia.go b/internal/httpcontroller/imageprovider/wikipedia.go new file mode 100644 index 00000000..d9806c88 --- /dev/null +++ b/internal/httpcontroller/imageprovider/wikipedia.go @@ -0,0 +1,281 @@ +package imageprovider + +import ( + "bytes" + "fmt" + "strings" + + "cgt.name/pkg/go-mwclient" + "github.com/antonholmquist/jason" + "github.com/k3a/html2text" + "golang.org/x/net/html" +) + +type wikiMediaProvider struct { + client *mwclient.Client +} + +type wikiMediaAuthor struct { + name string + url string + licenseName string + licenseUrl string +} + +func NewWikiMediaProvider() (*wikiMediaProvider, error) { + client, err := mwclient.New("https://wikipedia.org/w/api.php", "Birdnet-Go") + if err != nil { + return nil, fmt.Errorf("failed to create mwclient: %w", err) + } + return &wikiMediaProvider{ + client: client, + }, nil +} + +// queryAndGetFirstPage helper function that queries Wikipedia with given parameters and returns the first page hit +func (l *wikiMediaProvider) queryAndGetFirstPage(params map[string]string) (*jason.Object, error) { + resp, err := l.client.Get(params) + if err != nil { + return nil, fmt.Errorf("failed to query Wikipedia: %w", err) + } + + pages, err := resp.GetObjectArray("query", "pages") + if err != nil { + return nil, fmt.Errorf("failed to get pages from response: %w", err) + } + + if len(pages) == 0 { + return nil, fmt.Errorf("no pages found for request: %v", params) + } + + return pages[0], nil +} + +// fetch retrieves the bird image for a given scientific name. +func (l *wikiMediaProvider) fetch(scientificName string) (BirdImage, error) { + // Query for the thumbnail image URL and source file name + thumbnailUrl, thumbnailSourceFile, err := l.queryThumbnail(scientificName) + if err != nil { + return BirdImage{}, fmt.Errorf("failed to query thumbnail of bird: %s : %w", scientificName, err) + } + + // Query for the image author information + authorInfo, err := l.queryAuthorInfo(thumbnailSourceFile) + if err != nil { + return BirdImage{}, fmt.Errorf("failed to query thumbnail credit of bird: %s : %w", scientificName, err) + } + + // Return the bird image struct with the image URL and author information + return BirdImage{ + Url: thumbnailUrl, + AuthorName: authorInfo.name, + AuthorUrl: authorInfo.url, + LicenseName: authorInfo.licenseName, + LicenseUrl: authorInfo.licenseUrl, + }, nil +} + +func (l *wikiMediaProvider) queryThumbnail(scientificName string) (url, fileName string, err error) { + params := map[string]string{ + "action": "query", + "prop": "pageimages", + "piprop": "thumbnail|name", + "pilicense": "free", + "titles": scientificName, + "pithumbsize": "400", + "redirects": "", + } + + page, err := l.queryAndGetFirstPage(params) + if err != nil { + return "", "", fmt.Errorf("failed to query thumbnail: %w", err) + } + + url, err = page.GetString("thumbnail", "source") + if err != nil { + return "", "", fmt.Errorf("failed to get thumbnail URL: %w", err) + } + + fileName, err = page.GetString("pageimage") + if err != nil { + return "", "", fmt.Errorf("failed to get thumbnail file name: %w", err) + } + + return url, fileName, nil +} + +func (l *wikiMediaProvider) queryAuthorInfo(thumbnailURL string) (*wikiMediaAuthor, error) { + params := map[string]string{ + "action": "query", + "prop": "imageinfo", + "iiprop": "extmetadata", + "titles": "File:" + thumbnailURL, + "redirects": "", + } + + page, err := l.queryAndGetFirstPage(params) + if err != nil { + return nil, fmt.Errorf("failed to query thumbnail: %w", err) + } + + imageInfo, err := page.GetObjectArray("imageinfo") + if err != nil { + return nil, fmt.Errorf("failed to get image info from response: %w", err) + } + if len(imageInfo) == 0 { + return nil, fmt.Errorf("no image info found for thumbnail URL: %s", thumbnailURL) + } + + extMetadata, err := imageInfo[0].GetObject("extmetadata") + if err != nil { + return nil, fmt.Errorf("failed to get extmetadata from response: %w", err) + } + + licenseName, err := extMetadata.GetString("LicenseShortName", "value") + if err != nil { + return nil, fmt.Errorf("failed to get license name from extmetadata: %w", err) + } + + licenseURL, err := extMetadata.GetString("LicenseUrl", "value") + if err != nil { + return nil, fmt.Errorf("failed to get license URL from extmetadata: %w", err) + } + + artistHref, err := extMetadata.GetString("Artist", "value") + if err != nil { + return nil, fmt.Errorf("failed to get artist from extmetadata: %w", err) + } + + href, text, err := extractArtistInfo(artistHref) + if err != nil { + return nil, fmt.Errorf("failed to extract link information: %w", err) + } + + return &wikiMediaAuthor{ + name: text, + url: href, + licenseName: licenseName, + licenseUrl: licenseURL, + }, nil +} + +// extractArtistInfo tries to extract the author information as best as possible +// from the given input which may consist of nested html tags. +func extractArtistInfo(htmlStr string) (href, text string, err error) { + // Parse the HTML string into an HTML document + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + return "", "", err + } + + // Find all the links in the document + links := findLinks(doc) + + // If no links are found, extract the inner text and return it + if len(links) == 0 { + return "", html2text.HTML2Text(htmlStr), nil + } + + // If there is only one link, extract the href and inner text and return them + if len(links) == 1 { + link := links[0] + href = extractHref(link) + text = extractText(link) + return href, text, nil + } + + // Look for a Wikipedia user link and extract the href and inner text + wikipediaUserLinks := findWikipediaUserLinks(links) + + if len(wikipediaUserLinks) == 0 { + return "", "", fmt.Errorf("failed to extract link from HTML: %s", htmlStr) + } + + if len(wikipediaUserLinks) == 1 { + // Return the href and inner text of the Wikipedia user link + wikipediaLink := wikipediaUserLinks[0] + href = extractHref(wikipediaLink) + text = extractText(wikipediaLink) + return href, text, nil + } + + // Check if all the links have the same href value + firstHref := extractHref(wikipediaUserLinks[0]) + allSameHref := true + for _, link := range wikipediaUserLinks[1:] { + if extractHref(link) != firstHref { + allSameHref = false + break + } + } + + if allSameHref { + // Return the href and inner text of the first Wikipedia user link + wikipediaLink := wikipediaUserLinks[0] + href = extractHref(wikipediaLink) + text = extractText(wikipediaLink) + return href, text, nil + } + + return "", "", fmt.Errorf("multiple Wikipedia user links found in HTML: %s", htmlStr) +} + +func findWikipediaUserLinks(nodes []*html.Node) []*html.Node { + var wikiUserLinks []*html.Node + + for _, node := range nodes { + for _, attr := range node.Attr { + if attr.Key == "href" && isWikipediaUserLink(attr.Val) { + wikiUserLinks = append(wikiUserLinks, node) + break + } + } + } + + return wikiUserLinks +} + +// isWikipediaUserLink checks if the given href is a link to a Wikipedia user page. +func isWikipediaUserLink(href string) bool { + return strings.Contains(href, "/wiki/User:") +} + +// findLinks traverses the HTML document and returns all anchor () tags. +func findLinks(doc *html.Node) []*html.Node { + var linkNodes []*html.Node + + var traverse func(*html.Node) + traverse = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "a" { + linkNodes = append(linkNodes, node) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + traverse(child) + } + } + + traverse(doc) + + return linkNodes +} + +func extractHref(link *html.Node) string { + for _, attr := range link.Attr { + if attr.Key == "href" { + return attr.Val + } + } + return "" +} + +func extractText(link *html.Node) string { + if link.FirstChild != nil { + var b bytes.Buffer + err := html.Render(&b, link.FirstChild) + if err != nil { + return "" + } + return html2text.HTML2Text(b.String()) + } + return "" +} diff --git a/internal/httpcontroller/init.go b/internal/httpcontroller/init.go index 3e04f0d3..1f1198a9 100644 --- a/internal/httpcontroller/init.go +++ b/internal/httpcontroller/init.go @@ -5,6 +5,7 @@ import ( "html/template" "io" "log" + "sync" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -12,6 +13,7 @@ import ( "github.com/tphakala/birdnet-go/internal/conf" "github.com/tphakala/birdnet-go/internal/datastore" + "github.com/tphakala/birdnet-go/internal/httpcontroller/imageprovider" "github.com/tphakala/birdnet-go/internal/logger" ) @@ -27,10 +29,11 @@ func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c // Server encapsulates Echo server and related configurations. type Server struct { - Echo *echo.Echo // Echo framework instance - ds datastore.Interface // Datastore interface - Settings *conf.Settings // Application settings - Logger *logger.Logger // Custom logger + Echo *echo.Echo // Echo framework instance + ds datastore.Interface // Datastore interface + Settings *conf.Settings // Application settings + Logger *logger.Logger // Custom logger + BirdImageCache *imageprovider.BirdImageCache } // New initializes a new HTTP server with given context and datastore. @@ -38,10 +41,16 @@ func New(settings *conf.Settings, dataStore datastore.Interface) *Server { // Default port configuration configureDefaultSettings(settings) + cache, err := imageprovider.CreateDefaultCache() + if err != nil { + log.Fatal(err) + } + s := &Server{ - Echo: echo.New(), - ds: dataStore, - Settings: settings, + Echo: echo.New(), + ds: dataStore, + Settings: settings, + BirdImageCache: cache, } // Server initialization @@ -97,6 +106,7 @@ func (s *Server) initializeServer() { s.initLogger() s.configureMiddleware() s.initRoutes() + go s.initBirdImageCache() } // handleServerError listens for server errors and handles them. @@ -142,3 +152,26 @@ func (s *Server) initLogger() { }, })) } + +func (s *Server) initBirdImageCache() { + speciesList, err := s.ds.GetAllDetectedSpecies() + if err != nil { + s.Echo.Logger.Fatal(err) + } + + var wg sync.WaitGroup + + for _, species := range speciesList { + wg.Add(1) + + go func(speciesName string) { + defer wg.Done() + _, err := s.BirdImageCache.Get(speciesName) + if err != nil { + s.Echo.Logger.Error(err) + } + }(species.ScientificName) + } + + wg.Wait() +} diff --git a/internal/httpcontroller/routes.go b/internal/httpcontroller/routes.go index ffdd34ca..a1bd2012 100644 --- a/internal/httpcontroller/routes.go +++ b/internal/httpcontroller/routes.go @@ -5,8 +5,6 @@ import ( "embed" "html/template" "io/fs" - "log" - "sync" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -32,42 +30,21 @@ var routes = []routeConfig{ {Path: "/settings", TemplateName: "settings", Title: "General Settings"}, } -func (s *Server) initThumbnailCache() { - notes, err := s.ds.GetAllDetectedSpecies() - if err != nil { - s.Echo.Logger.Fatal(err) - } - - var wg sync.WaitGroup - - for _, note := range notes { - wg.Add(1) - - go func(note string) { - defer wg.Done() - if _, err := thumbnail(note); err != nil { - log.Printf("Error fetching thumbnail for %s: %v", note, err) - } - }(note.ScientificName) - } - - wg.Wait() -} - // initRoutes initializes the routes for the server. func (s *Server) initRoutes() { // Define function map for templates. funcMap := template.FuncMap{ - "even": even, - "calcWidth": calcWidth, - "heatmapColor": heatmapColor, - "title": cases.Title(language.English).String, - "confidence": confidence, - "confidenceColor": confidenceColor, - "thumbnail": thumbnail, - "RenderContent": s.RenderContent, - "sub": func(a, b int) int { return a - b }, - "add": func(a, b int) int { return a + b }, + "even": even, + "calcWidth": calcWidth, + "heatmapColor": heatmapColor, + "title": cases.Title(language.English).String, + "confidence": confidence, + "confidenceColor": confidenceColor, + "thumbnail": s.thumbnail, + "thumbnailAttribution": s.thumbnailAttribution, + "RenderContent": s.RenderContent, + "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, } // Parse templates from the embedded filesystem. @@ -106,9 +83,6 @@ func (s *Server) initRoutes() { s.Echo.POST("/update-settings", s.updateSettingsHandler) - // Initialize thumbnail cache - go s.initThumbnailCache() - // Specific handler for settings route //s.Echo.GET("/settings", s.settingsHandler) } diff --git a/internal/httpcontroller/utils.go b/internal/httpcontroller/utils.go index 35cbddce..94d3aa36 100644 --- a/internal/httpcontroller/utils.go +++ b/internal/httpcontroller/utils.go @@ -3,6 +3,7 @@ package httpcontroller import ( "fmt" + "html/template" "log" "os" "os/exec" @@ -10,10 +11,8 @@ import ( "runtime" "strconv" "strings" - "sync" "time" - "cgt.name/pkg/go-mwclient" "github.com/tphakala/birdnet-go/internal/datastore" ) @@ -240,75 +239,35 @@ func parseOffset(offsetStr string, defaultOffset int) int { return offset } -var ( - thumbnailMap sync.Map - thumbnailMutexMap sync.Map -) - -func queryWikiMedia(scientificName string) (string, error) { - w, err := mwclient.New("https://wikipedia.org/w/api.php", "Birdnet-Go") - if err != nil { - return "", err - } - - // Specify parameters to send. - parameters := map[string]string{ - "action": "query", - "prop": "pageimages", - "piprop": "thumbnail", - "pilicense": "free", - "titles": scientificName, - "pithumbsize": "400", - "redirects": "", - } - - // Make the request. - resp, err := w.Get(parameters) - if err != nil { - return "", err - } - - // Print the *jason.Object - pages, err := resp.GetObjectArray("query", "pages") - if err != nil { - return "", err - } - - thumbnail, err := pages[0].GetString("thumbnail", "source") +// thumbnail returns the url of a given bird's thumbnail +func (s *Server) thumbnail(scientificName string) string { + birdImage, err := s.BirdImageCache.Get(scientificName) if err != nil { - return "", err + return "" } - return string(thumbnail), nil + return birdImage.Url } -// thumbnail returns the url of a given bird's thumbnail -func thumbnail(scientificName string) (string, error) { - // Check if thumbnail is already cached - if thumbnail, ok := thumbnailMap.Load(scientificName); ok { - log.Printf("Bird: %s, Thumbnail (cached): %s\n", scientificName, thumbnail) - return thumbnail.(string), nil +// thumbnailAttribution returns the thumbnail credits of a given bird. +func (s *Server) thumbnailAttribution(scientificName string) template.HTML { + birdImage, err := s.BirdImageCache.Get(scientificName) + if err != nil { + log.Printf("Error getting thumbnail info for %s: %v", scientificName, err) + return template.HTML("") } - // Use a per-item mutex to ensure only one GraphQL query is performed per item - mu, _ := thumbnailMutexMap.LoadOrStore(scientificName, &sync.Mutex{}) - mutex := mu.(*sync.Mutex) - - mutex.Lock() - defer mutex.Unlock() - - // Check again if thumbnail is cached after acquiring the lock - if thumbnail, ok := thumbnailMap.Load(scientificName); ok { - log.Printf("Bird: %s, Thumbnail (cached): %s\n", scientificName, thumbnail) - return thumbnail.(string), nil + // Skip if no author or license information + if birdImage.AuthorName == "" || birdImage.LicenseName == "" { + return template.HTML("") } - thumbn, err := queryWikiMedia(scientificName) - if err != nil { - log.Printf("error querying wikimedia endpoint: %v", err) + var toReturn string + if birdImage.AuthorUrl == "" { + toReturn = fmt.Sprintf("© %s / %s", birdImage.AuthorName, birdImage.LicenseUrl, birdImage.LicenseName) + } else { + toReturn = fmt.Sprintf("© %s / %s", birdImage.AuthorUrl, birdImage.AuthorName, birdImage.LicenseUrl, birdImage.LicenseName) } - thumbnailMap.Store(scientificName, thumbn) - log.Printf("Bird: %s, Thumbnail (fetched): %s\n", scientificName, thumbn) - return thumbn, nil + return template.HTML(toReturn) } diff --git a/views/fragments/birdsTableHTML.html b/views/fragments/birdsTableHTML.html index d7bca286..c924ef3b 100644 --- a/views/fragments/birdsTableHTML.html +++ b/views/fragments/birdsTableHTML.html @@ -5,6 +5,7 @@ Species + Thumbnail Detections {{range .Hours}} {{printf "%02d" .}} @@ -24,8 +25,14 @@ hx-trigger="click" hx-push-url="true">{{title .Note.CommonName}} + + - Bird Image + + +
+ {{thumbnailAttribution .ScientificName}} +
diff --git a/views/fragments/recentDetections.html b/views/fragments/recentDetections.html index 5057aa6b..5660e902 100644 --- a/views/fragments/recentDetections.html +++ b/views/fragments/recentDetections.html @@ -6,8 +6,9 @@ Date Time - Common Name - Confidence + Common Name + Thumbnail + Confidence Recording @@ -24,8 +25,13 @@ hx-trigger="click" hx-push-url="true"> {{ .CommonName}} + + - Bird Image + +
+ {{thumbnailAttribution .ScientificName}} +
@@ -68,7 +74,12 @@ {{title .CommonName}} - Bird Image +
+ +
+ {{thumbnailAttribution .ScientificName}} +
+
{{confidence .Confidence}}