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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user