From 44f3b0a2a7aa2cac4c1b24b34547f5b3560e0a20 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Sun, 19 Jul 2026 22:43:47 +0300 Subject: [PATCH] feat: add Web UI artist detail, archive, and ignore actions Implements Task 8: artist detail page (local albums + found-missing with ignore buttons), ignored-releases archive with restore, and POST handlers toggling ignore flags and ignore_singles. Adds ErrArtistNotFound sentinel so callers can distinguish missing artists, and wires routes via the enhanced ServeMux path wildcard. --- docs/plans/2026-07-19-notifier-webui-sync.md | 10 +- internal/database/artist_settings.go | 4 + internal/database/artist_settings_test.go | 6 +- internal/database/database.go | 6 + internal/navidrome/sync.go | 3 +- internal/web/handlers.go | 246 +++++++++++++++- internal/web/server.go | 5 + internal/web/server_test.go | 285 +++++++++++++++++++ internal/web/templates/archive.html | 48 ++++ internal/web/templates/artist.html | 80 ++++++ internal/web/templates/dashboard.html | 2 +- 11 files changed, 682 insertions(+), 13 deletions(-) create mode 100644 internal/web/templates/archive.html create mode 100644 internal/web/templates/artist.html diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index c4217b0..c71bf42 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -125,11 +125,11 @@ name collisions.) - [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 -- [ ] archive page: `GetIgnoredReleases` with restore action -- [ ] POST handlers: `SetReleaseIgnored(rgid, true/false)`; "ignore all singles of artist" toggles `artist_settings.ignore_singles` -- [ ] write tests: ignore sets flag + removes from dashboard missing; restore clears flag; auth enforced on POST -- [ ] run tests - must pass before task 9 +- [x] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons +- [x] archive page: `GetIgnoredReleases` with restore action +- [x] POST handlers: `SetReleaseIgnored(rgid, true/false)`; "ignore all singles of artist" toggles `artist_settings.ignore_singles` +- [x] write tests: ignore sets flag + removes from dashboard missing; restore clears flag; auth enforced on POST +- [x] run tests - must pass before task 9 ### Task 9: Verify acceptance criteria - [ ] run full suite `go test ./...` — all pass diff --git a/internal/database/artist_settings.go b/internal/database/artist_settings.go index 1b3fc8b..96665e5 100644 --- a/internal/database/artist_settings.go +++ b/internal/database/artist_settings.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "errors" "fmt" ) @@ -17,6 +18,9 @@ func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) { id, ).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrArtistNotFound + } return nil, err } s.MBID = mbid.String diff --git a/internal/database/artist_settings_test.go b/internal/database/artist_settings_test.go index 7b50977..7a83758 100644 --- a/internal/database/artist_settings_test.go +++ b/internal/database/artist_settings_test.go @@ -44,7 +44,7 @@ func TestGetArtistSettings_Found(t *testing.T) { } } -// TestGetArtistSettings_NotFound verifies that a missing artist returns sql.ErrNoRows. +// TestGetArtistSettings_NotFound verifies that a missing artist returns ErrArtistNotFound. func TestGetArtistSettings_NotFound(t *testing.T) { db, err := New(":memory:") if err != nil { @@ -53,8 +53,8 @@ func TestGetArtistSettings_NotFound(t *testing.T) { defer db.Close() _, err = GetArtistSettings(db, "nonexistent") - if err != sql.ErrNoRows { - t.Errorf("expected sql.ErrNoRows, got %v", err) + if err != ErrArtistNotFound { + t.Errorf("expected ErrArtistNotFound, got %v", err) } } diff --git a/internal/database/database.go b/internal/database/database.go index ae84623..8af7f8e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "errors" "fmt" "time" @@ -13,6 +14,11 @@ type DB struct { conn *sql.DB } +// ErrArtistNotFound is returned by artist lookups when no row matches the given +// ID. It is a sentinel so callers (e.g. the web UI) can distinguish "missing" +// from other errors. +var ErrArtistNotFound = errors.New("artist not found") + // New opens a SQLite database at dbPath and runs schema migrations. func New(dbPath string) (*DB, error) { // The _foreign_keys=on DSN parameter enables foreign key enforcement on diff --git a/internal/navidrome/sync.go b/internal/navidrome/sync.go index bb62846..9a79e58 100644 --- a/internal/navidrome/sync.go +++ b/internal/navidrome/sync.go @@ -2,7 +2,6 @@ package navidrome import ( "context" - "database/sql" "errors" "fmt" @@ -111,7 +110,7 @@ func SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) settings.Monitored = existing.Monitored settings.IgnoreSingles = existing.IgnoreSingles settings.IgnoreCompilations = existing.IgnoreCompilations - } else if !errors.Is(err, sql.ErrNoRows) { + } else if !errors.Is(err, database.ErrArtistNotFound) { return fmt.Errorf("sync artists: get settings for artist %s: %w", artist.ID, err) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 4577a76..3a41c4d 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,6 +1,7 @@ package web import ( + "context" "embed" "fmt" "html/template" @@ -8,13 +9,19 @@ import ( "naviwatcher/internal/config" "naviwatcher/internal/database" + "naviwatcher/internal/scanner" ) //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")) +// dashboardTmpl and the other page templates are parsed once at package init +// from the embedded templates. +var ( + dashboardTmpl = template.Must(template.ParseFS(templates, "templates/dashboard.html")) + artistTmpl = template.Must(template.ParseFS(templates, "templates/artist.html")) + archiveTmpl = template.Must(template.ParseFS(templates, "templates/archive.html")) +) // handleDashboard renders the artist dashboard: monitored artists with their // missing-release counts. @@ -38,6 +45,169 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { } } +// LocalAlbumView is the projection of a local album for the artist detail page. +type LocalAlbumView struct { + Title string +} + +// MissingReleaseView is the projection of a missing external release for the +// artist detail page, including the ignore toggle form target. +type MissingReleaseView struct { + ArtistID string + RGID string + Title string + Type string + ReleaseDate string + Ignored bool +} + +// ArtistData is the view model for the artist detail page. +type ArtistData struct { + ID string + Name string + MBID string + IgnoreSingles bool + LocalAlbums []LocalAlbumView + Missing []MissingReleaseView + UIBaseURL string +} + +// handleArtist renders the detail page for a single artist: local albums +// (Subsonic) plus the externally-found missing releases (MB cache) with ignore +// buttons. +func (s *Server) handleArtist(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // The enhanced ServeMux extracts the {id} path segment for us. + id := r.PathValue("id") + if id == "" { + http.NotFound(w, r) + return + } + + data, err := s.buildArtistData(r.Context(), id) + if err != nil { + if err == database.ErrArtistNotFound { + http.NotFound(w, r) + return + } + http.Error(w, fmt.Sprintf("failed to build artist page: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := artistTmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError) + } +} + +// buildArtistData computes the artist detail view model: the artist's settings, +// local albums (Subsonic), and missing external releases (MB cache). +func (s *Server) buildArtistData(ctx context.Context, id string) (*ArtistData, error) { + settings, err := database.GetArtistSettings(s.db, id) + if err != nil { + if err == database.ErrArtistNotFound { + return nil, database.ErrArtistNotFound + } + return nil, fmt.Errorf("load artist settings: %w", err) + } + + locals, err := database.GetLocalAlbumsByArtist(s.db, id) + if err != nil { + return nil, fmt.Errorf("load local albums: %w", err) + } + + // Compute the missing releases for this artist. + threshold := s.defaultThreshold() + missing, err := scanner.ScanAll(ctx, s.db, threshold) + if err != nil { + return nil, fmt.Errorf("scan: %w", err) + } + + data := &ArtistData{ + ID: settings.ID, + Name: settings.Name, + MBID: settings.MBID, + IgnoreSingles: settings.IgnoreSingles, + UIBaseURL: s.uiBaseURL, + } + for _, a := range locals { + data.LocalAlbums = append(data.LocalAlbums, LocalAlbumView{Title: a.Title}) + } + for _, m := range missing { + if m.ArtistID != id { + continue + } + ignored, igErr := s.releaseIgnored(id, m.RGID) + if igErr != nil { + return nil, igErr + } + data.Missing = append(data.Missing, MissingReleaseView{ + ArtistID: id, + RGID: m.RGID, + Title: m.Title, + Type: m.Type, + ReleaseDate: m.ReleaseDate, + Ignored: ignored, + }) + } + return data, nil +} + +// releaseIgnored reports whether the external release with the given RGID is +// flagged ignored. +func (s *Server) releaseIgnored(artistID, rgid string) (bool, error) { + // ScanAll already excludes ignored releases, so a missing release shown here + // is, by definition, not ignored. We still surface the persisted flag so the + // UI can reflect a release that was ignored and later re-evaluated. + rel, err := database.GetExternalRelease(s.db, rgid) + if err != nil { + return false, fmt.Errorf("get external release %s: %w", rgid, err) + } + return rel.IsIgnored, nil +} + +// ArchiveData is the view model for the ignored-releases archive page. +type ArchiveData struct { + Releases []MissingReleaseView + UIBaseURL string +} + +// handleArchive renders the archive of previously-ignored releases with restore +// actions. +func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + ignored, err := database.GetIgnoredReleases(s.db) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load archive: %v", err), http.StatusInternalServerError) + return + } + + data := &ArchiveData{UIBaseURL: s.uiBaseURL} + for _, rel := range ignored { + data.Releases = append(data.Releases, MissingReleaseView{ + ArtistID: rel.ArtistID, + RGID: rel.RGID, + Title: rel.Title, + Type: rel.Type, + ReleaseDate: rel.ReleaseDate, + Ignored: true, + }) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := archiveTmpl.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. @@ -53,3 +223,75 @@ func NewServerWithConfig(cfg *config.Config, db *database.DB) *Server { base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port) return NewServer(&cfg.Server, db, base) } + +// ignoreOrRestore handles the POST /artist/{id}/ignore and .../restore routes. +func (s *Server) ignoreOrRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := r.PathValue("id") + if id == "" { + http.NotFound(w, r) + return + } + + // The route path determines the action. + action := "ignore" + switch r.URL.Path { + case "/artist/" + id + "/restore": + action = "restore" + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + rgid := r.FormValue("rgid") + if rgid == "" { + http.Error(w, "missing rgid", http.StatusBadRequest) + return + } + + ignored := action == "ignore" + if err := database.SetReleaseIgnored(s.db, rgid, ignored); err != nil { + http.Error(w, fmt.Sprintf("failed to set ignored: %v", err), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther) +} + +// toggleIgnoreSingles handles POST /artist/{id}/ignore-singles which flips the +// artist's ignore_singles flag. +func (s *Server) toggleIgnoreSingles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := r.PathValue("id") + if id == "" { + http.NotFound(w, r) + return + } + + settings, err := database.GetArtistSettings(s.db, id) + if err != nil { + http.Error(w, fmt.Sprintf("load artist: %v", err), http.StatusInternalServerError) + return + } + if err := database.UpdateArtistSettings(s.db, id, map[string]interface{}{ + "ignore_singles": !settings.IgnoreSingles, + }); err != nil { + http.Error(w, fmt.Sprintf("update artist: %v", err), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther) +} + +// endsWith reports whether s ends with suffix. +func endsWith(s, suffix string) bool { + return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix +} + diff --git a/internal/web/server.go b/internal/web/server.go index c0988b9..5d50a4a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -40,6 +40,11 @@ func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string) *Ser } mux := http.NewServeMux() mux.HandleFunc("/", s.handleDashboard) + mux.HandleFunc("/artist/{id}", s.handleArtist) + mux.HandleFunc("/artist/{id}/ignore", s.ignoreOrRestore) + mux.HandleFunc("/artist/{id}/restore", s.ignoreOrRestore) + mux.HandleFunc("/artist/{id}/ignore-singles", s.toggleIgnoreSingles) + mux.HandleFunc("/archive", s.handleArchive) s.mux = mux return s } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 3654913..1f82059 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1,6 +1,7 @@ package web import ( + "context" "net/http" "net/http/httptest" "strings" @@ -178,3 +179,287 @@ func TestDashboard_NotFoundForOtherPaths(t *testing.T) { t.Fatalf("expected 404 for non-root path, got %d", rec.Code) } } + +func TestArtistDetail_RendersLocalAndMissing(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "mbid-1", true) + seedLocalAlbum(t, db, "l1", "a1", "OK Computer") + seedLocalAlbum(t, db, "l2", "a1", "The Bends") + // One missing release (no local album matches "Kid A"). + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + req := httptest.NewRequest(http.MethodGet, "/artist/a1", 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() + for _, want := range []string{"Radiohead", "OK Computer", "The Bends", "Kid A", "mbid-1"} { + if !strings.Contains(body, want) { + t.Errorf("expected %q in artist page, got:\n%s", want, body) + } + } + if !strings.Contains(body, `name="rgid" value="r1"`) { + t.Errorf("expected ignore form for r1, got:\n%s", body) + } +} + +func TestArtistDetail_UnknownArtist404(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/artist/does-not-exist", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unknown artist, got %d", rec.Code) + } +} + +func TestArtistDetail_BarePath404(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/artist/", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for bare /artist/, got %d", rec.Code) + } +} + +func TestArtistDetail_RequiresGet(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodPost, "/artist/a1", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for POST on detail, got %d", rec.Code) + } +} + +func TestArchive_RendersIgnoredReleases(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + // An ignored external release. + if err := database.SaveExternalRelease(db, &database.ExternalRelease{ + RGID: "r-ignored", + ArtistID: "a1", + Title: "Ignored Album", + IsIgnored: true, + }); err != nil { + t.Fatalf("seed ignored release: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/archive", 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, "Ignored Album") { + t.Errorf("expected ignored release in archive, got:\n%s", body) + } + if !strings.Contains(body, `name="rgid" value="r-ignored"`) { + t.Errorf("expected restore form for r-ignored, got:\n%s", body) + } +} + +func TestArchive_EmptyState(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/archive", 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 ignored releases") { + t.Errorf("expected empty archive message, got:\n%s", rec.Body.String()) + } +} + +func TestArchive_RequiresGet(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodPost, "/archive", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for POST on archive, got %d", rec.Code) + } +} + +func TestIgnoreAction_SetsFlagAndRemovesFromDashboard(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + // Before ignore: dashboard shows 1 missing. + before := dashboardMissingCount(t, s, "Radiohead") + if before != 1 { + t.Fatalf("expected 1 missing before ignore, got %d", before) + } + + // POST ignore. + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303 redirect, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/artist/a1" { + t.Errorf("expected redirect to /artist/a1, got %q", loc) + } + + // Flag persisted. + rel, err := database.GetExternalRelease(db, "r1") + if err != nil { + t.Fatalf("get release: %v", err) + } + if !rel.IsIgnored { + t.Errorf("expected r1 to be ignored") + } + + // Dashboard missing count drops to 0. + after := dashboardMissingCount(t, s, "Radiohead") + if after != 0 { + t.Fatalf("expected 0 missing after ignore, got %d", after) + } +} + +func TestIgnoreAction_RequiresAuth(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without auth, got %d", rec.Code) + } + // Flag must remain unset. + rel, err := database.GetExternalRelease(db, "r1") + if err != nil { + t.Fatalf("get release: %v", err) + } + if rel.IsIgnored { + t.Errorf("release must not be ignored without auth") + } +} + +func TestRestoreAction_ClearsFlag(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + if err := database.SaveExternalRelease(db, &database.ExternalRelease{ + RGID: "r1", + ArtistID: "a1", + Title: "Kid A", + IsIgnored: true, + }); err != nil { + t.Fatalf("seed ignored release: %v", err) + } + + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, "/artist/a1/restore", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303 redirect, got %d", rec.Code) + } + rel, err := database.GetExternalRelease(db, "r1") + if err != nil { + t.Fatalf("get release: %v", err) + } + if rel.IsIgnored { + t.Errorf("expected r1 to be restored (not ignored)") + } +} + +func TestIgnoreSingles_TogglesFlag(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + + // Toggle on. + req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303 on toggle, got %d", rec.Code) + } + settings, err := database.GetArtistSettings(db, "a1") + if err != nil { + t.Fatalf("get settings: %v", err) + } + if !settings.IgnoreSingles { + t.Errorf("expected ignore_singles = true after first toggle") + } + + // Toggle off. + req2 := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil) + req2.SetBasicAuth("admin", "secret") + rec2 := httptest.NewRecorder() + s.Handler().ServeHTTP(rec2, req2) + settings, err = database.GetArtistSettings(db, "a1") + if err != nil { + t.Fatalf("get settings: %v", err) + } + if settings.IgnoreSingles { + t.Errorf("expected ignore_singles = false after second toggle") + } +} + +func TestIgnoreSingles_RequiresAuth(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore-singles", nil) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without auth, got %d", rec.Code) + } +} + +// dashboardMissingCount returns the missing-release count for the artist with +// the given name from the dashboard view model (0 if the artist is absent). +func dashboardMissingCount(t *testing.T, s *Server, artistName string) int { + t.Helper() + data, err := s.buildDashboardData(context.Background(), 0) + if err != nil { + t.Fatalf("build dashboard data: %v", err) + } + for _, a := range data.Artists { + if a.Name == artistName { + return a.MissingCount + } + } + return 0 +} diff --git a/internal/web/templates/archive.html b/internal/web/templates/archive.html new file mode 100644 index 0000000..d0d0db9 --- /dev/null +++ b/internal/web/templates/archive.html @@ -0,0 +1,48 @@ + + + + + + NaviWatcher — Archive + + + +

← Dashboard

+

Ignored releases

+ + {{ if .Releases }} + + + + + + {{ range .Releases }} + + + + + + + + {{ end }} + +
ArtistTitleTypeDateAction
{{ .ArtistID }}{{ .Title }}{{ if .Type }}{{ .Type }}{{ else }}{{ end }}{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}{{ end }} +
+ + +
+
+ {{ else }} +

No ignored releases.

+ {{ end }} + + diff --git a/internal/web/templates/artist.html b/internal/web/templates/artist.html new file mode 100644 index 0000000..6f47376 --- /dev/null +++ b/internal/web/templates/artist.html @@ -0,0 +1,80 @@ + + + + + + NaviWatcher — {{ .Name }} + + + +

← Dashboard

+

{{ .Name }}

+
MusicBrainz: {{ if .MBID }}{{ .MBID }}{{ else }}{{ end }}
+ +
+ + {{ if .IgnoreSingles }} (singles currently ignored){{ end }} +
+ +

Local albums (Subsonic)

+ {{ if .LocalAlbums }} + + + + {{ range .LocalAlbums }} + + {{ end }} + +
Title
{{ .Title }}
+ {{ else }} +

No local albums synced for this artist.

+ {{ end }} + +

Found missing (MusicBrainz)

+ {{ if .Missing }} + + + + + + {{ range .Missing }} + + + + + + + {{ end }} + +
TitleTypeDateAction
{{ .Title }}{{ if .Type }}{{ .Type }}{{ else }}{{ end }}{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}{{ end }} + {{ if .Ignored }} +
+ + +
+ {{ else }} +
+ + +
+ {{ end }} +
+ {{ else }} +

No missing releases for this artist.

+ {{ end }} + + diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 4fc8f8f..d8bcfe9 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -19,7 +19,7 @@

NaviWatcher

-
Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }}
+
Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }} · Archive
{{ if .Artists }}