fix: address code review findings
- Honor ignore_singles/ignore_compilations at scanner read time so toggles take effect immediately on the dashboard, artist page, and digest instead of waiting for the MusicBrainz cache to expire and prune rows. - Run notifier notify synchronously in the scheduler loop to avoid overlapping read-send-mark runs double-sending the digest. - Show artist name (with ID fallback) on the archive page instead of raw IDs. - Select last_synced in GetAllArtistSettings for contract consistency. - Fix stale startPeriodicSync comment and remove redundant error var. - Remove dead ignored-branch from the artist template (never rendered). - Add tests: CSRF sameOrigin, ArtistCacheFresh, secondary_types round-trip, and scanner type-toggle filtering. - Update Specification.md schema/config to reflect mbid, last_synced, secondary_types, sync.interval, and server.public_url.
This commit is contained in:
@@ -231,7 +231,9 @@ func (a *App) startPeriodicSync(ctx context.Context) {
|
|||||||
ticker := time.NewTicker(a.cfg.Sync.Interval)
|
ticker := time.NewTicker(a.cfg.Sync.Interval)
|
||||||
defer ticker.Stop()
|
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)
|
var free = make(chan struct{}, 1)
|
||||||
free <- struct{}{}
|
free <- struct{}{}
|
||||||
|
|
||||||
|
|||||||
@@ -75,21 +75,24 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP
|
|||||||
|
|
||||||
### Таблица `artist_settings`
|
### Таблица `artist_settings`
|
||||||
Хранит параметры мониторинга для каждого артиста из Navidrome.
|
Хранит параметры мониторинга для каждого артиста из Navidrome.
|
||||||
* `id`: string (MBID или имя)
|
* `id`: string (Navidrome artist ID — Primary Key)
|
||||||
* `name`: string
|
* `name`: string
|
||||||
|
* `mbid`: string (MusicBrainz Artist ID; разрешается лениво при первой синхронизации и кэшируется; миграция `008_add_mbid_to_artist_settings`). `NULL` до первого разрешения.
|
||||||
* `ignore_singles`: boolean (default: false)
|
* `ignore_singles`: boolean (default: false)
|
||||||
* `ignore_compilations`: boolean (default: false)
|
* `ignore_compilations`: boolean (default: false)
|
||||||
* `monitored`: boolean (default: true)
|
* `monitored`: boolean (default: true)
|
||||||
|
* `last_synced`: datetime — время последней синхронизации дискографии; сигнал свежести кэша, чтобы пустые дискографии соблюдали TTL (миграция `009_add_last_synced_to_artist_settings`). `NULL` — ещё не синхронизировался.
|
||||||
|
|
||||||
### Таблица `external_releases`
|
### Таблица `external_releases`
|
||||||
Кэш релизов, найденных во внешнем мире.
|
Кэш релизов, найденных во внешнем мире.
|
||||||
* `rgid`: string (MusicBrainz Release Group ID) — Primary Key.
|
* `rgid`: string (MusicBrainz Release Group ID) — Primary Key.
|
||||||
* `artist_id`: string (FK)
|
* `artist_id`: string (FK)
|
||||||
* `title`: string
|
* `title`: string
|
||||||
* `type`: string (album/single/ep)
|
* `type`: string (album/single/ep/compilation)
|
||||||
* `release_date`: string
|
* `release_date`: string
|
||||||
* `is_ignored`: boolean (флаг скрытия из списка новинок)
|
* `is_ignored`: boolean (флаг скрытия из списка новинок)
|
||||||
* `cached_at`: datetime — время последней синхронизации/кэширования из MusicBrainz; используется для проверки TTL кэша (см. миграцию `005_add_cached_at_to_external_releases`). Значение `NULL` означает отсутствие актуального кэша.
|
* `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`
|
### Таблица `local_albums`
|
||||||
Локальные альбомы, синхронизированные из Navidrome через Subsonic API.
|
Локальные альбомы, синхронизированные из Navidrome через Subsonic API.
|
||||||
@@ -128,6 +131,9 @@ server:
|
|||||||
# Basic Auth для доступа к веб-интерфейсу
|
# Basic Auth для доступа к веб-интерфейсу
|
||||||
username: "admin"
|
username: "admin"
|
||||||
password: "password123"
|
password: "password123"
|
||||||
|
# Внешний адрес веб-интерфейса для ссылок в Telegram-дайджестах.
|
||||||
|
# Если пусто, используется host:port (кроме 0.0.0.0 — тогда ссылка не формируется).
|
||||||
|
public_url: "https://naviwatcher.example.com"
|
||||||
|
|
||||||
navidrome:
|
navidrome:
|
||||||
url: "http://localhost:4533"
|
url: "http://localhost:4533"
|
||||||
@@ -147,6 +153,10 @@ telegram:
|
|||||||
|
|
||||||
scanner:
|
scanner:
|
||||||
fuzzy_threshold: 0.85
|
fuzzy_threshold: 0.85
|
||||||
|
|
||||||
|
# Периодический цикл sync+scan (длительность Go, напр. "6h", "30m"); по умолчанию 6h
|
||||||
|
sync:
|
||||||
|
interval: 6h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func nullIfEmpty(s string) interface{} {
|
|||||||
// GetAllArtistSettings returns all rows from artist_settings.
|
// GetAllArtistSettings returns all rows from artist_settings.
|
||||||
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||||
rows, err := db.Conn().Query(
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query all artist settings: %w", err)
|
return nil, fmt.Errorf("query all artist settings: %w", err)
|
||||||
@@ -100,10 +100,14 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s ArtistSettings
|
var s ArtistSettings
|
||||||
var mbid sql.NullString
|
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)
|
return nil, fmt.Errorf("scan artist settings: %w", err)
|
||||||
}
|
}
|
||||||
s.MBID = mbid.String
|
s.MBID = mbid.String
|
||||||
|
if lastSynced.Valid {
|
||||||
|
s.LastSynced = lastSynced.Time
|
||||||
|
}
|
||||||
results = append(results, s)
|
results = append(results, s)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction.
|
// 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,9 +114,11 @@ type notifyFunc func(ctx context.Context) error
|
|||||||
|
|
||||||
// StartScheduler runs the notify function on a schedule until ctx is cancelled.
|
// 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
|
// 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
|
// a goroutine. Each firing runs synchronously (in the scheduler's own
|
||||||
// delay the next scheduled tick; the scheduler still computes the next tick from
|
// goroutine): NotifyOnce reads the unnotified set and marks releases sent
|
||||||
// the wall clock and does not drift.
|
// 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
|
// The schedule and notify function are injectable so tests can drive a fixed or
|
||||||
// frequent schedule without a real cron spec or Telegram server.
|
// 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.")
|
log.Println("Notifier scheduler stopped.")
|
||||||
return
|
return
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
go func() {
|
if err := notify(ctx); err != nil {
|
||||||
if err := notify(ctx); err != nil {
|
if ctx.Err() != nil {
|
||||||
if ctx.Err() != nil {
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("Notifier run failed: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
log.Printf("Notifier run failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -15,16 +15,56 @@ type MissingRelease struct {
|
|||||||
ReleaseDate string `json:"release_date"`
|
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
|
// FindMissingReleases compares an artist's external discography against the
|
||||||
// user's local albums and returns the releases that are present externally but
|
// user's local albums and returns the releases that are present externally but
|
||||||
// have no sufficiently similar local album.
|
// have no sufficiently similar local album.
|
||||||
//
|
//
|
||||||
// Rules:
|
// Rules:
|
||||||
// - External releases flagged IsIgnored are never reported.
|
// - 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.
|
// - A local album only matches an external release for the same ArtistID.
|
||||||
// - An external release is "missing" when none of the local albums (same
|
// - An external release is "missing" when none of the local albums (same
|
||||||
// ArtistID) IsMatch at the given threshold.
|
// 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
|
// Resolve the threshold exactly as ScanArtist/ScanAll do, so the exported
|
||||||
// primitive honors the same zero-means-default contract rather than treating
|
// primitive honors the same zero-means-default contract rather than treating
|
||||||
// 0 as "always match" (which would report nothing as missing).
|
// 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 {
|
if ext.IsIgnored {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if filter.suppressed(ext) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
albums := localByArtist[ext.ArtistID]
|
albums := localByArtist[ext.ArtistID]
|
||||||
matched := false
|
matched := false
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
|
|||||||
return nil, err
|
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
|
return missing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,3 +246,61 @@ func seedArtistUnmonitored(t *testing.T, db *database.DB, id, name string) {
|
|||||||
t.Fatalf("seedArtistUnmonitored(%s) error: %v", id, err)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func TestFindMissingReleases(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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))
|
gotRGIDs := make([]string, 0, len(got))
|
||||||
for _, m := range 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) {
|
func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) {
|
||||||
// A title at exactly the threshold must NOT be reported as missing
|
// A title at exactly the threshold must NOT be reported as missing
|
||||||
// (IsMatch uses >= threshold).
|
// (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";
|
// With default threshold 0.85, "The Wall Live" does not match "The Wall";
|
||||||
// at a low threshold it would. Confirms threshold is honoured.
|
// 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")
|
t.Errorf("expected 1 missing at 0.85 threshold")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type LocalAlbumView struct {
|
|||||||
// artist detail page, including the ignore toggle form target.
|
// artist detail page, including the ignore toggle form target.
|
||||||
type MissingReleaseView struct {
|
type MissingReleaseView struct {
|
||||||
ArtistID string
|
ArtistID string
|
||||||
|
ArtistName string
|
||||||
RGID string
|
RGID string
|
||||||
Title string
|
Title string
|
||||||
Type string
|
Type string
|
||||||
@@ -189,14 +190,18 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data := &ArchiveData{UIBaseURL: s.uiBaseURL}
|
data := &ArchiveData{UIBaseURL: s.uiBaseURL}
|
||||||
for _, rel := range ignored {
|
for _, rel := range ignored {
|
||||||
data.Releases = append(data.Releases, MissingReleaseView{
|
view := MissingReleaseView{
|
||||||
ArtistID: rel.ArtistID,
|
ArtistID: rel.ArtistID,
|
||||||
RGID: rel.RGID,
|
RGID: rel.RGID,
|
||||||
Title: rel.Title,
|
Title: rel.Title,
|
||||||
Type: rel.Type,
|
Type: rel.Type,
|
||||||
ReleaseDate: rel.ReleaseDate,
|
ReleaseDate: rel.ReleaseDate,
|
||||||
Ignored: true,
|
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")
|
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
|
// A 0-rows-affected error means the release was already removed by a
|
||||||
// concurrent re-sync (it disappeared from MusicBrainz). That is benign:
|
// concurrent re-sync (it disappeared from MusicBrainz). That is benign:
|
||||||
// redirect back rather than surfacing a 500 for a now-nonexistent row.
|
// redirect back rather than surfacing a 500 for a now-nonexistent row.
|
||||||
var notFoundErr error = database.ErrReleaseNotFound
|
if errors.Is(err, database.ErrReleaseNotFound) {
|
||||||
if errors.Is(err, notFoundErr) {
|
|
||||||
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
|
http.Redirect(w, r, "/artist/"+id, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,3 +469,81 @@ func dashboardMissingCount(t *testing.T, s *Server, artistName string) int {
|
|||||||
}
|
}
|
||||||
return 0
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{ range .Releases }}
|
{{ range .Releases }}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ .ArtistID }}</td>
|
<td>{{ if .ArtistName }}{{ .ArtistName }}{{ else }}{{ .ArtistID }}{{ end }}</td>
|
||||||
<td>{{ .Title }}</td>
|
<td>{{ .Title }}</td>
|
||||||
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
||||||
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
||||||
|
|||||||
@@ -57,17 +57,10 @@
|
|||||||
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
<td>{{ if .Type }}{{ .Type }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
||||||
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
<td>{{ if .ReleaseDate }}{{ .ReleaseDate }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ if .Ignored }}
|
|
||||||
<form method="POST" action="/artist/{{ $.ID }}/restore">
|
|
||||||
<input type="hidden" name="rgid" value="{{ .RGID }}">
|
|
||||||
<button type="submit">Restore</button>
|
|
||||||
</form>
|
|
||||||
{{ else }}
|
|
||||||
<form method="POST" action="/artist/{{ $.ID }}/ignore">
|
<form method="POST" action="/artist/{{ $.ID }}/ignore">
|
||||||
<input type="hidden" name="rgid" value="{{ .RGID }}">
|
<input type="hidden" name="rgid" value="{{ .RGID }}">
|
||||||
<button type="submit">Ignore</button>
|
<button type="submit">Ignore</button>
|
||||||
</form>
|
</form>
|
||||||
{{ end }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user