musicbrainz-provider #2

Merged
Mrixs merged 72 commits from musicbrainz-provider into master 2026-08-05 20:19:16 +00:00
5 changed files with 460 additions and 4 deletions
Showing only changes of commit cea20957e7 - Show all commits

View File

@@ -119,10 +119,10 @@ name collisions.)
- [x] run tests - must pass before task 7
### Task 7: Web UI — server + auth + dashboard
- [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
- [ ] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local)
- [ ] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered
- [ ] run tests - must pass before task 8
- [x] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
- [x] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local)
- [x] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered
- [x] run tests - must pass before task 8
### Task 8: Web UI — artist detail + archive + ignore actions
- [ ] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons

55
internal/web/handlers.go Normal file
View File

@@ -0,0 +1,55 @@
package web
import (
"embed"
"fmt"
"html/template"
"net/http"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
)
//go:embed templates/*.html
var templates embed.FS
// dashboardTmpl is parsed once at package init from the embedded templates.
var dashboardTmpl = template.Must(template.ParseFS(templates, "templates/dashboard.html"))
// handleDashboard renders the artist dashboard: monitored artists with their
// missing-release counts.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
// Only serve the index at "/" (and not e.g. "/favicon.ico" fallthroughs).
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
threshold := s.defaultThreshold()
data, err := s.buildDashboardData(r.Context(), threshold)
if err != nil {
http.Error(w, fmt.Sprintf("failed to build dashboard: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := dashboardTmpl.Execute(w, data); err != nil {
http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError)
}
}
// defaultThreshold returns the fuzzy threshold to use when scanning for the
// dashboard. It is currently fixed at the engine default; later wiring can
// thread the configured threshold through the Server if desired.
func (s *Server) defaultThreshold() float64 {
return 0 // 0 → scanner.DefaultThreshold
}
// NewServerWithConfig is a convenience constructor that accepts the full
// *config.Config (mirroring how the app constructs other components). It
// forwards the server sub-config and derives uiBaseURL from host/port.
func NewServerWithConfig(cfg *config.Config, db *database.DB) *Server {
// Build a best-effort external base URL from the server config.
base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)
return NewServer(&cfg.Server, db, base)
}

172
internal/web/server.go Normal file
View File

@@ -0,0 +1,172 @@
// Package web implements the NaviWatcher HTTP dashboard: a net/http server with
// embedded templates, basic-auth protection, and a dashboard that lists
// monitored artists with their missing-release counts.
package web
import (
"context"
"crypto/subtle"
"encoding/base64"
"fmt"
"log"
"net/http"
"strings"
"time"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
"naviwatcher/internal/scanner"
)
// Server is the NaviWatcher web dashboard HTTP server.
type Server struct {
cfg *config.ServerConfig
db *database.DB
mux *http.ServeMux
// uiBaseURL is the externally reachable base URL of the dashboard (scheme +
// host), used to build links in notifications and elsewhere. Optional.
uiBaseURL string
}
// NewServer constructs a dashboard Server bound to the given DB and server
// config. uiBaseURL is the externally reachable origin (e.g.
// "http://localhost:8080") used when rendering absolute links; pass "" to omit.
func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string) *Server {
s := &Server{
cfg: cfg,
db: db,
uiBaseURL: strings.TrimRight(uiBaseURL, "/"),
}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleDashboard)
s.mux = mux
return s
}
// Handler returns the http.Handler (auth-wrapped mux) for the server. It is
// exported so callers can embed the dashboard in a larger handler tree or test
// it directly via httptest.
func (s *Server) Handler() http.Handler {
return s.authMiddleware(s.mux)
}
// Addr returns the listen address ("host:port") for this server.
func (s *Server) Addr() string {
return fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
}
// Start begins listening and serving until the context is cancelled, then shuts
// down gracefully. It returns any unrecoverable serve error (a clean shutdown
// due to ctx cancellation returns nil).
func (s *Server) Start(ctx context.Context) error {
srv := &http.Server{
Addr: s.Addr(),
Handler: s.Handler(),
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("web server shutdown error: %v", err)
}
}()
log.Printf("Web UI listening on %s", s.Addr())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("web server serve: %w", err)
}
return nil
}
// authMiddleware enforces HTTP Basic auth per RFC 7617 using a constant-time
// comparison of the base64-encoded "user:pass" credential. When either
// Username or Password is empty, auth is disabled (useful for local/dev).
func (s *Server) authMiddleware(next http.Handler) http.Handler {
user := s.cfg.Username
pass := s.cfg.Password
if user == "" || pass == "" {
return next
}
// Precompute the expected Authorization header value once.
want := "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
unauthorized(w)
return
}
// Constant-time compare of the full header value.
if subtle.ConstantTimeCompare([]byte(header), []byte(want)) != 1 {
unauthorized(w)
return
}
next.ServeHTTP(w, r)
})
}
// unauthorized writes a 401 with a Basic auth challenge.
func unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="NaviWatcher"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("401 Unauthorized\n"))
}
// ArtistSummary is the dashboard projection of a single monitored artist and
// its missing-release count.
type ArtistSummary struct {
ID string
Name string
MBID string
MissingCount int
Monitored bool
}
// DashboardData is the view model passed to the dashboard template.
type DashboardData struct {
Artists []ArtistSummary
// TotalMissing is the sum of all artists' missing counts.
TotalMissing int
// UIBaseURL is the externally reachable origin, for building links.
UIBaseURL string
}
// buildDashboardData computes the dashboard view model: every monitored artist
// joined with its current missing-release count (from the scanner).
func (s *Server) buildDashboardData(ctx context.Context, threshold float64) (*DashboardData, error) {
settings, err := database.GetAllArtistSettings(s.db)
if err != nil {
return nil, fmt.Errorf("load artist settings: %w", err)
}
// Compute missing releases once and group by artist.
missing, err := scanner.ScanAll(ctx, s.db, threshold)
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
missingByArtist := make(map[string]int)
for _, m := range missing {
missingByArtist[m.ArtistID]++
}
data := &DashboardData{UIBaseURL: s.uiBaseURL}
for _, a := range settings {
if !a.Monitored {
continue
}
count := missingByArtist[a.ID]
data.Artists = append(data.Artists, ArtistSummary{
ID: a.ID,
Name: a.Name,
MBID: a.MBID,
MissingCount: count,
Monitored: true,
})
data.TotalMissing += count
}
return data, nil
}

