- notifier: show artist display names (not internal IDs) in digest; resolve names from artist_settings and fall back to ID when unavailable - notifier: skip sending an empty digest to avoid daily spam - config: require telegram token/chat_id when enabled - web: warn loudly when auth is disabled on a non-loopback bind; add HTTP server timeouts - web: treat SetReleaseIgnored "release not found" as benign redirect (0 rows) - musicbrainz: reject low-score/name-mismatched MBID resolutions instead of silently caching the wrong artist - database: remove dead duplicate err check; harden DSN param appending - musicbrainz: check rows.Err() after iterating existing releases
285 lines
8.6 KiB
Go
285 lines
8.6 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
|
|
"naviwatcher/internal/config"
|
|
"naviwatcher/internal/database"
|
|
"naviwatcher/internal/scanner"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templates embed.FS
|
|
|
|
// 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.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
// Only serve the index at "/" (and not e.g. "/favicon.ico" fallthroughs).
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
threshold := s.threshold
|
|
data, err := s.buildDashboardData(r.Context(), threshold)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to build dashboard: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := dashboardTmpl.Execute(w, data); err != nil {
|
|
http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// 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 single artist (ScanArtist scopes the
|
|
// query to the artist instead of scanning every monitored artist). ScanAll
|
|
// excludes ignored releases, so every missing release surfaced here is, by
|
|
// definition, not ignored.
|
|
threshold := s.threshold
|
|
missing, err := scanner.ScanArtist(ctx, s.db, id, threshold)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan artist: %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 {
|
|
data.Missing = append(data.Missing, MissingReleaseView{
|
|
ArtistID: m.ArtistID,
|
|
RGID: m.RGID,
|
|
Title: m.Title,
|
|
Type: m.Type,
|
|
ReleaseDate: m.ReleaseDate,
|
|
Ignored: false,
|
|
})
|
|
}
|
|
return data, 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)
|
|
}
|
|
}
|
|
|
|
// NewServerWithConfig is a convenience constructor that accepts the full
|
|
// *config.Config (mirroring how the app constructs other components). It
|
|
// forwards the server sub-config and derives uiBaseURL from the configured
|
|
// public_url, falling back to a best-effort host:port.
|
|
func NewServerWithConfig(cfg *config.Config, db *database.DB) *Server {
|
|
// Prefer an explicit, externally-reachable public_url (e.g. behind a
|
|
// reverse proxy). Fall back to host:port — but if the bind host is the
|
|
// unspecified "0.0.0.0", it is not reachable from outside the host, so
|
|
// omit the link rather than advertise an unusable address.
|
|
base := cfg.Server.PublicURL
|
|
if base == "" && cfg.Server.Host != "0.0.0.0" && cfg.Server.Host != "" {
|
|
base = fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)
|
|
}
|
|
return NewServer(&cfg.Server, db, base, cfg.Scanner.FuzzyThreshold)
|
|
}
|
|
|
|
// 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 {
|
|
// A 0-rows-affected error means the release was already removed by a
|
|
// concurrent re-sync (it disappeared from MusicBrainz). That is benign:
|
|
// redirect back rather than surfacing a 500 for a now-nonexistent row.
|
|
var notFoundErr error = database.ErrReleaseNotFound
|
|
if errors.Is(err, notFoundErr) {
|
|
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|