24 KiB
Feature Research: Quality Improvements
Domain: Go/Wails/Lit desktop music player — consolidation milestone Researched: 2026-02-27 Confidence: HIGH (improvements grounded in codebase analysis + verified patterns)
Feature Landscape
This is a consolidation milestone. "Features" here are quality improvements, not new user-facing functionality. Each improvement addresses a specific concern documented in .planning/codebase/CONCERNS.md.
Table Stakes (Must Fix — Codebase Is Unreliable Without These)
These are correctness and reliability issues. Leaving them unfixed means the codebase has known race conditions, swallowed errors, and untested critical paths.
| Improvement | Why Required | Complexity | Concern Ref |
|---|---|---|---|
| Fix SetContext data races in Queue, Library, Playlist | q.ctx, l.ctx, s.ctx are written without locks but read under locks. This is a textbook data race detectable by -race. Even if startup ordering makes it safe today, any refactoring that changes init order silently introduces corruption. |
LOW | Concurrency Concerns |
Fix package-level startupErr variable |
Mutable package-level variable shared between OnStartup and OnDomReady. Not thread-safe, untestable. Move to YellowJacketApp struct field. |
LOW | Tech Debt |
| Fix config file permissions (0o666 → 0o644) | Writing world-writable config files is a security defect. One-line fix. | LOW | Error Handling Gaps |
| Fix swallowed errors in MPRIS lifecycle callbacks | _ = on Pause() and Seek() errors from OS media controls. Invisible failures. At minimum log; ideally emit frontend notification. |
LOW | Error Handling Gaps |
| Fix silently swallowed artist credit link error | _, _ = CreateArtistCreditArtist(...) discards non-duplicate errors. Check error, ignore only UNIQUE constraint violations. |
LOW | Error Handling Gaps |
| Separate scan warnings from fatal errors | Scan() returns errors.Join() of all errors. Callers cannot distinguish "scan completed with 3 file warnings" from "scan completely failed". Return warnings in metrics, fatal errors as the error return. |
MEDIUM | Error Handling Gaps |
| Unit tests for queue operations | Queue is central to playback — SetQueue, navigation, shuffle, repeat, persistence — all untested. Bugs here cause tracks to skip, repeat wrong, or lose queue on restart. | HIGH | Test Coverage Gaps |
| Unit tests for library scan logic | Metadata processing, entity cache, orphan cleanup — all untested. Bugs silently drop tracks or create duplicates. | HIGH | Test Coverage Gaps |
| Unit tests for database layer (FTS5, migrations) | FTS5 edge cases (special chars, empty queries) and migration failures are completely untested. | MEDIUM | Test Coverage Gaps |
| Unit tests for config (load/save roundtrip) | Config corruption or silent settings loss on upgrade has no safety net. | MEDIUM | Test Coverage Gaps |
Concurrency Fix Details
Pattern: For SetContext race conditions, the fix is uniform across Queue, Library, and Playlist:
// BEFORE (Queue — race condition):
func (q *Queue) SetContext(ctx context.Context) {
q.ctx = ctx // no lock, but q.ctx read under q.mu elsewhere
}
// AFTER (correct):
func (q *Queue) SetContext(ctx context.Context) {
q.mu.Lock()
defer q.mu.Unlock()
q.ctx = ctx
}
Player already does this correctly (locks around p.ctx = ctx in SetContext). Apply the same pattern to Queue, Library, and Playlist. For Library and Playlist which don't currently have a mutex, add one — or document the "set during startup only, before any concurrent access" contract with a comment and // SAFETY: annotation.
Recommendation: Add a sync.Mutex to Library and Playlist. The cost is negligible, and it eliminates the -race detector finding permanently. Documenting "safe because startup ordering" is fragile — the next developer (or future-you) may change init order. Confidence: HIGH — standard Go concurrency practice.
Testing Strategy Details
In-memory SQLite for DB-dependent tests: Use sql.Open("sqlite", ":memory:") with the modernc.org/sqlite driver (already in deps). Apply the same schema migrations used in production. This gives:
- Fast test execution (no disk I/O)
- Clean state per test (new DB per test function)
- Identical query behavior to production
Pattern for queue/library tests:
func setupTestDB(t *testing.T) *database.DB {
t.Helper()
db, err := database.NewTestDB(t) // in-memory, migrations applied
require.NoError(t, err)
return db
}
func TestSetQueueAndNavigate(t *testing.T) {
db := setupTestDB(t)
q := queue.NewQueue(slog.Default(), db)
// No SetContext needed — test without Wails runtime
// Test pure queue logic without event emission
}
Extract testable pure logic from Player: Volume math (UserVolume → Volume conversion), state serialization, and format detection can be tested without audio hardware. Create volume_test.go with pure function tests. Confidence: HIGH — standard Go testing pattern.
Event-driven testing approach: For packages that emit events, provide a test double or capture mechanism. Options:
- Accept an
EventEmitterinterface (allows mock in tests) - Make event emission optional when
ctx == nil(already partially the case —emitmethods check for nil context) - Test state mutations independent of event emission
Recommendation: Option 2 is already partially implemented. Lean into it: test queue/library state mutations without Wails context, verify state is correct, don't test event emission in unit tests. Confidence: HIGH.
Differentiators (Raises Quality Significantly)
These improvements go beyond "not broken" to "genuinely well-engineered." They improve performance, maintainability, and user experience noticeably.
| Improvement | Value Proposition | Complexity | Concern Ref |
|---|---|---|---|
| Eliminate duplicated FTS5 JOIN query pattern | Same 5-table JOIN repeated 5+ times across search functions. Schema changes require updating all copies. Extract into shared constant or consolidate into fewer sqlc queries. | MEDIUM | Code Quality |
| Migrate raw SQL in queue persistence to sqlc | lookupChunk and insertTrackBatch use fmt.Sprintf for batch operations. Use sqlc.slice() for lookups. Batch inserts can remain hand-crafted but documented. |
MEDIUM | Code Quality |
| Optimize library store — lazy loading instead of eager fetch | eagerFetch() loads all tracks, albums, artists, genres simultaneously on startup. For 50k+ tracks, this is tens of MB of JS objects loaded before user sees anything. Load only the active view's data. |
HIGH | Performance |
| Optimize queue persistence — incremental updates | Every add/remove/move does DELETE ALL + INSERT ALL. For a 5000-track queue, every single mutation rewrites the entire table. Use INSERT/DELETE for individual operations; reserve full rewrite for SetQueue. | MEDIUM | Performance |
| Fix SetQueue Phase 2 redundant lookups | Phase 2 re-fetches metadata for ALL file paths including those already resolved in Phase 1. Pass Phase 1 results to Phase 2, only lookup remaining paths. | LOW | Performance |
| Extract testable player logic | Volume conversion, state serialization, format detection — all testable without audio hardware. Currently locked inside Player struct behind hardware dependency. | LOW | Test Coverage |
| Event name parity validation | Event names must match exactly between Go and TypeScript. No compile-time or runtime verification. Add a build-time check (code generation or test). | LOW | Fragile Areas |
| Polish UI transitions and visual consistency | CSS transitions for panel open/close, list item hover states, loading skeletons. Makes the app feel responsive and intentional. | MEDIUM | UX |
| Improve frontend rendering for large libraries | Even with lit-virtualizer, store updates trigger re-renders. Optimize with repeat() directive keyed by stable IDs, memoized render functions, and avoiding full-array replacement on updates. |
MEDIUM | Performance |
FTS5 Query Consolidation Details
Current state: The same JOIN pattern appears in:
SearchFTS()— 5 columnsSearchFTSByFilename()— 5 columns (same query, different WHERE)SearchFTSTracks()— 16 columns (extended version)RebuildSearchIndex()— 5 columns (INSERT INTO ... SELECT)migration2BasenameAndFTS()— same pattern in migration
Recommended approach: Create a SQL view for the common JOIN:
CREATE VIEW IF NOT EXISTS track_metadata_view AS
SELECT
af.id AS audio_file_id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
r.track_number,
r.disc_number,
-- ... other fields
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
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id;
Then search queries become SELECT ... FROM search_index si JOIN track_metadata_view tmv ON tmv.audio_file_id = si.rowid WHERE search_index MATCH ?. Single source of truth for the JOIN pattern.
Alternative: Extract the JOIN clause as a Go string constant and compose queries from it. Less elegant but simpler to implement.
Recommendation: Use the SQL view approach. SQLite views are essentially macros — no performance penalty. They can be referenced in sqlc queries. Add the view to the schema, then rewrite search queries against it. Confidence: MEDIUM — SQLite views in sqlc need verification during implementation. The concept is sound, but sqlc's handling of views with FTS5 virtual tables may have edge cases.
Queue Persistence Optimization Details
Current pattern:
Every mutation → commitMutation() → persistTracks() → DELETE ALL + batch INSERT ALL
Improved pattern:
AddTrack → INSERT single row + shift positions
RemoveTrack → DELETE single row + shift positions
MoveTrack → UPDATE positions for affected range
SetQueue / RestoreState → DELETE ALL + batch INSERT ALL (keep current)
The sqlc queries InsertQueueTrack, RemoveQueueTrack, ShiftQueuePositionsDown, ShiftQueuePositionsUp already exist but aren't used by commitMutation(). Wire them up for single-track operations.
Confidence: HIGH — the individual queries already exist in sqlc.
Library Store Lazy Loading Details
Current: Constructor calls eagerFetch() → 4 parallel Wails binding calls → 4 full table scans with JOINs → all data in JS memory.
Improved pattern:
class LibraryStore {
// Load on first access, not constructor
async getTracks(): Promise<library.Track[]> {
if (this.tracks !== null) return this.tracks;
// ... existing lazy logic (already implemented!)
}
// Remove eagerFetch() from constructor
constructor() {
EventsOn(Events.LibraryScanComplete, () => this.invalidate());
this.loadCoverSize();
// Don't call eagerFetch() — let components trigger loading
}
}
The store already has lazy loading logic in getTracks(), getAlbums(), etc. The only change needed is removing the eagerFetch() call from the constructor and from invalidate(). Components already call the async getters. The eager fetch is redundant.
For even larger libraries (100k+): Consider pagination. Backend already returns full result sets — add LIMIT/OFFSET or cursor-based pagination to the sqlc queries. Frontend virtualizer already handles rendering — it just needs a data provider that fetches pages instead of the full list.
Confidence: HIGH — the lazy loading infrastructure already exists.
Frontend Performance Details
Already in place: @lit-labs/virtualizer with flow layout for track-list and grid layout for cover-grid. This handles DOM virtualization.
Additional optimizations:
- Use
repeat()with stable keys for virtualized lists. Lit'srepeatdirective reorders DOM nodes instead of recreating them when list order changes. Usetrack.filePathas key (unique, stable). - Avoid full-array replacement in store updates. When a scan completes,
invalidate()setstracks = nullforcing a full refetch. Instead, diff the new data against cached data and apply deltas. For scan completion, a full invalidation is appropriate, but for queue mutations, use the delta protocol already in place (applyTracksDelta). - Debounce store notifications. When multiple store properties update in rapid succession (e.g., during scan), batch notifications using
queueMicrotask()instead of notifying per-property.
Confidence: MEDIUM — repeat() performance gains depend on the update patterns. For initially sorted lists that rarely reorder, map() is equally fast. For the cover-grid with resize/reflow, repeat() is clearly beneficial.
Anti-Features (Things to Deliberately NOT Do During Refactoring)
| Anti-Pattern | Why Tempting | Why Problematic | What to Do Instead |
|---|---|---|---|
| Splitting large files purely for line count | playlist.go (1778 lines) and library.go (1328 lines) feel large. Some components exceed 2000 lines. |
The project explicitly decided against cosmetic splitting (PROJECT.md: "No cosmetic file splitting"). Splitting for its own sake creates navigation overhead and can break logical grouping. | Extract only when it enables reuse (e.g., shared controllers) or fixes a real problem (e.g., testing). |
| Adding a full ORM or query builder | Raw SQL in lookupChunk/insertTrackBatch feels inconsistent with sqlc-generated code. |
An ORM would fight the existing sqlc architecture. A query builder adds a dependency for 2-3 queries. The hand-crafted SQL is safe (parameterized) and performant. | Document the hand-crafted queries with // SAFETY: comments explaining why they're not in sqlc. Use sqlc.slice() where it fits. Accept that batch INSERT with dynamic row count is a legitimate sqlc gap for SQLite. |
| Rewriting the event system | Event names are fragile strings that must match between Go and TypeScript. A typed event system would be safer. | The current system works. A rewrite touches every component in both frontend and backend. The risk-to-reward ratio is terrible for a consolidation milestone. | Add a build-time parity check (a test or codegen script that compares event constants). Fix the symptom (fragility) not the architecture. |
| Adding frontend unit tests for all components | No frontend tests exist. The temptation is to add comprehensive Lit component testing. | Large Lit components (1400-2600 lines) are expensive to test in isolation. Testing requires JSDOM or a browser harness, Shadow DOM handling, and Wails binding mocks. The backend is the source of truth — frontend bugs are visual, not data-corruption. | Test frontend-only logic (search ranking, column sorting, selection controller) as pure function tests if extracted. Defer full component testing to a future milestone. |
| Making all queue mutations atomic/transactional from Go to frontend | The delta protocol between queue store and backend could diverge. Adding sequence numbers or full-state hashes seems robust. | The existing QueueChanged event already acts as periodic full-state correction. Adding a sequence protocol adds complexity to every mutation path for a problem that manifests as a temporary visual glitch, self-correcting on the next full emit. |
Keep the existing delta + periodic full-state pattern. If divergence becomes a real problem (not theoretical), add a generation counter then. |
| Over-engineering error types | The project uses sentinel errors and fmt.Errorf("%w"). Defining custom error types with fields (e.g., ScanError{File, Phase, Cause}) seems more structured. |
Custom error types add boilerplate for minimal benefit in a desktop app. The structured logging already captures context via slog key-value pairs. Error types shine in API servers where callers branch on error details — not here. | Keep sentinel errors for errors.Is() checks. Keep fmt.Errorf("%w") for wrapping with context. Use errors.Join() for accumulation. Separate warnings from fatal errors in scan results via the return signature, not error types. |
| Adding connection pooling or health checks for SQLite | PROJECT.md mentions "No Database Connection Pooling/Health Check" in missing features. | This is a desktop app with a local SQLite file and SetMaxOpenConns(1). Connection pooling is meaningless. Health checks add complexity for a failure mode (corrupt SQLite file) that's better handled by "show error dialog, suggest DB reset." |
Leave as-is. This was correctly scoped as out-of-scope in PROJECT.md. |
| Wrapping the entire test suite in Docker for CI | Integration tests require audio hardware. Docker could theoretically provide a virtual audio device. | Massive CI complexity for marginal benefit. The goal is to make unit tests work without hardware, not to make integration tests work in CI. | Extract testable pure logic. Run unit tests in CI. Keep integration tests as manual/local-only with YELLOWJACKET_INTEGRATION=1. |
Feature Dependencies
[Fix SetContext races]
└── (no deps — standalone fix)
[Fix error handling gaps (MPRIS, artist credit, config perms)]
└── (no deps — standalone fixes)
[Separate scan warnings from fatal errors]
└── (no deps — changes Library.Scan return signature)
[Add in-memory SQLite test infrastructure]
└──requires──> [database.NewTestDB() helper]
└──enables──> [Queue unit tests]
└──enables──> [Library unit tests]
└──enables──> [Database layer tests]
└──enables──> [Config tests]
[Extract testable player logic]
└── (no deps — pure function extraction)
└──enables──> [Player pure logic tests]
[FTS5 query consolidation (SQL view)]
└──should-precede──> [Database layer tests]
(test the consolidated queries, not the duplicated ones)
[Queue persistence optimization (incremental updates)]
└──should-precede──> [Queue unit tests]
(test the optimized persistence, not the DELETE-ALL pattern)
[Library store lazy loading]
└── (no deps — remove eagerFetch() call)
[SetQueue Phase 2 optimization]
└──requires──> [Queue unit tests]
(need tests to verify the optimization doesn't break resolution)
[Event name parity validation]
└── (no deps — standalone build-time check)
[UI polish / transitions]
└── (no deps — CSS-only or Lit reactive changes)
[Frontend rendering optimization]
└──benefits-from──> [Library store lazy loading]
(less data in memory = faster re-renders)
Dependency Notes
- Test infrastructure is the critical enabler: Almost all other improvements benefit from having tests first (to verify refactoring safety) or should happen before tests (to test the right code). The ordering matters: fix persistence patterns before writing persistence tests, consolidate SQL before writing SQL tests.
- Concurrency fixes are independent: They're small, self-contained, and should be done first — they represent known correctness issues.
- Performance optimizations benefit from tests: The queue persistence optimization and SetQueue Phase 2 fix both modify core queue logic. Having queue tests first provides a safety net.
- Frontend work is independent of backend work: Library store lazy loading, UI polish, and rendering optimization don't depend on backend changes.
Prioritization
Phase 1: Correctness & Test Foundation (Do First)
Fixes known bugs and establishes the test infrastructure that makes everything else safe.
- Fix SetContext data races (Queue, Library, Playlist) — LOW effort, HIGH value
- Fix package-level
startupErr→ struct field — LOW effort - Fix config file permissions — LOW effort
- Fix swallowed errors (MPRIS, artist credit) — LOW effort
- Separate scan warnings from fatal errors — MEDIUM effort
- Create in-memory SQLite test helper (
database.NewTestDB()) — MEDIUM effort - Extract testable player pure logic (volume, state) — LOW effort
Phase 2: SQL & Performance Foundations (Do Second)
Improves the code that tests will be written against.
- Consolidate FTS5 JOIN pattern (SQL view or constant) — MEDIUM effort
- Migrate queue lookups to
sqlc.slice()— MEDIUM effort - Optimize queue persistence (incremental updates) — MEDIUM effort
- Fix SetQueue Phase 2 redundant lookups — LOW effort
- Remove
eagerFetch()from library store constructor — LOW effort
Phase 3: Comprehensive Tests (Do Third)
Tests verify the improved code from Phases 1-2.
- Queue unit tests (SetQueue, navigation, shuffle, repeat, persistence) — HIGH effort
- Library scan unit tests (metadata, entity cache, orphan cleanup) — HIGH effort
- Database layer tests (FTS5 queries, migrations) — MEDIUM effort
- Config tests (load/save roundtrip, validation, defaults) — MEDIUM effort
- Player pure logic tests (volume math, state serialization) — LOW effort
- Event name parity test — LOW effort
Phase 4: Polish & Frontend (Do Last)
Visual and frontend improvements that don't affect backend correctness.
- UI transitions and responsive feedback — MEDIUM effort
- Frontend rendering optimization (repeat directive, debounced notifications) — MEDIUM effort
- Document intentional exceptions (hand-crafted SQL, singleton store lifecycle) — LOW effort
Feature Prioritization Matrix
| Improvement | Reliability Value | Implementation Cost | Priority |
|---|---|---|---|
| Fix SetContext data races | HIGH | LOW | P1 |
| Fix startupErr, config perms | HIGH | LOW | P1 |
| Fix swallowed errors | HIGH | LOW | P1 |
| Separate scan warnings/errors | HIGH | MEDIUM | P1 |
| In-memory SQLite test helper | HIGH | MEDIUM | P1 |
| Extract testable player logic | MEDIUM | LOW | P1 |
| FTS5 query consolidation | MEDIUM | MEDIUM | P2 |
| Queue persistence optimization | MEDIUM | MEDIUM | P2 |
| SetQueue Phase 2 fix | MEDIUM | LOW | P2 |
| Library store lazy loading | MEDIUM | LOW | P2 |
| Queue unit tests | HIGH | HIGH | P2 |
| Library unit tests | HIGH | HIGH | P2 |
| Database tests | MEDIUM | MEDIUM | P2 |
| Config tests | MEDIUM | MEDIUM | P2 |
| Event name parity validation | MEDIUM | LOW | P2 |
| Player pure logic tests | MEDIUM | LOW | P2 |
| UI transitions / polish | LOW | MEDIUM | P3 |
| Frontend rendering optimization | LOW | MEDIUM | P3 |
| Migrate queue SQL to sqlc | LOW | MEDIUM | P3 |
Priority key:
- P1: Must do — correctness issues or critical enablers
- P2: Should do — significant quality improvement
- P3: Nice to have — polish, can defer if time-constrained
Sources
- Go race detector: https://go.dev/doc/articles/race_detector — HIGH confidence (official Go docs)
- sqlc
sqlc.slice()for SQLite: https://docs.sqlc.dev/en/stable/reference/macros.html — HIGH confidence (official sqlc docs, verified via WebFetch) - sqlc batch operations: https://docs.sqlc.dev/en/stable/howto/select.html#mysql-and-sqlite — HIGH confidence (official docs)
- Lit
repeatdirective: https://lit.dev/docs/templates/lists/#the-repeat-directive — HIGH confidence (official Lit docs, verified via WebFetch) - Lit rendering model: https://lit.dev/docs/components/rendering/ — HIGH confidence (official docs)
@lit-labs/virtualizer— already in use in codebase (track-list, cover-grid)modernc.org/sqlitein-memory DB — HIGH confidence (:memory:is standard SQLite, driver already in deps)- Go
errors.Join()— HIGH confidence (standard library since Go 1.20, already used in codebase) - Go mutex patterns — HIGH confidence (standard library, matches existing codebase conventions)
Feature research for: YellowJacket consolidation milestone Researched: 2026-02-27