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) }