fix: address code review findings

- Fix duplicate Telegram notifications: SyncArtistDiscography no longer wipes
  notifications_sent for the whole artist on every cache-miss re-sync; only
  markers for releases that disappear are pruned (FK-safe via INSERT OR REPLACE
  + rgid NOT IN (...)).
- Cache empty MusicBrainz discographies via a new artist_settings.last_synced
  column (migration 009) so zero-release artists honor the TTL instead of being
  re-fetched every cycle.
- Wire the Web UI server and Telegram notifier scheduler into main.run/NewApp.
- Guard startPeriodicSync against overlapping syncs with a done-channel slot.
- Add server.public_url config; NewServerWithConfig derives reachable links
  and no longer advertises the 0.0.0.0 bind address.
- Web handlers: use scanner.ScanArtist per artist, drop always-false
  releaseIgnored lookup and dead endsWith, thread configured threshold.
- Limit :memory: DB pool to one connection so migrations and queries share the
  same in-memory store.
This commit is contained in:
2026-07-19 23:38:33 +03:00
parent e493a4d228
commit 389d177d85
15 changed files with 386 additions and 126 deletions

View File

@@ -32,7 +32,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
return
}
threshold := s.defaultThreshold()
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)
@@ -120,11 +120,14 @@ func (s *Server) buildArtistData(ctx context.Context, id string) (*ArtistData, e
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)
// 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: %w", err)
return nil, fmt.Errorf("scan artist: %w", err)
}
data := &ArtistData{
@@ -138,38 +141,18 @@ func (s *Server) buildArtistData(ctx context.Context, id string) (*ArtistData, e
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,
ArtistID: m.ArtistID,
RGID: m.RGID,
Title: m.Title,
Type: m.Type,
ReleaseDate: m.ReleaseDate,
Ignored: ignored,
Ignored: false,
})
}
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
@@ -208,20 +191,20 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
}
}
// 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.
func (s *Server) defaultThreshold() float64 {
return 0 // 0 → scanner.DefaultThreshold
}
// 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 host/port.
// 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 {
// Build a best-effort external base URL from the server config.
base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)
return NewServer(&cfg.Server, db, base)
// 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.
@@ -290,8 +273,3 @@ func (s *Server) toggleIgnoreSingles(w http.ResponseWriter, r *http.Request) {
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
}