From fc0de695b7f72de8df1c87b26c3a2f8793555b39 Mon Sep 17 00:00:00 2001 From: Tomi Hakala Date: Sat, 22 Jun 2024 13:12:55 +0300 Subject: [PATCH] feat:_ add settings to enable thumbnails on dashboard --- assets/custom.css | 23 ++++++ internal/analysis/realtime.go | 9 ++- internal/conf/config.go | 14 +++- internal/conf/config.yaml | 5 ++ internal/conf/defaults.go | 4 + internal/httpcontroller/handlers.go | 28 +++++-- internal/httpcontroller/init.go | 20 ++--- internal/httpcontroller/utils.go | 97 ++++++++----------------- internal/imageprovider/imageprovider.go | 12 +-- internal/imageprovider/wikipedia.go | 16 ++-- views/fragments/birdsTableHTML.html | 11 ++- views/fragments/recentDetections.html | 31 ++++++-- 12 files changed, 161 insertions(+), 109 deletions(-) diff --git a/assets/custom.css b/assets/custom.css index 851242e..55db73e 100644 --- a/assets/custom.css +++ b/assets/custom.css @@ -19,6 +19,29 @@ box-shadow: 0 0 0 2px rgba(164, 202, 254, 0.45); } +.thumbnail-container { + position: relative; + display: inline-block; +} + +.thumbnail-tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + z-index: 10; +} + +.thumbnail-container:hover .thumbnail-tooltip { + display: block; +} + /* Define your custom background colors here if the default Tailwind classes aren't working */ .bg-confidence-high { background-color: #10b981; } /* Green for high confidence */ .bg-confidence-medium { background-color: #f97316; } /* Orange for average confidence */ diff --git a/internal/analysis/realtime.go b/internal/analysis/realtime.go index d665ac5..b7cc63b 100644 --- a/internal/analysis/realtime.go +++ b/internal/analysis/realtime.go @@ -110,8 +110,13 @@ func RealtimeAnalysis(settings *conf.Settings) error { log.Fatalf("Error initializing metrics: %v", err) } - // Intialize bird image cache - birdImageCache := initBirdImageCache(dataStore, metrics) + var birdImageCache *imageprovider.BirdImageCache + if settings.Realtime.Dashboard.Thumbnails.Summary || settings.Realtime.Dashboard.Thumbnails.Recent { + // Initialize the bird image cache + birdImageCache = initBirdImageCache(dataStore, metrics) + } else { + birdImageCache = nil + } // Start worker pool for processing detections processor.New(settings, dataStore, bn, metrics, birdImageCache) diff --git a/internal/conf/config.go b/internal/conf/config.go index 1e5c96b..befbd94 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -1,4 +1,4 @@ -// conf/config.go +// config.go: This file contains the configuration for the BirdNET-Go application. It defines the settings struct and functions to load and save the settings. package conf import ( @@ -17,11 +17,18 @@ import ( //go:embed config.yaml var configFiles embed.FS +type Dashboard struct { + Thumbnails struct { + Summary bool // show thumbnails on summary table + Recent bool // show thumbnails on recent table + } +} + type Settings struct { Debug bool // true to enable debug mode Main struct { - Name string // name of go-birdnet node, can be used to identify source of notes + Name string // name of BirdNET-Go node, can be used to identify source of notes TimeAs24h bool // true 24-hour time format, false 12-hour time format Log LogConfig } @@ -66,6 +73,9 @@ type Settings struct { } } + // Apply dashboard settings + Dashboard Dashboard + DynamicThreshold struct { Enabled bool // true to enable dynamic threshold Debug bool // true to enable debug mode diff --git a/internal/conf/config.yaml b/internal/conf/config.yaml index 296d654..cd943db 100644 --- a/internal/conf/config.yaml +++ b/internal/conf/config.yaml @@ -44,6 +44,11 @@ realtime: maxusage: 80% # usage policy: percentage of disk usage to trigger eviction minclips: 10 # minumum number of clips per species to keep before starting evictions + dashboard: + thumbnails: + summary: false + recent: true + dynamicthreshold: enabled: true # true to enable dynamic confidence threshold trigger: 0.90 # dynamic threshold is activated on detections at this confidence level diff --git a/internal/conf/defaults.go b/internal/conf/defaults.go index de9a170..c41eb38 100644 --- a/internal/conf/defaults.go +++ b/internal/conf/defaults.go @@ -44,6 +44,10 @@ func setDefaultConfig() { viper.SetDefault("realtime.audio.export.path", "clips/") viper.SetDefault("realtime.audio.export.type", "wav") + // Dashboard thumbnails configuration + viper.SetDefault("realtime.dashboard.thumbnails.summary", false) + viper.SetDefault("realtime.dashboard.thumbnails.recent", true) + // Retention policy configuration viper.SetDefault("realtime.audio.export.retention.enabled", true) viper.SetDefault("realtime.audio.export.retention.debug", false) diff --git a/internal/httpcontroller/handlers.go b/internal/httpcontroller/handlers.go index 29bb1f2..939c937 100644 --- a/internal/httpcontroller/handlers.go +++ b/internal/httpcontroller/handlers.go @@ -1,4 +1,4 @@ -// httpcontroller/handlers.go +// handlers.go: This file contains the request handlers for the web server. package httpcontroller import ( @@ -152,13 +152,15 @@ func (s *Server) topBirdsHandler(c echo.Context) error { // Preparing data for rendering in the template data := struct { - NotesWithIndex []NoteWithIndex - Hours []int - SelectedDate string + NotesWithIndex []NoteWithIndex + Hours []int + SelectedDate string + DashboardSettings *conf.Dashboard }{ - NotesWithIndex: notesWithIndex, - Hours: hours, - SelectedDate: selectedDate, + NotesWithIndex: notesWithIndex, + Hours: hours, + SelectedDate: selectedDate, + DashboardSettings: s.DashboardSettings, } // Render the birdsTableHTML template with the data @@ -360,13 +362,23 @@ func (s *Server) searchHandler(c echo.Context) error { func (s *Server) getLastDetections(c echo.Context) error { numDetections := parseNumDetections(c.QueryParam("numDetections"), 10) // Default value is 10 + // Retrieve the last detections from the database notes, err := s.ds.GetLastDetections(numDetections) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Error fetching detections"}) } + // Preparing data for rendering in the template + data := struct { + Notes []datastore.Note + DashboardSettings conf.Dashboard + }{ + Notes: notes, + DashboardSettings: *s.DashboardSettings, + } + // render the recentDetections template with the data - return c.Render(http.StatusOK, "recentDetections", notes) + return c.Render(http.StatusOK, "recentDetections", data) } // serveSpectrogramHandler serves or generates a spectrogram for a given clip. diff --git a/internal/httpcontroller/init.go b/internal/httpcontroller/init.go index cbf52c5..494e8d7 100644 --- a/internal/httpcontroller/init.go +++ b/internal/httpcontroller/init.go @@ -28,11 +28,12 @@ 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 - BirdImageCache *imageprovider.BirdImageCache + Echo *echo.Echo // Echo framework instance + ds datastore.Interface // Datastore interface + Settings *conf.Settings // Application settings + DashboardSettings *conf.Dashboard // Dashboard settings + Logger *logger.Logger // Custom logger + BirdImageCache *imageprovider.BirdImageCache } // New initializes a new HTTP server with given context and datastore. @@ -41,10 +42,11 @@ func New(settings *conf.Settings, dataStore datastore.Interface, birdImageCache configureDefaultSettings(settings) s := &Server{ - Echo: echo.New(), - ds: dataStore, - Settings: settings, - BirdImageCache: birdImageCache, + Echo: echo.New(), + ds: dataStore, + Settings: settings, + BirdImageCache: birdImageCache, + DashboardSettings: &settings.Realtime.Dashboard, } // Server initialization diff --git a/internal/httpcontroller/utils.go b/internal/httpcontroller/utils.go index 94d3aa3..b9f384f 100644 --- a/internal/httpcontroller/utils.go +++ b/internal/httpcontroller/utils.go @@ -1,8 +1,9 @@ -// utils.go +// utils.go: This file contains utility functions for the HTTP controller package. package httpcontroller import ( "fmt" + "html" "html/template" "log" "os" @@ -142,61 +143,6 @@ func (s *Server) getSpectrogramPath(wavFileName string, width int) (string, erro return webFriendlyPath, nil } -/* -// wrapNotesWithSpectrogram wraps notes with their corresponding spectrogram paths. -func (s *Server) wrapNotesWithSpectrogram(notes []datastore.Note) ([]NoteWithSpectrogram, error) { - notesWithSpectrogram := make([]NoteWithSpectrogram, len(notes)) - - // Create a channel to communicate between goroutines for results - type result struct { - index int - path string - err error - } - results := make(chan result, len(notes)) - - // Create a channel to limit the number of concurrent goroutines - semaphore := make(chan struct{}, 4) // Limit to 4 - - // Set the width of the spectrogram in pixels - const width = 400 // pixels - - for i, note := range notes { - // Acquire a slot in the semaphore before starting a goroutine - semaphore <- struct{}{} - - // Launch a goroutine for each spectrogram generation - go func(i int, note datastore.Note) { - defer func() { <-semaphore }() // Release the slot when done - - spectrogramPath, err := s.getSpectrogramPath(note.ClipName, width) - results <- result{i, spectrogramPath, err} - }(i, note) - } - - // Wait for all goroutines to finish - for i := 0; i < len(notes); i++ { - res := <-results - if res.err != nil { - log.Printf("Error generating spectrogram for %s: %v", notes[res.index].ClipName, res.err) - continue - } - notesWithSpectrogram[res.index] = NoteWithSpectrogram{ - Note: notes[res.index], - Spectrogram: res.path, - } - } - - // Wait for all slots to be released ensuring all goroutines have completed - for i := 0; i < cap(semaphore); i++ { - semaphore <- struct{}{} - } - close(results) - close(semaphore) - - return notesWithSpectrogram, nil -}*/ - // sumHourlyCounts calculates the total counts from hourly counts. func sumHourlyCounts(hourlyCounts [24]int) int { total := 0 @@ -239,35 +185,56 @@ func parseOffset(offsetStr string, defaultOffset int) int { return offset } -// thumbnail returns the url of a given bird's thumbnail +// Thumbnail returns the URL of a given bird's thumbnail image. +// It takes the bird's scientific name as input and returns the image URL as a string. +// If the image is not found or an error occurs, it returns an empty string. func (s *Server) thumbnail(scientificName string) string { + if s.BirdImageCache == nil { + // Return empty string if the cache is not initialized + return "" + } + birdImage, err := s.BirdImageCache.Get(scientificName) if err != nil { + // Return empty string if an error occurs return "" } - return birdImage.Url + return birdImage.URL } -// thumbnailAttribution returns the thumbnail credits of a given bird. +// ThumbnailAttribution returns the HTML-formatted attribution for a bird's thumbnail image. +// It takes the bird's scientific name as input and returns a template.HTML string. +// If the attribution information is incomplete or an error occurs, it returns an empty template.HTML. func (s *Server) thumbnailAttribution(scientificName string) template.HTML { + if s.BirdImageCache == nil { + // Return empty string if the cache is not initialized + return 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("") } - // Skip if no author or license information if birdImage.AuthorName == "" || birdImage.LicenseName == "" { return template.HTML("") } - var toReturn string - if birdImage.AuthorUrl == "" { - toReturn = fmt.Sprintf("© %s / %s", birdImage.AuthorName, birdImage.LicenseUrl, birdImage.LicenseName) + var attribution string + if birdImage.AuthorURL == "" { + attribution = fmt.Sprintf("© %s / %s", + html.EscapeString(birdImage.AuthorName), + html.EscapeString(birdImage.LicenseURL), + html.EscapeString(birdImage.LicenseName)) } else { - toReturn = fmt.Sprintf("© %s / %s", birdImage.AuthorUrl, birdImage.AuthorName, birdImage.LicenseUrl, birdImage.LicenseName) + attribution = fmt.Sprintf("© %s / %s", + html.EscapeString(birdImage.AuthorURL), + html.EscapeString(birdImage.AuthorName), + html.EscapeString(birdImage.LicenseURL), + html.EscapeString(birdImage.LicenseName)) } - return template.HTML(toReturn) + return template.HTML(attribution) } diff --git a/internal/imageprovider/imageprovider.go b/internal/imageprovider/imageprovider.go index e2fefd3..52e6bf4 100644 --- a/internal/imageprovider/imageprovider.go +++ b/internal/imageprovider/imageprovider.go @@ -13,11 +13,11 @@ type ImageProvider interface { } type BirdImage struct { - Url string + URL string LicenseName string - LicenseUrl string + LicenseURL string AuthorName string - AuthorUrl string + AuthorURL string } // BirdImageCache represents a cache for bird images. @@ -112,9 +112,9 @@ func (c *BirdImageCache) fetch(scientificName string) (BirdImage, error) { // EstimateSize estimates the memory size of a BirdImage instance in bytes func (img *BirdImage) EstimateSize() int { return int(unsafe.Sizeof(*img)) + - len(img.Url) + len(img.LicenseName) + - len(img.LicenseUrl) + len(img.AuthorName) + - len(img.AuthorUrl) + len(img.URL) + len(img.LicenseName) + + len(img.LicenseURL) + len(img.AuthorName) + + len(img.AuthorURL) } // MemoryUsage returns the approximate memory usage of the image cache in bytes diff --git a/internal/imageprovider/wikipedia.go b/internal/imageprovider/wikipedia.go index 4ebc0ca..5a410f8 100644 --- a/internal/imageprovider/wikipedia.go +++ b/internal/imageprovider/wikipedia.go @@ -18,9 +18,9 @@ type wikiMediaProvider struct { type wikiMediaAuthor struct { name string - url string + URL string licenseName string - licenseUrl string + licenseURL string } // NewWikiMediaProvider creates a new Wikipedia media provider @@ -56,7 +56,7 @@ func (l *wikiMediaProvider) queryAndGetFirstPage(params map[string]string) (*jas // 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) + thumbnailURL, thumbnailSourceFile, err := l.queryThumbnail(scientificName) if err != nil { return BirdImage{}, fmt.Errorf("failed to query thumbnail of bird: %s : %w", scientificName, err) } @@ -69,11 +69,11 @@ func (l *wikiMediaProvider) fetch(scientificName string) (BirdImage, error) { // Return the bird image struct with the image URL and author information return BirdImage{ - Url: thumbnailUrl, + URL: thumbnailURL, AuthorName: authorInfo.name, - AuthorUrl: authorInfo.url, + AuthorURL: authorInfo.URL, LicenseName: authorInfo.licenseName, - LicenseUrl: authorInfo.licenseUrl, + LicenseURL: authorInfo.licenseURL, }, nil } @@ -157,9 +157,9 @@ func (l *wikiMediaProvider) queryAuthorInfo(thumbnailURL string) (*wikiMediaAuth return &wikiMediaAuthor{ name: text, - url: href, + URL: href, licenseName: licenseName, - licenseUrl: licenseURL, + licenseURL: licenseURL, }, nil } diff --git a/views/fragments/birdsTableHTML.html b/views/fragments/birdsTableHTML.html index f012fc1..c3b4a79 100644 --- a/views/fragments/birdsTableHTML.html +++ b/views/fragments/birdsTableHTML.html @@ -5,7 +5,9 @@ Species + {{if .DashboardSettings.Thumbnails.Summary}} Thumbnail + {{end}} Detections {{range .Hours}} {{printf "%02d" .}} @@ -28,9 +30,16 @@ + {{if $.DashboardSettings.Thumbnails.Summary}} - +
+ + +
+ {{end}} diff --git a/views/fragments/recentDetections.html b/views/fragments/recentDetections.html index 490ca26..b4e2096 100644 --- a/views/fragments/recentDetections.html +++ b/views/fragments/recentDetections.html @@ -1,19 +1,20 @@ {{define "recentDetections"}} - + {{if .DashboardSettings.Thumbnails.Recent}} + {{end}} - {{range .}} + {{range .Notes}} @@ -26,10 +27,19 @@ hx-push-url="true"> {{ .CommonName}} + - + {{end}} +