diff --git a/backend/app.go b/backend/app.go index 376f0ae..2aa531c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -775,6 +775,7 @@ func (yj *YellowJacketApp) startJanitor() { } yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database)) + yj.janitor.Register(maintenance.StaleArtistMetadataJob(yj.database)) yj.janitor.Register(maintenance.OrphanedCoverFilesJob( yj.database, coversDir, library.CoverArtFileSet, )) diff --git a/backend/maintenance/maintenance_test.go b/backend/maintenance/maintenance_test.go index f88d621..27bf0f7 100644 --- a/backend/maintenance/maintenance_test.go +++ b/backend/maintenance/maintenance_test.go @@ -666,3 +666,71 @@ func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) { t.Errorf("kept %q, want the longest-lived row", kept) } } + +// TestStaleArtistMetadataJob pins the sweep's two keep rules: an owned +// artist's metadata survives, a browsed artist's survives while it still +// holds cached artwork, and everything else goes (#248). +func TestStaleArtistMetadataJob(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + + const ( + ownedMBID = "11111111-1111-1111-1111-111111111111" + browsedMBID = "22222222-2222-2222-2222-222222222222" + staleMBID = "33333333-3333-3333-3333-333333333333" + ) + + // The owned artist is in the library - which means a *file* says + // so. An artists row on its own is not ownership. + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/owned.mp3", + Artist: "Owned", + ArtistMBID: ownedMBID, + }) + + for _, mbid := range []string{ownedMBID, browsedMBID, staleMBID} { + if _, err := db.ExecContext( + `INSERT INTO artist_metadata (mbid, source, data, fetched_at) + VALUES (?, 'wikidata-p18', x'00', CURRENT_TIMESTAMP)`, + mbid, + ); err != nil { + t.Fatalf("seed artist_metadata for %s: %v", mbid, err) + } + } + + // The browsed artist holds cached artwork, so its metadata is still + // referenced and must survive. + if _, err := db.ExecContext( + `INSERT INTO artist_images + (artist_mbid, source, source_url, file_path) + VALUES (?, 'test', 'http://x', '/art/primary.jpg')`, + browsedMBID, + ); err != nil { + t.Fatalf("seed artist_images: %v", err) + } + + if _, err := StaleArtistMetadataJob(db).Run(context.Background()); err != nil { + t.Fatalf("run job: %v", err) + } + + for _, tc := range []struct { + mbid string + want int + }{ + {ownedMBID, 1}, + {browsedMBID, 1}, + {staleMBID, 0}, + } { + var n int + if err := db.QueryRowWriter( + "SELECT COUNT(*) FROM artist_metadata WHERE mbid = ?", tc.mbid, + ).Scan(&n); err != nil { + t.Fatalf("count %s: %v", tc.mbid, err) + } + + if n != tc.want { + t.Errorf("artist_metadata rows for %s = %d, want %d", tc.mbid, n, tc.want) + } + } +} diff --git a/backend/maintenance/sweeps.go b/backend/maintenance/sweeps.go index 049eae4..b1b50af 100644 --- a/backend/maintenance/sweeps.go +++ b/backend/maintenance/sweeps.go @@ -628,3 +628,37 @@ func dirSize(dir string) (bytes, files int64) { return bytes, files } + +// StaleArtistMetadataJob evicts long-lived artist metadata (bios, wiki +// leads, relationships) for artists the user no longer has any reason +// to keep around: not owned and holding no cached artwork. +// +// artist_metadata has no TTL by design — entity data changes rarely and +// re-fetching spends someone else's rate limit — so without a sweep it +// grows for the life of the install. This is the "swept when the +// artist is no longer referenced" contract the datamap always declared +// for it and nothing ever performed (#248). +func StaleArtistMetadataJob(db *database.DB) Job { + return Job{ + Name: "artist-metadata-sweep", + MinInterval: dailyInterval, + Run: func(_ context.Context) (Result, error) { + res, err := db.ExecContext( + `DELETE FROM artist_metadata + WHERE mbid NOT IN (` + ownedArtistMBIDs + `) + AND mbid NOT IN ( + SELECT artist_mbid FROM artist_images + )`, + ) + if err != nil { + return Result{}, fmt.Errorf( + "delete stale artist_metadata rows: %w", err, + ) + } + + rows, _ := res.RowsAffected() + + return Result{RowsDeleted: rows}, nil + }, + } +}