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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
48
internal/web/templates/archive.html
Normal file
48
internal/web/templates/archive.html
Normal 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="/">← 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>
|
||||
80
internal/web/templates/artist.html
Normal file
80
internal/web/templates/artist.html
Normal 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="/">← 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>
|
||||
@@ -19,7 +19,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<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 }}
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user