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.
This commit is contained in:
2026-07-19 22:43:47 +03:00
parent cea20957e7
commit 44f3b0a2a7
11 changed files with 682 additions and 13 deletions

View File

@@ -125,11 +125,11 @@ name collisions.)
- [x] run tests - must pass before task 8 - [x] run tests - must pass before task 8
### Task 8: Web UI — artist detail + archive + ignore actions ### Task 8: Web UI — artist detail + archive + ignore actions
- [ ] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons - [x] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons
- [ ] archive page: `GetIgnoredReleases` with restore action - [x] archive page: `GetIgnoredReleases` with restore action
- [ ] POST handlers: `SetReleaseIgnored(rgid, true/false)`; "ignore all singles of artist" toggles `artist_settings.ignore_singles` - [x] 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 - [x] write tests: ignore sets flag + removes from dashboard missing; restore clears flag; auth enforced on POST
- [ ] run tests - must pass before task 9 - [x] run tests - must pass before task 9
### Task 9: Verify acceptance criteria ### Task 9: Verify acceptance criteria
- [ ] run full suite `go test ./...` — all pass - [ ] run full suite `go test ./...` — all pass

View File

@@ -2,6 +2,7 @@ package database
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
) )
@@ -17,6 +18,9 @@ func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
id, id,
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored) ).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArtistNotFound
}
return nil, err return nil, err
} }
s.MBID = mbid.String s.MBID = mbid.String

View File

