Files
NaviWatcher/internal/web/handlers.go

423 lines
13 KiB
Go

package web
import (
"context"
"embed"
"errors"
"fmt"
"html/template"
"net/http"
"regexp"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
"naviwatcher/internal/scanner"
)
// idPattern bounds the {id} path segment accepted by artist routes. Artist IDs
// come from Navidrome (numeric/UUID) and MusicBrainz (UUID), so word
// characters and hyphens cover every legitimate value. Rejecting anything else
// prevents a crafted id (containing "/", control characters, or whitespace)
// from breaking route matching or being reflected into a Location header.
var idPattern = regexp.MustCompile(`^[\w-]+$`)
// isValidID reports whether s is a safe artist-ID path segment.
func isValidID(s string) bool {
return idPattern.MatchString(s)
}
//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
ArtistName 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
IgnoreCompilations bool
IgnoreLive bool
IgnoreRemix 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 == "" || !isValidID(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,
IgnoreCompilations: settings.IgnoreCompilations,
IgnoreLive: settings.IgnoreLive,
IgnoreRemix: settings.IgnoreRemix,
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 {
view := MissingReleaseView{
ArtistID: rel.ArtistID,
RGID: rel.RGID,
Title: rel.Title,
Type: rel.Type,
ReleaseDate: rel.ReleaseDate,
Ignored: true,
}
if settings, err := database.GetArtistSettings(s.db, rel.ArtistID); err == nil {
view.ArtistName = settings.Name
}
data.Releases = append(data.Releases, view)
}
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.
// ResolveUIBaseURL derives the externally-reachable base URL of the Web UI from
// config. An explicit public_url (e.g. behind a reverse proxy) is preferred.
// When unset, it falls back to http://host:port — unless the bind host is the
// unspecified "0.0.0.0" (not reachable from outside the host), in which case an
// empty string is returned so callers omit the link rather than advertise an
// unusable address. The same derivation is used by both the Web UI and the
// Telegram notifier so dashboard links are consistent across surfaces.
func ResolveUIBaseURL(cfg *config.ServerConfig) string {
base := cfg.PublicURL
if base == "" && cfg.Host != "0.0.0.0" && cfg.Host != "" {
base = fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
}
return base
}
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 := ResolveUIBaseURL(&cfg.Server)
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
}
if !s.sameOrigin(r) {
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
id := r.PathValue("id")
if id == "" || !isValidID(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.
if errors.Is(err, database.ErrReleaseNotFound) {
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
}
if !s.sameOrigin(r) {
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
id := r.PathValue("id")
if id == "" || !isValidID(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)
}
// toggleIgnoreCompilations handles POST /artist/{id}/ignore-compilations which flips the
// artist's ignore_compilations flag.
func (s *Server) toggleIgnoreCompilations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.sameOrigin(r) {
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
id := r.PathValue("id")
if id == "" || !isValidID(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_compilations": !settings.IgnoreCompilations,
}); err != nil {
http.Error(w, fmt.Sprintf("update artist: %v", err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
}
// toggleIgnoreLive handles POST /artist/{id}/ignore-live which flips the
// artist's ignore_live flag.
func (s *Server) toggleIgnoreLive(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.sameOrigin(r) {
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
id := r.PathValue("id")
if id == "" || !isValidID(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_live": !settings.IgnoreLive,
}); err != nil {
http.Error(w, fmt.Sprintf("update artist: %v", err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
}
// toggleIgnoreRemix handles POST /artist/{id}/ignore-remix which flips the
// artist's ignore_remix flag.
func (s *Server) toggleIgnoreRemix(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.sameOrigin(r) {
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
id := r.PathValue("id")
if id == "" || !isValidID(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_remix": !settings.IgnoreRemix,
}); err != nil {
http.Error(w, fmt.Sprintf("update artist: %v", err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
}