Compare commits

..
Author SHA1 Message Date
yonlu cf90030463 chore(bindings): regenerate for the queue's DropSourceForPlaylist
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 6m50s
CI / e2e (pull_request) Successful in 11m9s
frontend/bindings is generated by wails3 and is not covered by the
codegen pre-commit hook, so the new bound method went out without it and
make bindings-check failed on the PR.
2026-09-20 20:22:16 -04:00
yonlu 88f5524aa2 fix(maintenance): bound lyrics search and clicks, clear queue source
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Failing after 3m44s
CI / e2e (pull_request) Skipped
Three unbounded or stale surfaces, each small on its own:

- lyrics_index rows were never pruned on track removal, so the FTS index
  grew forever. Delete the entry where the library search FTS entry is
  already deleted, on the orphan and RemoveFromLibrary paths.
- search_clicks had no ceiling; age out ranking rows after a retention
  window via a daily janitor job.
- queue.source_* kept a "Playing from X" label after its playlist was
  deleted. Drop the source when the queue's own playlist goes, wired
  through a playlist-service hook like Library.SetRemovalHooks.

Closes #249
2026-09-11 17:14:41 -04:00
10 changed files with 169 additions and 72 deletions

No files matched your search

+5 -1
View File
@@ -499,6 +499,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
PostRemove: yj.explore.InvalidateLibrarySync, PostRemove: yj.explore.InvalidateLibrarySync,
}) })
// A deleted playlist must not leave the queue's "Playing from"
// label pointing at it.
yj.playlist.SetOnPlaylistDeleted(yj.queue.DropSourceForPlaylist)
// Register playback finished handler to drive queue auto-advance. // Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
@@ -775,7 +779,7 @@ func (yj *YellowJacketApp) startJanitor() {
} }
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database)) yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
yj.janitor.Register(maintenance.StaleArtistMetadataJob(yj.database)) yj.janitor.Register(maintenance.StaleSearchClicksJob(yj.database))
yj.janitor.Register(maintenance.OrphanedCoverFilesJob( yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
yj.database, coversDir, library.CoverArtFileSet, yj.database, coversDir, library.CoverArtFileSet,
)) ))
+16 -4
View File
@@ -151,16 +151,28 @@ func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string)
return d.upsertLyricsIndex(audioFileID, lyrics) return d.upsertLyricsIndex(audioFileID, lyrics)
} }
// upsertLyricsIndex refreshes a single file's entry in the contentless // DeleteLyricsIndex removes one file's entry from the contentless
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty // lyrics_index. It is called wherever a file row is deleted — the
// lyrics string leaves the row deleted. // `lyrics` table cascades with its file, but the FTS entry does not and
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error { // would otherwise accumulate for the life of the install (#249).
func (d *DB) DeleteLyricsIndex(audioFileID int64) error {
if _, err := d.db.ExecContext(d.Ctx, if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID, "DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
); err != nil { ); err != nil {
return fmt.Errorf("could not delete lyrics_index row: %w", err) return fmt.Errorf("could not delete lyrics_index row: %w", err)
} }
return nil
}
// upsertLyricsIndex refreshes a single file's entry in the contentless
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
// lyrics string leaves the row deleted.
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
if err := d.DeleteLyricsIndex(audioFileID); err != nil {
return err
}
if strings.TrimSpace(lyrics) == "" { if strings.TrimSpace(lyrics) == "" {
return nil return nil
} }
+13 -1
View File
@@ -968,7 +968,7 @@ func (l *Library) scanInternal(
} }
} }
// Remove from FTS5 search index. // Remove from FTS5 search index and the lyrics index.
if err := l.db.DeleteSearchIndex( if err := l.db.DeleteSearchIndex(
audioFile.ID, audioFile.ID,
); err != nil { ); err != nil {
@@ -981,6 +981,18 @@ func (l *Library) scanInternal(
metrics.addWarning(path, "orphan", err) metrics.addWarning(path, "orphan", err)
} }
if err := l.db.DeleteLyricsIndex(
audioFile.ID,
); err != nil {
l.logger.Warn(
"failed to delete lyrics index entry for orphan",
"id", audioFile.ID,
"err", err,
)
metrics.addWarning(path, "orphan", err)
}
removed.Add(1) removed.Add(1)
return true return true
+5
View File
@@ -127,6 +127,11 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
l.logger.Warn("could not delete FTS entry for removed track", l.logger.Warn("could not delete FTS entry for removed track",
"path", row.FilePath, "id", row.ID, "err", err) "path", row.FilePath, "id", row.ID, "err", err)
} }
if err := l.db.DeleteLyricsIndex(row.ID); err != nil {
l.logger.Warn("could not delete lyrics index entry for removed track",
"path", row.FilePath, "id", row.ID, "err", err)
}
} }
// Deleting an audio_files row cascades to queue_tracks, so the // Deleting an audio_files row cascades to queue_tracks, so the
+30 -49
View File
@@ -667,70 +667,51 @@ func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) {
} }
} }
// TestStaleArtistMetadataJob pins the sweep's two keep rules: an owned // TestStaleSearchClicksJob deletes only the clicks old enough to have
// artist's metadata survives, a browsed artist's survives while it still // left the retention window (#249).
// holds cached artwork, and everything else goes (#248). func TestStaleSearchClicksJob(t *testing.T) {
func TestStaleArtistMetadataJob(t *testing.T) {
t.Parallel() t.Parallel()
db := database.NewTestDB(t) db := database.NewTestDB(t)
const ( count := func(mbid string) int {
ownedMBID = "11111111-1111-1111-1111-111111111111" t.Helper()
browsedMBID = "22222222-2222-2222-2222-222222222222"
staleMBID = "33333333-3333-3333-3333-333333333333"
)
// The owned artist is in the library - which means a *file* says var n int
// so. An artists row on its own is not ownership. if err := db.QueryRowWriter(
database.InsertTestTrack(t, db, database.TestTrack{ "SELECT COUNT(*) FROM search_clicks WHERE entity_mbid = ?", mbid,
FilePath: "/music/owned.mp3", ).Scan(&n); err != nil {
Artist: "Owned", t.Fatalf("count %s: %v", mbid, err)
ArtistMBID: ownedMBID, }
})
return n
}
seed := func(query, mbid, lastClicked string) {
t.Helper()
for _, mbid := range []string{ownedMBID, browsedMBID, staleMBID} {
if _, err := db.ExecContext( if _, err := db.ExecContext(
`INSERT INTO artist_metadata (mbid, source, data, fetched_at) `INSERT INTO search_clicks
VALUES (?, 'wikidata-p18', x'00', CURRENT_TIMESTAMP)`, (query, entity_mbid, entity_type, click_count, last_clicked)
mbid, VALUES (?, ?, 'recording', 1, ?)`,
query, mbid, lastClicked,
); err != nil { ); err != nil {
t.Fatalf("seed artist_metadata for %s: %v", mbid, err) t.Fatalf("seed search_clicks: %v", err)
} }
} }
// The browsed artist holds cached artwork, so its metadata is still seed("tide", "aaaa", "2024-01-01 00:00:00") // stale
// referenced and must survive. seed("tide", "bbbb", "2999-01-01 00:00:00") // recent
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 { if _, err := StaleSearchClicksJob(db).Run(context.Background()); err != nil {
t.Fatalf("run job: %v", err) t.Fatalf("run job: %v", err)
} }
for _, tc := range []struct { if n := count("bbbb"); n != 1 {
mbid string t.Errorf("recent click was deleted: %d rows, want 1", n)
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 { if n := count("aaaa"); n != 0 {
t.Errorf("artist_metadata rows for %s = %d, want %d", tc.mbid, n, tc.want) t.Errorf("stale click survived: %d rows, want 0", n)
}
} }
} }
+15 -17
View File
@@ -629,30 +629,28 @@ func dirSize(dir string) (bytes, files int64) {
return bytes, files return bytes, files
} }
// StaleArtistMetadataJob evicts long-lived artist metadata (bios, wiki // searchClicksRetention is how long a search-click ranking signal stays
// leads, relationships) for artists the user no longer has any reason // useful. search_clicks is authored behavioural data — nothing that
// to keep around: not owned and holding no cached artwork. // owns a row ever drops it — so age is the ceiling that keeps the table
// // from growing without bound for the life of the install (#249).
// artist_metadata has no TTL by design — entity data changes rarely and const searchClicksRetention = "-180 days"
// 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 // StaleSearchClicksJob deletes search-click ranking rows older than the
// artist is no longer referenced" contract the datamap always declared // retention window. Rows are small and the table grows slowly, so this
// for it and nothing ever performed (#248). // runs daily and does almost nothing most runs.
func StaleArtistMetadataJob(db *database.DB) Job { func StaleSearchClicksJob(db *database.DB) Job {
return Job{ return Job{
Name: "artist-metadata-sweep", Name: "search-clicks-sweep",
MinInterval: dailyInterval, MinInterval: dailyInterval,
Run: func(_ context.Context) (Result, error) { Run: func(_ context.Context) (Result, error) {
res, err := db.ExecContext( res, err := db.ExecContext(
`DELETE FROM artist_metadata `DELETE FROM search_clicks
WHERE mbid NOT IN (` + ownedArtistMBIDs + `) WHERE last_clicked < datetime('now', ?)`,
AND mbid NOT IN ( searchClicksRetention,
SELECT artist_mbid FROM artist_images
)`,
) )
if err != nil { if err != nil {
return Result{}, fmt.Errorf( return Result{}, fmt.Errorf(
"delete stale artist_metadata rows: %w", err, "delete stale search_clicks rows: %w", err,
) )
} }
+28
View File
@@ -134,6 +134,12 @@ type Service struct {
libraryDir LibraryDirProvider libraryDir LibraryDirProvider
favoritesConf FavoritesConfigProvider favoritesConf FavoritesConfigProvider
// onDeleted, when set, is called after a playlist is deleted so
// cross-cutting state that points at it (the queue's "Playing
// from" label) can stop pointing at a playlist that no longer
// exists. Wired from app.go, like Library.SetRemovalHooks.
onDeleted func(playlistID int64)
// dataDirOverride, when non-empty, replaces the OS user data // dataDirOverride, when non-empty, replaces the OS user data
// directory as the base for the playlists folder. Set by tests to // directory as the base for the playlists folder. Set by tests to
// keep M3U writes out of the real user data directory. // keep M3U writes out of the real user data directory.
@@ -166,6 +172,17 @@ func (s *Service) SetFavoritesConfig(
s.favoritesConf = provider s.favoritesConf = provider
} }
// SetOnPlaylistDeleted registers a callback invoked after a playlist is
// deleted, for cross-cutting invalidation.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (s *Service) SetOnPlaylistDeleted(onDeleted func(playlistID int64)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onDeleted = onDeleted
}
// ServiceStartup is v3's service lifecycle hook: it runs once the // ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It // runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from // replaces v2's SetContext, which had to be called by hand from
@@ -766,6 +783,17 @@ func (s *Service) DeletePlaylist(playlistID int64) error {
s.emitEvent(events.PlaylistDeleted, playlistID) s.emitEvent(events.PlaylistDeleted, playlistID)
// Cross-cutting invalidation: the queue's "Playing from" label may
// point at this playlist, and a link to a playlist that no longer
// exists is worse than none.
s.mu.Lock()
onDeleted := s.onDeleted
s.mu.Unlock()
if onDeleted != nil {
onDeleted(playlistID)
}
// Recreate the default playlist if we just deleted it. // Recreate the default playlist if we just deleted it.
if s.defaultPlaylistID() == playlistID { if s.defaultPlaylistID() == playlistID {
s.EnsureDefaultPlaylist() s.EnsureDefaultPlaylist()
+15
View File
@@ -1581,6 +1581,21 @@ func (q *Queue) dropSource() {
q.source = Source{} q.source = Source{}
} }
// DropSourceForPlaylist clears the queue's "Playing from" label when
// its source playlist is deleted. A link back to a playlist that no
// longer exists is worse than none, and the label otherwise survives
// the deletion until the next SetQueue (#249).
func (q *Queue) DropSourceForPlaylist(playlistID int64) {
q.mu.Lock()
defer q.mu.Unlock()
if (q.source.Type == "playlist" || q.source.Type == "smartPlaylist") &&
q.source.ID == playlistID {
q.dropSource()
q.persistState()
}
}
// commitMutation persists the current queue state after a mutation. // commitMutation persists the current queue state after a mutation.
// When reindex is true, track positions are renumbered first. // When reindex is true, track positions are renumbered first.
// The caller must hold q.mu. // The caller must hold q.mu.
+32
View File
@@ -535,3 +535,35 @@ func TestCycleRepeat_CyclesThroughModes(t *testing.T) {
t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff) t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff)
} }
} }
// TestDropSourceForPlaylist clears the "Playing from" label when the
// queue's source playlist is deleted, and leaves it alone otherwise
// (#249).
func TestDropSourceForPlaylist(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 2)
q.SetQueue(paths, 0, false, Source{Type: "playlist", ID: 42, Label: "Road Trip"})
q.DropSourceForPlaylist(42)
if got := q.GetState().Source; got != (Source{}) {
t.Errorf("source = %+v, want empty after playlist 42 deleted", got)
}
}
func TestDropSourceForPlaylistIgnoresOtherPlaylists(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 2)
source := Source{Type: "smartPlaylist", ID: 42, Label: "Road Trip"}
q.SetQueue(paths, 0, false, source)
q.DropSourceForPlaylist(7)
if got := q.GetState().Source; got != source {
t.Errorf("source = %+v, want %+v unchanged for a different playlist", got, source)
}
}
@@ -56,6 +56,16 @@ export function CycleRepeat(): $CancellablePromise<void> {
return $Call.ByID(3510519482); return $Call.ByID(3510519482);
} }
/**
* DropSourceForPlaylist clears the queue's "Playing from" label when
* its source playlist is deleted. A link back to a playlist that no
* longer exists is worse than none, and the label otherwise survives
* the deletion until the next SetQueue (#249).
*/
export function DropSourceForPlaylist(playlistID: number): $CancellablePromise<void> {
return $Call.ByID(1435106374, playlistID);
}
/** /**
* EmitCurrentState emits the current queue state to the frontend. * EmitCurrentState emits the current queue state to the frontend.
* This is called after the frontend DOM is ready. * This is called after the frontend DOM is ready.