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 } if !s.sameOrigin(r) { http.Error(w, "forbidden: cross-origin request", http.StatusForbidden) 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 } if !s.sameOrigin(r) { http.Error(w, "forbidden: cross-origin request", http.StatusForbidden) 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) }