@@ -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) { func TestGetArtistSettings_NotFound(t *testing.T) {
db, err := New(":memory:") db, err := New(":memory:")
if err != nil { if err != nil {
@@ -53,8 +53,8 @@ func TestGetArtistSettings_NotFound(t *testing.T) {
defer db.Close() defer db.Close()
_, err = GetArtistSettings(db, "nonexistent") _, err = GetArtistSettings(db, "nonexistent")
if err != sql.ErrNoRows { if err != ErrArtistNotFound {
t.Errorf("expected sql.ErrNoRows, got %v", err) t.Errorf("expected ErrArtistNotFound, got %v", err)
} }
} }

View File

@@ -2,6 +2,7 @@ package database
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"time" "time"
@@ -13,6 +14,11 @@ type DB struct {
conn *sql.DB 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. // New opens a SQLite database at dbPath and runs schema migrations.
func New(dbPath string) (*DB, error) { func New(dbPath string) (*DB, error) {
// The _foreign_keys=on DSN parameter enables foreign key enforcement on // The _foreign_keys=on DSN parameter enables foreign key enforcement on

View File

@@ -2,7 +2,6 @@ package navidrome
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -111,7 +110,7 @@ func SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB)
settings.Monitored = existing.Monitored settings.Monitored = existing.Monitored
settings.IgnoreSingles = existing.IgnoreSingles settings.IgnoreSingles = existing.IgnoreSingles
settings.IgnoreCompilations = existing.IgnoreCompilations 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) return fmt.Errorf("sync artists: get settings for artist %s: %w", artist.ID, err)
} }

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"embed" "embed"
"fmt" "fmt"
"html/template" "html/template"
@@ -8,13 +9,19 @@ import (
"naviwatcher/internal/config" "naviwatcher/internal/config"
"naviwatcher/internal/database" "naviwatcher/internal/database"
"naviwatcher/internal/scanner"
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var templates embed.FS
// dashboardTmpl is parsed once at package init from the embedded templates. // dashboardTmpl and the other page templates are parsed once at package init
var dashboardTmpl = template.Must(template.ParseFS(templates, "templates/dashboard.html")) // 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 // handleDashboard renders the artist dashboard: monitored artists with their
// missing-release counts. // 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 // defaultThreshold returns the fuzzy threshold to use when scanning for the
// dashboard. It is currently fixed at the engine default; later wiring can // dashboard. It is currently fixed at the engine default; later wiring can
// thread the configured threshold through the Server if desired. // 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) base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)
return NewServer(&cfg.Server, db, base) 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
}

View File

@@ -40,6 +40,11 @@ func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string) *Ser
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", s.handleDashboard) 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 s.mux = mux
return s return s
} }

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -178,3 +179,287 @@ func TestDashboard_NotFoundForOtherPaths(t *testing.T) {
t.Fatalf("expected 404 for non-root path, got %d", rec.Code) 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
}

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NaviWatcher — Archive</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; }
h1 { margin-bottom: 0.25rem; }
a { color: #1565c0; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e2e2; }
th { background: #f6f6f6; }
.empty { color: #888; font-style: italic; }
form { display: inline; }
</style>
</head>
<body>
<p><a href="/">&larr; Dashboard</a></p>
<h1>Ignored releases</h1>
{{ if .Releases }}
<table>
<thead>
<tr><th>Artist</th><th>Title</th><th>Type</th><th>Date</th><th>Action</th></tr>
</thead>
<tbody>
{{ range .Releases }}
<tr>
<td>{{ .ArtistID }}</td>
<td>{{ .Title }}</td>
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty"></span>{{ end }}</td>
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty"></span>{{ end }}</td>
<td>
<form method="POST" action="/artist/{{ .ArtistID }}/restore">
<input type="hidden" name="rgid" value="{{ .RGID }}">
<button type="submit">Restore</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="empty">No ignored releases.</p>
{{ end }}
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NaviWatcher — {{ .Name }}</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; }
a { color: #1565c0; }
table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e2e2; }
th { background: #f6f6f6; }
.empty { color: #888; font-style: italic; }
.missing { color: #c62828; }
.ignored { color: #888; text-decoration: line-through; }
.toggle { display: inline-block; margin-bottom: 1rem; }
form { display: inline; }
button { cursor: pointer; }
</style>
</head>
<body>
<p><a href="/">&larr; Dashboard</a></p>
<h1>{{ .Name }}</h1>
<div class="sub">MusicBrainz: {{ if .MBID }}{{ .MBID }}{{ else }}<span class="empty"></span>{{ end }}</div>
<form class="toggle" method="POST" action="/artist/{{ .ID }}/ignore-singles">
<button type="submit">{{ if .IgnoreSingles }}Enable singles{{ else }}Ignore all singles{{ end }}</button>
{{ if .IgnoreSingles }}<span class="sub"> (singles currently ignored)</span>{{ end }}
</form>
<h2>Local albums (Subsonic)</h2>
{{ if .LocalAlbums }}
<table>
<thead><tr><th>Title</th></tr></thead>
<tbody>
{{ range .LocalAlbums }}
<tr><td>{{ .Title }}</td></tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="empty">No local albums synced for this artist.</p>
{{ end }}
<h2>Found missing (MusicBrainz)</h2>
{{ if .Missing }}
<table>
<thead>
<tr><th>Title</th><th>Type</th><th>Date</th><th>Action</th></tr>
</thead>
<tbody>
{{ range .Missing }}
<tr class="{{ if .Ignored }}ignored{{ else }}missing{{ end }}">
<td>{{ .Title }}</td>
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty"></span>{{ end }}</td>
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty"></span>{{ end }}</td>
<td>
{{ if .Ignored }}
<form method="POST" action="/artist/{{ $.ID }}/restore">
<input type="hidden" name="rgid" value="{{ .RGID }}">
<button type="submit">Restore</button>
</form>
{{ else }}
<form method="POST" action="/artist/{{ $.ID }}/ignore">
<input type="hidden" name="rgid" value="{{ .RGID }}">
<button type="submit">Ignore</button>
</form>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="empty">No missing releases for this artist.</p>
{{ end }}
</body>
</html>

View File

@@ -19,7 +19,7 @@
</head> </head>
<body> <body>
<h1>NaviWatcher</h1> <h1>NaviWatcher</h1>
<div class="sub">Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }}</div> <div class="sub">Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }} · <a href="/archive">Archive</a></div>
{{ if .Artists }} {{ if .Artists }}
<table> <table>