mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-13 02:37:18 +03:00
feat(Insights): add anonymous usage data collection (#3543)
* feat(insights): initial code (WIP) * feat(insights): add more info * feat(insights): add fs info * feat(insights): export insights.Data Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): more config info Signed-off-by: Deluan <deluan@navidrome.org> * refactor(insights): move data struct to its own package Signed-off-by: Deluan <deluan@navidrome.org> * refactor(insights): omit some attrs if empty Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): send insights to server, add option to disable Signed-off-by: Deluan <deluan@navidrome.org> * fix(insights): remove info about anonymous login Signed-off-by: Deluan <deluan@navidrome.org> * chore(insights): fix lint Signed-off-by: Deluan <deluan@navidrome.org> * fix(insights): disable collector if EnableExternalServices is false Signed-off-by: Deluan <deluan@navidrome.org> * fix(insights): fix type casting for 32bit platforms Signed-off-by: Deluan <deluan@navidrome.org> * fix(insights): remove EnableExternalServices from the collection (as it will always be false) Signed-off-by: Deluan <deluan@navidrome.org> * chore(insights): fix lint Signed-off-by: Deluan <deluan@navidrome.org> * refactor(insights): rename function for consistency Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): log the data sent to the collector server Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): add last collection timestamp to the "about" dialog. Also add opt-out info to the SignUp form Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): only sends the initial data collection after an admin user is created Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): remove dangling comment Signed-off-by: Deluan <deluan@navidrome.org> * feat(insights): Translate insights messages Signed-off-by: Deluan <deluan@navidrome.org> * fix(insights): reporting empty library Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move URL to consts.js Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
bc3576e092
commit
8e2052ff95
2
Makefile
2
Makefile
@ -60,7 +60,7 @@ format: ##@Development Format code
|
|||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go run github.com/google/wire/cmd/wire@latest ./...
|
go run github.com/google/wire/cmd/wire@latest gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
|
19
cmd/root.go
19
cmd/root.go
@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
@ -80,6 +80,7 @@ func runNavidrome(ctx context.Context) {
|
|||||||
g.Go(startPlaybackServer(ctx))
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(schedulePeriodicScan(ctx))
|
g.Go(schedulePeriodicScan(ctx))
|
||||||
g.Go(schedulePeriodicBackup(ctx))
|
g.Go(schedulePeriodicBackup(ctx))
|
||||||
|
g.Go(startInsightsCollector(ctx))
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
@ -111,7 +112,7 @@ func startServer(ctx context.Context) func() error {
|
|||||||
}
|
}
|
||||||
if conf.Server.Prometheus.Enabled {
|
if conf.Server.Prometheus.Enabled {
|
||||||
// blocking call because takes <1ms but useful if fails
|
// blocking call because takes <1ms but useful if fails
|
||||||
core.WriteInitialMetrics()
|
metrics.WriteInitialMetrics()
|
||||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
||||||
}
|
}
|
||||||
if conf.Server.DevEnableProfiler {
|
if conf.Server.DevEnableProfiler {
|
||||||
@ -199,6 +200,20 @@ func startScheduler(ctx context.Context) func() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
|
||||||
|
func startInsightsCollector(ctx context.Context) func() error {
|
||||||
|
return func() error {
|
||||||
|
if !conf.Server.EnableInsightsCollector {
|
||||||
|
log.Info(ctx, "Insight Collector is DISABLED")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Info(ctx, "Starting Insight Collector")
|
||||||
|
ic := CreateInsights()
|
||||||
|
ic.Run(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
// It is responsible for the Jukebox functionality
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
@ -29,25 +30,27 @@ import (
|
|||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
|
|
||||||
func CreateServer(musicFolder string) *server.Server {
|
func CreateServer(musicFolder string) *server.Server {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
serverServer := server.New(dataStore, broker)
|
insights := metrics.GetInstance(dataStore)
|
||||||
|
serverServer := server.New(dataStore, broker, insights)
|
||||||
return serverServer
|
return serverServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
router := nativeapi.New(dataStore, share, playlists)
|
insights := metrics.GetInstance(dataStore)
|
||||||
|
router := nativeapi.New(dataStore, share, playlists, insights)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.New(dataStore)
|
agentsAgents := agents.New(dataStore)
|
||||||
@ -69,8 +72,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.New(dataStore)
|
agentsAgents := agents.New(dataStore)
|
||||||
@ -85,22 +88,29 @@ func CreatePublicRouter() *public.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreateLastFMRouter() *lastfm.Router {
|
func CreateLastFMRouter() *lastfm.Router {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
router := lastfm.NewRouter(dataStore)
|
router := lastfm.NewRouter(dataStore)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
router := listenbrainz.NewRouter(dataStore)
|
router := listenbrainz.NewRouter(dataStore)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateInsights() metrics.Insights {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
dataStore := persistence.New(sqlDB)
|
||||||
|
insights := metrics.GetInstance(dataStore)
|
||||||
|
return insights
|
||||||
|
}
|
||||||
|
|
||||||
func GetScanner() scanner.Scanner {
|
func GetScanner() scanner.Scanner {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
@ -114,8 +124,8 @@ func GetScanner() scanner.Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetPlaybackServer() playback.PlaybackServer {
|
func GetPlaybackServer() playback.PlaybackServer {
|
||||||
dbDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(dbDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
@ -70,6 +71,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateInsights() metrics.Insights {
|
||||||
|
panic(wire.Build(
|
||||||
|
allProviders,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func GetScanner() scanner.Scanner {
|
func GetScanner() scanner.Scanner {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
@ -42,6 +42,7 @@ type configOptions struct {
|
|||||||
EnableTranscodingConfig bool
|
EnableTranscodingConfig bool
|
||||||
EnableDownloads bool
|
EnableDownloads bool
|
||||||
EnableExternalServices bool
|
EnableExternalServices bool
|
||||||
|
EnableInsightsCollector bool
|
||||||
EnableMediaFileCoverArt bool
|
EnableMediaFileCoverArt bool
|
||||||
TranscodingCacheSize string
|
TranscodingCacheSize string
|
||||||
ImageCacheSize string
|
ImageCacheSize string
|
||||||
@ -295,6 +296,7 @@ func parseIniFileConfiguration() {
|
|||||||
|
|
||||||
func disableExternalServices() {
|
func disableExternalServices() {
|
||||||
log.Info("All external integrations are DISABLED!")
|
log.Info("All external integrations are DISABLED!")
|
||||||
|
Server.EnableInsightsCollector = false
|
||||||
Server.LastFM.Enabled = false
|
Server.LastFM.Enabled = false
|
||||||
Server.Spotify.ID = ""
|
Server.Spotify.ID = ""
|
||||||
Server.ListenBrainz.Enabled = false
|
Server.ListenBrainz.Enabled = false
|
||||||
@ -412,6 +414,7 @@ func init() {
|
|||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("gatrackingid", "")
|
viper.SetDefault("gatrackingid", "")
|
||||||
|
viper.SetDefault("enableinsightscollector", true)
|
||||||
viper.SetDefault("enablelogredacting", true)
|
viper.SetDefault("enablelogredacting", true)
|
||||||
viper.SetDefault("authrequestlimit", 5)
|
viper.SetDefault("authrequestlimit", 5)
|
||||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||||
|
@ -86,6 +86,12 @@ const (
|
|||||||
AlbumPlayCountModeNormalized = "normalized"
|
AlbumPlayCountModeNormalized = "normalized"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
InsightsIDKey = "InsightsID"
|
||||||
|
InsightsEndpoint = "https://insights.navidrome.org/collect"
|
||||||
|
InsightsUpdateInterval = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
DefaultDownsamplingFormat = "opus"
|
DefaultDownsamplingFormat = "opus"
|
||||||
DefaultTranscodings = []struct {
|
DefaultTranscodings = []struct {
|
||||||
|
227
core/metrics/insights.go
Normal file
227
core/metrics/insights.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Insights interface {
|
||||||
|
Run(ctx context.Context)
|
||||||
|
LastRun(ctx context.Context) (timestamp time.Time, success bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
insightsID string
|
||||||
|
)
|
||||||
|
|
||||||
|
type insightsCollector struct {
|
||||||
|
ds model.DataStore
|
||||||
|
lastRun time.Time
|
||||||
|
lastStatus bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstance(ds model.DataStore) Insights {
|
||||||
|
return singleton.GetInstance(func() *insightsCollector {
|
||||||
|
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
||||||
|
id = uuid.NewString()
|
||||||
|
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace("Could not save Insights ID to DB", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insightsID = id
|
||||||
|
return &insightsCollector{ds: ds}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *insightsCollector) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
c.sendInsights(ctx)
|
||||||
|
select {
|
||||||
|
case <-time.After(consts.InsightsUpdateInterval):
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
|
||||||
|
return c.lastRun, c.lastStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||||
|
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Could not check user count", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
log.Trace(ctx, "No users found, skipping Insights data collection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
|
data := c.collect(ctx)
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := bytes.NewReader(data)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Could not create Insights request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Could not send Insights data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
|
||||||
|
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
|
||||||
|
c.lastRun = time.Now()
|
||||||
|
c.lastStatus = resp.StatusCode < 300
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfo() (map[string]string, string) {
|
||||||
|
bInfo := map[string]string{}
|
||||||
|
var version string
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, setting := range info.Settings {
|
||||||
|
if setting.Value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bInfo[setting.Key] = setting.Value
|
||||||
|
}
|
||||||
|
version = info.GoVersion
|
||||||
|
}
|
||||||
|
return bInfo, version
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFSInfo(path string) *insights.FSInfo {
|
||||||
|
var info insights.FSInfo
|
||||||
|
|
||||||
|
// Normalize the path
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
absPath = filepath.Clean(absPath)
|
||||||
|
|
||||||
|
fsType, err := getFilesystemType(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info.Type = fsType
|
||||||
|
return &info
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticData = sync.OnceValue(func() insights.Data {
|
||||||
|
// Basic info
|
||||||
|
data := insights.Data{
|
||||||
|
InsightsID: insightsID,
|
||||||
|
Version: consts.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build info
|
||||||
|
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||||
|
|
||||||
|
// OS info
|
||||||
|
data.OS.Type = runtime.GOOS
|
||||||
|
data.OS.Arch = runtime.GOARCH
|
||||||
|
data.OS.NumCPU = runtime.NumCPU()
|
||||||
|
data.OS.Version, data.OS.Distro = getOSVersion()
|
||||||
|
|
||||||
|
// FS info
|
||||||
|
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
|
||||||
|
data.FS.Data = getFSInfo(conf.Server.DataFolder)
|
||||||
|
if conf.Server.CacheFolder != "" {
|
||||||
|
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
|
||||||
|
}
|
||||||
|
if conf.Server.Backup.Path != "" {
|
||||||
|
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config info
|
||||||
|
data.Config.LogLevel = conf.Server.LogLevel
|
||||||
|
data.Config.LogFileConfigured = conf.Server.LogFile != ""
|
||||||
|
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||||
|
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||||
|
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||||
|
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||||
|
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||||
|
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||||
|
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||||
|
data.Config.EnableLastFM = conf.Server.LastFM.Enabled
|
||||||
|
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
|
||||||
|
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
|
||||||
|
data.Config.EnableSpotify = conf.Server.Spotify.ID != ""
|
||||||
|
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
|
||||||
|
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
|
||||||
|
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||||
|
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||||
|
data.Config.ScanSchedule = conf.Server.ScanSchedule
|
||||||
|
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||||
|
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||||
|
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||||
|
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||||
|
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||||
|
data.Config.BackupCount = conf.Server.Backup.Count
|
||||||
|
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||||
|
data := staticData()
|
||||||
|
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
|
||||||
|
|
||||||
|
// Library info
|
||||||
|
data.Library.Tracks, _ = c.ds.MediaFile(ctx).CountAll()
|
||||||
|
data.Library.Albums, _ = c.ds.Album(ctx).CountAll()
|
||||||
|
data.Library.Artists, _ = c.ds.Artist(ctx).CountAll()
|
||||||
|
data.Library.Playlists, _ = c.ds.Playlist(ctx).Count()
|
||||||
|
data.Library.Shares, _ = c.ds.Share(ctx).CountAll()
|
||||||
|
data.Library.Radios, _ = c.ds.Radio(ctx).Count()
|
||||||
|
data.Library.ActiveUsers, _ = c.ds.User(ctx).CountAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Memory info
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
data.Mem.Alloc = m.Alloc
|
||||||
|
data.Mem.TotalAlloc = m.TotalAlloc
|
||||||
|
data.Mem.Sys = m.Sys
|
||||||
|
data.Mem.NumGC = m.NumGC
|
||||||
|
|
||||||
|
// Marshal to JSON
|
||||||
|
resp, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Could not marshal Insights data", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
71
core/metrics/insights/data.go
Normal file
71
core/metrics/insights/data.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package insights
|
||||||
|
|
||||||
|
type Data struct {
|
||||||
|
InsightsID string `json:"id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Uptime int64 `json:"uptime"`
|
||||||
|
Build struct {
|
||||||
|
// build settings used by the Go compiler
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
GoVersion string `json:"goVersion"`
|
||||||
|
} `json:"build"`
|
||||||
|
OS struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Distro string `json:"distro,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
NumCPU int `json:"numCPU"`
|
||||||
|
} `json:"os"`
|
||||||
|
Mem struct {
|
||||||
|
Alloc uint64 `json:"alloc"`
|
||||||
|
TotalAlloc uint64 `json:"totalAlloc"`
|
||||||
|
Sys uint64 `json:"sys"`
|
||||||
|
NumGC uint32 `json:"numGC"`
|
||||||
|
} `json:"mem"`
|
||||||
|
FS struct {
|
||||||
|
Music *FSInfo `json:"music,omitempty"`
|
||||||
|
Data *FSInfo `json:"data,omitempty"`
|
||||||
|
Cache *FSInfo `json:"cache,omitempty"`
|
||||||
|
Backup *FSInfo `json:"backup,omitempty"`
|
||||||
|
} `json:"fs"`
|
||||||
|
Library struct {
|
||||||
|
Tracks int64 `json:"tracks"`
|
||||||
|
Albums int64 `json:"albums"`
|
||||||
|
Artists int64 `json:"artists"`
|
||||||
|
Playlists int64 `json:"playlists"`
|
||||||
|
Shares int64 `json:"shares"`
|
||||||
|
Radios int64 `json:"radios"`
|
||||||
|
ActiveUsers int64 `json:"activeUsers"`
|
||||||
|
} `json:"library"`
|
||||||
|
Config struct {
|
||||||
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
|
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||||
|
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||||
|
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||||
|
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||||
|
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||||
|
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||||
|
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||||
|
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||||
|
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||||
|
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||||
|
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||||
|
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||||
|
EnableSpotify bool `json:"enableSpotify,omitempty"`
|
||||||
|
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||||
|
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||||
|
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||||
|
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||||
|
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||||
|
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||||
|
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||||
|
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||||
|
BackupCount int `json:"backupCount,omitempty"`
|
||||||
|
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||||
|
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FSInfo struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
37
core/metrics/insights_darwin.go
Normal file
37
core/metrics/insights_darwin.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getOSVersion() (string, string) {
|
||||||
|
cmd := exec.Command("sw_vers", "-productVersion")
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFilesystemType(path string) (string, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
err := syscall.Statfs(path, &stat)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the filesystem type name from [16]int8 to string
|
||||||
|
fsType := make([]byte, 0, 16)
|
||||||
|
for _, c := range stat.Fstypename {
|
||||||
|
if c == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fsType = append(fsType, byte(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(fsType), nil
|
||||||
|
}
|
9
core/metrics/insights_default.go
Normal file
9
core/metrics/insights_default.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux && !windows && !darwin
|
||||||
|
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func getOSVersion() (string, string) { return "", "" }
|
||||||
|
|
||||||
|
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }
|
77
core/metrics/insights_linux.go
Normal file
77
core/metrics/insights_linux.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getOSVersion() (string, string) {
|
||||||
|
file, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
osRelease, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(osRelease), "\n")
|
||||||
|
version := ""
|
||||||
|
distro := ""
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||||
|
version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "ID=") {
|
||||||
|
distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return version, distro
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountInfo represents an entry from /proc/self/mountinfo
|
||||||
|
type MountInfo struct {
|
||||||
|
MountPoint string
|
||||||
|
FSType string
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsTypeMap = map[int64]string{
|
||||||
|
// Add filesystem type mappings
|
||||||
|
0x9123683E: "btrfs",
|
||||||
|
0x0000EF53: "ext2/ext3/ext4",
|
||||||
|
0x00006969: "nfs",
|
||||||
|
0x58465342: "xfs",
|
||||||
|
0x2FC12FC1: "zfs",
|
||||||
|
0x01021994: "tmpfs",
|
||||||
|
0x28cd3d45: "cramfs",
|
||||||
|
0x64626720: "debugfs",
|
||||||
|
0x73717368: "squashfs",
|
||||||
|
0x62656572: "sysfs",
|
||||||
|
0x9fa0: "proc",
|
||||||
|
0x61756673: "aufs",
|
||||||
|
0x794c7630: "overlayfs",
|
||||||
|
0x6a656a63: "fakeowner", // FS inside a container
|
||||||
|
// Include other filesystem types as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFilesystemType(path string) (string, error) {
|
||||||
|
var fsStat syscall.Statfs_t
|
||||||
|
err := syscall.Statfs(path, &fsStat)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fsType := fsStat.Type
|
||||||
|
|
||||||
|
fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert
|
||||||
|
if !exists {
|
||||||
|
fsName = fmt.Sprintf("unknown(0x%x)", fsType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fsName, nil
|
||||||
|
}
|
53
core/metrics/insights_windows.go
Normal file
53
core/metrics/insights_windows.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ex: Microsoft Windows [Version 10.0.26100.1742]
|
||||||
|
var winVerRegex = regexp.MustCompile(`Microsoft Windows \[Version ([\d\.]+)\]`)
|
||||||
|
|
||||||
|
func getOSVersion() (version string, _ string) {
|
||||||
|
cmd := exec.Command("cmd", "/c", "ver")
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := winVerRegex.FindStringSubmatch(string(output))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return string(output), ""
|
||||||
|
}
|
||||||
|
return matches[1], ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFilesystemType(path string) (string, error) {
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumeName, filesystemName [windows.MAX_PATH + 1]uint16
|
||||||
|
var serialNumber uint32
|
||||||
|
var maxComponentLen, filesystemFlags uint32
|
||||||
|
|
||||||
|
err = windows.GetVolumeInformation(
|
||||||
|
pathPtr,
|
||||||
|
&volumeName[0],
|
||||||
|
windows.MAX_PATH,
|
||||||
|
&serialNumber,
|
||||||
|
&maxComponentLen,
|
||||||
|
&filesystemFlags,
|
||||||
|
&filesystemName[0],
|
||||||
|
windows.MAX_PATH)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows.UTF16ToString(filesystemName[:]), nil
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package core
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
)
|
)
|
||||||
@ -20,4 +21,5 @@ var Set = wire.NewSet(
|
|||||||
ffmpeg.New,
|
ffmpeg.New,
|
||||||
scrobbler.GetPlayTracker,
|
scrobbler.GetPlayTracker,
|
||||||
playback.GetInstance,
|
playback.GetInstance,
|
||||||
|
metrics.GetInstance,
|
||||||
)
|
)
|
||||||
|
2
go.mod
2
go.mod
@ -54,6 +54,7 @@ require (
|
|||||||
golang.org/x/image v0.23.0
|
golang.org/x/image v0.23.0
|
||||||
golang.org/x/net v0.32.0
|
golang.org/x/net v0.32.0
|
||||||
golang.org/x/sync v0.10.0
|
golang.org/x/sync v0.10.0
|
||||||
|
golang.org/x/sys v0.28.0
|
||||||
golang.org/x/text v0.21.0
|
golang.org/x/text v0.21.0
|
||||||
golang.org/x/time v0.8.0
|
golang.org/x/time v0.8.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@ -105,7 +106,6 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.30.0 // indirect
|
golang.org/x/crypto v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
|
||||||
golang.org/x/tools v0.27.0 // indirect
|
golang.org/x/tools v0.27.0 // indirect
|
||||||
google.golang.org/protobuf v1.35.1 // indirect
|
google.golang.org/protobuf v1.35.1 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
@ -52,4 +52,5 @@ type ShareRepository interface {
|
|||||||
Exists(id string) (bool, error)
|
Exists(id string) (bool, error)
|
||||||
Get(id string) (*Share, error)
|
Get(id string) (*Share, error)
|
||||||
GetAll(options ...QueryOptions) (Shares, error)
|
GetAll(options ...QueryOptions) (Shares, error)
|
||||||
|
CountAll(options ...QueryOptions) (int64, error)
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuild
|
|||||||
func (r *shareRepository) Exists(id string) (bool, error) {
|
func (r *shareRepository) Exists(id string) (bool, error) {
|
||||||
return r.exists(Select().Where(Eq{"id": id}))
|
return r.exists(Select().Where(Eq{"id": id}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
||||||
sel := r.selectShare().Where(Eq{"share.id": id})
|
sel := r.selectShare().Where(Eq{"share.id": id})
|
||||||
var res model.Share
|
var res model.Share
|
||||||
|
@ -212,7 +212,8 @@
|
|||||||
"password": "Senha",
|
"password": "Senha",
|
||||||
"sign_in": "Entrar",
|
"sign_in": "Entrar",
|
||||||
"sign_in_error": "Erro na autenticação, tente novamente.",
|
"sign_in_error": "Erro na autenticação, tente novamente.",
|
||||||
"logout": "Sair"
|
"logout": "Sair",
|
||||||
|
"insightsCollectionNote": "Navidrome coleta dados de uso anônimos para\najudar a melhorar o projeto. Clique [aqui] para\nsaber mais e para desativar se desejar"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalidChars": "Somente use letras e numeros",
|
"invalidChars": "Somente use letras e numeros",
|
||||||
@ -433,7 +434,8 @@
|
|||||||
"links": {
|
"links": {
|
||||||
"homepage": "Website",
|
"homepage": "Website",
|
||||||
"source": "Código fonte",
|
"source": "Código fonte",
|
||||||
"featureRequests": "Solicitar funcionalidade"
|
"featureRequests": "Solicitar funcionalidade",
|
||||||
|
"lastInsightsCollection": "Última coleta de dados"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
@ -209,10 +210,10 @@ func (s *scanner) RescanAll(ctx context.Context, fullRescan bool) error {
|
|||||||
}
|
}
|
||||||
if hasError {
|
if hasError {
|
||||||
log.Error(ctx, "Errors while scanning media. Please check the logs")
|
log.Error(ctx, "Errors while scanning media. Please check the logs")
|
||||||
core.WriteAfterScanMetrics(ctx, s.ds, false)
|
metrics.WriteAfterScanMetrics(ctx, s.ds, false)
|
||||||
return ErrScanError
|
return ErrScanError
|
||||||
}
|
}
|
||||||
core.WriteAfterScanMetrics(ctx, s.ds, true)
|
metrics.WriteAfterScanMetrics(ctx, s.ds, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,11 +3,13 @@ package nativeapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
)
|
)
|
||||||
@ -17,10 +19,11 @@ type Router struct {
|
|||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
share core.Share
|
share core.Share
|
||||||
playlists core.Playlists
|
playlists core.Playlists
|
||||||
|
insights metrics.Insights
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists) *Router {
|
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights) *Router {
|
||||||
r := &Router{ds: ds, share: share, playlists: playlists}
|
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -55,6 +58,16 @@ func (n *Router) routes() http.Handler {
|
|||||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Insights status endpoint
|
||||||
|
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
last, success := n.insights.LastRun(r.Context())
|
||||||
|
if conf.Server.EnableInsightsCollector {
|
||||||
|
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
@ -27,14 +28,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router chi.Router
|
router chi.Router
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
appRoot string
|
appRoot string
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
|
insights metrics.Insights
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, broker events.Broker) *Server {
|
func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server {
|
||||||
s := &Server{ds: ds, broker: broker}
|
s := &Server{ds: ds, broker: broker, insights: insights}
|
||||||
initialSetup(ds)
|
initialSetup(ds)
|
||||||
auth.Init(s.ds)
|
auth.Init(s.ds)
|
||||||
s.initRoutes()
|
s.initRoutes()
|
||||||
|
@ -123,6 +123,7 @@ const Admin = (props) => {
|
|||||||
<Resource name="genre" />,
|
<Resource name="genre" />,
|
||||||
<Resource name="playlistTrack" />,
|
<Resource name="playlistTrack" />,
|
||||||
<Resource name="keepalive" />,
|
<Resource name="keepalive" />,
|
||||||
|
<Resource name="insights" />,
|
||||||
<Player />,
|
<Player />,
|
||||||
]}
|
]}
|
||||||
</RAAdmin>
|
</RAAdmin>
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
export const REST_URL = '/api'
|
export const REST_URL = '/api'
|
||||||
|
|
||||||
|
export const INSIGHTS_DOC_URL =
|
||||||
|
'https://navidrome.org/docs/getting-started/insights'
|
||||||
|
|
||||||
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
|
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
|
||||||
|
|
||||||
export const AUTO_THEME_ID = 'AUTO_THEME_ID'
|
export const AUTO_THEME_ID = 'AUTO_THEME_ID'
|
||||||
|
@ -11,10 +11,11 @@ import TableCell from '@material-ui/core/TableCell'
|
|||||||
import Paper from '@material-ui/core/Paper'
|
import Paper from '@material-ui/core/Paper'
|
||||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||||
import inflection from 'inflection'
|
import inflection from 'inflection'
|
||||||
import { useTranslate } from 'react-admin'
|
import { useGetOne, usePermissions, useTranslate } from 'react-admin'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { DialogTitle } from './DialogTitle'
|
import { DialogTitle } from './DialogTitle'
|
||||||
import { DialogContent } from './DialogContent'
|
import { DialogContent } from './DialogContent'
|
||||||
|
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||||
|
|
||||||
const links = {
|
const links = {
|
||||||
homepage: 'navidrome.org',
|
homepage: 'navidrome.org',
|
||||||
@ -51,6 +52,9 @@ const LinkToVersion = ({ version }) => {
|
|||||||
|
|
||||||
const AboutDialog = ({ open, onClose }) => {
|
const AboutDialog = ({ open, onClose }) => {
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
|
const { permissions } = usePermissions()
|
||||||
|
const { data, loading } = useGetOne('insights', 'insights_status')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
|
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
|
||||||
<DialogTitle id="about-dialog-title" onClose={onClose}>
|
<DialogTitle id="about-dialog-title" onClose={onClose}>
|
||||||
@ -87,6 +91,18 @@ const AboutDialog = ({ open, onClose }) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{permissions === 'admin' ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="right" component="th" scope="row">
|
||||||
|
{translate(`about.links.lastInsightsCollection`)}:
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="left">
|
||||||
|
<Link href={INSIGHTS_DOC_URL}>
|
||||||
|
{(!loading && data?.lastRun) || 'N/A'}{' '}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="right" component="th" scope="row">
|
<TableCell align="right" component="th" scope="row">
|
||||||
<Link
|
<Link
|
||||||
|
@ -214,7 +214,8 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"sign_in_error": "Authentication failed, please retry",
|
"sign_in_error": "Authentication failed, please retry",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"insightsCollectionNote": "Navidrome collects anonymous usage data to\nhelp improve the project. Click [here] to learn\nmore and to opt-out if you want"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalidChars": "Please only use letters and numbers",
|
"invalidChars": "Please only use letters and numbers",
|
||||||
@ -435,7 +436,8 @@
|
|||||||
"links": {
|
"links": {
|
||||||
"homepage": "Home page",
|
"homepage": "Home page",
|
||||||
"source": "Source code",
|
"source": "Source code",
|
||||||
"featureRequests": "Feature requests"
|
"featureRequests": "Feature requests",
|
||||||
|
"lastInsightsCollection": "Last insights collection"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
|
@ -6,6 +6,7 @@ import Button from '@material-ui/core/Button'
|
|||||||
import Card from '@material-ui/core/Card'
|
import Card from '@material-ui/core/Card'
|
||||||
import CardActions from '@material-ui/core/CardActions'
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||||
|
import Link from '@material-ui/core/Link'
|
||||||
import TextField from '@material-ui/core/TextField'
|
import TextField from '@material-ui/core/TextField'
|
||||||
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
|
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
@ -24,6 +25,7 @@ import useCurrentTheme from '../themes/useCurrentTheme'
|
|||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { clearQueue } from '../actions'
|
import { clearQueue } from '../actions'
|
||||||
import { retrieveTranslation } from '../i18n'
|
import { retrieveTranslation } from '../i18n'
|
||||||
|
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@ -81,6 +83,13 @@ const useStyles = makeStyles(
|
|||||||
systemNameLink: {
|
systemNameLink: {
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
},
|
},
|
||||||
|
message: {
|
||||||
|
marginTop: '1em',
|
||||||
|
padding: '0 1em 1em 1em',
|
||||||
|
textAlign: 'center',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: '0.875em',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: 'NDLogin' },
|
{ name: 'NDLogin' },
|
||||||
)
|
)
|
||||||
@ -173,6 +182,62 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const InsightsNotice = ({ url }) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const anchorRegex = /\[(.+?)]/g
|
||||||
|
const originalMsg = translate('ra.auth.insightsCollectionNote')
|
||||||
|
|
||||||
|
// Split the entire message on newlines
|
||||||
|
const lines = originalMsg.split('\n')
|
||||||
|
|
||||||
|
const renderedLines = lines.map((line, lineIndex) => {
|
||||||
|
const segments = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
// Find bracketed text in each line
|
||||||
|
while ((match = anchorRegex.exec(line)) !== null) {
|
||||||
|
// match.index is where "[something]" starts
|
||||||
|
// match[1] is the text inside the brackets
|
||||||
|
const bracketText = match[1]
|
||||||
|
|
||||||
|
// Push the text before the bracket
|
||||||
|
segments.push(line.slice(lastIndex, match.index))
|
||||||
|
|
||||||
|
// Push the <Link> component
|
||||||
|
segments.push(
|
||||||
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
key={`${lineIndex}-${match.index}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{bracketText}
|
||||||
|
</Link>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update lastIndex to the character right after the bracketed text
|
||||||
|
lastIndex = match.index + match[0].length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the remaining text after the last bracket
|
||||||
|
segments.push(line.slice(lastIndex))
|
||||||
|
|
||||||
|
// Return this line’s parts, plus a <br/> if not the last line
|
||||||
|
return (
|
||||||
|
<React.Fragment key={lineIndex}>
|
||||||
|
{segments}
|
||||||
|
{lineIndex < lines.length - 1 && <br />}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div className={classes.message}>{renderedLines}</div>
|
||||||
|
}
|
||||||
|
|
||||||
const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
@ -237,6 +302,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
|||||||
{translate('ra.auth.buttonCreateAdmin')}
|
{translate('ra.auth.buttonCreateAdmin')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
<InsightsNotice url={INSIGHTS_DOC_URL} />
|
||||||
</Card>
|
</Card>
|
||||||
<Notification />
|
<Notification />
|
||||||
</div>
|
</div>
|
||||||
@ -245,6 +311,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Login = ({ location }) => {
|
const Login = ({ location }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user