package api import ( "net/http" "syscall" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" ) // The admin Overview's status sections (M504): after the verdict, who is // listening, what is downloading, what the library holds and how much room // it has, and how the server itself is doing. One read for the page, polled // while it is open. type listeningView struct { Username string `json:"username"` TrackID string `json:"track_id"` Title string `json:"title"` ArtistName string `json:"artist_name"` AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` DurationMs int32 `json:"duration_ms"` StartedAt string `json:"started_at"` Client string `json:"client"` DeviceClass string `json:"device_class"` } type listeningStatusView struct { Now []listeningView `json:"now"` // The last day: how many plays, by how many people, the latest when. PlaysDay int64 `json:"plays_day"` ListenersDay int64 `json:"listeners_day"` LastPlayAt *string `json:"last_play_at"` } type downloadsStatusView struct { Running int64 `json:"running"` NeedsYou int64 `json:"needs_you"` NeedsYouSince *string `json:"needs_you_since"` ImportedDay int64 `json:"imported_day"` GivenUpDay int64 `json:"given_up_day"` // Live is the downloads not yet ended, the ones needing a person first. Live []downloadView `json:"live"` ClientsDown []clientDownView `json:"clients_down"` } // diskView is the filesystem one library folder, or the data folder, sits // on. type diskView struct { Path string `json:"path"` // Data marks Minstrel's own data folder (covers, fingerprints), beside // the library's. Data bool `json:"data,omitempty"` TotalBytes uint64 `json:"total_bytes"` FreeBytes uint64 `json:"free_bytes"` // Error says why the folder could not be read, which is itself the // news: a drive that did not mount. Error string `json:"error,omitempty"` } type libraryStatusView struct { Tracks int64 `json:"tracks"` Albums int64 `json:"albums"` Artists int64 `json:"artists"` Bytes int64 `json:"bytes"` Disks []diskView `json:"disks"` } type serverErrorView struct { At string `json:"at"` Message string `json:"message"` Attrs string `json:"attrs"` } type serverStatusView struct { Version string `json:"version"` Channel string `json:"channel"` StartedAt string `json:"started_at"` // Migration is the database schema's version, and Dirty whether its // last migration stopped part way. Migration int64 `json:"migration"` Dirty bool `json:"dirty"` // Errors is the newest few errors logged since the start, newest first; // ErrorCount is all of them. Errors []serverErrorView `json:"errors"` ErrorCount int64 `json:"error_count"` } type adminStatusView struct { Listening listeningStatusView `json:"listening"` Downloads downloadsStatusView `json:"downloads"` Library libraryStatusView `json:"library"` Server serverStatusView `json:"server"` } // overviewLiveDownloads is how many running downloads the Overview lists; // Activity has the rest. const overviewLiveDownloads = 5 // overviewErrors is how many recent errors the Overview lists. const overviewErrors = 10 // handleGetAdminStatus implements GET /api/admin/status. func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) { q := dbq.New(h.pool) ctx := r.Context() var out adminStatusView now, err := q.ListListeningNow(ctx) if err != nil { h.logger.Error("admin status: listening", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } out.Listening.Now = make([]listeningView, 0, len(now)) for _, p := range now { out.Listening.Now = append(out.Listening.Now, listeningView{ Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle, ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle, DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt), Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass), }) } day, err := q.PlaysLastDay(ctx) if err != nil { h.logger.Error("admin status: plays", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners if day.LastAt.Valid { at := formatTimestamp(day.LastAt) out.Listening.LastPlayAt = &at } if err := h.downloadsStatus(r, q, &out.Downloads); err != nil { h.logger.Error("admin status: downloads", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } lib, err := q.LibraryStatus(ctx) if err != nil { h.logger.Error("admin status: library", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists, Bytes: lib.Bytes, Disks: []diskView{}} if h.importer != nil { for _, root := range h.importer.Roots() { out.Library.Disks = append(out.Library.Disks, diskOf(root)) } } if h.dataDir != "" { d := diskOf(h.dataDir) d.Data = true out.Library.Disks = append(out.Library.Disks, d) } out.Server = serverStatus(logging.Errors) // golang-migrate's own table, outside sqlc's schema. if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`). Scan(&out.Server.Migration, &out.Server.Dirty); err != nil { h.logger.Warn("admin status: migration version", "err", err) } writeJSON(w, http.StatusOK, out) } func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error { st, err := q.DownloadsStatus(r.Context()) if err != nil { return err } *out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou, ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay, Live: []downloadView{}, ClientsDown: []clientDownView{}} if st.NeedsYou > 0 && st.NeedsYouSince.Valid { since := formatTimestamp(st.NeedsYouSince) out.NeedsYouSince = &since } // ListGrabs puts the ones needing a person first, then the running ones. rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads}) if err != nil { return err } for _, g := range rows { if !g.EndedAt.Valid { out.Live = append(out.Live, downloadFrom(g)) } } down, err := q.ListClientsDown(r.Context()) if err != nil { return err } for _, c := range down { out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail, Since: formatTimestamp(c.FirstSeen)}) } return nil } // diskOf reads the size and free space of the filesystem path is on. func diskOf(path string) diskView { var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err != nil { return diskView{Path: path, Error: err.Error()} } bsize := uint64(st.Bsize) return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize} } func serverStatus(ring *logging.ErrorRing) serverStatusView { kept, total := ring.Snapshot() v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel, StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total, Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))} for i, e := range kept { if i == overviewErrors { break } v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339), Message: e.Message, Attrs: e.Attrs}) } return v }