diff --git a/cmd/naviwatcher/main.go b/cmd/naviwatcher/main.go index e4db985..24d2c82 100644 --- a/cmd/naviwatcher/main.go +++ b/cmd/naviwatcher/main.go @@ -231,7 +231,9 @@ func (a *App) startPeriodicSync(ctx context.Context) { ticker := time.NewTicker(a.cfg.Sync.Interval) defer ticker.Stop() - // free is a sentinel channel: nil means a sync is currently in flight. + // free is a buffered token (capacity 1). A sync is in flight while the token + // is drained; the in-flight goroutine returns it when done so the next tick + // can start a new sync. While the token is held, ticks are skipped. var free = make(chan struct{}, 1) free <- struct{}{} diff --git a/docs/Specification.md b/docs/Specification.md index 2922e0d..3c2ee05 100644 --- a/docs/Specification.md +++ b/docs/Specification.md @@ -75,21 +75,24 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP ### Таблица `artist_settings` Хранит параметры мониторинга для каждого артиста из Navidrome. -* `id`: string (MBID или имя) +* `id`: string (Navidrome artist ID — Primary Key) * `name`: string +* `mbid`: string (MusicBrainz Artist ID; разрешается лениво при первой синхронизации и кэшируется; миграция `008_add_mbid_to_artist_settings`). `NULL` до первого разрешения. * `ignore_singles`: boolean (default: false) * `ignore_compilations`: boolean (default: false) * `monitored`: boolean (default: true) +* `last_synced`: datetime — время последней синхронизации дискографии; сигнал свежести кэша, чтобы пустые дискографии соблюдали TTL (миграция `009_add_last_synced_to_artist_settings`). `NULL` — ещё не синхронизировался. ### Таблица `external_releases` Кэш релизов, найденных во внешнем мире. * `rgid`: string (MusicBrainz Release Group ID) — Primary Key. * `artist_id`: string (FK) * `title`: string -* `type`: string (album/single/ep) +* `type`: string (album/single/ep/compilation) * `release_date`: string * `is_ignored`: boolean (флаг скрытия из списка новинок) * `cached_at`: datetime — время последней синхронизации/кэширования из MusicBrainz; используется для проверки TTL кэша (см. миграцию `005_add_cached_at_to_external_releases`). Значение `NULL` означает отсутствие актуального кэша. +* `secondary_types`: text — вторичные типы Release Group (Single/EP/Compilation и т.д.), через запятую; используются для фильтрации по типам наряду с первичным `type` (миграция `006_add_secondary_types_to_external_releases`). ### Таблица `local_albums` Локальные альбомы, синхронизированные из Navidrome через Subsonic API. @@ -128,6 +131,9 @@ server: # Basic Auth для доступа к веб-интерфейсу username: "admin" password: "password123" + # Внешний адрес веб-интерфейса для ссылок в Telegram-дайджестах. + # Если пусто, используется host:port (кроме 0.0.0.0 — тогда ссылка не формируется). + public_url: "https://naviwatcher.example.com" navidrome: url: "http://localhost:4533" @@ -147,6 +153,10 @@ telegram: scanner: fuzzy_threshold: 0.85 + +# Периодический цикл sync+scan (длительность Go, напр. "6h", "30m"); по умолчанию 6h +sync: + interval: 6h ``` diff --git a/internal/database/artist_settings.go b/internal/database/artist_settings.go index e4220e6..fceea0f 100644 --- a/internal/database/artist_settings.go +++ b/internal/database/artist_settings.go @@ -89,7 +89,7 @@ func nullIfEmpty(s string) interface{} { // GetAllArtistSettings returns all rows from artist_settings. func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) { rows, err := db.Conn().Query( - "SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored FROM artist_settings", + "SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings", ) if err != nil { return nil, fmt.Errorf("query all artist settings: %w", err) @@ -100,10 +100,14 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) { for rows.Next() { var s ArtistSettings var mbid sql.NullString - if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored); err != nil { + var lastSynced sql.NullTime + if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil { return nil, fmt.Errorf("scan artist settings: %w", err) } s.MBID = mbid.String + if lastSynced.Valid { + s.LastSynced = lastSynced.Time + } results = append(results, s) } if err := rows.Err(); err != nil { diff --git a/internal/database/external_releases_test.go b/internal/database/external_releases_test.go index 1ea1786..a259aac 100644 --- a/internal/database/external_releases_test.go +++ b/internal/database/external_releases_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "testing" + "time" ) // insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction. @@ -378,3 +379,108 @@ func TestSetReleaseIgnored_NotFound(t *testing.T) { t.Error("expected error for nonexistent RGID, got nil") } } + +// TestSecondaryTypesRoundTrip verifies that the comma-joined secondary_types +// column round-trips through save + read with the same slice, so the cache-hit +// type filtering (ignore_singles / ignore_compilations) sees the same data as +// the cache-miss path. +func TestSecondaryTypesRoundTrip(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + + cases := []struct { + name string + in []string + want []string + }{ + {"empty", nil, nil}, + {"single", []string{"Compilation"}, []string{"Compilation"}}, + {"multiple", []string{"Compilation", "Live", "EP"}, []string{"Compilation", "Live", "EP"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &ExternalRelease{ + RGID: "rgid-" + c.name, + ArtistID: "artist-1", + Title: "Title " + c.name, + Type: "Album", + SecondaryTypes: c.in, + } + if err := SaveExternalRelease(db, r); err != nil { + t.Fatalf("SaveExternalRelease() error: %v", err) + } + got, err := GetExternalRelease(db, r.RGID) + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + if len(got.SecondaryTypes) != len(c.want) { + t.Fatalf("secondary types = %v, want %v", got.SecondaryTypes, c.want) + } + for i := range c.want { + if got.SecondaryTypes[i] != c.want[i] { + t.Errorf("secondary types[%d] = %q, want %q", i, got.SecondaryTypes[i], c.want[i]) + } + } + }) + } +} + +// TestArtistCacheFresh covers the two-signal freshness logic: a fresh +// external_releases row, a fresh last_synced marker (empty discography), a stale +// state, and the ttl<=0 guard. +func TestArtistCacheFresh(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + + const ttl = 24 * time.Hour + + // No rows at all => not fresh. + if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh { + t.Fatalf("empty state: fresh=%v err=%v, want (false, nil)", fresh, err) + } + + // ttl <= 0 => never fresh. + if fresh, err := ArtistCacheFresh(db, "artist-1", 0); err != nil || fresh { + t.Fatalf("ttl=0: fresh=%v err=%v, want (false, nil)", fresh, err) + } + + // Fresh release row => fresh, even with an empty last_synced. + if _, err := db.Conn().Exec( + "INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)", + "rgid-1", "artist-1", "Album", FormatCachedAt(time.Now().UTC()), + ); err != nil { + t.Fatalf("insert release: %v", err) + } + if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh { + t.Fatalf("fresh release: fresh=%v err=%v, want (true, nil)", fresh, err) + } + + // Remove the release row, set a fresh last_synced (empty discography still cached). + if _, err := db.Conn().Exec("DELETE FROM external_releases WHERE artist_id = ?", "artist-1"); err != nil { + t.Fatalf("delete releases: %v", err) + } + if _, err := db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC()), "artist-1"); err != nil { + t.Fatalf("touch synced: %v", err) + } + if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh { + t.Fatalf("fresh last_synced: fresh=%v err=%v, want (true, nil)", fresh, err) + } + + // Stale both signals => not fresh. + db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC().Add(-2*ttl)), "artist-1") + if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh { + t.Fatalf("stale both: fresh=%v err=%v, want (false, nil)", fresh, err) + } +} diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go index 7c03df9..dc5d476 100644 --- a/internal/notifier/scheduler.go +++ b/internal/notifier/scheduler.go @@ -114,9 +114,11 @@ type notifyFunc func(ctx context.Context) error // StartScheduler runs the notify function on a schedule until ctx is cancelled. // It is no-op-safe: if enabled is false it returns immediately without starting -// a goroutine. Each firing runs in its own goroutine so a slow send does not -// delay the next scheduled tick; the scheduler still computes the next tick from -// the wall clock and does not drift. +// a goroutine. Each firing runs synchronously (in the scheduler's own +// goroutine): NotifyOnce reads the unnotified set and marks releases sent +// non-atomically, so overlapping runs would double-send the digest. Running one +// fire at a time keeps the read-send-mark sequence safe; the next tick is still +// computed from the wall clock and does not drift. // // The schedule and notify function are injectable so tests can drive a fixed or // frequent schedule without a real cron spec or Telegram server. @@ -157,14 +159,12 @@ func StartScheduler(ctx context.Context, enabled bool, schedule Schedule, notify log.Println("Notifier scheduler stopped.") return case <-timer.C: - go func() { - if err := notify(ctx); err != nil { - if ctx.Err() != nil { - return - } - log.Printf("Notifier run failed: %v", err) + if err := notify(ctx); err != nil { + if ctx.Err() != nil { + return } - }() + log.Printf("Notifier run failed: %v", err) + } } } }() diff --git a/internal/scanner/diff.go b/internal/scanner/diff.go index 3297774..efcd2ad 100644 --- a/internal/scanner/diff.go +++ b/internal/scanner/diff.go @@ -15,16 +15,56 @@ type MissingRelease struct { ReleaseDate string `json:"release_date"` } +// TypeFilter carries the per-artist type toggles that suppress whole release +// categories from the missing set. It mirrors the ignore_singles / +// ignore_compilations columns on artist_settings. +// +// These toggles are applied at scan/read time (not only when the MusicBrainz +// discography is synced) so a user flipping a toggle takes effect immediately on +// the dashboard, artist page, and Telegram digest — rather than waiting for the +// artist's MusicBrainz cache to expire and the rows to be pruned on the next +// cache-miss re-sync. +type TypeFilter struct { + IgnoreSingles bool + IgnoreCompilations bool +} + +// suppressed reports whether an external release is dropped by the type toggles. +// A release counts as a Single/Compilation via either its primary Type or its +// secondary types, matching musicbrainz.FilterReleaseGroups so both the +// cache-miss (store-time) and read-time paths agree. +func (f TypeFilter) suppressed(ext database.ExternalRelease) bool { + if f.IgnoreSingles && (ext.Type == "Single" || hasType(ext.SecondaryTypes, "Single")) { + return true + } + if f.IgnoreCompilations && (ext.Type == "Compilation" || hasType(ext.SecondaryTypes, "Compilation")) { + return true + } + return false +} + +// hasType reports whether types contains want. +func hasType(types []string, want string) bool { + for _, t := range types { + if t == want { + return true + } + } + return false +} + // FindMissingReleases compares an artist's external discography against the // user's local albums and returns the releases that are present externally but // have no sufficiently similar local album. // // Rules: // - External releases flagged IsIgnored are never reported. +// - External releases suppressed by the per-artist type toggles (filter) are +// never reported. // - A local album only matches an external release for the same ArtistID. // - An external release is "missing" when none of the local albums (same // ArtistID) IsMatch at the given threshold. -func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64) []MissingRelease { +func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter TypeFilter) []MissingRelease { // Resolve the threshold exactly as ScanArtist/ScanAll do, so the exported // primitive honors the same zero-means-default contract rather than treating // 0 as "always match" (which would report nothing as missing). @@ -41,6 +81,9 @@ func FindMissingReleases(local []database.LocalAlbum, external []database.Extern if ext.IsIgnored { continue } + if filter.suppressed(ext) { + continue + } albums := localByArtist[ext.ArtistID] matched := false diff --git a/internal/scanner/scan.go b/internal/scanner/scan.go index 3aa16b8..76b6a70 100644 --- a/internal/scanner/scan.go +++ b/internal/scanner/scan.go @@ -28,7 +28,19 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold return nil, err } - missing := FindMissingReleases(local, external, threshold) + // Apply the artist's type toggles at read time so ignore_singles / + // ignore_compilations changes take effect immediately, without waiting for + // the MusicBrainz cache to expire and prune rows on the next re-sync. + settings, err := database.GetArtistSettings(db, artistID) + if err != nil { + return nil, err + } + filter := TypeFilter{ + IgnoreSingles: settings.IgnoreSingles, + IgnoreCompilations: settings.IgnoreCompilations, + } + + missing := FindMissingReleases(local, external, threshold, filter) return missing, nil } diff --git a/internal/scanner/scan_test.go b/internal/scanner/scan_test.go index b34e39e..3ed972c 100644 --- a/internal/scanner/scan_test.go +++ b/internal/scanner/scan_test.go @@ -246,3 +246,61 @@ func seedArtistUnmonitored(t *testing.T, db *database.DB, id, name string) { t.Fatalf("seedArtistUnmonitored(%s) error: %v", id, err) } } + +// TestScanArtist_TypeToggle verifies that ScanArtist honors the artist's +// ignore_singles / ignore_compilations toggles at read time, so a toggled +// artist stops reporting those categories as missing immediately (without +// waiting for the MusicBrainz cache to expire). +func TestScanArtist_TypeToggle(t *testing.T) { + db := newTestDB(t) + defer db.Close() + + seedArtist(t, db, "a1", "Artist") + seedExternalRelease(t, db, "rg-album", "a1", "Album", false) + seedExternalRelease(t, db, "rg-single", "a1", "Single", false) + // seedExternalRelease leaves Type empty; set the primary type that the + // toggle filtering keys on. + if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Album", "rg-album"); err != nil { + t.Fatalf("set album type: %v", err) + } + if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Single", "rg-single"); err != nil { + t.Fatalf("set single type: %v", err) + } + + // No toggle: both reported missing (no local albums). + got, err := ScanArtist(context.Background(), db, "a1", 0) + if err != nil { + t.Fatalf("ScanArtist error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 missing before toggle, got %d", len(got)) + } + + // Toggle ignore_singles on. + if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": true}); err != nil { + t.Fatalf("toggle ignore_singles: %v", err) + } + got, err = ScanArtist(context.Background(), db, "a1", 0) + if err != nil { + t.Fatalf("ScanArtist error: %v", err) + } + if len(got) != 1 || got[0].RGID != "rg-album" { + ids := make([]string, len(got)) + for i, m := range got { + ids[i] = m.RGID + } + t.Fatalf("expected only rg-album after toggle, got %v", ids) + } + + // Toggle back off: single reappears. + if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": false}); err != nil { + t.Fatalf("toggle ignore_singles off: %v", err) + } + got, err = ScanArtist(context.Background(), db, "a1", 0) + if err != nil { + t.Fatalf("ScanArtist error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 missing after toggle off, got %d", len(got)) + } +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index a8b2ade..f5679ea 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -202,7 +202,7 @@ func TestFindMissingReleases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := FindMissingReleases(tt.local, tt.external, threshold) + got := FindMissingReleases(tt.local, tt.external, threshold, TypeFilter{}) gotRGIDs := make([]string, 0, len(got)) for _, m := range got { @@ -225,6 +225,67 @@ func TestFindMissingReleases(t *testing.T) { } } +func TestFindMissingReleases_TypeFilter(t *testing.T) { + const threshold = 0.85 + artist := "artist-a" + + external := []database.ExternalRelease{ + {RGID: "rg-album", ArtistID: artist, Title: "The Wall", Type: "Album"}, + {RGID: "rg-single", ArtistID: artist, Title: "B-side", Type: "Single"}, + {RGID: "rg-comp", ArtistID: artist, Title: "Hits", Type: "Compilation"}, + {RGID: "rg-comp-sec", ArtistID: artist, Title: "Live at X", Type: "Album", SecondaryTypes: []string{"Compilation"}}, + } + + tests := []struct { + name string + filter TypeFilter + want []string + }{ + { + name: "no filter reports all", + filter: TypeFilter{}, + want: []string{"rg-album", "rg-single", "rg-comp", "rg-comp-sec"}, + }, + { + name: "ignore singles drops Single primary type", + filter: TypeFilter{IgnoreSingles: true}, + want: []string{"rg-album", "rg-comp", "rg-comp-sec"}, + }, + { + name: "ignore compilations drops Compilation primary and secondary type", + filter: TypeFilter{IgnoreCompilations: true}, + want: []string{"rg-album", "rg-single"}, + }, + { + name: "both toggles drop singles and compilations", + filter: TypeFilter{IgnoreSingles: true, IgnoreCompilations: true}, + want: []string{"rg-album"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindMissingReleases(nil, external, threshold, tt.filter) + gotRGIDs := make([]string, 0, len(got)) + for _, m := range got { + gotRGIDs = append(gotRGIDs, m.RGID) + } + wantSet := make(map[string]struct{}, len(tt.want)) + for _, r := range tt.want { + wantSet[r] = struct{}{} + } + if len(gotRGIDs) != len(tt.want) { + t.Fatalf("got %v, want %v", gotRGIDs, tt.want) + } + for _, r := range gotRGIDs { + if _, ok := wantSet[r]; !ok { + t.Errorf("unexpected RGID %q", r) + } + } + }) + } +} + func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) { // A title at exactly the threshold must NOT be reported as missing // (IsMatch uses >= threshold). @@ -240,7 +301,7 @@ func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) { } // With default threshold 0.85, "The Wall Live" does not match "The Wall"; // at a low threshold it would. Confirms threshold is honoured. - if len(FindMissingReleases(local, external, 0.85)) != 1 { + if len(FindMissingReleases(local, external, 0.85, TypeFilter{})) != 1 { t.Errorf("expected 1 missing at 0.85 threshold") } } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 7db839b..0c2ae44 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -68,6 +68,7 @@ type LocalAlbumView struct { // artist detail page, including the ignore toggle form target. type MissingReleaseView struct { ArtistID string + ArtistName string RGID string Title string Type string @@ -189,14 +190,18 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { data := &ArchiveData{UIBaseURL: s.uiBaseURL} for _, rel := range ignored { - data.Releases = append(data.Releases, MissingReleaseView{ + 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") @@ -271,8 +276,7 @@ func (s *Server) ignoreOrRestore(w http.ResponseWriter, r *http.Request) { // 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) { + if errors.Is(err, database.ErrReleaseNotFound) { http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther) return } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 4a43e1f..3947bc8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -469,3 +469,81 @@ func dashboardMissingCount(t *testing.T, s *Server, artistName string) int { } return 0 } + +// postStateChanging issues a state-changing POST to the given route with the +// provided Origin/Referer header and basic auth, returning the response code. +func postStateChanging(t *testing.T, s *Server, path, originHeader string) int { + t.Helper() + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, path, form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if originHeader != "" { + req.Header.Set("Origin", originHeader) + } + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec.Code +} + +func TestStateChangingEnforcesSameOrigin(t *testing.T) { + served := "http://0.0.0.0:8080" // matches the server's Addr() + + tests := []struct { + name string + route string + origin string + wantCode int + }{ + {"same-origin Origin allowed", "/artist/a1/ignore", served, http.StatusSeeOther}, + {"no Origin header allowed (same-origin form post)", "/artist/a1/ignore", "", http.StatusSeeOther}, + {"cross-origin Origin rejected", "/artist/a1/ignore", "http://evil.example", http.StatusForbidden}, + {"cross-origin Referer rejected", "/artist/a1/ignore", "", http.StatusForbidden}, + {"cross-origin on toggle rejected", "/artist/a1/ignore-singles", "http://evil.example", http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + // For the cross-origin Referer case, use Referer instead of Origin. + var code int + if tt.name == "cross-origin Referer rejected" { + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, tt.route, form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", "http://evil.example/artist/a1") + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + code = rec.Code + } else { + code = postStateChanging(t, s, tt.route, tt.origin) + } + + if code != tt.wantCode { + t.Fatalf("route %s origin %q: got %d, want %d", tt.route, tt.origin, code, tt.wantCode) + } + }) + } +} + +func TestStateChanging_MalformedOriginRejected(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "", true) + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + form := strings.NewReader("rgid=r1") + req := httptest.NewRequest(http.MethodPost, "/artist/a1/ignore", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // An Origin that does not parse as a valid URL with a host. + req.Header.Set("Origin", "http://") + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for malformed origin, got %d", rec.Code) + } +} diff --git a/internal/web/templates/archive.html b/internal/web/templates/archive.html index d0d0db9..302d156 100644 --- a/internal/web/templates/archive.html +++ b/internal/web/templates/archive.html @@ -27,7 +27,7 @@ {{ range .Releases }} - {{ .ArtistID }} + {{ if .ArtistName }}{{ .ArtistName }}{{ else }}{{ .ArtistID }}{{ end }} {{ .Title }} {{ if .Type }}{{ .Type }}{{ else }}{{ end }} {{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}{{ end }} diff --git a/internal/web/templates/artist.html b/internal/web/templates/artist.html index 6f47376..83b7199 100644 --- a/internal/web/templates/artist.html +++ b/internal/web/templates/artist.html @@ -57,17 +57,10 @@ {{ if .Type }}{{ .Type }}{{ else }}{{ end }} {{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}{{ end }} - {{ if .Ignored }} -
- - -
- {{ else }}
- {{ end }} {{ end }}