180
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,180 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
)
// seedArtist inserts an artist_settings row and returns its ID.
func seedArtist(t *testing.T, db *database.DB, id, name, mbid string, monitored bool) {
t.Helper()
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: id,
Name: name,
MBID: mbid,
Monitored: monitored,
}); err != nil {
t.Fatalf("seed artist %s: %v", id, err)
}
}
// seedLocalAlbum inserts a local_albums row.
func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) {
t.Helper()
if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ID: id, ArtistID: artistID, Title: title}); err != nil {
t.Fatalf("seed local album %s: %v", id, err)
}
}
// seedExternalRelease inserts an external_releases row (not ignored).
func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string) {
t.Helper()
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: rgid,
ArtistID: artistID,
Title: title,
}); err != nil {
t.Fatalf("seed external release %s: %v", rgid, err)
}
}
func newServer(t *testing.T, user, pass string) (*Server, *database.DB) {
t.Helper()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
cfg := &config.ServerConfig{Host: "0.0.0.0", Port: 8080, Username: user, Password: pass}
s := NewServer(cfg, db, "http://ui.example")
return s, db
}
func TestDashboard_Authenticated200(t *testing.T) {
s, db := newServer(t, "admin", "secret")
seedArtist(t, db, "a1", "Radiohead", "mbid-1", true)
seedLocalAlbum(t, db, "l1", "a1", "OK Computer")
// One missing release (no local album matches "Kid A").
seedExternalRelease(t, db, "r1", "a1", "Kid A")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "secret")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Radiohead") {
t.Errorf("expected artist name in body, got:\n%s", body)
}
if !strings.Contains(body, "1") {
t.Errorf("expected missing count rendered, got:\n%s", body)
}
if !strings.Contains(body, "mbid-1") {
t.Errorf("expected MBID rendered, got:\n%s", body)
}
}
func TestDashboard_Unauthenticated401(t *testing.T) {
s, _ := newServer(t, "admin", "secret")
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
t.Errorf("expected Basic auth challenge, got headers: %v", rec.Header())
}
}
func TestDashboard_WrongPassword401(t *testing.T) {
s, _ := newServer(t, "admin", "secret")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "wrong")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for wrong password, got %d", rec.Code)
}
}
func TestDashboard_NoAuthWhenDisabled(t *testing.T) {
// When username or password is empty, auth is bypassed.
s, db := newServer(t, "", "")
seedArtist(t, db, "a1", "Boards of Canada", "", true)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 when auth disabled, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Boards of Canada") {
t.Errorf("expected artist rendered, got:\n%s", rec.Body.String())
}
}
func TestDashboard_SkipsUnmonitored(t *testing.T) {
s, db := newServer(t, "admin", "secret")
seedArtist(t, db, "mon", "Monitored Artist", "", true)
seedArtist(t, db, "unmon", "Unmonitored Artist", "", false)
seedExternalRelease(t, db, "r1", "unmon", "Should not appear")
seedExternalRelease(t, db, "r2", "mon", "Missing here")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "secret")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "Unmonitored Artist") {
t.Errorf("unmonitored artist should not appear, got:\n%s", body)
}
if !strings.Contains(body, "Monitored Artist") {
t.Errorf("monitored artist should appear, got:\n%s", body)
}
}
func TestDashboard_EmptyState(t *testing.T) {
s, _ := newServer(t, "admin", "secret")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "secret")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No monitored artists") {
t.Errorf("expected empty-state message, got:\n%s", rec.Body.String())
}
}
func TestDashboard_NotFoundForOtherPaths(t *testing.T) {
s, _ := newServer(t, "admin", "secret")
req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
req.SetBasicAuth("admin", "secret")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for non-root path, got %d", rec.Code)
}
}

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NaviWatcher — Dashboard</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; }
h1 { margin-bottom: 0.25rem; }
.sub { color: #666; margin-bottom: 1.5rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e2e2; }
th { background: #f6f6f6; }
.count { font-weight: bold; }
.count-zero { color: #2e7d32; }
.count-missing { color: #c62828; }
.empty { color: #888; font-style: italic; }
</style>
</head>
<body>
<h1>NaviWatcher</h1>
<div class="sub">Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }}</div>
{{ if .Artists }}
<table>
<thead>
<tr>
<th>Artist</th>
<th>Missing</th>
<th>MusicBrainz</th>
</tr>
</thead>
<tbody>
{{ range .Artists }}
<tr>
<td>{{ .Name }}</td>
<td class="count {{ if eq .MissingCount 0 }}count-zero{{ else }}count-missing{{ end }}">
{{ .MissingCount }}
</td>
<td>{{ if .MBID }}{{ .MBID }}{{ else }}<span class="empty"></span>{{ end }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="empty">No monitored artists yet. Run a sync to populate the dashboard.</p>
{{ end }}
</body>
</html>