diff --git a/.opencode/plans/refactoring-catalog.md b/.opencode/plans/refactoring-catalog.md index e82dcff..2aaebf2 100644 --- a/.opencode/plans/refactoring-catalog.md +++ b/.opencode/plans/refactoring-catalog.md @@ -6,38 +6,17 @@ Prioritized list of architectural improvements identified during a full codebase ## P1 — Should fix before adding major features -### 1. Resolve `RequestPlay` dual-handler ambiguity - -**Problem:** Both `player.registerEventHandlers()` (`player.go`) and `queue.registerEventHandlers()` (`queue.go:182`) listen for the `RequestPlay` event. The player calls `Play()` (resume audio), while the queue calls `PlayFromStart()` (play from beginning if `currentIndex == -1`). Both fire on every `RequestPlay` event since Wails delivers to all listeners. - -**Why it matters:** This works by coincidence — `PlayFromStart` is a no-op when `currentIndex != -1`, so the two handlers don't conflict in the common case. But it's fragile and semantically confusing. A single event triggering two different actions in two packages is an anti-pattern that will cause bugs as the codebase grows. - -**Approach:** Remove the `RequestPlay` handler from the player. The queue should be the sole handler — it already calls `player.Play()` internally when needed. If the queue needs to distinguish "resume" from "play from start", add a separate event or an argument to the existing one. +### 1. ~~Resolve `RequestPlay` dual-handler ambiguity~~ — solved --- -### 2. Remove player from Wails `FEBindings` (or remove event handlers) - -**Problem:** The player is added to `FEBindings` in `app.go:163`, which generates JS bindings for all exported methods (`Play()`, `Pause()`, `LoadFile()`, `Seek()`, `SetVolume()`, etc.). However, the frontend exclusively uses events for player control. This creates two parallel APIs for the same operations. - -**Why it matters:** It exposes internal lifecycle methods (`SetContext()`, `SaveState()`, `RestoreState()`) to the frontend as callable JS functions. New developers won't know which API to use. Any method added to the player automatically becomes a frontend-callable binding. - -**Approach:** Remove the player from `FEBindings`. The frontend uses events exclusively and the player doesn't need direct bindings. If `GetCurrentTrackInfo()` is needed as a binding for some edge case, extract it to a separate small struct that only exposes that method. +### 2. ~~Remove player from Wails `FEBindings` (or remove event handlers)~~ — solved --- -### 3. Split `queue.go` (2254 lines) +### 3. ~~Split `queue.go` (2254 lines)~~ — solved -**Problem:** The queue package is a single 2254-line file containing types, state management, ~300 lines of event handler boilerplate, persistence logic, shuffle algorithms, and emit helpers. - -**Why it matters:** Hard to navigate, hard to review changes, easy to introduce bugs in unrelated sections. - -**Approach:** Split into focused files: -- `queue.go` — Core types (`Track`, `State`, `Queue` struct), constructor, `SetContext`, `SetPlayer` -- `handlers.go` — `registerEventHandlers()` and all `handle*` methods -- `persistence.go` — `persistTracks`, `persistState`, `RestoreState`, `SaveState`, `lookupTrackMetaBatch` -- `shuffle.go` — Shuffle order generation and navigation -- `emit.go` — All `emit*` methods +Split into 5 files: `queue.go` (core types, operations, constructor), `handlers.go` (event handlers + `toStringSlice`/`toIntSlice` helpers), `persistence.go` (DB I/O), `navigation.go` (shuffle/navigation), `emit.go` (event emission). Also applied in-place improvements: `trackMeta.toTrack()` method, `commitMutation()` helper, `slices.Insert` for slice operations, fixed `InsertNext` empty-queue bug, fixed `AddTracks` persist ordering, unified `AddTrack` persistence. --- @@ -154,11 +133,9 @@ Replace all 49 `as any` casts with calls to these utilities. --- -### 14. Unused queue sentinels: `ErrEmptyQueue`, `ErrNoPlayer` +### 14. ~~Unused queue sentinels: `ErrEmptyQueue`, `ErrNoPlayer`~~ — solved -**Problem:** Defined in `queue.go` but never returned or checked. - -**Approach:** Delete them, or wire them into the appropriate error paths if they were intended for future validation. +Removed during the queue.go split/rewrite (item #3). --- @@ -186,11 +163,9 @@ Replace all 49 `as any` casts with calls to these utilities. --- -### 18. Custom `sortInts` in queue instead of `slices.Sort` +### 18. ~~Custom `sortInts` in queue instead of `slices.Sort`~~ — solved -**Problem:** `queue.go` has a hand-written insertion sort for int slices, but `slices.Sort()` is already used elsewhere in the same file. - -**Approach:** Replace the custom `sortInts` with `slices.Sort`. Single-line change. +Replaced during the queue.go refactoring (item #3). --- diff --git a/.opencode/plans/split-queue-go.md b/.opencode/plans/split-queue-go.md new file mode 100644 index 0000000..f4bdac8 --- /dev/null +++ b/.opencode/plans/split-queue-go.md @@ -0,0 +1,267 @@ +# Plan: Split and Refactor `backend/queue/queue.go` + +Addresses refactoring catalog #3 (split `queue.go`), #14 (unused sentinels), and #18 (custom `sortInts`), plus two bug fixes and four DRY improvements discovered during analysis. + +## Current State + +`backend/queue/queue.go` is a single 2297-line file containing: +- Type definitions (9 types/constants) +- Constructor and lifecycle methods +- 11 event handler methods (~310 lines of boilerplate) +- 15+ queue operation methods (add, insert, remove, move, play, etc.) +- 6 navigation/shuffle functions +- 7 database I/O functions +- 4 event emission helpers + +The file is hard to navigate, hard to review, and mixes unrelated concerns. + +--- + +## Part 1: File Split + +### 1a. `queue.go` (~1200 lines) — Types, struct, constructor, business logic + +**Keep:** +- Package doc comment +- All type/const definitions: `RepeatMode`, `PreviousRestartThreshold`, `maxSQLiteVars`, `initialBatchSize`, `trackMeta`, `TrackLoader`, `Track`, `State`, `IndexChanged`, `ModeChanged`, `TracksModified`, `Queue` struct +- Constructor: `NewQueue` +- Lifecycle: `SetContext`, `SetPlayer` +- All public queue operations: `SetQueue`, `resolveRemainingTracks`, `AddTrack`, `AddTracks`, `InsertNext`, `InsertNextTracks`, `InsertTracksAt`, `MoveQueueTracks`, `RemoveTrack`, `RemoveTracks`, `Play`, `playFromStart`, `PlayIndex`, `ToggleShuffle`, `CycleRepeat`, `GetState`, `Clear`, `EmitCurrentState` +- Playback helpers: `playOrLoadCurrentTrack`, `loadCurrentTrack`, `playCurrentTrack`, `handleCurrentTrackRemoved`, `onQueueExhausted`, `reindexPositions` +- New helpers: `trackMeta.toTrack()`, `commitMutation()` + +**Imports:** `context`, `log/slog`, `slices`, `sync`, `sync/atomic`, `github.com/wailsapp/wails/v2/pkg/runtime`, `yellowjacket/backend/database`, `yellowjacket/backend/profiling` + +### 1b. `handlers.go` (~280 lines) — Event handlers and external callbacks + +**Move:** +- `OnPlaybackFinished` (external callback from player — same dispatch pattern as event handlers) +- `registerEventHandlers` +- All 10 `handle*` methods +- New helpers: `toStringSlice()`, `toIntSlice()` + +**Imports:** `github.com/wailsapp/wails/v2/pkg/runtime`, `yellowjacket/backend/events` + +**Rationale:** Pure dispatch boilerplate. Adding/modifying event handlers only touches this file plus event constants. `OnPlaybackFinished` is included because it's an inbound callback invoked from outside (the player), same conceptual layer as the event handlers. + +### 1c. `persistence.go` (~330 lines) — All database I/O + +**Move:** +- `lookupTrackMetaBatch`, `lookupChunk` (metadata lookup) +- `persistTracks`, `insertTrackBatch` (track persistence) +- `persistState` (state persistence) +- `SaveState` (public wrapper) +- `RestoreState` (public, loads from DB) + +**Imports:** `database/sql`, `encoding/json`, `fmt`, `strings`, `yellowjacket/backend/database/sql/sqlcgen`, `yellowjacket/backend/profiling` + +**Rationale:** All database interaction in one place. Schema changes, query optimizations, or persistence strategy changes only affect this file. + +### 1d. `navigation.go` (~130 lines) — Index navigation and shuffle order + +**Move:** +- `nextIndex`, `previousIndex` (linear/shuffled dispatch with repeat logic) +- `nextShuffledIndex`, `previousShuffledIndex` +- `currentShufflePosition` +- `generateShuffleOrder` (Fisher-Yates) + +**Imports:** `math/rand/v2` + +**Rationale:** The catalog suggested `shuffle.go`, but these 6 functions form a cohesive "navigation" group — `nextIndex`/`previousIndex` contain both the linear (repeat-aware) and the shuffle dispatching logic. Naming it `shuffle.go` would be misleading since half the file handles non-shuffle navigation. These functions only access `q.tracks`, `q.currentIndex`, `q.shuffleOrder`, and `q.repeatMode` — a cleanly bounded dependency set. + +### 1e. `emit.go` (~75 lines) — Event emission helpers + +**Move:** +- `emitQueueChanged` +- `emitIndexChanged` +- `emitModeChanged` +- `emitTracksModified` + +**Imports:** `github.com/wailsapp/wails/v2/pkg/runtime`, `yellowjacket/backend/events` + +**Rationale:** Clean boundary — the rest of the code calls `q.emit*()` without knowing event names or payload shapes. + +--- + +## Part 2: Bug Fixes (behavior-preserving — fixing existing broken behavior) + +### 2a. Fix `InsertNext` empty-queue bug + +**Location:** `queue.go:991-1041` (current) + +**Problem:** `InsertNext` does not handle the empty-queue case. When called on an empty queue: +- `insertPos = currentIndex + 1 = 0 + 1 = 1` (out of bounds clamped to 0 by the guard) +- A track is inserted, but `currentIndex` stays at 0 and `loadCurrentTrack` is never called +- The user sees a queue with one track but nothing loaded + +Compare with `InsertNextTracks` (line 977-980) which correctly checks `wasEmpty` and loads the first track. + +**Fix:** Add after the persist calls in `InsertNext`: +```go +wasEmpty := len(q.tracks) == 0 +// ... existing insert logic ... +// After commitMutation: +if wasEmpty && len(q.tracks) > 0 { + q.currentIndex = 0 + q.loadCurrentTrack() +} +``` + +### 2b. Fix `AddTracks` persist-before-index ordering + +**Location:** `queue.go:906-912` (current) + +**Problem:** `AddTracks` calls `persistTracks()` + `persistState()` at lines 906-907, then sets `currentIndex = 0` and calls `loadCurrentTrack()` at lines 909-912. If the app crashes between persist and index update, the restored state has the wrong `currentIndex`. `AddTrack` does this correctly (sets index before persist). + +**Fix:** Move the `wasEmpty` check and `currentIndex = 0` assignment to before the `commitMutation()` call, matching the pattern in `AddTrack`. + +--- + +## Part 3: DRY Improvements (behavior-preserving) + +### 3a. Extract `toStringSlice` and `toIntSlice` helpers (in `handlers.go`) + +**Problem:** The `[]interface{} -> []string` conversion is copy-pasted in 4 handlers (`handleSetQueue`, `handleAddTracksToQueue`, `handleInsertTracksAtIndex`, `handlePlayTracksNext`). The `[]interface{} -> []int` conversion is in 2 handlers (`handleRemoveTracksFromQueue`, `handleMoveQueueTracks`). + +**New helpers:** +```go +// toStringSlice extracts strings from a Wails event argument. +func toStringSlice(raw []interface{}) []string { + result := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result +} + +// toIntSlice extracts ints (from float64) from a Wails event argument. +func toIntSlice(raw []interface{}) []int { + result := make([]int, 0, len(raw)) + for _, v := range raw { + if f, ok := v.(float64); ok { + result = append(result, int(f)) + } + } + return result +} +``` + +Eliminates ~30 lines of repetition, centralizes type-coercion logic. + +### 3b. Extract `trackMeta.toTrack(position)` method (in `queue.go`) + +**Problem:** The `trackMeta` -> `Track` struct literal appears 7 times across `SetQueue`, `resolveRemainingTracks`, `AddTrack`, `AddTracks`, `InsertNextTracks`, `InsertNext`, `InsertTracksAt`. + +**New method:** +```go +// toTrack converts metadata lookup results into a queue Track. +func (m trackMeta) toTrack(position int64) Track { + return Track{ + AudioFileID: m.AudioFileID, + FilePath: m.FilePath, + Position: position, + Title: m.Title, + Artist: m.Artist, + } +} +``` + +Eliminates ~35 lines. Creates one authoritative mapping point — if a field is added to `Track`, only one place needs updating. + +### 3c. Extract `commitMutation(reindex bool)` helper (in `queue.go`) + +**Problem:** The post-mutation epilogue (reindex positions → regenerate shuffle order → persist tracks → persist state) is repeated in 8+ methods: `InsertNextTracks`, `InsertNext`, `InsertTracksAt`, `MoveQueueTracks`, `RemoveTrack`, `RemoveTracks`, `AddTracks`, `SetQueue` (small-batch path), `AddTrack` (after unification). + +**New helper:** +```go +// commitMutation persists the current queue state after a mutation. +// When reindex is true, track positions are renumbered first. +func (q *Queue) commitMutation(reindex bool) { + if reindex { + q.reindexPositions() + } + if q.shuffleMode { + q.generateShuffleOrder() + } + q.persistTracks() + q.persistState() +} +``` + +Eliminates ~40 lines. Ensures every mutation consistently applies the full epilogue — no risk of forgetting one of the steps. + +### 3d. Use `slices.Insert` for slice insertions (in `queue.go`) + +**Problem:** The manual tail-copy insertion pattern appears 3 times: +```go +tail := make([]Track, len(q.tracks[insertPos:])) +copy(tail, q.tracks[insertPos:]) +q.tracks = append(q.tracks[:insertPos], newTracks...) +q.tracks = append(q.tracks, tail...) +``` +in `InsertNextTracks`, `InsertTracksAt`, and `MoveQueueTracks`. `InsertNext` has a variant. + +**Fix:** Replace all with `q.tracks = slices.Insert(q.tracks, insertPos, newTracks...)`. The `slices` package is already imported. + +### 3e. Unify `AddTrack` persistence strategy (in `queue.go`) + +**Problem:** `AddTrack` is the only method that uses a single-row `InsertQueueTrack` DB call (line 834), while every other mutating method uses `persistTracks` (full table rewrite). This dual strategy means: +- If the single-row insert fails, the in-memory state diverges from the DB +- `AddTrack` has different error recovery behavior than all other methods +- The shuffle order append (line 847) is an optimization that `AddTracks` doesn't share, creating inconsistency + +**Fix:** Replace `AddTrack`'s custom DB insert with `commitMutation(false)` (no reindex needed since it appends). This makes it consistent with every other method. The performance cost of a full table rewrite for a single-track add is negligible for music-player queue sizes (typically <10K tracks). + +--- + +## Part 4: Cleanup (bundled from catalog #14 and #18) + +### 4a. Delete `sortInts`, use `slices.Sort` (catalog #18) + +**Location:** `queue.go:1274-1281` (current) + +Delete the hand-rolled insertion sort. Replace its one call site in `MoveQueueTracks` (`sortInts(sorted)` → `slices.Sort(sorted)`). `slices.Sort` is already used elsewhere in the same file (line 1364). + +### 4b. Remove exported `PlayFromStart` wrapper + +**Location:** `queue.go:1521-1530` (current) + +`PlayFromStart` is exported but has zero callers outside the package. The unexported `playFromStart` already exists. Remove the exported wrapper — if external access is ever needed, it can be re-added. + +--- + +## Execution Order + +The order matters because later steps depend on earlier ones: + +1. **Replace `sortInts` with `slices.Sort`** — single-line change, eliminates a function before the split +2. **Remove `PlayFromStart`** — eliminates dead code before the split +3. **Add `trackMeta.toTrack()` method** — replace all 7 call sites +4. **Add `commitMutation()` helper** — replace all 8+ call sites +5. **Fix `InsertNext` empty-queue bug** — add `wasEmpty` guard +6. **Fix `AddTracks` persist ordering** — move index assignment before persist +7. **Unify `AddTrack` persistence** — replace custom insert with `commitMutation` +8. **Use `slices.Insert`** — replace 3-4 manual insertion patterns +9. **Extract `handlers.go`** — move `OnPlaybackFinished`, `registerEventHandlers`, all `handle*` methods; add `toStringSlice`/`toIntSlice` helpers; update all 6 call sites +10. **Extract `emit.go`** — move all 4 `emit*` methods +11. **Extract `navigation.go`** — move all 6 navigation/shuffle functions +12. **Extract `persistence.go`** — move all 7 persistence/lookup functions +13. **Clean up `queue.go` imports** — remove now-unused imports (`encoding/json`, `fmt`, `strings`, `math/rand/v2`, `errors`, `yellowjacket/backend/events`, `yellowjacket/backend/database/sql/sqlcgen`) +14. **Delete `ErrEmptyQueue` and `ErrNoPlayer`** — unused sentinels (catalog #14) +15. **Run `make lint`** — fix any formatting/import-order issues +16. **Run `make test`** — verify nothing is broken (note: no queue-specific tests exist, but this catches compilation errors and any tests that depend on queue indirectly) +17. **Update refactoring catalog** — mark #3, #14, #18 as solved + +## Risk Assessment + +**Very low risk.** All files remain in the same `queue` package — field access, unexported methods, and mutex sharing work identically across files within a package. The Go compiler catches any missing imports or broken references at build time. The two bug fixes change behavior only in edge cases that are currently broken. The DRY extractions are mechanical transformations that preserve identical behavior. + +## What This Does NOT Change + +- No changes to the public API surface (except removing unused `PlayFromStart` and the unused sentinels) +- No changes to the mutex strategy or locking granularity +- No changes to the event system or frontend +- No changes to database schema or query logic +- No new dependencies diff --git a/backend/app.go b/backend/app.go index ed315a7..51f778f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -158,9 +158,6 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // Register playback finished handler to drive queue auto-advance. yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) - - // Add player to frontend bindings - yj.FEBindings = append(yj.FEBindings, yj.player) } // OnBeforeClose captures window state while the window is still alive. diff --git a/backend/queue/emit.go b/backend/queue/emit.go new file mode 100644 index 0000000..e936246 --- /dev/null +++ b/backend/queue/emit.go @@ -0,0 +1,82 @@ +package queue + +import ( + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/events" +) + +// emitQueueChanged emits the full queue state to the frontend. +func (q *Queue) emitQueueChanged() { + if q.ctx == nil { + return + } + + state := State{ + Tracks: q.tracks, + CurrentIndex: q.currentIndex, + ShuffleMode: q.shuffleMode, + RepeatMode: q.repeatMode, + SourcePlaylistID: q.sourcePlaylistID, + } + + // Ensure tracks is never nil in JSON. + if state.Tracks == nil { + state.Tracks = []Track{} + } + + runtime.EventsEmit(q.ctx, events.QueueChanged, state) +} + +// emitIndexChanged emits only the current index to the frontend. +func (q *Queue) emitIndexChanged() { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueIndexChanged, + IndexChanged{CurrentIndex: q.currentIndex}, + ) +} + +// emitModeChanged emits only the shuffle/repeat mode to the frontend. +func (q *Queue) emitModeChanged() { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueModeChanged, + ModeChanged{ + ShuffleMode: q.shuffleMode, + RepeatMode: q.repeatMode, + }, + ) +} + +// emitTracksModified emits a delta update for track list changes. +func (q *Queue) emitTracksModified( + action string, + tracks []Track, + index int, + positions []int, +) { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueTracksModified, + TracksModified{ + Action: action, + Tracks: tracks, + Index: index, + Positions: positions, + CurrentIndex: q.currentIndex, + }, + ) +} diff --git a/backend/queue/handlers.go b/backend/queue/handlers.go new file mode 100644 index 0000000..197484f --- /dev/null +++ b/backend/queue/handlers.go @@ -0,0 +1,461 @@ +package queue + +import ( + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/events" +) + +// OnPlaybackFinished is called when a track finishes playing naturally. +// This drives the auto-advance behavior. +func (q *Queue) OnPlaybackFinished() { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.tracks) == 0 { + return + } + + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + q.playCurrentTrack() + q.emitIndexChanged() + + return + } + + nextIdx := q.nextIndex() + if nextIdx == -1 { + // Queue exhausted — this is the extension point for a future fallback playlist. + q.onQueueExhausted() + + return + } + + q.currentIndex = nextIdx + q.playCurrentTrack() + q.emitIndexChanged() +} + +// registerEventHandlers sets up Wails event listeners for queue commands. +func (q *Queue) registerEventHandlers() { + if q.ctx == nil { + q.logger.Error("Context is nil, cannot register event handlers") + + return + } + + runtime.EventsOn(q.ctx, events.RequestPlay, func(_ ...any) { + q.logger.Info("Received RequestPlay") + q.Play() + }) + + runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) { + q.logger.Info("Received RequestNext") + q.Next() + }) + + runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) { + q.logger.Info("Received RequestPrevious") + q.Previous() + }) + + runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) { + q.logger.Info("Received RequestSetQueue") + q.handleSetQueue(data...) + }) + + runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) { + q.logger.Info("Received RequestAddToQueue") + q.handleAddToQueue(data...) + }) + + runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) { + q.logger.Info("Received RequestPlayNext") + q.handlePlayNext(data...) + }) + + runtime.EventsOn( + q.ctx, + events.RequestRemoveFromQueue, + func(data ...any) { + q.logger.Info("Received RequestRemoveFromQueue") + q.handleRemoveFromQueue(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestToggleShuffle, + func(_ ...any) { + q.logger.Info("Received RequestToggleShuffle") + q.ToggleShuffle() + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestCycleRepeat, + func(_ ...any) { + q.logger.Info("Received RequestCycleRepeat") + q.CycleRepeat() + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestAddTracksToQueue, + func(data ...any) { + q.logger.Info("Received RequestAddTracksToQueue") + q.handleAddTracksToQueue(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestPlayTracksNext, + func(data ...any) { + q.logger.Info("Received RequestPlayTracksNext") + q.handlePlayTracksNext(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestPlayQueueIndex, + func(data ...any) { + q.logger.Info("Received RequestPlayQueueIndex") + q.handlePlayQueueIndex(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestRemoveTracksFromQueue, + func(data ...any) { + q.logger.Info( + "Received RequestRemoveTracksFromQueue", + ) + q.handleRemoveTracksFromQueue(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestInsertTracksAtIndex, + func(data ...any) { + q.logger.Info( + "Received RequestInsertTracksAtIndex", + ) + q.handleInsertTracksAtIndex(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestMoveQueueTracks, + func(data ...any) { + q.logger.Info( + "Received RequestMoveQueueTracks", + ) + q.handleMoveQueueTracks(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestClearQueue, + func(_ ...any) { + q.logger.Info("Received RequestClearQueue") + q.Clear() + }, + ) +} + +// toStringSlice extracts strings from a Wails event argument. +func toStringSlice(raw []interface{}) []string { + result := make([]string, 0, len(raw)) + + for _, v := range raw { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + + return result +} + +// toIntSlice extracts ints (from float64) from a Wails event argument. +func toIntSlice(raw []interface{}) []int { + result := make([]int, 0, len(raw)) + + for _, v := range raw { + if f, ok := v.(float64); ok { + result = append(result, int(f)) + } + } + + return result +} + +// handleSetQueue processes the RequestSetQueue event payload. +// Expects data[0] = []interface{} of file path strings, +// data[1] = float64 start index, data[2] = bool shuffleStart (optional). +func (q *Queue) handleSetQueue(data ...any) { + if len(data) < 2 { + q.logger.Error("RequestSetQueue: missing data") + + return + } + + filePathsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error("RequestSetQueue: invalid filePaths type") + + return + } + + filePaths := toStringSlice(filePathsRaw) + + startIndex := 0 + + if si, ok := data[1].(float64); ok { + startIndex = int(si) + } + + shuffleStart := false + + if len(data) > 2 { + if ss, ok := data[2].(bool); ok { + shuffleStart = ss + } + } + + q.SetQueue(filePaths, startIndex, shuffleStart) +} + +// handleAddToQueue processes the RequestAddToQueue event payload. +// Expects data[0] = string file path. +func (q *Queue) handleAddToQueue(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestAddToQueue: missing data") + + return + } + + filePath, ok := data[0].(string) + if !ok { + q.logger.Error( + "RequestAddToQueue: invalid filePath type", + "got", data[0], + ) + + return + } + + q.AddTrack(filePath) +} + +// handlePlayNext processes the RequestPlayNext event payload. +// Expects data[0] = string file path. +func (q *Queue) handlePlayNext(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestPlayNext: missing data") + + return + } + + filePath, ok := data[0].(string) + if !ok { + q.logger.Error( + "RequestPlayNext: invalid filePath type", + "got", data[0], + ) + + return + } + + q.InsertNext(filePath) +} + +// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload. +// Expects data[0] = float64 position. +func (q *Queue) handleRemoveFromQueue(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestRemoveFromQueue: missing data") + + return + } + + position, ok := data[0].(float64) + if !ok { + q.logger.Error( + "RequestRemoveFromQueue: invalid position type", + "got", data[0], + ) + + return + } + + q.RemoveTrack(int(position)) +} + +// handleRemoveTracksFromQueue processes the RequestRemoveTracksFromQueue +// event payload. Expects data[0] = []interface{} of float64 positions. +func (q *Queue) handleRemoveTracksFromQueue(data ...any) { + if len(data) < 1 { + q.logger.Error( + "RequestRemoveTracksFromQueue: missing data", + ) + + return + } + + positionsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestRemoveTracksFromQueue: invalid positions type", + "got", data[0], + ) + + return + } + + q.RemoveTracks(toIntSlice(positionsRaw)) +} + +// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload. +// Expects data[0] = []interface{} of file path strings. +func (q *Queue) handleAddTracksToQueue(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestAddTracksToQueue: missing data") + + return + } + + filePathsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestAddTracksToQueue: invalid filePaths type", + "got", data[0], + ) + + return + } + + q.AddTracks(toStringSlice(filePathsRaw)) +} + +// handleInsertTracksAtIndex processes the RequestInsertTracksAtIndex event +// payload. Expects data[0] = []interface{} of file path strings, +// data[1] = float64 target index. +func (q *Queue) handleInsertTracksAtIndex(data ...any) { + if len(data) < 2 { + q.logger.Error( + "RequestInsertTracksAtIndex: missing data", + ) + + return + } + + filePathsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestInsertTracksAtIndex: invalid filePaths type", + "got", data[0], + ) + + return + } + + idx, ok := data[1].(float64) + if !ok { + q.logger.Error( + "RequestInsertTracksAtIndex: invalid index type", + "got", data[1], + ) + + return + } + + q.InsertTracksAt(toStringSlice(filePathsRaw), int(idx)) +} + +// handleMoveQueueTracks processes the RequestMoveQueueTracks event payload. +// Expects data[0] = []interface{} of float64 source indices, +// data[1] = float64 target index. +func (q *Queue) handleMoveQueueTracks(data ...any) { + if len(data) < 2 { + q.logger.Error( + "RequestMoveQueueTracks: missing data", + ) + + return + } + + indicesRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestMoveQueueTracks: invalid indices type", + "got", data[0], + ) + + return + } + + toIdx, ok := data[1].(float64) + if !ok { + q.logger.Error( + "RequestMoveQueueTracks: invalid toIndex type", + "got", data[1], + ) + + return + } + + q.MoveQueueTracks(toIntSlice(indicesRaw), int(toIdx)) +} + +// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload. +// Expects data[0] = float64 index. +func (q *Queue) handlePlayQueueIndex(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestPlayQueueIndex: missing data") + + return + } + + index, ok := data[0].(float64) + if !ok { + q.logger.Error( + "RequestPlayQueueIndex: invalid index type", + "got", data[0], + ) + + return + } + + q.PlayIndex(int(index)) +} + +// handlePlayTracksNext processes the RequestPlayTracksNext event payload. +// Expects data[0] = []interface{} of file path strings. +func (q *Queue) handlePlayTracksNext(data ...any) { + if len(data) < 1 { + q.logger.Error("RequestPlayTracksNext: missing data") + + return + } + + filePathsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestPlayTracksNext: invalid filePaths type", + "got", data[0], + ) + + return + } + + q.InsertNextTracks(toStringSlice(filePathsRaw)) +} diff --git a/backend/queue/navigation.go b/backend/queue/navigation.go new file mode 100644 index 0000000..203e010 --- /dev/null +++ b/backend/queue/navigation.go @@ -0,0 +1,132 @@ +package queue + +import "math/rand/v2" + +// nextIndex returns the next track index respecting shuffle and repeat modes. +// Returns -1 if there is no next track (queue exhausted). +func (q *Queue) nextIndex() int { + if len(q.tracks) == 0 { + return -1 + } + + if q.shuffleMode && len(q.shuffleOrder) > 0 { + return q.nextShuffledIndex() + } + + next := q.currentIndex + 1 + if next >= len(q.tracks) { + if q.repeatMode == RepeatAll { + return 0 + } + + return -1 + } + + return next +} + +// previousIndex returns the previous track index respecting shuffle and repeat. +// Returns -1 if there is no previous track. +func (q *Queue) previousIndex() int { + if len(q.tracks) == 0 { + return -1 + } + + if q.shuffleMode && len(q.shuffleOrder) > 0 { + return q.previousShuffledIndex() + } + + prev := q.currentIndex - 1 + if prev < 0 { + if q.repeatMode == RepeatAll { + return len(q.tracks) - 1 + } + + return -1 + } + + return prev +} + +// nextShuffledIndex finds the next index in the shuffle order. +func (q *Queue) nextShuffledIndex() int { + shufflePos := q.currentShufflePosition() + if shufflePos == -1 { + // Current track not found in shuffle order — shouldn't happen. + return -1 + } + + nextShufflePos := shufflePos + 1 + if nextShufflePos >= len(q.shuffleOrder) { + if q.repeatMode == RepeatAll { + return q.shuffleOrder[0] + } + + return -1 + } + + return q.shuffleOrder[nextShufflePos] +} + +// previousShuffledIndex finds the previous index in the shuffle order. +func (q *Queue) previousShuffledIndex() int { + shufflePos := q.currentShufflePosition() + if shufflePos == -1 { + return -1 + } + + prevShufflePos := shufflePos - 1 + if prevShufflePos < 0 { + if q.repeatMode == RepeatAll { + return q.shuffleOrder[len(q.shuffleOrder)-1] + } + + return -1 + } + + return q.shuffleOrder[prevShufflePos] +} + +// currentShufflePosition finds where the current track index is in the shuffle order. +func (q *Queue) currentShufflePosition() int { + for i, idx := range q.shuffleOrder { + if idx == q.currentIndex { + return i + } + } + + return -1 +} + +// generateShuffleOrder creates a Fisher-Yates shuffled index order, +// placing the current track at position 0 so it doesn't replay immediately. +func (q *Queue) generateShuffleOrder() { + n := len(q.tracks) + if n == 0 { + q.shuffleOrder = nil + + return + } + + order := make([]int, n) + for i := range order { + order[i] = i + } + + // Fisher-Yates shuffle. + for i := n - 1; i > 0; i-- { + j := rand.IntN(i + 1) + order[i], order[j] = order[j], order[i] + } + + // Move the current track to position 0 so it doesn't replay immediately. + for i, idx := range order { + if idx == q.currentIndex { + order[0], order[i] = order[i], order[0] + + break + } + } + + q.shuffleOrder = order +} diff --git a/backend/queue/persistence.go b/backend/queue/persistence.go new file mode 100644 index 0000000..3657a68 --- /dev/null +++ b/backend/queue/persistence.go @@ -0,0 +1,333 @@ +package queue + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/profiling" +) + +// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of +// file paths using a single query per chunk (instead of 2 queries per track). +// Returns a map keyed by file path. This is safe to call without holding q.mu. +func (q *Queue) lookupTrackMetaBatch( + filePaths []string, +) map[string]trackMeta { + result := make(map[string]trackMeta, len(filePaths)) + + // Deduplicate paths to avoid redundant work. + unique := make([]string, 0, len(filePaths)) + seen := make(map[string]bool, len(filePaths)) + + for _, fp := range filePaths { + if !seen[fp] { + seen[fp] = true + + unique = append(unique, fp) + } + } + + // Process in chunks to stay under the SQLite bind variable limit. + for i := 0; i < len(unique); i += maxSQLiteVars { + end := i + maxSQLiteVars + if end > len(unique) { + end = len(unique) + } + + chunk := unique[i:end] + q.lookupChunk(chunk, result) + } + + return result +} + +// lookupChunk executes a single batch query for a chunk of file paths. +func (q *Queue) lookupChunk( + paths []string, + result map[string]trackMeta, +) { + if len(paths) == 0 { + return + } + + placeholders := make([]string, len(paths)) + args := make([]any, len(paths)) + + for i, fp := range paths { + placeholders[i] = "?" + args[i] = fp + } + + query := fmt.Sprintf( + `SELECT af.id, af.file_path, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + WHERE af.file_path IN (%s)`, + strings.Join(placeholders, ","), + ) + + rows, err := q.db.QueryContext(query, args...) + if err != nil { + q.logger.Error("Batch metadata lookup failed", "err", err) + + return + } + + defer func() { + if closeErr := rows.Close(); closeErr != nil { + q.logger.Error( + "Failed to close rows", + "err", closeErr, + ) + } + }() + + for rows.Next() { + var m trackMeta + + if scanErr := rows.Scan( + &m.AudioFileID, &m.FilePath, &m.Title, &m.Artist, + ); scanErr != nil { + q.logger.Error( + "Failed to scan batch metadata row", + "err", scanErr, + ) + + continue + } + + result[m.FilePath] = m + } + + if rowsErr := rows.Err(); rowsErr != nil { + q.logger.Error( + "Error iterating batch metadata rows", + "err", rowsErr, + ) + } +} + +// persistTracks writes the current queue tracks to the database atomically +// using a transaction with batched multi-row inserts. +func (q *Queue) persistTracks() { + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + // Clear existing tracks. + txQueries := q.db.Queries.WithTx(tx) + + if clearErr := txQueries.ClearQueueTracks(q.db.Ctx); clearErr != nil { + q.logger.Error("Failed to clear queue tracks", "err", clearErr) + + return + } + + // Batch insert tracks. Each row needs 2 bind vars (audio_file_id, position). + const varsPerRow = 2 + + batchSize := maxSQLiteVars / varsPerRow + + for i := 0; i < len(q.tracks); i += batchSize { + end := i + batchSize + if end > len(q.tracks) { + end = len(q.tracks) + } + + batch := q.tracks[i:end] + + if insertErr := q.insertTrackBatch(tx, batch); insertErr != nil { + q.logger.Error( + "Failed to batch insert queue tracks", + "err", insertErr, + ) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// insertTrackBatch inserts a batch of tracks in a single multi-row INSERT. +func (q *Queue) insertTrackBatch(tx *sql.Tx, batch []Track) error { + if len(batch) == 0 { + return nil + } + + valuePlaceholders := make([]string, len(batch)) + args := make([]any, 0, len(batch)*2) + + for i, track := range batch { + valuePlaceholders[i] = "(?, ?)" + + args = append(args, track.AudioFileID, track.Position) + } + + query := "INSERT INTO queue_tracks (audio_file_id, position) VALUES " + + strings.Join(valuePlaceholders, ",") + + _, err := tx.ExecContext(q.db.Ctx, query, args...) + if err != nil { + return fmt.Errorf("batch insert failed: %w", err) + } + + return nil +} + +// persistState writes the queue metadata to the database. +func (q *Queue) persistState() { + var shuffleOrderJSON sql.NullString + + if len(q.shuffleOrder) > 0 { + data, err := json.Marshal(q.shuffleOrder) + if err != nil { + q.logger.Error( + "Failed to marshal shuffle order", + "err", err, + ) + } else { + shuffleOrderJSON = sql.NullString{ + String: string(data), + Valid: true, + } + } + } + + sourcePlaylistID := sql.NullInt64{} + if q.sourcePlaylistID > 0 { + sourcePlaylistID = sql.NullInt64{ + Int64: q.sourcePlaylistID, + Valid: true, + } + } + + err := q.db.Queries.UpdateQueueState( + q.db.Ctx, + sqlcgen.UpdateQueueStateParams{ + SourcePlaylistID: sourcePlaylistID, + CurrentPosition: int64(q.currentIndex), + ShuffleMode: q.shuffleMode, + RepeatMode: string(q.repeatMode), + ShuffleOrder: shuffleOrderJSON, + }, + ) + if err != nil { + q.logger.Error("Failed to persist queue state", "err", err) + } +} + +// SaveState persists the queue state to the database. +func (q *Queue) SaveState() { + q.mu.Lock() + defer q.mu.Unlock() + + q.persistTracks() + q.persistState() + q.logger.Info("Queue state saved", + "trackCount", len(q.tracks), + "currentIndex", q.currentIndex, + "shuffleMode", q.shuffleMode, + "repeatMode", q.repeatMode, + ) +} + +// RestoreState loads the queue state from the database. +func (q *Queue) RestoreState() { + defer profiling.TimeOp(q.logger, "queue.RestoreState")() + + q.mu.Lock() + defer q.mu.Unlock() + + // Restore queue metadata. + state, err := q.db.Queries.GetQueueState(q.db.Ctx) + if err != nil { + q.logger.Error("Failed to load queue state", "err", err) + + return + } + + q.currentIndex = int(state.CurrentPosition) + q.shuffleMode = state.ShuffleMode + q.repeatMode = RepeatMode(state.RepeatMode) + + if state.SourcePlaylistID.Valid { + q.sourcePlaylistID = state.SourcePlaylistID.Int64 + } + + // Restore shuffle order. + if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" { + var order []int + + if err := json.Unmarshal( + []byte(state.ShuffleOrder.String), &order, + ); err != nil { + q.logger.Warn("Failed to parse shuffle order", "err", err) + } else { + q.shuffleOrder = order + } + } + + // Restore queue tracks. + rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx) + if err != nil { + q.logger.Error("Failed to load queue tracks", "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, + }) + } + + // Clamp current index. A value of -1 is valid and means "no current + // track" (e.g. the queue was exhausted before shutdown). Only clamp + // when the index exceeds the restored track count. + if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { + q.currentIndex = len(q.tracks) - 1 + } + + q.logger.Info("Queue state restored", + "trackCount", len(q.tracks), + "currentIndex", q.currentIndex, + "shuffleMode", q.shuffleMode, + "repeatMode", q.repeatMode, + ) +} diff --git a/backend/queue/queue.go b/backend/queue/queue.go index ac263a2..422db7b 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -3,22 +3,12 @@ package queue import ( "context" - "database/sql" - "encoding/json" - "errors" - "fmt" "log/slog" - "math/rand/v2" "slices" - "strings" "sync" "sync/atomic" - "github.com/wailsapp/wails/v2/pkg/runtime" - "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" - "yellowjacket/backend/events" "yellowjacket/backend/profiling" ) @@ -52,6 +42,17 @@ type trackMeta struct { Artist string } +// toTrack converts metadata lookup results into a queue Track. +func (m trackMeta) toTrack(position int64) Track { + return Track{ + AudioFileID: m.AudioFileID, + FilePath: m.FilePath, + Position: position, + Title: m.Title, + Artist: m.Artist, + } +} + // TrackLoader is the interface the queue uses to tell the player to load a file. type TrackLoader interface { LoadFile(filePath string) error @@ -140,480 +141,6 @@ func (q *Queue) SetPlayer(player TrackLoader) { q.player = player } -// OnPlaybackFinished is called when a track finishes playing naturally. -// This drives the auto-advance behavior. -func (q *Queue) OnPlaybackFinished() { - q.mu.Lock() - defer q.mu.Unlock() - - if len(q.tracks) == 0 { - return - } - - // Repeat One: replay the current track. - if q.repeatMode == RepeatOne { - q.playCurrentTrack() - q.emitIndexChanged() - - return - } - - nextIdx := q.nextIndex() - if nextIdx == -1 { - // Queue exhausted — this is the extension point for a future fallback playlist. - q.onQueueExhausted() - - return - } - - q.currentIndex = nextIdx - q.playCurrentTrack() - q.emitIndexChanged() -} - -// registerEventHandlers sets up Wails event listeners for queue commands. -func (q *Queue) registerEventHandlers() { - if q.ctx == nil { - q.logger.Error("Context is nil, cannot register event handlers") - - return - } - - runtime.EventsOn(q.ctx, events.RequestPlay, func(_ ...any) { - q.logger.Info("Received RequestPlay") - q.Play() - }) - - runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) { - q.logger.Info("Received RequestNext") - q.Next() - }) - - runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) { - q.logger.Info("Received RequestPrevious") - q.Previous() - }) - - runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) { - q.logger.Info("Received RequestSetQueue") - q.handleSetQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) { - q.logger.Info("Received RequestAddToQueue") - q.handleAddToQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) { - q.logger.Info("Received RequestPlayNext") - q.handlePlayNext(data...) - }) - - runtime.EventsOn( - q.ctx, - events.RequestRemoveFromQueue, - func(data ...any) { - q.logger.Info("Received RequestRemoveFromQueue") - q.handleRemoveFromQueue(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestToggleShuffle, - func(_ ...any) { - q.logger.Info("Received RequestToggleShuffle") - q.ToggleShuffle() - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestCycleRepeat, - func(_ ...any) { - q.logger.Info("Received RequestCycleRepeat") - q.CycleRepeat() - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestAddTracksToQueue, - func(data ...any) { - q.logger.Info("Received RequestAddTracksToQueue") - q.handleAddTracksToQueue(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestPlayTracksNext, - func(data ...any) { - q.logger.Info("Received RequestPlayTracksNext") - q.handlePlayTracksNext(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestPlayQueueIndex, - func(data ...any) { - q.logger.Info("Received RequestPlayQueueIndex") - q.handlePlayQueueIndex(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestRemoveTracksFromQueue, - func(data ...any) { - q.logger.Info( - "Received RequestRemoveTracksFromQueue", - ) - q.handleRemoveTracksFromQueue(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestInsertTracksAtIndex, - func(data ...any) { - q.logger.Info( - "Received RequestInsertTracksAtIndex", - ) - q.handleInsertTracksAtIndex(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestMoveQueueTracks, - func(data ...any) { - q.logger.Info( - "Received RequestMoveQueueTracks", - ) - q.handleMoveQueueTracks(data...) - }, - ) - - runtime.EventsOn( - q.ctx, - events.RequestClearQueue, - func(_ ...any) { - q.logger.Info("Received RequestClearQueue") - q.Clear() - }, - ) -} - -// handleSetQueue processes the RequestSetQueue event payload. -// Expects data[0] = []interface{} of file path strings, -// data[1] = float64 start index, data[2] = bool shuffleStart (optional). -func (q *Queue) handleSetQueue(data ...any) { - if len(data) < 2 { - q.logger.Error("RequestSetQueue: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error("RequestSetQueue: invalid filePaths type") - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - startIndex := 0 - - if si, ok := data[1].(float64); ok { - startIndex = int(si) - } - - shuffleStart := false - - if len(data) > 2 { - if ss, ok := data[2].(bool); ok { - shuffleStart = ss - } - } - - q.SetQueue(filePaths, startIndex, shuffleStart) -} - -// handleAddToQueue processes the RequestAddToQueue event payload. -// Expects data[0] = string file path. -func (q *Queue) handleAddToQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestAddToQueue: missing data") - - return - } - - filePath, ok := data[0].(string) - if !ok { - q.logger.Error( - "RequestAddToQueue: invalid filePath type", - "got", data[0], - ) - - return - } - - q.AddTrack(filePath) -} - -// handlePlayNext processes the RequestPlayNext event payload. -// Expects data[0] = string file path. -func (q *Queue) handlePlayNext(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestPlayNext: missing data") - - return - } - - filePath, ok := data[0].(string) - if !ok { - q.logger.Error( - "RequestPlayNext: invalid filePath type", - "got", data[0], - ) - - return - } - - q.InsertNext(filePath) -} - -// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload. -// Expects data[0] = float64 position. -func (q *Queue) handleRemoveFromQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestRemoveFromQueue: missing data") - - return - } - - position, ok := data[0].(float64) - if !ok { - q.logger.Error( - "RequestRemoveFromQueue: invalid position type", - "got", data[0], - ) - - return - } - - q.RemoveTrack(int(position)) -} - -// handleRemoveTracksFromQueue processes the RequestRemoveTracksFromQueue -// event payload. Expects data[0] = []interface{} of float64 positions. -func (q *Queue) handleRemoveTracksFromQueue(data ...any) { - if len(data) < 1 { - q.logger.Error( - "RequestRemoveTracksFromQueue: missing data", - ) - - return - } - - positionsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error( - "RequestRemoveTracksFromQueue: invalid positions type", - "got", data[0], - ) - - return - } - - positions := make([]int, 0, len(positionsRaw)) - - for _, p := range positionsRaw { - if f, ok := p.(float64); ok { - positions = append(positions, int(f)) - } - } - - q.RemoveTracks(positions) -} - -// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload. -// Expects data[0] = []interface{} of file path strings. -func (q *Queue) handleAddTracksToQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestAddTracksToQueue: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error( - "RequestAddTracksToQueue: invalid filePaths type", - "got", data[0], - ) - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - q.AddTracks(filePaths) -} - -// handleInsertTracksAtIndex processes the RequestInsertTracksAtIndex event -// payload. Expects data[0] = []interface{} of file path strings, -// data[1] = float64 target index. -func (q *Queue) handleInsertTracksAtIndex(data ...any) { - if len(data) < 2 { - q.logger.Error( - "RequestInsertTracksAtIndex: missing data", - ) - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error( - "RequestInsertTracksAtIndex: invalid filePaths type", - "got", data[0], - ) - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - idx, ok := data[1].(float64) - if !ok { - q.logger.Error( - "RequestInsertTracksAtIndex: invalid index type", - "got", data[1], - ) - - return - } - - q.InsertTracksAt(filePaths, int(idx)) -} - -// handleMoveQueueTracks processes the RequestMoveQueueTracks event payload. -// Expects data[0] = []interface{} of float64 source indices, -// data[1] = float64 target index. -func (q *Queue) handleMoveQueueTracks(data ...any) { - if len(data) < 2 { - q.logger.Error( - "RequestMoveQueueTracks: missing data", - ) - - return - } - - indicesRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error( - "RequestMoveQueueTracks: invalid indices type", - "got", data[0], - ) - - return - } - - fromIndices := make([]int, 0, len(indicesRaw)) - - for _, v := range indicesRaw { - if f, ok := v.(float64); ok { - fromIndices = append(fromIndices, int(f)) - } - } - - toIdx, ok := data[1].(float64) - if !ok { - q.logger.Error( - "RequestMoveQueueTracks: invalid toIndex type", - "got", data[1], - ) - - return - } - - q.MoveQueueTracks(fromIndices, int(toIdx)) -} - -// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload. -// Expects data[0] = float64 index. -func (q *Queue) handlePlayQueueIndex(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestPlayQueueIndex: missing data") - - return - } - - index, ok := data[0].(float64) - if !ok { - q.logger.Error( - "RequestPlayQueueIndex: invalid index type", - "got", data[0], - ) - - return - } - - q.PlayIndex(int(index)) -} - -// handlePlayTracksNext processes the RequestPlayTracksNext event payload. -// Expects data[0] = []interface{} of file path strings. -func (q *Queue) handlePlayTracksNext(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestPlayTracksNext: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error( - "RequestPlayTracksNext: invalid filePaths type", - "got", data[0], - ) - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - q.InsertNextTracks(filePaths) -} - // SetQueue replaces the entire queue with new tracks and starts playing. // When shuffleStart is true and shuffle mode is active, a random first // track is chosen instead of the one at startIndex. This is intended for @@ -657,13 +184,7 @@ func (q *Queue) SetQueue( continue } - tracks = append(tracks, Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Position: int64(i), - Title: m.Title, - Artist: m.Artist, - }) + tracks = append(tracks, m.toTrack(int64(i))) } if len(tracks) == 0 { @@ -721,6 +242,7 @@ func (q *Queue) SetQueue( q.persistTracks() q.persistState() + q.mu.Unlock() return @@ -767,13 +289,7 @@ func (q *Queue) resolveRemainingTracks( continue } - tracks = append(tracks, Track{ - AudioFileID: meta.AudioFileID, - FilePath: meta.FilePath, - Position: int64(i), - Title: meta.Title, - Artist: meta.Artist, - }) + tracks = append(tracks, meta.toTrack(int64(i))) } q.tracks = tracks @@ -791,12 +307,7 @@ func (q *Queue) resolveRemainingTracks( } } - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.commitMutation(false) q.emitQueueChanged() } @@ -820,40 +331,17 @@ func (q *Queue) AddTrack(filePath string) { wasEmpty := len(q.tracks) == 0 - track := Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Position: int64(len(q.tracks)), - Title: m.Title, - Artist: m.Artist, - } + track := m.toTrack(int64(len(q.tracks))) q.tracks = append(q.tracks, track) - // Persist. - _, insertErr := q.db.Queries.InsertQueueTrack( - q.db.Ctx, - sqlcgen.InsertQueueTrackParams{ - AudioFileID: m.AudioFileID, - Position: track.Position, - }, - ) - if insertErr != nil { - q.logger.Error("Failed to persist queue track", "err", insertErr) - } - - // Update shuffle order if shuffle is on. - if q.shuffleMode { - q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1) - } - // Load (paused) if this is the first track added to an empty queue. if wasEmpty { q.currentIndex = 0 q.loadCurrentTrack() } - q.persistState() + q.commitMutation(false) q.emitTracksModified( "add", []Track{track}, @@ -886,31 +374,19 @@ func (q *Queue) AddTracks(filePaths []string) { continue } - track := Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Position: int64(len(q.tracks)), - Title: m.Title, - Artist: m.Artist, - } - + track := m.toTrack(int64(len(q.tracks))) q.tracks = append(q.tracks, track) newTracks = append(newTracks, track) } - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() - + // Load (paused) if this is the first track added to an empty queue. if wasEmpty && len(q.tracks) > 0 { q.currentIndex = 0 q.loadCurrentTrack() } + q.commitMutation(false) q.emitTracksModified( "add", newTracks, @@ -947,38 +423,21 @@ func (q *Queue) InsertNextTracks(filePaths []string) { continue } - newTracks = append(newTracks, Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Title: m.Title, - Artist: m.Artist, - }) + newTracks = append(newTracks, m.toTrack(0)) } if len(newTracks) == 0 { return } - // Insert the block into the slice at insertPos. - tail := make([]Track, len(q.tracks[insertPos:])) - copy(tail, q.tracks[insertPos:]) - q.tracks = append(q.tracks[:insertPos], newTracks...) - q.tracks = append(q.tracks, tail...) - - q.reindexPositions() - - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.tracks = slices.Insert(q.tracks, insertPos, newTracks...) if wasEmpty { q.currentIndex = 0 q.loadCurrentTrack() } + q.commitMutation(true) q.emitTracksModified( "insert", newTracks, @@ -988,6 +447,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) { } // InsertNext inserts a track right after the currently playing track. +// If the queue was empty, it loads the inserted track in a paused state. func (q *Queue) InsertNext(filePath string) { meta := q.lookupTrackMetaBatch([]string{filePath}) @@ -1004,34 +464,23 @@ func (q *Queue) InsertNext(filePath string) { return } + wasEmpty := len(q.tracks) == 0 + insertPos := q.currentIndex + 1 if insertPos > len(q.tracks) { insertPos = len(q.tracks) } - track := Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Position: int64(insertPos), - Title: m.Title, - Artist: m.Artist, + track := m.toTrack(int64(insertPos)) + q.tracks = slices.Insert(q.tracks, insertPos, track) + + // Load (paused) if this is the first track added to an empty queue. + if wasEmpty { + q.currentIndex = 0 + q.loadCurrentTrack() } - // Insert into slice. - q.tracks = append(q.tracks, Track{}) - copy(q.tracks[insertPos+1:], q.tracks[insertPos:]) - q.tracks[insertPos] = track - - // Reindex positions. - q.reindexPositions() - - // Regenerate shuffle order if needed. - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.commitMutation(true) q.emitTracksModified( "insert", []Track{track}, @@ -1072,43 +521,26 @@ func (q *Queue) InsertTracksAt(filePaths []string, index int) { continue } - newTracks = append(newTracks, Track{ - AudioFileID: m.AudioFileID, - FilePath: m.FilePath, - Title: m.Title, - Artist: m.Artist, - }) + newTracks = append(newTracks, m.toTrack(0)) } if len(newTracks) == 0 { return } - // Insert the block into the slice at index. - tail := make([]Track, len(q.tracks[index:])) - copy(tail, q.tracks[index:]) - q.tracks = append(q.tracks[:index], newTracks...) - q.tracks = append(q.tracks, tail...) + q.tracks = slices.Insert(q.tracks, index, newTracks...) // Shift currentIndex if insertion is at or before it. if q.currentIndex >= 0 && index <= q.currentIndex { q.currentIndex += len(newTracks) } - q.reindexPositions() - - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() - if wasEmpty { q.currentIndex = 0 q.loadCurrentTrack() } + q.commitMutation(true) q.emitTracksModified( "insert", newTracks, @@ -1148,7 +580,7 @@ func (q *Queue) MoveQueueTracks( return } - sortInts(sorted) + slices.Sort(sorted) // Clamp toIndex. if toIndex < 0 { @@ -1219,11 +651,7 @@ func (q *Queue) MoveQueueTracks( } // Insert the moved block at the adjusted position. - tail := make([]Track, len(remaining[adjustedIdx:])) - copy(tail, remaining[adjustedIdx:]) - remaining = append(remaining[:adjustedIdx], moving...) - remaining = append(remaining, tail...) - q.tracks = remaining + q.tracks = slices.Insert(remaining, adjustedIdx, moving...) // Track currentIndex through the move. if currentTrackIdx >= 0 { @@ -1255,14 +683,7 @@ func (q *Queue) MoveQueueTracks( } } - q.reindexPositions() - - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.commitMutation(true) q.emitTracksModified( "move", moving, @@ -1271,15 +692,6 @@ func (q *Queue) MoveQueueTracks( ) } -// sortInts sorts a slice of ints in ascending order. -func sortInts(s []int) { - for i := 1; i < len(s); i++ { - for j := i; j > 0 && s[j-1] > s[j]; j-- { - s[j], s[j-1] = s[j-1], s[j] - } - } -} - // RemoveTrack removes a track at the given position from the queue. func (q *Queue) RemoveTrack(position int) { q.mu.Lock() @@ -1308,14 +720,7 @@ func (q *Queue) RemoveTrack(position int) { q.currentIndex = len(q.tracks) - 1 } - q.reindexPositions() - - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.commitMutation(true) q.emitTracksModified( "remove", nil, @@ -1377,14 +782,7 @@ func (q *Queue) RemoveTracks(positions []int) { } } - q.reindexPositions() - - if q.shuffleMode { - q.generateShuffleOrder() - } - - q.persistTracks() - q.persistState() + q.commitMutation(true) q.emitTracksModified( "remove", nil, @@ -1518,18 +916,10 @@ func (q *Queue) Play() { q.playFromStart() } -// PlayFromStart restarts playback from the beginning of the queue. +// playFromStart restarts playback from the beginning of the queue. // If shuffle is enabled, a new shuffle order is generated and playback // starts from a random track. This is a no-op when a track is already // active (currentIndex != -1) or the queue is empty. -func (q *Queue) PlayFromStart() { - q.mu.Lock() - defer q.mu.Unlock() - - q.playFromStart() -} - -// playFromStart is the lock-free inner implementation of PlayFromStart. // The caller must hold q.mu. func (q *Queue) playFromStart() { if q.currentIndex != -1 { @@ -1644,8 +1034,7 @@ func (q *Queue) Clear() { q.player.UnloadTrack() } - q.persistTracks() - q.persistState() + q.commitMutation(false) q.emitQueueChanged() } @@ -1658,222 +1047,6 @@ func (q *Queue) EmitCurrentState() { q.emitQueueChanged() } -// SaveState persists the queue state to the database. -func (q *Queue) SaveState() { - q.mu.Lock() - defer q.mu.Unlock() - - q.persistTracks() - q.persistState() - q.logger.Info("Queue state saved", - "trackCount", len(q.tracks), - "currentIndex", q.currentIndex, - "shuffleMode", q.shuffleMode, - "repeatMode", q.repeatMode, - ) -} - -// RestoreState loads the queue state from the database. -func (q *Queue) RestoreState() { - defer profiling.TimeOp(q.logger, "queue.RestoreState")() - - q.mu.Lock() - defer q.mu.Unlock() - - // Restore queue metadata. - state, err := q.db.Queries.GetQueueState(q.db.Ctx) - if err != nil { - q.logger.Error("Failed to load queue state", "err", err) - - return - } - - q.currentIndex = int(state.CurrentPosition) - q.shuffleMode = state.ShuffleMode - q.repeatMode = RepeatMode(state.RepeatMode) - - if state.SourcePlaylistID.Valid { - q.sourcePlaylistID = state.SourcePlaylistID.Int64 - } - - // Restore shuffle order. - if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" { - var order []int - - if err := json.Unmarshal( - []byte(state.ShuffleOrder.String), &order, - ); err != nil { - q.logger.Warn("Failed to parse shuffle order", "err", err) - } else { - q.shuffleOrder = order - } - } - - // Restore queue tracks. - rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx) - if err != nil { - q.logger.Error("Failed to load queue tracks", "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, - }) - } - - // Clamp current index. A value of -1 is valid and means "no current - // track" (e.g. the queue was exhausted before shutdown). Only clamp - // when the index exceeds the restored track count. - if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { - q.currentIndex = len(q.tracks) - 1 - } - - q.logger.Info("Queue state restored", - "trackCount", len(q.tracks), - "currentIndex", q.currentIndex, - "shuffleMode", q.shuffleMode, - "repeatMode", q.repeatMode, - ) -} - -// nextIndex returns the next track index respecting shuffle and repeat modes. -// Returns -1 if there is no next track (queue exhausted). -func (q *Queue) nextIndex() int { - if len(q.tracks) == 0 { - return -1 - } - - if q.shuffleMode && len(q.shuffleOrder) > 0 { - return q.nextShuffledIndex() - } - - next := q.currentIndex + 1 - if next >= len(q.tracks) { - if q.repeatMode == RepeatAll { - return 0 - } - - return -1 - } - - return next -} - -// previousIndex returns the previous track index respecting shuffle and repeat. -// Returns -1 if there is no previous track. -func (q *Queue) previousIndex() int { - if len(q.tracks) == 0 { - return -1 - } - - if q.shuffleMode && len(q.shuffleOrder) > 0 { - return q.previousShuffledIndex() - } - - prev := q.currentIndex - 1 - if prev < 0 { - if q.repeatMode == RepeatAll { - return len(q.tracks) - 1 - } - - return -1 - } - - return prev -} - -// nextShuffledIndex finds the next index in the shuffle order. -func (q *Queue) nextShuffledIndex() int { - shufflePos := q.currentShufflePosition() - if shufflePos == -1 { - // Current track not found in shuffle order — shouldn't happen. - return -1 - } - - nextShufflePos := shufflePos + 1 - if nextShufflePos >= len(q.shuffleOrder) { - if q.repeatMode == RepeatAll { - return q.shuffleOrder[0] - } - - return -1 - } - - return q.shuffleOrder[nextShufflePos] -} - -// previousShuffledIndex finds the previous index in the shuffle order. -func (q *Queue) previousShuffledIndex() int { - shufflePos := q.currentShufflePosition() - if shufflePos == -1 { - return -1 - } - - prevShufflePos := shufflePos - 1 - if prevShufflePos < 0 { - if q.repeatMode == RepeatAll { - return q.shuffleOrder[len(q.shuffleOrder)-1] - } - - return -1 - } - - return q.shuffleOrder[prevShufflePos] -} - -// currentShufflePosition finds where the current track index is in the shuffle order. -func (q *Queue) currentShufflePosition() int { - for i, idx := range q.shuffleOrder { - if idx == q.currentIndex { - return i - } - } - - return -1 -} - -// generateShuffleOrder creates a Fisher-Yates shuffled index order, -// placing the current track at position 0 so it doesn't replay immediately. -func (q *Queue) generateShuffleOrder() { - n := len(q.tracks) - if n == 0 { - q.shuffleOrder = nil - - return - } - - order := make([]int, n) - for i := range order { - order[i] = i - } - - // Fisher-Yates shuffle. - for i := n - 1; i > 0; i-- { - j := rand.IntN(i + 1) - order[i], order[j] = order[j], order[i] - } - - // Move the current track to position 0 so it doesn't replay immediately. - for i, idx := range order { - if idx == q.currentIndex { - order[0], order[i] = order[i], order[0] - - break - } - } - - q.shuffleOrder = order -} - // playOrLoadCurrentTrack loads the current track and optionally starts // playback. When autoPlay is true it behaves like playCurrentTrack; // when false it only loads the file (leaving the player paused). @@ -1979,318 +1152,18 @@ func (q *Queue) reindexPositions() { } } -// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of -// file paths using a single query per chunk (instead of 2 queries per track). -// Returns a map keyed by file path. This is safe to call without holding q.mu. -func (q *Queue) lookupTrackMetaBatch( - filePaths []string, -) map[string]trackMeta { - result := make(map[string]trackMeta, len(filePaths)) - - // Deduplicate paths to avoid redundant work. - unique := make([]string, 0, len(filePaths)) - seen := make(map[string]bool, len(filePaths)) - - for _, fp := range filePaths { - if !seen[fp] { - seen[fp] = true - - unique = append(unique, fp) - } +// commitMutation persists the current queue state after a mutation. +// When reindex is true, track positions are renumbered first. +// The caller must hold q.mu. +func (q *Queue) commitMutation(reindex bool) { + if reindex { + q.reindexPositions() } - // Process in chunks to stay under the SQLite bind variable limit. - for i := 0; i < len(unique); i += maxSQLiteVars { - end := i + maxSQLiteVars - if end > len(unique) { - end = len(unique) - } - - chunk := unique[i:end] - q.lookupChunk(chunk, result) + if q.shuffleMode { + q.generateShuffleOrder() } - return result + q.persistTracks() + q.persistState() } - -// lookupChunk executes a single batch query for a chunk of file paths. -func (q *Queue) lookupChunk( - paths []string, - result map[string]trackMeta, -) { - if len(paths) == 0 { - return - } - - placeholders := make([]string, len(paths)) - args := make([]any, len(paths)) - - for i, fp := range paths { - placeholders[i] = "?" - args[i] = fp - } - - query := fmt.Sprintf( - `SELECT af.id, af.file_path, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist - FROM audio_files af - LEFT JOIN recordings r ON af.recording_id = r.id - LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id - WHERE af.file_path IN (%s)`, - strings.Join(placeholders, ","), - ) - - rows, err := q.db.QueryContext(query, args...) - if err != nil { - q.logger.Error("Batch metadata lookup failed", "err", err) - - return - } - - defer func() { - if closeErr := rows.Close(); closeErr != nil { - q.logger.Error( - "Failed to close rows", - "err", closeErr, - ) - } - }() - - for rows.Next() { - var m trackMeta - - if scanErr := rows.Scan( - &m.AudioFileID, &m.FilePath, &m.Title, &m.Artist, - ); scanErr != nil { - q.logger.Error( - "Failed to scan batch metadata row", - "err", scanErr, - ) - - continue - } - - result[m.FilePath] = m - } - - if rowsErr := rows.Err(); rowsErr != nil { - q.logger.Error( - "Error iterating batch metadata rows", - "err", rowsErr, - ) - } -} - -// persistTracks writes the current queue tracks to the database atomically -// using a transaction with batched multi-row inserts. -func (q *Queue) persistTracks() { - tx, err := q.db.BeginTx() - if err != nil { - q.logger.Error("Failed to begin transaction", "err", err) - - return - } - - committed := false - - defer func() { - if !committed { - if rbErr := tx.Rollback(); rbErr != nil { - q.logger.Error( - "Failed to rollback transaction", - "err", rbErr, - ) - } - } - }() - - // Clear existing tracks. - txQueries := q.db.Queries.WithTx(tx) - - if clearErr := txQueries.ClearQueueTracks(q.db.Ctx); clearErr != nil { - q.logger.Error("Failed to clear queue tracks", "err", clearErr) - - return - } - - // Batch insert tracks. Each row needs 2 bind vars (audio_file_id, position). - const varsPerRow = 2 - - batchSize := maxSQLiteVars / varsPerRow - - for i := 0; i < len(q.tracks); i += batchSize { - end := i + batchSize - if end > len(q.tracks) { - end = len(q.tracks) - } - - batch := q.tracks[i:end] - - if insertErr := q.insertTrackBatch(tx, batch); insertErr != nil { - q.logger.Error( - "Failed to batch insert queue tracks", - "err", insertErr, - ) - - return - } - } - - if commitErr := tx.Commit(); commitErr != nil { - q.logger.Error("Failed to commit transaction", "err", commitErr) - - return - } - - committed = true -} - -// insertTrackBatch inserts a batch of tracks in a single multi-row INSERT. -func (q *Queue) insertTrackBatch(tx *sql.Tx, batch []Track) error { - if len(batch) == 0 { - return nil - } - - valuePlaceholders := make([]string, len(batch)) - args := make([]any, 0, len(batch)*2) - - for i, track := range batch { - valuePlaceholders[i] = "(?, ?)" - - args = append(args, track.AudioFileID, track.Position) - } - - query := "INSERT INTO queue_tracks (audio_file_id, position) VALUES " + - strings.Join(valuePlaceholders, ",") - - _, err := tx.ExecContext(q.db.Ctx, query, args...) - if err != nil { - return fmt.Errorf("batch insert failed: %w", err) - } - - return nil -} - -// persistState writes the queue metadata to the database. -func (q *Queue) persistState() { - var shuffleOrderJSON sql.NullString - - if len(q.shuffleOrder) > 0 { - data, err := json.Marshal(q.shuffleOrder) - if err != nil { - q.logger.Error( - "Failed to marshal shuffle order", - "err", err, - ) - } else { - shuffleOrderJSON = sql.NullString{ - String: string(data), - Valid: true, - } - } - } - - sourcePlaylistID := sql.NullInt64{} - if q.sourcePlaylistID > 0 { - sourcePlaylistID = sql.NullInt64{ - Int64: q.sourcePlaylistID, - Valid: true, - } - } - - err := q.db.Queries.UpdateQueueState( - q.db.Ctx, - sqlcgen.UpdateQueueStateParams{ - SourcePlaylistID: sourcePlaylistID, - CurrentPosition: int64(q.currentIndex), - ShuffleMode: q.shuffleMode, - RepeatMode: string(q.repeatMode), - ShuffleOrder: shuffleOrderJSON, - }, - ) - if err != nil { - q.logger.Error("Failed to persist queue state", "err", err) - } -} - -// emitQueueChanged emits the full queue state to the frontend. -func (q *Queue) emitQueueChanged() { - if q.ctx == nil { - return - } - - state := State{ - Tracks: q.tracks, - CurrentIndex: q.currentIndex, - ShuffleMode: q.shuffleMode, - RepeatMode: q.repeatMode, - SourcePlaylistID: q.sourcePlaylistID, - } - - // Ensure tracks is never nil in JSON. - if state.Tracks == nil { - state.Tracks = []Track{} - } - - runtime.EventsEmit(q.ctx, events.QueueChanged, state) -} - -// emitIndexChanged emits only the current index to the frontend. -func (q *Queue) emitIndexChanged() { - if q.ctx == nil { - return - } - - runtime.EventsEmit( - q.ctx, - events.QueueIndexChanged, - IndexChanged{CurrentIndex: q.currentIndex}, - ) -} - -// emitModeChanged emits only the shuffle/repeat mode to the frontend. -func (q *Queue) emitModeChanged() { - if q.ctx == nil { - return - } - - runtime.EventsEmit( - q.ctx, - events.QueueModeChanged, - ModeChanged{ - ShuffleMode: q.shuffleMode, - RepeatMode: q.repeatMode, - }, - ) -} - -// emitTracksModified emits a delta update for track list changes. -func (q *Queue) emitTracksModified( - action string, - tracks []Track, - index int, - positions []int, -) { - if q.ctx == nil { - return - } - - runtime.EventsEmit( - q.ctx, - events.QueueTracksModified, - TracksModified{ - Action: action, - Tracks: tracks, - Index: index, - Positions: positions, - CurrentIndex: q.currentIndex, - }, - ) -} - -// Sentinel errors. -var ( - ErrEmptyQueue = errors.New("queue is empty") - ErrNoPlayer = errors.New("no player set") -)