musicbrainz-provider #2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user