From 5995dfd01d61cd4d2c0749eeeee2a1f93b739d68 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 12 Mar 2026 19:38:36 -0400 Subject: [PATCH] feat(12-01): add queue compaction method and wire removal hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CompactAfterLibraryRemoval on Queue: reloads surviving tracks from DB, resets currentIndex, unloads player if current track was removed, clears shuffle order - Wire RemovalHooks in app.go OnStartup: StopPlayback → player.UnloadTrack, CompactQueue → queue.CompactAfterLibraryRemoval - All existing tests pass with no regressions --- backend/app.go | 8 +++++ backend/queue/queue.go | 80 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/backend/app.go b/backend/app.go index 1e2a1d3..f7b789a 100644 --- a/backend/app.go +++ b/backend/app.go @@ -171,6 +171,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { PostScan: yj.playlist.RestoreAllPlaylists, }) + // Wire removal hooks so the library can stop playback and + // compact the queue during library removal without depending + // on the player or queue packages directly. + yj.library.SetRemovalHooks(library.RemovalHooks{ + StopPlayback: func() { yj.player.UnloadTrack() }, + CompactQueue: yj.queue.CompactAfterLibraryRemoval, + }) + // Register playback finished handler to drive queue auto-advance. yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) diff --git a/backend/queue/queue.go b/backend/queue/queue.go index a84f96a..84b68c2 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -1246,6 +1246,86 @@ func (q *Queue) onQueueExhausted() { q.persistState() } +// CompactAfterLibraryRemoval reloads queue state from the database +// after a library removal has cascade-deleted queue_tracks rows. +// It resets currentIndex to 0 (or -1 if empty), clears shuffleOrder, +// unloads the current track if it was removed, and emits QueueChanged. +func (q *Queue) CompactAfterLibraryRemoval() { + q.mu.Lock() + defer q.mu.Unlock() + + // Remember the current track's file path so we can detect if it survived. + var previousFilePath string + + if q.currentIndex >= 0 && q.currentIndex < len(q.tracks) { + previousFilePath = q.tracks[q.currentIndex].FilePath + } + + // Reload surviving tracks from the database. The CASCADE delete + // already removed the rows from queue_tracks — we just need to + // reload and reindex. + rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx) + if err != nil { + q.logger.Error("could not reload queue tracks after library removal", "err", err) + + return + } + + q.tracks = make([]Track, 0, len(rows)) + + for _, row := range rows { + q.tracks = append(q.tracks, Track{ + ID: row.ID, + AudioFileID: row.AudioFileID, + FilePath: row.FilePath, + Position: row.Position, + Title: row.Title, + Artist: row.Artist, + }) + } + + // Check if the previously-playing track survived. + found := false + + if previousFilePath != "" { + for i, t := range q.tracks { + if t.FilePath == previousFilePath { + q.currentIndex = i + found = true + + break + } + } + } + + if !found { + if len(q.tracks) > 0 { + q.currentIndex = 0 + } else { + q.currentIndex = -1 + } + + // The current track was removed — unload it from the player. + if q.player != nil { + q.player.UnloadTrack() + } + } + + // Clear shuffle order — it will be regenerated on next shuffle toggle. + q.shuffleOrder = nil + + // Reindex positions and persist the compacted state. + q.reindexPositions() + q.persistTracks() + q.persistState() + q.emitQueueChanged() + + q.logger.Info("queue compacted after library removal", + "survivingTracks", len(q.tracks), + "currentIndex", q.currentIndex, + ) +} + // reindexPositions updates the Position field of all tracks to match slice index. func (q *Queue) reindexPositions() { for i := range q.tracks {