docs: map existing codebase

This commit is contained in:
2026-02-26 16:58:25 -05:00
parent d01f5e5d14
commit 572b7fb664
7 changed files with 2526 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
# Architecture
**Analysis Date:** 2026-02-26
## Pattern Overview
**Overall:** Wails v2 Desktop Application — Go backend with embedded web frontend
YellowJacket is a cross-platform desktop music player. The Wails framework hosts a Go backend that manages audio playback, library scanning, queue management, and data persistence. The frontend is a TypeScript/Lit web application rendered in a native webview. Communication between the two layers uses Wails' bidirectional event system and auto-generated function bindings.
**Key Characteristics:**
- Backend is the single source of truth for all application state
- Frontend stores are reactive mirrors that cache backend state for rendering
- Event-driven communication replaces direct function calls for state synchronization
- Two-phase initialization pattern separates object creation from Wails runtime wiring
- SQLite with WAL mode and single-writer constraint for all persistent data
- Code generation via sqlc (SQL → Go) and templ (Go templates → Go)
## Layers
**Application Shell (`main.go`, `backend/app.go`):**
- Purpose: Bootstrap the application, wire dependencies, manage Wails lifecycle
- Location: `main.go`, `backend/app.go`
- Contains: `YellowJacketApp` struct, lifecycle hooks (`OnStartup`, `OnDomReady`, `OnBeforeClose`, `OnShutdown`), dependency wiring, frontend binding registration
- Depends on: All backend packages, Wails runtime
- Used by: Wails framework (lifecycle callbacks)
**Domain Layer (backend packages):**
- Purpose: Implement all business logic — playback, queue management, library scanning, playlists
- Location: `backend/player/`, `backend/queue/`, `backend/library/`, `backend/playlist/`
- Contains: Core domain structs, state management, audio decoding, metadata extraction, scan pipeline
- Depends on: `backend/database/`, `backend/events/`, `backend/metadata/`, `backend/coverart/`, Wails runtime (for event emission)
- Used by: Application shell (via lifecycle hooks), frontend (via Wails bindings and events)
**Data Layer (`backend/database/`):**
- Purpose: SQLite database access with type-safe queries
- Location: `backend/database/database.go`, `backend/database/search.go`, `backend/database/sql/`
- Contains: DB wrapper, schema migrations, FTS5 search queries, sqlc-generated query code
- Depends on: `modernc.org/sqlite` (pure-Go SQLite driver), `backend/system/` (for data directory)
- Used by: All domain packages (player, queue, library, playlist)
**Events Layer (`backend/events/`, `frontend/src/events.ts`):**
- Purpose: Centralized event name constants ensuring backend/frontend parity
- Location: `backend/events/events.go` (Go), `frontend/src/events.ts` (TypeScript)
- Contains: String constants for all event names — must match exactly between files
- Depends on: Nothing
- Used by: All backend packages (emission), all frontend stores (subscription)
**Frontend Store Layer (`frontend/src/store/`):**
- Purpose: Cache backend state as reactive data for Lit components
- Location: `frontend/src/store/`
- Contains: Singleton store classes (`PlayerStore`, `QueueStore`, `ThemeStore`, etc.) with subscription system
- Depends on: Wails event system (`@runtime/runtime`), Wails Go bindings (`@go/*`)
- Used by: Frontend controllers and components
**Frontend Controller Layer (`frontend/src/store/controllers/`):**
- Purpose: Connect Lit components to stores via Lit's `ReactiveController` pattern
- Location: `frontend/src/store/controllers/`
- Contains: Controller classes implementing `ReactiveController` — subscribe on `hostConnected()`, unsubscribe on `hostDisconnected()`
- Depends on: Stores
- Used by: Lit components
**Frontend Component Layer (`frontend/src/components/`):**
- Purpose: UI rendering via Lit Web Components with shadow DOM
- Location: `frontend/src/components/`
- Contains: Custom elements for player controls, track list, queue panel, sidebar, cover grid, config page, etc.
- Depends on: Controllers, stores, Wails bindings
- Used by: HTML entry point (`frontend/index.html`)
**Infrastructure Layer:**
- Purpose: Cross-cutting concerns — config persistence, asset serving, OS integration, logging
- Location: `backend/config/`, `backend/assets/`, `backend/system/`, `backend/logging/`, `backend/mediacontrols/`, `backend/coverart/`, `backend/frontendutil/`
- Contains: TOML config management, custom asset server with cover art routing, OS-specific user directories, MPRIS media controls, profiling utilities
- Depends on: `backend/events/`, Wails runtime
- Used by: Application shell, domain packages
## Data Flow
**Track Playback Flow:**
1. User clicks track in frontend `track-list` component
2. Component calls `queueStore.setQueue(filePaths, startIndex)` → delegates to `Queue.SetQueue()` via Wails binding
3. `Queue.SetQueue()` in Go resolves track metadata from DB, sets queue state, calls `q.playCurrentTrack()`
4. `playCurrentTrack()` calls `player.LoadFile(filePath)` then `player.Play()`
5. `Player.LoadFile()` opens file, decodes via `metadata.DecodeFile()`, builds beep streamer chain (resample → ctrl → volume), registers with speaker
6. Player emits `TrackChanged` and `PlaybackStateChanged` events via `runtime.EventsEmit()`
7. Frontend `PlayerStore` receives events, updates cached state, notifies subscribers
8. `PlayerController` triggers `host.requestUpdate()` on connected Lit components
9. Components re-render with new track info and playback state
**Library Scan Flow:**
1. Config change triggers `LibraryConfigChanged` event (or user initiates rescan)
2. `Library.Scan()` executes multi-phase pipeline:
- Phase 1: Load existing audio files from DB into `sync.Map`
- Phase 2: Walk filesystem directory tree, dispatch new/updated files to work channel
- Phase 3: Worker pool extracts metadata (tags + duration) concurrently
- Phase 4: Single DB writer goroutine batches results into transactions
- Phase 5: Orphan cleanup — remove DB entries for deleted files
- Phase 6: Generate missing cover art thumbnails
3. `LibraryScanComplete` event emitted with `ScanMetrics` payload
4. Frontend receives event, refreshes track list
**Queue Auto-Advance Flow:**
1. `beep.Callback` fires when track stream ends (runs with speaker lock held)
2. Callback dispatches `player.onPlaybackFinished()` to a new goroutine (avoids deadlock)
3. `onPlaybackFinished()` sets state to Stopped, emits `PlaybackFinished` and `PlaybackStateChanged` events
4. Calls `playbackFinishedHandler` (wired to `queue.OnPlaybackFinished()`) without holding `p.mu`
5. Queue determines next track (respecting shuffle/repeat modes), loads and plays it
6. Queue emits `QueueIndexChanged` event for frontend sync
**State Management:**
- **Backend is source of truth**: Player state (volume, position, current track), queue state (tracks, index, shuffle/repeat modes), library data, playlists — all owned by Go
- **Frontend stores are mirrors**: `PlayerStore`, `QueueStore`, `ThemeStore` etc. subscribe to backend events and cache state for reactive rendering
- **Startup synchronization**: After frontend DOM is ready, `index.ts` calls `Player.EmitCurrentState()` and `Queue.EmitCurrentState()` via Wails bindings. These methods push the full current state to the frontend via events, ensuring stores are populated on app launch
- **State persistence**: Player state (volume, muted, last track, position) and queue state (tracks, index, modes) are persisted to SQLite. On startup, `RestoreState()` loads from DB; `SaveState()` writes on shutdown and on significant changes
## Key Abstractions
**Player (`backend/player/player.go`):**
- Purpose: Audio file decoding, playback control (play/pause/seek), volume management, state persistence
- Pattern: Mutex-protected state with beep audio library streamer chain (decode → resample → ctrl → volume → speaker)
- Lock ordering: Always acquire `p.mu` before `speaker.Lock()`
- Key types: `Player`, `State` (playing/paused/stopped), `TrackInfo`, `UserVolume`
**Queue (`backend/queue/queue.go`, `navigation.go`, `handlers.go`, `emit.go`, `persistence.go`):**
- Purpose: Ordered track list management, auto-advance, shuffle/repeat, track loading coordination
- Pattern: Mutex-protected state, delegates to `TrackLoader` interface (player) for file loading
- Uses `TrackLoader` interface to avoid circular dependency with player package
- Two-phase SetQueue: initial batch resolves immediately for instant UI, remaining tracks resolve in background goroutine with generation counter for staleness detection
**Library (`backend/library/library.go`, `query.go`, `rescan.go`, `coverart.go`):**
- Purpose: Music collection scanning, metadata extraction, database population, query interface
- Pattern: Multi-phase concurrent pipeline (walk → extract → write → cleanup) with configurable worker count based on storage type (SSD vs HDD)
- Entity caching during scan to avoid redundant DB upserts for repeated artists/albums
- `RescanHooks` pattern for cross-cutting orchestration without circular dependencies
**Database (`backend/database/database.go`, `search.go`):**
- Purpose: SQLite access layer with embedded schema management and FTS5 full-text search
- Pattern: Embedded SQL schemas applied on startup, incremental migrations via `PRAGMA user_version`, sqlc-generated type-safe queries
- WAL mode with `SetMaxOpenConns(1)` for single-writer safety
- FTS5 `search_index` virtual table for title/artist/album/filepath search
**Playlist (`backend/playlist/playlist.go`, `m3u.go`, `favorites.go`, `match.go`):**
- Purpose: Playlist CRUD, M3U8 file import/export, phantom track resolution
- Pattern: Dual storage — DB rows for resolved tracks + M3U8 files as persistent backup. Phantom tracks represent unresolved M3U8 entries (file moved/renamed) with fuzzy matching for resolution
**Config (`backend/config/config.go`):**
- Purpose: Application settings persistence and event-driven propagation
- Pattern: TOML file on disk, loaded at startup, saved on changes. `SetContext()` enables Wails event emission. Config changes emit typed events (`ThemeConfigChanged`, `TrackListConfigChanged`, etc.) so listeners react automatically
## Entry Points
**`main.go`:**
- Location: `main.go`
- Triggers: OS process start
- Responsibilities: Create logger, initialize asset handler, create `YellowJacketApp`, configure Wails options (window size, lifecycle hooks, bindings), call `wails.Run()`
**`backend/app.go``NewYellowJacketApp()`:**
- Location: `backend/app.go`
- Triggers: Called from `main.go` before `wails.Run()`
- Responsibilities: Phase 1 initialization — create database, config, library, player, queue, playlist service, cover art handler. Register Wails frontend bindings (`FEBindings` slice). No Wails runtime access yet.
**`backend/app.go``OnStartup(ctx)`:**
- Location: `backend/app.go`
- Triggers: Wails calls this after the runtime is initialized
- Responsibilities: Phase 2 initialization — call `SetContext(ctx)` on all components, initialize speaker hardware, wire cross-cutting hooks (player↔queue, library↔queue/playlist), initialize MPRIS media controls
**`backend/app.go``OnDomReady(ctx)`:**
- Location: `backend/app.go`
- Triggers: Wails calls this when frontend DOM is fully loaded
- Responsibilities: Check for startup errors and quit if fatal. State sync is driven by frontend calling `EmitCurrentState()` methods.
**`frontend/index.html`:**
- Location: `frontend/index.html`
- Triggers: Wails loads this as the webview content
- Responsibilities: Define page layout structure, load `index.ts` module, instantiate root custom elements (`<search-bar>`, `<app-sidebar>`, `<track-list>`, `<queue-panel>`, `<now-playing>`, `<audio-player>`)
## Two-Phase Initialization
Components that need Wails runtime (for events, dialogs, window APIs) use a two-phase pattern because the runtime is unavailable when objects are first created for Wails binding registration:
**Phase 1 — `New*()`** (called in `NewYellowJacketApp`, before `wails.Run`):
- Create struct with injected dependencies (logger, database)
- Initialize internal state to safe defaults
- Do NOT access Wails runtime or emit events
**Phase 2 — `SetContext(ctx context.Context)`** (called in `OnStartup`, after runtime ready):
- Store the Wails context
- Register event handlers via `runtime.EventsOn()`
- Restore persisted state from database
- Begin emitting events
Components using this pattern:
- `backend/player/player.go``NewPlayer()` + `SetContext()` + `InitSpeaker()`
- `backend/queue/queue.go``NewQueue()` + `SetContext()` + `SetPlayer()` + `RestoreState()`
- `backend/library/library.go``NewLibrary()` + `SetContext()`
- `backend/playlist/playlist.go``NewService()` + `SetContext()`
- `backend/config/config.go``NewConfig()` + `SetContext()`
- `backend/frontendutil/frontendutil.go``NewFrontendUtil()` + `SetContext()`
## Error Handling
**Strategy:** Errors are wrapped with context at each layer, surfaced via structured logging, and propagated to callers. Fatal startup errors cause application exit. Runtime errors are logged and the operation is gracefully degraded.
**Patterns:**
- Sentinel errors as package-level vars: `var errNoAudioFileLoaded = errors.New("no audio file loaded")`
- Error wrapping: `fmt.Errorf("failed to open file: %w", err)`
- `errors.Join()` for accumulating multiple non-fatal errors during scans
- Early return with blank line after error checks (enforced by `nlreturn` linter)
- Startup errors accumulated via `errors.Join(startupErr, ...)` and checked in `OnDomReady` — fatal errors cause `wailsruntime.Quit(ctx)`
## Cross-Cutting Concerns
**Logging:** `log/slog` with structured key-value pairs. Logger injected via constructors and scoped with `logger.WithGroup("player")`. Dev builds use `devslog` handler with debug level; prod builds use info level.
**Validation:** Config validation at load time and before save. Library config validates directory existence. Theme config validates hex color and shade values. TrackList config validates column IDs.
**Authentication:** Not applicable — local desktop application with no network auth.
**OS Integration:**
- MPRIS2 media controls on Linux (`backend/mediacontrols/mpris_linux.go`), no-op stub on other platforms (`backend/mediacontrols/stub.go`)
- OS-specific user data/config directories (`backend/system/userdata.go`)
- Disk type detection for scan concurrency optimization (`backend/system/disktype_linux.go`)
**Asset Serving:** Custom `assets.Handler` wraps Wails' default asset handler with additional routes (cover art serving via `coverart.Handler`). The handler uses `http.ServeMux` for custom routes with fallback to Wails asset handler.
**Profiling:** Dev-only pprof server and operation timing via `backend/profiling/`. Production builds compile to no-ops.
---
*Architecture analysis: 2026-02-26*
+283
View File
@@ -0,0 +1,283 @@
# Codebase Concerns
**Analysis Date:** 2026-02-26
## Tech Debt
**Hardcoded Speaker Configuration:**
- Issue: Speaker sample rate (44100) and buffer size (100ms) are hardcoded constants with no user configuration
- Files: `backend/player/player.go` line 104, line 127
- Impact: Users with high-resolution audio (96kHz, 192kHz) get resampled down to 44.1kHz. Users cannot tune buffer size for latency vs. stability tradeoff
- Fix approach: Add `AudioOutput` section to config TOML (`SampleRate`, `BufferSizeMs`). Plumb through to `InitSpeaker()` and `updateStreamers()` resample quality param (currently hardcoded `4` at line 308)
**Fixed Resample Quality:**
- Issue: Resample quality is hardcoded to `4` in `beep.Resample()` call
- Files: `backend/player/player.go` line 307-309
- Impact: No ability to trade CPU for quality. Low quality may produce audible artifacts on large sample rate deltas
- Fix approach: Make resample quality configurable via config, expose in settings UI. The TODO comment at line 307 acknowledges this
**Tag Writing Not Implemented:**
- Issue: Track details editing UI exists but save is a no-op
- Files: `frontend/src/components/track-details/track-details.ts` line 651
- Impact: Users see an edit interface that doesn't persist changes. Misleading UX
- Fix approach: Implement backend tag writing endpoint using a tag library (e.g. `github.com/dhowden/tag` already in deps supports reading; writing may need additional library). Gate the save button behind a "tag writing supported" check
**HTML Template Component Incomplete:**
- Issue: The `struct2html` templ component has a TODO for supporting more types
- Files: `pkg/templcomp/struct2html_templ.go` line 242
- Impact: Config page form generation may not handle all field types correctly
- Fix approach: Extend the type switch to cover missing types (maps, nested structs, etc.)
**Package-Level `startupErr` Variable:**
- Issue: `startupErr` is a package-level mutable variable used to communicate startup failures between `OnStartup` and `OnDomReady`
- Files: `backend/app.go` line 134
- Impact: Not thread-safe if Wails calls these lifecycle methods concurrently. Also makes testing difficult
- Fix approach: Move to a field on `YellowJacketApp` struct, protected by the struct's lifecycle guarantees
## Code Quality
**Large Frontend Components:**
- Issue: Several Lit components exceed 1000+ lines, combining rendering, state management, event handling, drag-and-drop, context menus, and search filtering
- Files:
- `frontend/src/components/playlist-view/playlist-view.ts` (2669 lines)
- `frontend/src/components/cover-grid/cover-grid.ts` (2092 lines)
- `frontend/src/components/track-list/track-list.ts` (1875 lines)
- `frontend/src/components/config-page/config-page.ts` (1464 lines)
- `frontend/src/components/queue-panel/queue-panel.ts` (1424 lines)
- Impact: Difficult to reason about, test in isolation, or modify without regressions. High coupling between rendering and business logic
- Fix approach: Extract reusable behaviors into additional controllers (the project already uses `SelectionController`, `ContextMenuController`, etc.). Consider splitting rendering into sub-components
**Large Backend Files:**
- Issue: `backend/playlist/playlist.go` (1778 lines) and `backend/library/library.go` (1328 lines) handle too many responsibilities
- Files: `backend/playlist/playlist.go`, `backend/library/library.go`
- Impact: Hard to navigate; mixing CRUD, M3U8 file management, phantom resolution, and search in a single file
- Fix approach: `playlist.go` already has some splitting (m3u.go, match.go, favorites.go). Consider further extraction: phantom resolution into `phantom.go`, M3U file management is already split. Library could extract `saveAudioFile`/`updateAudioFileMetadata`/`processMetadata` into a dedicated `import.go` file
**Duplicated FTS Search Query:**
- Issue: The same complex FTS5 JOIN query pattern (audio_files + recordings + artist_credit + release_group_recordings + release_groups) is repeated in `SearchFTS`, `SearchFTSByFilename`, `SearchFTSTracks`, `RebuildSearchIndex`, and `migration2BasenameAndFTS`
- Files: `backend/database/search.go` lines 34-57, 92-116, 232-274, 168-188; `backend/database/database.go` lines 287-311
- Impact: Changes to the schema require updating 5+ copies of essentially the same JOIN pattern. Risk of them diverging
- Fix approach: Extract the common JOIN clause into a constant or query builder helper. Alternatively, consolidate into fewer sqlc-generated queries
**Raw SQL in Persistence Layer:**
- Issue: Queue persistence and search use hand-crafted SQL with string concatenation for batch operations (`lookupChunk`, `insertTrackBatch`) instead of sqlc-generated queries
- Files: `backend/queue/persistence.go` lines 56-73, 186-203; `backend/database/search.go`
- Impact: These queries bypass sqlc's type-safety guarantees. The `fmt.Sprintf` pattern for IN clauses is safe (only `?` placeholders are interpolated) but diverges from the project's pattern of using generated queries
- Fix approach: Consider using sqlc's `sqlc.slice()` feature or a query builder for batch operations. Alternatively, document these as intentional exceptions
## Error Handling Gaps
**Swallowed Errors in App Lifecycle Callbacks:**
- Issue: MPRIS callbacks in `app.go` discard errors from `Pause()` and `Seek()` with `_ =`
- Files: `backend/app.go` lines 183, 186, 191, 195
- Impact: If pause or seek fails from OS media controls, the failure is invisible to the user and to logs
- Fix approach: Log errors at minimum. Consider emitting a frontend notification for user-visible failures
**Silently Swallowed Artist Credit Link Error:**
- Issue: `CreateArtistCreditArtist` result and error are both discarded with `_, _`
- Files: `backend/library/library.go` line 1092
- Impact: If the link creation fails for a non-duplicate reason, the data model is silently incomplete
- Fix approach: Check error; ignore only `UNIQUE constraint` violations (which are expected for idempotent upserts), log all others
**Library Scan Error Accumulation:**
- Issue: `Scan()` accumulates errors via `errors.Join` but individual file failures don't stop the scan — which is correct behavior — but the accumulated `scanErr` is returned alongside valid metrics, and callers may not distinguish "scan completed with warnings" from "scan failed"
- Files: `backend/library/library.go` lines 216-218, 310-320, 427-430
- Impact: Callers cannot differentiate between partial success and total failure
- Fix approach: Consider separating scan warnings from fatal scan errors. Return warnings in metrics, fatal errors as the error return
**Config File Permissions:**
- Issue: Config file is written with `0o666` permissions
- Files: `backend/config/config.go` line 152
- Impact: On multi-user systems, any user can read/write the config file. While this is a desktop app, it's not best practice
- Fix approach: Use `0o644` or `0o600` for user-only read/write
## Performance Concerns
**Eager Full-Library Fetch on Startup:**
- Issue: `libraryStore.eagerFetch()` calls `GetAllTracks()`, `GetAllAlbums()`, `GetAllArtists()`, `GetAllGenres()` simultaneously on construction
- Files: `frontend/src/store/library-store.ts` lines 300-305
- Impact: For large libraries (50k+ tracks), this loads all track data into memory at once. Each call triggers a full table scan with multiple JOINs
- Fix approach: Consider lazy loading only the active view's data, or implement pagination. The `GetAllTracks` query with full metadata joins is particularly expensive for large libraries
**Full Queue Re-persist on Every Mutation:**
- Issue: `commitMutation()` calls `persistTracks()` which does `DELETE FROM queue_tracks` + batch INSERT for the entire queue on every add/remove/move operation
- Files: `backend/queue/persistence.go` lines 118-178; `backend/queue/queue.go` line 1157
- Impact: For a queue with thousands of tracks, every single track add/remove triggers a full table rewrite. This is O(n) for every mutation
- Fix approach: Use incremental persistence (INSERT/DELETE individual rows) for add/remove operations. Reserve full rewrite for SetQueue and restore
**SetQueue Phase 2 Re-lookups All Tracks:**
- Issue: `resolveRemainingTracks` re-fetches metadata for ALL file paths including those already resolved in Phase 1
- Files: `backend/queue/queue.go` lines 258-311
- Impact: For large albums/playlists, this doubles the DB work for the initial batch
- Fix approach: Pass the already-resolved metadata from Phase 1 to Phase 2, only lookup the remaining paths
**Entity Cache Never Evicted During Scan:**
- Issue: The `entityCache` in library scanning grows unbounded during a scan - it accumulates every artist, album, genre, and cover art seen
- Files: `backend/library/library.go` lines 41-61
- Impact: For very large libraries with thousands of unique artists/albums, this could consume significant memory. However, since it's only held for the duration of a scan and reduces DB round-trips, this is an acceptable tradeoff for most libraries
- Fix approach: Low priority. Could add an LRU eviction policy if memory becomes an issue with extremely large libraries
## Security Considerations
**File Path Handling:**
- Risk: Library scan uses `filepath.Join(basePath, path)` where `path` comes from `fs.WalkDir` which should be safe, but playlist import accepts user-provided file paths (`ImportPlaylist`, `AddTracksToPlaylist`)
- Files: `backend/playlist/playlist.go` lines 677-784, 442-484; `backend/library/library.go` line 247
- Current mitigation: File paths come from Wails file dialogs (OS-level) and are validated by checking file existence. sqlc parameterized queries prevent SQL injection
- Recommendations: Consider adding path traversal validation (ensure paths don't escape expected directories). Validate that playlist import paths resolve within the library directory
**SQL Injection Protection:**
- Risk: Most queries use sqlc-generated parameterized queries, but hand-crafted SQL exists in search and queue persistence
- Files: `backend/queue/persistence.go` lines 64-73, 195-198; `backend/database/search.go` lines 34-58, 92-116
- Current mitigation: All hand-crafted queries use `?` placeholders with separate args — no string interpolation of user values into SQL
- Recommendations: The `fmt.Sprintf` in `lookupChunk` only interpolates placeholder strings (`"?"` literals), not user data. This is safe but should be documented with a comment explaining why
**Config Data Logged:**
- Risk: Config struct is attached to the logger context at construction time
- Files: `backend/config/config.go` line 46
- Current mitigation: Config currently contains no secrets (file paths, theme settings, window dimensions)
- Recommendations: If secrets are ever added to config (API keys, auth tokens), the logger attachment must be removed or filtered
## Fragile Areas
**Event Name Synchronization:**
- Files: `backend/events/events.go`, `frontend/src/events.ts`
- Why fragile: Event names must match exactly between Go and TypeScript. There is no compile-time or runtime verification that they match. A typo in either file silently breaks communication
- Safe modification: Always update both files simultaneously. The AGENTS.md documents this requirement
- Test coverage: No automated test verifies event name parity
**Player Lock Ordering:**
- Files: `backend/player/player.go` lines 31-39
- Why fragile: The player has two locks (its own `sync.Mutex` and the global `speaker.Lock()`) with a documented ordering requirement: "always acquire p.mu BEFORE speaker.Lock()". The `onPlaybackFinished` callback runs on a goroutine to avoid holding both locks simultaneously
- Safe modification: Never call `speaker.Lock()` while holding `p.mu` in a code path that could block. The `go p.onPlaybackFinished()` pattern in the beep callback (line 351) is critical — removing the goroutine dispatch would deadlock
- Test coverage: No test validates the lock ordering. The integration test requires hardware
**Two-Phase Queue Initialization:**
- Files: `backend/queue/queue.go` lines 152-251
- Why fragile: `SetQueue` uses a two-phase approach with generation counters to handle concurrent calls. The background goroutine (`resolveRemainingTracks`) must check the generation counter under the lock to avoid overwriting newer state
- Safe modification: Always increment `setQueueGen` before starting background work. Always check the counter both before and after acquiring the lock
- Test coverage: No unit test for concurrent SetQueue calls
**Player SetContext Double Lock:**
- Files: `backend/player/player.go` lines 163-171
- Why fragile: `SetContext` acquires and releases `p.mu` twice in succession. Between the two lock acquisitions, another goroutine could modify state
- Safe modification: Consider combining into a single lock acquisition, or document why the two-phase approach is intentional (it appears to be separating the context set from the state restore for clarity)
- Test coverage: Integration test only
**Config TOML Serialization Roundtrip:**
- Files: `backend/config/config.go` lines 100-139, 142-160
- Why fragile: `Load()` applies defaults, then decodes TOML over them, then validates. If a new config field is added without a proper default, existing config files will have the zero value. The `applyDefaults()` runs after decode which could overwrite valid zero values
- Safe modification: Always add defaults in `applyDefaults()` for new fields. Test with an empty config file
## Missing Features
**No Graceful Scan Cancellation:**
- Problem: Library scan cannot be cancelled by the user once started
- Files: `backend/library/library.go` lines 166-540
- Blocks: Users with large libraries cannot abort a scan that's taking too long. The `l.ctx.Done()` checks exist but depend on the Wails context which is only cancelled on app shutdown
- Fix approach: Add a separate cancellation context that can be triggered from the frontend
**No Database Connection Pooling/Health Check:**
- Problem: The database connection is opened once at startup with no health checking or reconnection logic
- Files: `backend/database/database.go` lines 35-136
- Blocks: If the SQLite file becomes corrupted or the disk fills up, errors propagate to every component with no recovery path
- Fix approach: Add a health check method and consider periodic PRAGMA integrity_check for dev builds
**No Cross-Platform Media Controls:**
- Problem: Media controls only work on Linux (MPRIS). macOS and Windows get a no-op stub
- Files: `backend/mediacontrols/mpris_linux.go`, `backend/mediacontrols/stub.go`
- Blocks: macOS users cannot control playback from the media keys overlay or Control Center
- Fix approach: Implement `NSMPRemoteCommandCenter` for macOS, `SystemMediaTransportControls` for Windows
## Test Coverage Gaps
**No Queue Unit Tests:**
- What's not tested: Queue operations (SetQueue, AddTrack, RemoveTrack, Next, Previous, shuffle, repeat modes, persistence)
- Files: `backend/queue/queue.go`, `backend/queue/navigation.go`, `backend/queue/persistence.go`, `backend/queue/handlers.go`
- Risk: The queue is central to playback. Bugs in index tracking, shuffle order, or persistence could cause tracks to skip, repeat incorrectly, or lose the queue on restart
- Priority: High
**No Library Service Unit Tests:**
- What's not tested: Library scan logic, metadata processing, entity cache behavior, batch commit logic, orphan cleanup
- Files: `backend/library/library.go`, `backend/library/rescan.go`, `backend/library/coverart.go`
- Risk: Scan bugs could silently drop tracks, create duplicate entities, or fail to clean up orphans
- Priority: High
**No Database Layer Tests:**
- What's not tested: Search index operations (FTS5 queries), migration logic, transaction handling
- Files: `backend/database/search.go`, `backend/database/database.go`
- Risk: FTS5 query edge cases (special characters, empty queries, very long queries) and migration failures on existing databases
- Priority: Medium
**No Config Tests:**
- What's not tested: Config load/save roundtrip, validation, default application, migration from older config formats
- Files: `backend/config/config.go`
- Risk: Config corruption or silent loss of settings on upgrade
- Priority: Medium
**Player Tests Require Hardware:**
- What's not tested: All player tests require an audio device and are skipped in CI
- Files: `backend/player/player_test.go` line 21
- Risk: Player regressions are only caught manually. The volume conversion, streamer chain, and state persistence logic could all be tested without hardware
- Priority: Medium — extract pure logic (volume math, state serialization) into testable functions
**No Frontend Tests:**
- What's not tested: All TypeScript/Lit components, stores, and controllers
- Files: `frontend/src/` (entire directory)
- Risk: Frontend regressions in event handling, state synchronization, search filtering, drag-and-drop, and selection logic
- Priority: Medium — the backend is the source of truth, but frontend-only logic (search ranking, column sorting, selection controller) could have unit tests
## Concurrency Concerns
**Queue Context Set Without Lock:**
- Issue: `Queue.SetContext()` sets `q.ctx` without holding `q.mu`, while `q.ctx` is read by emit methods that are called under `q.mu`
- Files: `backend/queue/queue.go` lines 134-136
- Impact: Technically a data race on `q.ctx` if SetContext is called concurrently with emit methods. In practice, SetContext is called once during startup before any other queue operations
- Fix approach: Acquire `q.mu` in SetContext for correctness
**Library Fields Not Protected:**
- Issue: `Library` struct fields (`ctx`, `conf`, `rescanHooks`) are set via setter methods without any synchronization
- Files: `backend/library/library.go` lines 78-84, 88-90, 120-123
- Impact: If `SetContext`, `SetRescanHooks`, or config updates occur concurrently with a scan, there could be data races. In practice, these are called during the single-threaded startup phase
- Fix approach: Low priority — document the "set during startup only" contract, or add a mutex if the initialization order becomes less predictable
**Playlist Service Context Race:**
- Issue: `playlist.Service` has a `ctx` field set by `SetContext()` without synchronization, read by `emitEvent()` and all methods
- Files: `backend/playlist/playlist.go` lines 98-104, 130-133, 1169-1178
- Impact: Same pattern as Queue — safe in practice due to startup ordering but technically a race
- Fix approach: Same as Queue — acquire lock or document contract
## Frontend Concerns
**No Event Listener Cleanup:**
- Issue: Singleton stores (`playerStore`, `queueStore`, `libraryStore`) register `EventsOn` listeners in their constructors but never unregister them
- Files: `frontend/src/store/player-store.ts` lines 54-71, `frontend/src/store/queue-store.ts` lines 65-105, `frontend/src/store/library-store.ts` line 51
- Impact: As singletons that live for the app lifetime, this is acceptable — they never need cleanup. However, the Wails `EventsOn` API returns a cancel function that is never captured. If the architecture ever changes to non-singleton stores, this would leak
- Fix approach: Low priority — capture the cancel functions for documentation purposes even if they're never called
**Library Store Potential Memory Pressure:**
- Issue: `libraryStore` caches the entire track, album, artist, and genre lists in memory simultaneously
- Files: `frontend/src/store/library-store.ts` lines 29-32
- Impact: For a library with 100k+ tracks, this could be tens of MB of JavaScript objects. The eager fetch on construction (`eagerFetch()`) means all four datasets are loaded simultaneously
- Fix approach: Consider lazy loading per-view and releasing data for inactive views, or implementing virtual scrolling data providers that don't require holding the full dataset
**Queue Store Delta Application Trusts Backend:**
- Issue: The `applyTracksDelta` method in `QueueStore` applies backend-sent delta operations without validation. If the frontend state diverges from the backend (e.g. missed event), the delta application produces incorrect state
- Files: `frontend/src/store/queue-store.ts` lines 107-171
- Impact: Could cause visual glitches where the queue panel shows incorrect tracks or indices. The full-state `QueueChanged` event acts as a periodic correction mechanism
- Fix approach: Consider adding a sequence number or hash to detect state divergence and trigger a full re-sync
## Dependencies at Risk
**Wails v2 Framework Lock-in:**
- Risk: Wails v2 uses WebView2 (Windows), WebKit2 (Linux), WKWebView (macOS). The project requires `-tags webkit2_41` for Linux builds. Wails v3 is in active development with breaking API changes
- Impact: Migration to Wails v3 will require significant refactoring of the lifecycle management (`OnStartup`, `OnDomReady`, `OnShutdown`), event system, and binding registration
- Migration plan: Monitor Wails v3 stability. The event-based architecture and clean separation of concerns make migration more feasible than a tightly coupled approach
**beep Audio Library:**
- Risk: The `gopxl/beep/v2` library handles all audio decoding and playback. It wraps platform-specific audio output (oto) and codec libraries. The speaker is initialized with global state (`speaker.Init`, `speaker.Lock`)
- Impact: The global speaker lock creates an implicit coupling between all audio operations. If beep has bugs in seeking or resampling, workarounds are limited
- Migration plan: The `metadata.DecodeFile()` abstraction and `TrackLoader` interface provide some insulation. A replacement would require reimplementing the streamer chain
---
*Concerns audit: 2026-02-26*
+715
View File
@@ -0,0 +1,715 @@
# Coding Conventions
**Analysis Date:** 2026-02-26
## Go Code Style
### Package Documentation
Every package begins with a doc comment ending with a period. Use `// Package <name> <description>.` format:
```go
// Package player provides audio playback functionality.
package player
// Package queue manages the playback queue and auto-advance logic.
package queue
// Package events contains centralized event name constants for
// Wails frontend/backend communication. These names must match
// the corresponding event names in the TypeScript frontend.
package events
```
Enforced by `godot` linter. Multi-line doc comments are acceptable:
```go
// Package profiling provides dev-only performance profiling via pprof and runtime/trace.
//
// In dev builds (build tag "dev"), Start launches an HTTP server on localhost:6060...
package profiling
```
### Import Organization
Three groups separated by blank lines, enforced by `gci` formatter:
1. **Standard library** (e.g., `context`, `fmt`, `log/slog`)
2. **Third-party** (e.g., `github.com/...`)
3. **Internal** (prefix `yellowjacket/...`)
```go
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/gopxl/beep/v2"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/metadata"
)
```
Use import aliases sparingly and only when needed to resolve conflicts:
```go
import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
goruntime "runtime"
)
```
Blank identifier imports for side effects include a comment:
```go
import (
_ "modernc.org/sqlite" // Register sqlite driver.
)
```
### Error Handling
**Wrap errors with context** using `fmt.Errorf` and `%w`:
```go
return fmt.Errorf("failed to open file: %w", err)
return fmt.Errorf("could not connect to sqlite database: %w", err)
```
**Define sentinel errors as package-level vars** (enforced by `err113`). Never use `errors.New()` inline in return statements:
```go
// Exported sentinels for external consumers:
var ErrUnsupportedFileType = errors.New("unsupported file type")
// Unexported sentinels for internal use:
var (
errNoControlStreamer = errors.New("no control streamer")
errNoAudioFileLoaded = errors.New("no audio file loaded")
errNoStreamerToPlay = errors.New("no streamer to play")
errLibraryDirNotConfigured = errors.New("library directory not configured")
)
```
**Use `errors.Join()`** for accumulating multiple non-fatal errors:
```go
var batchErr error
for _, result := range batch {
if saveErr := l.saveAudioFile(...); saveErr != nil {
batchErr = errors.Join(batchErr, saveErr)
}
}
```
**Return early on errors** with a blank line after the early-return block (enforced by `nlreturn`):
```go
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
// continue with normal flow
```
## Naming Conventions
### Exported vs Unexported
- **Structs/types**: `PascalCase` for exported, `camelCase` for unexported
- **Functions/methods**: `PascalCase` for exported, `camelCase` for unexported
- **Constants**: `PascalCase` for exported, `camelCase` for unexported
- **Variables**: `PascalCase` for exported, `camelCase` for unexported
### Custom Domain Types
Use typed aliases for domain-specific values rather than raw primitives:
```go
// backend/player/volume.go
type UserVolume int
type Volume float64
// backend/player/player.go
type State string
// backend/metadata/metadata.go
type AudioFileExtension string
// backend/queue/queue.go
type RepeatMode string
// backend/library/config.go
type Directory string
type ScanConcurrency string
```
### No Stuttering (enforced by `revive`)
Exported types must not repeat the package name. Consumers write `queue.Track`, not `queue.QueueTrack`:
```go
// Good — in package queue:
type Track struct { ... }
type State struct { ... }
// Bad — would stutter:
type QueueTrack struct { ... }
type QueueState struct { ... }
```
### Constants
Group related constants with `const (...)`:
```go
const (
Playing State = "playing"
Paused State = "paused"
Stopped State = "stopped"
)
const (
MinUserVol UserVolume = 0
MaxUserVol UserVolume = 100
DefaultUserVol UserVolume = 50
)
```
### JSON Tags
Use `camelCase` JSON tags on exported struct fields for frontend serialization:
```go
type TrackInfo struct {
FileName string `json:"fileName"`
FilePath string `json:"filePath"`
State State `json:"state"`
TrackLength int `json:"trackLength"`
TrackChangeID uint64 `json:"trackChangeId"`
}
```
## Constructor Pattern
Use `New*` constructors with dependency injection. Accept `*slog.Logger` and scope it with `logger.WithGroup()`:
```go
// backend/queue/queue.go
func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
return &Queue{
logger: logger.WithGroup("queue"),
db: db,
repeatMode: RepeatOff,
}
}
// backend/player/player.go
func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
return &Player{
logger: logger,
db: db,
state: Stopped,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
},
}
}
// backend/database/database.go
func NewDB(logger *slog.Logger) (*DB, error) {
// ...
return &DB{
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
}, err
}
```
Logger scoping with `.WithGroup()` or `.With()`:
```go
logger.WithGroup("queue")
logger.WithGroup("player")
logger.WithGroup("config").With("config", conf)
```
## SetContext Pattern (Two-Phase Initialization)
Components needing the Wails runtime use two phases because the runtime is unavailable until `OnStartup`:
1. **Phase 1**: `New*()` constructor — created before `wails.Run` for binding registration
2. **Phase 2**: `SetContext(ctx context.Context)` — called after runtime starts; registers event handlers, restores state
```go
// Phase 1: in NewYellowJacketApp()
yjApp.player = player.NewPlayer(yjApp.logger.WithGroup("player"), yjApp.database)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
// Phase 2: in OnStartup()
yj.player.SetContext(ctx)
yj.queue.SetContext(ctx)
yj.library.SetContext(ctx)
yj.appConfig.SetContext(ctx)
```
SetContext implementations vary by component:
```go
// backend/player/player.go — restores persisted state
func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
p.ctx = ctx
p.mu.Unlock()
p.mu.Lock()
p.restoreStateLocked()
p.mu.Unlock()
}
// backend/queue/queue.go — simple context assignment
func (q *Queue) SetContext(ctx context.Context) {
q.ctx = ctx
}
// backend/library/library.go — registers event handlers
func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
l.registerEventHandlers()
}
```
## Logging Conventions
Use `log/slog` with structured key-value pairs. Logger injected via constructors and scoped with `WithGroup`:
```go
// Info-level with structured data:
p.logger.Info("File loaded, state set to paused", "file", filePath)
p.logger.Info("Player state saved",
"volume", volume,
"muted", muted,
"trackPath", trackPath,
"positionSeconds", positionSeconds,
)
// Error-level:
p.logger.Error("Failed to decode", "path", filePath, "err", err)
// Warning-level:
p.logger.Warn("failed to close previous audio file", "err", closeErr)
// Debug-level:
p.logger.Debug("attempting to seek",
"target-seconds", targetSeconds,
"song-length", lengthSecs,
"samples", samples,
)
```
**sloglint enforces**: consistent key-value pair formatting. Always use string keys and structured values.
### Operation Timing
Use `profiling.TimeOp` (dev-only, no-op in production) with defer:
```go
defer profiling.TimeOp(p.logger, "player.LoadFile")()
defer profiling.TimeOp(logger, "database.NewDB")()
defer profiling.TimeOp(q.logger, "queue.SetQueue")()
```
## Comment & Documentation Requirements
### Doc Comments (enforced by `godot`)
All doc comments on exported types and functions must end with a period:
```go
// Player handles audio playback and state management.
type Player struct { ... }
// NewPlayer creates a player. Call InitSpeaker separately to
// initialize the audio output device.
func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
// SetVolume sets the playback volume (0-100), emits a
// VolumeChanged event, and persists the new level.
func (p *Player) SetVolume(desiredVolume UserVolume) {
```
### Section Comments
Use separator comments to organize large files into logical sections:
```go
// ---------------------------------------------------------------
// Emit helpers (must be called with p.mu held)
// ---------------------------------------------------------------
// ---------------------------------------------------------------
// Streamer management (must be called with p.mu held)
// ---------------------------------------------------------------
// ---------------------------------------------------------------
// LoadFile
// ---------------------------------------------------------------
```
### Internal Implementation Comments
Unexported functions get concise comments explaining purpose and lock requirements:
```go
// saveState is the internal helper that writes the current player
// state to the database. Must be called with p.mu held.
func (p *Player) saveState() {
```
## Linting Rules
### golangci-lint v2 Configuration
Config: `.golangci.yml` — version 2 format with `default: standard`.
**Enabled linters:**
- `gocritic` — common Go pitfalls
- `errorlint` — proper error wrapping with `%w`
- `err113` — sentinel errors must be package-level vars
- `godot` — doc comments end with periods
- `revive` — Go best practices (no stuttering, etc.)
- `sloglint` — consistent slog usage
- `nlreturn` — blank line after early returns
- `wsl` — whitespace linting (cuddled declarations)
- `perfsprint` — prefer `strconv` over `fmt.Sprintf` for simple conversions
- `misspell` — spelling in comments
- `nakedret` — no naked returns in long functions
- `dupword` — duplicated words in comments
- `whitespace` — trailing whitespace
- `usetesting` — prefer `t.Context()` and `t.TempDir()`
**Enabled formatters:**
- `gci` — import ordering (stdlib → third-party → `yellowjacket/`)
- `gofmt`, `gofumpt` — standard formatting
- `goimports` — import management
- `golines` — line length (keep under 100 characters)
### Common Linting Pitfalls
**Line length (`golines`)** — Keep under 100 characters. Break long function calls:
```go
// Bad — over 100 characters:
q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
// Good — broken across lines:
q.logger.Warn(
"Current index out of range",
"index", q.currentIndex,
"trackCount", len(q.tracks),
)
```
**Blank line after early returns (`nlreturn`)** — An `if` block ending with `return`/`continue`/`break` must be followed by a blank line:
```go
if err != nil {
return err
}
doNextThing()
```
**Cuddled declarations (`wsl`)**`var` and `const` must be separated from preceding statements by a blank line:
```go
// Good:
wasEmpty := len(q.tracks) == 0
var newTracks []Track
// Bad:
wasEmpty := len(q.tracks) == 0
var newTracks []Track
```
**Sentinel errors (`err113`)** — Never use `errors.New(...)` or `fmt.Errorf("...")` inline in returns. Define package-level sentinels:
```go
var errNotFound = errors.New("not found")
```
**Doc comments (`godot`)** — End with a period:
```go
// Track represents a track in the queue with its metadata.
type Track struct { ... }
```
**Stuttering (`revive`)** — Don't repeat the package name in type names.
## Concurrency Patterns
### Mutex Usage
Use `sync.Mutex` with `Lock()/defer Unlock()` for public methods. Internal `*Locked` suffix functions assume lock is held:
```go
// Public method acquires lock:
func (p *Player) Play() error {
p.mu.Lock()
defer p.mu.Unlock()
// ...
}
// Internal helper — caller must hold p.mu:
func (p *Player) loadFileLocked(filePath string) error {
// no lock acquired here
}
```
Document lock ordering in struct comments:
```go
// Player handles audio playback and state management.
//
// Lock ordering: always acquire p.mu BEFORE speaker.Lock().
type Player struct {
mu sync.Mutex
// ...
}
```
### Atomic Counters
Use `atomic.Int64` for cross-goroutine counters that don't need mutex protection:
```go
var added, skipped, updated atomic.Int64
added.Add(1)
metrics.Added = added.Load()
```
## Build Tags
Dev/prod detection via `internal/dev/`:
- `internal/dev/devbuild.go`: `//go:build dev``IsDev = true`
- `internal/dev/nondevbuild.go`: `//go:build !dev``IsDev = false`
Package-level functions use this for conditional behavior (e.g., `profiling.TimeOp` is a no-op in prod builds).
---
## TypeScript/Lit Conventions
### Component Pattern
Use `@customElement` decorator with `LitElement` base class:
```typescript
@customElement('now-playing')
export class NowPlaying extends LitElement {
// ReactiveControllers for store connection
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
// Component-local reactive state
@state()
private isDragging = false;
// Static styles (override keyword required)
static override styles = css`
:host { display: block; }
`;
// Lifecycle (override keyword required)
override connectedCallback() {
super.connectedCallback();
// setup
}
override disconnectedCallback() {
super.disconnectedCallback();
// cleanup
}
override render() {
return html`...`;
}
// Private event handlers as arrow functions
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
};
private handleCoverMouseEnter = () => {
// ...
};
}
// Register in global element map
declare global {
interface HTMLElementTagNameMap {
'now-playing': NowPlaying;
}
}
```
**Key rules:**
- `override` keyword required on all lifecycle methods (`noImplicitOverride: true`)
- Private event handlers as arrow functions (auto-bound `this`)
- `@state()` decorator for component-local reactive state
- `static override styles` for CSS-in-JS with `css` tag
### Store Pattern (Singleton + ReactiveController)
Backend is source of truth. Frontend stores cache backend state via Wails events.
**Store** (`frontend/src/store/player-store.ts`):
```typescript
class PlayerStore {
private state: PlayerState = { isPlaying: false, currentTrack: null, volume: 50 };
private subscribers = new Set<Subscriber>();
constructor() {
this.initializeEventListeners();
}
private initializeEventListeners(): void {
EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => {
this.update({ isPlaying: data.state === 'playing' });
});
}
getState(): Readonly<PlayerState> { return this.state; }
subscribe(callback: Subscriber): () => void { ... }
private update(partial: Partial<PlayerState>): void { ... }
private notify(): void { ... }
}
// Singleton instance
export const playerStore = new PlayerStore();
```
**Controller** (`frontend/src/store/controllers/player-controller.ts`):
```typescript
export class PlayerController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
this.unsubscribe = playerStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// Convenience getters
get isPlaying(): boolean { return this.state.isPlaying; }
get currentTrack(): TrackInfo | null { return this.state.currentTrack; }
}
```
### Import Organization
Use path aliases from `frontend/tsconfig.json`. Use `import type` for type-only imports (`verbatimModuleSyntax`):
```typescript
// Third-party
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
// Runtime/generated bindings
import { EventsOn, EventsEmit } from '@runtime/runtime';
import * as Player from '@go/player/Player';
// Internal stores/controllers
import type { TrackInfo } from '@store/player-store';
import { PlayerController } from '@store/controllers/player-controller';
// Components
import '@components/audio-player/audio-player';
```
**Available aliases:**
- `@go/*``./wailsjs/go/*` (Wails-generated Go bindings)
- `@components/*``./src/components/*`
- `@store/*``./src/store/*`
- `@runtime/*``./wailsjs/runtime/*` (Wails runtime)
- `@utils/*``./src/utils/*`
- `@assets/*``./src/assets/*`
- `@pages/*``./src/pages/*`
### TypeScript Strictness
Configured in `frontend/tsconfig.json`:
- `strict: true` — all strict checks
- `noUncheckedIndexedAccess: true` — array/object index checks
- `noImplicitOverride: true` — require `override` keyword
- `verbatimModuleSyntax: true` — require `import type`
- `noUnusedLocals: true`, `noUnusedParameters: true`
- `noImplicitReturns: true`
- `noFallthroughCasesInSwitch: true`
- `experimentalDecorators: true` — for Lit decorators
- `useDefineForClassFields: false` — for Lit property definitions
- Plugins: `ts-lit-plugin`, `typescript-lit-html-plugin`
### Event System
Events bridge Go backend and TypeScript frontend. Names must match **exactly** in both files:
- Go: `backend/events/events.go`
- TypeScript: `frontend/src/events.ts`
```go
// Go constants
const (
PlaybackStateChanged = "PlaybackStateChanged"
TrackChanged = "TrackChanged"
QueueChanged = "QueueChanged"
)
```
```typescript
// TypeScript constants (as const object)
export const Events = {
PlaybackStateChanged: "PlaybackStateChanged",
TrackChanged: "TrackChanged",
QueueChanged: "QueueChanged",
} as const;
export type EventName = (typeof Events)[keyof typeof Events];
```
### Store Barrel File
`frontend/src/store/index.ts` re-exports stores and types:
```typescript
export { playerStore } from './player-store';
export type { PlayerState, TrackInfo } from './player-store';
export { PlayerController } from './controllers/player-controller';
```
---
*Convention analysis: 2026-02-26*
+260
View File
@@ -0,0 +1,260 @@
# External Integrations
**Analysis Date:** 2026-02-26
## Wails Runtime Bridge (Go ↔ TypeScript)
**Primary Communication Mechanism: Events**
The Wails runtime provides a bidirectional event bus between Go and TypeScript. Event names are defined as string constants that must match exactly between both sides:
- Go: `backend/events/events.go` - Centralized event name constants
- TypeScript: `frontend/src/events.ts` - Mirrored constants
**Event Categories:**
| Category | Direction | Events |
|---|---|---|
| Playback | Backend → Frontend | `PlaybackStateChanged`, `PlaybackFinished`, `TrackChanged`, `SeekFailed`, `VolumeChanged` |
| Queue | Backend → Frontend | `QueueChanged`, `QueueIndexChanged`, `QueueModeChanged`, `QueueTracksModified` |
| Config | Backend → Frontend | `LibraryConfigChanged`, `ThemeConfigChanged`, `TrackListConfigChanged`, `FavoritesConfigChanged` |
| Playlist | Backend → Frontend | `PlaylistCreated`, `PlaylistDeleted`, `PlaylistRenamed`, `PlaylistTracksChanged`, `PlaylistsRestored`, `DefaultPlaylistChanged` |
| Library | Backend → Frontend | `LibraryScanStarted`, `LibraryScanComplete` |
**Go event emission pattern:**
```go
runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) { ... })
```
**TypeScript event subscription pattern:**
```typescript
EventsOn(Events.TrackChanged, (trackInfo: TrackInfo | null) => { ... });
```
**Wails Bindings (Direct Function Calls):**
Go structs listed in `FEBindings` in `backend/app.go` are automatically exposed as callable functions from TypeScript. Auto-generated binding stubs live in `frontend/wailsjs/go/` (do not edit).
Bound services:
- `backend/frontendutil/frontendutil.go``@go/frontendutil/FrontendUtil` - Directory/file picker dialogs
- `backend/config/config.go``@go/config/Config` - Get/set all configuration
- `backend/library/library.go``@go/library/Library` - Library scanning and queries
- `backend/playlist/playlist.go``@go/playlist/Service` - Playlist CRUD
- `backend/queue/queue.go``@go/queue/Queue` - Queue management
- `backend/player/player.go``@go/player/Player` - Playback control (play, pause, seek, volume, load)
**State Synchronization Pattern:**
The backend is the source of truth. The frontend requests initial state after its stores are ready:
```typescript
// frontend/index.ts (after all stores import and register listeners)
void Player.EmitCurrentState();
void Queue.EmitCurrentState();
```
Backend responds by emitting the full current state via events, which the stores receive and cache.
## Data Storage
**Database: SQLite**
- Driver: `modernc.org/sqlite` v1.45.0 (pure-Go, no CGo)
- DB file: `~/.local/share/yellowjacket/yj.db` (Linux)
- Connection: `backend/database/database.go`
- Pragmas: WAL journal mode, `busy_timeout=5000`, `foreign_keys=ON`
- Constraint: `SetMaxOpenConns(1)` (single writer)
- Code generation: sqlc (`backend/database/sqlc.yaml`)
- Schemas: `backend/database/sql/schemas/*.sql` (30 schema files)
- Queries: `backend/database/sql/queries/*.sql` (15 query files)
- Generated output: `backend/database/sql/sqlcgen/` (DO NOT EDIT)
- Schema migration: Custom migration system using `PRAGMA user_version` (`backend/database/database.go`, `runMigrations()`)
- Migration 1: Audio file property columns (sample_rate, bit_depth, channels, bitrate, file_size)
- Migration 2: Basename column, FTS5 search index
**Database Schema (key tables):**
| Table | Purpose |
|---|---|
| `audio_files` | Tracks with file paths, metadata references, audio properties |
| `recordings` | Track metadata (title, track number, year, genre, etc.) |
| `artists` | Artist entities |
| `artist_credit` | Artist credit display names |
| `artist_credit_artist` | M:N link between artists and credits |
| `release_groups` | Albums |
| `release_group_recordings` | M:N link between albums and recordings |
| `cover_art` | Cover art file references |
| `genres` | Genre entities |
| `genre_recordings` | M:N link between genres and recordings |
| `playlists` / `playlist_tracks` | User playlists |
| `queue` / `queue_tracks` | Playback queue with persistence |
| `player_state` | Persisted player state (volume, last track, position) |
| `file_types` | Supported audio file type registry |
| `search_index` | FTS5 full-text search index (file_path, title, artist, album) |
**File Storage:**
- Cover art cache: `~/.local/share/yellowjacket/covers/` (Linux)
- Managed by `backend/coverart/coverart.go` and `backend/library/coverart.go`
- Size variants: original, `_sm` (small), `_md` (medium), `_lg` (large)
- Served via custom asset handler at `/covers/` prefix
- Config file: `~/.config/yellowjacket/config.toml` (Linux)
- Managed by `backend/config/config.go`
- Format: TOML via `github.com/BurntSushi/toml`
**Caching:**
- In-memory entity cache during library scans (`entityCache` in `backend/library/library.go`) - caches artist credits, artists, release groups, cover art, genres to avoid redundant DB upserts
- No external caching service
## Audio Playback
**Library: `github.com/gopxl/beep/v2` v2.1.1**
Core audio engine providing decode → resample → control → volume → speaker pipeline.
- Decoder: `backend/metadata/decoder.go` - Routes by file extension to beep decoders
- Player: `backend/player/player.go` - Manages streamer chain and playback state
- Speaker: Initialized at 44100 Hz sample rate, 100ms buffer (`time.Second/10`)
**Supported Formats:**
| Format | Decoder | Extension |
|---|---|---|
| MP3 | `github.com/gopxl/beep/v2/mp3` (via `github.com/hajimehoshi/go-mp3`) | `.mp3` |
| FLAC | `github.com/gopxl/beep/v2/flac` (via `github.com/mewkiz/flac`) | `.flac` |
| Ogg Vorbis | `github.com/gopxl/beep/v2/vorbis` (via `github.com/jfreymuth/oggvorbis`) | `.ogg` |
| WAV | `github.com/gopxl/beep/v2/wav` | `.wav` |
**Audio Pipeline (per track):**
1. File opened → decoded to `beep.StreamSeekCloser`
2. Resampled from source sample rate to speaker rate (44100 Hz, quality=4)
3. Wrapped in `beep.Ctrl` for play/pause control
4. Wrapped in `effects.Volume` for volume control (base=2, range -5 to 0 internal)
5. Registered with `speaker.Play()` with a `beep.Callback` for end-of-track notification
**Speaker hardware** uses `github.com/ebitengine/oto/v3` (indirect dependency via beep) for cross-platform audio output.
**Volume System:**
- User-facing: 0100 integer scale (`player.UserVolume`)
- Internal: -5.0 to 0.0 float scale (`player.Volume`)
- Conversion: `backend/player/volume.go`
## Metadata Extraction
**Library: `github.com/dhowden/tag`**
- Extracts ID3v2, Vorbis Comment, and FLAC tags
- Implementation: `backend/metadata/tags.go` (`ExtractTags`, `ExtractTagsFromReader`)
- Extracted fields: title, artist, album, album artist, composer, genre, year, track/disc numbers, lyrics, comment, embedded cover art
**Custom Duration Parsers:**
- MP3: `backend/metadata/mp3duration.go` - Custom header parser for accurate duration (handles multiple ID3v2 tags that inflate `go-mp3`'s `Len()`)
- FLAC: `backend/metadata/flacduration.go` - Custom FLAC STREAMINFO header parser
- General: `backend/metadata/duration.go` - Fallback using beep decoder for WAV/OGG
**Combined Extraction:**
- `backend/metadata/metadata.go``ExtractAllMetadata()` - Single-pass extraction of tags, duration, and audio properties (sample rate, bit depth, channels, bitrate, file size)
## System Integrations
### MPRIS2 Media Controls (Linux)
- Implementation: `backend/mediacontrols/mpris_linux.go` (`//go:build linux`)
- D-Bus library: `github.com/godbus/dbus/v5`
- Bus name: `org.mpris.MediaPlayer2.yellowjacket`
- Object path: `/org/mpris/MediaPlayer2`
- Interfaces: `org.mpris.MediaPlayer2` (root), `org.mpris.MediaPlayer2.Player`
- Capabilities: Play, Pause, PlayPause, Stop, Next, Previous, Seek, SetPosition, Volume, Metadata push
- Non-Linux: No-op stub (`backend/mediacontrols/stub.go`, `//go:build !linux`)
**Architecture:** All D-Bus property updates are dispatched via a buffered channel (`updateChanSize = 64`) to a dedicated goroutine, preventing deadlocks between the player mutex and godbus property mutex.
### File System
- Library scanning: `backend/library/library.go` - Recursive `fs.WalkDir` with concurrent worker pool (`errgroup`)
- Disk type detection: `backend/system/disktype_linux.go` / `backend/system/disktype_other.go` - Detects HDD vs SSD for adaptive scan concurrency
- User data directories: `backend/system/userdata.go` - OS-specific paths for config and data
- Native dialogs: `backend/frontendutil/frontendutil.go` - Directory picker, file picker (for M3U import)
### Playlist Import/Export
- M3U/M3U8 parsing: `backend/playlist/m3u.go`
- Playlist matching: `backend/playlist/match.go` - Fuzzy matching of playlist entries to library tracks
- Favorites system: `backend/playlist/favorites.go` - Special playlist designated as favorites
### Cover Art System
- Extraction: Embedded art from audio file tags (`backend/library/coverart.go`)
- Storage: Hash-based filenames in `~/.local/share/yellowjacket/covers/`
- Size variants: Small (100px), Medium (200px), Large (400px) - generated via `golang.org/x/image`
- Serving: Custom HTTP handler at `/covers/` prefix (`backend/coverart/handler.go`)
- URL resolution: `backend/coverart/coverart.go``ResolveURLs()` converts filesystem paths to URL paths
### Custom Asset Server
- Implementation: `backend/assets/handler.go`
- Serves embedded frontend dist files via Wails asset server
- Supports custom route registration (used by cover art handler)
- Middleware pattern captures Wails' default handler for fallback
## Frontend Architecture
### Entry Points
- Main app: `frontend/index.html``frontend/index.ts`
- View routing: DOM-based navigation via `navigate` CustomEvent in `frontend/index.ts`
- Views: tracks, albums, playlists, artists, genres, libraries, settings, artist-details, genre-details
### State Management
Singleton stores in `frontend/src/store/`:
- `player-store.ts` - Playback state, current track, volume
- `queue-store.ts` - Queue tracks, current index, play mode
- `library-store.ts` - Library track listing
- `playlist-store.ts` - Playlist data
- `favorites-store.ts` - Favorites state
- `theme-store.ts` - Theme accent color and background shade
- `search-store.ts` - Search query and results
- `tracklist-store.ts` - Track list column configuration
Each store subscribes to Wails events and delegates actions to backend via Wails bindings.
### ReactiveController Pattern
Controllers in `frontend/src/store/controllers/` connect Lit components to stores:
- `player-controller.ts`, `queue-controller.ts`, `library-controller.ts`, `playlist-controller.ts`, `favorites-controller.ts`, `theme-controller.ts`, `search-controller.ts`, `tracklist-controller.ts`
- Subscribe in `hostConnected()`, unsubscribe in `hostDisconnected()`
## Profiling & Observability
**Development Only (eliminated in production builds):**
- pprof HTTP server: `localhost:6060` (`backend/profiling/profiling.go`, `//go:build dev`)
- Endpoints: `/debug/pprof/`, `/debug/trace`
- Block and mutex profiling enabled
- Custom `TimeOp()` function for operation timing
**Logging:**
- Framework: `log/slog` (structured, key-value pairs)
- Dev handler: `github.com/golang-cz/devslog` (pretty-printed to stdout)
- Wails logger bridge: `backend/logging/logging.go` (routes Wails logs through slog)
- Pattern: Logger injected via constructors, scoped with `logger.WithGroup("component")`
## External APIs & Services
**None.** YellowJacket is a fully local, offline application. There are no external API calls, cloud services, analytics, telemetry, or network requests. All data lives on the local filesystem.
## CI/CD & Deployment
**CI Pipeline:** Not detected in the repository (no `.github/workflows/`, `.gitlab-ci.yml`, etc.)
**Git Hooks (lefthook):**
- `lefthook.yml` - Pre-commit: go vet, golangci-lint, codegen check, frontend typecheck
- Pre-push: protect main branch, go test, go mod verify
**Distribution:** Binary builds via `make build-prod` (obfuscated + UPX compressed)
## Webhooks & Callbacks
**Incoming:** None
**Outgoing:** None
---
*Integration audit: 2026-02-26*
+166
View File
@@ -0,0 +1,166 @@
# Technology Stack
**Analysis Date:** 2026-02-26
## Languages
**Primary:**
- Go 1.25 - Backend application logic, audio playback, database, system integrations
- TypeScript ~5.9 - Frontend UI with Lit Web Components
**Secondary:**
- SQL - SQLite schemas and queries (via sqlc code generation)
- HTML/CSS - Frontend layout and styling (Lit `css` tagged templates, `index.html`, `index.css`)
- Bash - Build/profiling scripts (`scripts/profile.sh`)
## Runtime
**Environment:**
- Wails v2 runtime (WebView2 on Windows, WebKitGTK on Linux, WKWebView on macOS)
- Linux builds require `webkit2_41` build tag (passed to all Go commands)
**Package Manager:**
- Go modules (`go.mod`) - lockfile: `go.sum`
- pnpm - Frontend package manager; lockfile: `frontend/pnpm-lock.yaml`
## Frameworks
**Core:**
- Wails v2 (`github.com/wailsapp/wails/v2` v2.10.2) - Desktop application framework bridging Go backend to WebView frontend
- Lit (`lit` ^3.2.1) - Web Component framework for the frontend UI
- Web Awesome (`@awesome.me/webawesome` ^3.2.1) - Icon library and component toolkit (icons via `<wa-icon>`)
**Testing:**
- Go standard `testing` package with `go test`
- Race detector enabled: `-race` flag
**Build/Dev:**
- Make - Build orchestration (`Makefile`)
- Wails CLI (`go tool wails`) - Dev server, production builds
- Vite (^7.0.0) - Frontend bundler with HMR
- golangci-lint v2 - Go linting and formatting
## Key Dependencies
### Go (Critical)
- `github.com/gopxl/beep/v2` v2.1.1 - Audio playback engine (MP3, FLAC, OGG, WAV decoding; speaker output; resampling; volume effects)
- `modernc.org/sqlite` v1.45.0 - Pure-Go SQLite driver (no CGo required)
- `github.com/wailsapp/wails/v2` v2.10.2 - Desktop app framework (Go ↔ JS bridge, event system, window management)
- `github.com/dhowden/tag` v0.0.0-20240417053706 - Audio metadata/tag extraction (ID3, Vorbis, FLAC tags)
### Go (Infrastructure)
- `github.com/BurntSushi/toml` v1.6.0 - TOML config file parsing/writing (`config.toml`)
- `github.com/godbus/dbus/v5` v5.1.0 - D-Bus integration for MPRIS2 media controls (Linux)
- `github.com/golang-cz/devslog` v0.0.15 - Pretty-printed structured logging for development
- `golang.org/x/sync` v0.19.0 - `errgroup` for concurrent library scanning
- `golang.org/x/image` v0.12.0 - Image processing for cover art thumbnail generation
- `golang.org/x/text` v0.34.0 - Unicode normalization for text processing
- `github.com/a-h/templ` v0.3.977 - Type-safe HTML templating (used for config page fragments)
### Go (Build Tools - declared in `tool` directive)
- `github.com/sqlc-dev/sqlc` - SQL-to-Go code generator
- `github.com/a-h/templ/cmd/templ` - Templ HTML template compiler
- `github.com/golangci/golangci-lint/v2/cmd/golangci-lint` - Linter
- `github.com/evilmartians/lefthook` - Git hooks manager
- `golang.org/x/vuln/cmd/govulncheck` - Vulnerability scanner
- `github.com/wailsapp/wails/v2/cmd/wails` - Wails CLI
### Frontend (npm)
- `lit` ^3.2.1 - Web Component framework (decorators, reactive properties, shadow DOM)
- `@awesome.me/webawesome` ^3.2.1 - Web component library (icons)
- `@lit-labs/signals` ^0.2.0 - Signal-based reactivity for Lit
- `@lit-labs/virtualizer` ^2.1.1 - Virtual scrolling for large lists
- `vite` ^7.0.0 - Build tool with HMR
- `typescript` ^5.9.3 - TypeScript compiler
- `ts-lit-plugin` ^2.0.2 - Lit template type checking
- `vite-plugin-static-copy` ^3.0.0 - Static asset copying during build
- `stylelint-config-standard` ^40.0.0 - CSS linting
## Configuration
**Application Config:**
- `config.toml` in user config directory (`~/.config/yellowjacket/config.toml` on Linux)
- TOML format, managed by `backend/config/config.go`
- Sections: `[Library]`, `[Theme]`, `[Window]`, `[TrackList]`, `[Favorites]`
**Build Configuration:**
- `wails.json` - Wails project configuration (app name, frontend commands)
- `frontend/vite.config.mts` - Vite bundler config with path aliases
- `frontend/tsconfig.json` - TypeScript config (strict mode, decorators, path aliases)
- `.golangci.yml` - golangci-lint v2 config (standard + extra linters, formatters)
- `backend/database/sqlc.yaml` - sqlc code generation config
- `lefthook.yml` - Git hooks (pre-commit: vet, lint, codegen-check, typecheck; pre-push: test, mod-verify, protect-main)
**TypeScript Path Aliases** (defined in both `tsconfig.json` and `vite.config.mts`):
- `@go/*``frontend/wailsjs/go/*` (Wails Go bindings)
- `@components/*``frontend/src/components/*`
- `@store/*``frontend/src/store/*`
- `@runtime/*``frontend/wailsjs/runtime/*` (Wails runtime JS)
- `@utils/*``frontend/src/utils/*`
- `@assets/*``frontend/src/assets/*`
- `@pages/*``frontend/src/pages/*`
**Environment:**
- No `.env` files detected - application is self-contained
- Dev/prod detection via Go build tags: `internal/dev/devbuild.go` (`//go:build dev`) and `internal/dev/nondevbuild.go` (`//go:build !dev`)
## Build System
**Development:**
```bash
make dev # Full dev mode: install deps, generate, clean, wails dev with HMR
make lint # golangci-lint v2 with all enabled linters
make test # go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
```
**Production:**
```bash
make build-prod # wails build with -obfuscated -upx -ldflags "-s -w"
```
**Key Differences (Dev vs Prod):**
| Aspect | Development | Production |
|---|---|---|
| Build tag | `dev` (enables `IsDev = true`) | `!dev` (default, `IsDev = false`) |
| Log level | `slog.LevelDebug` | `slog.LevelInfo` |
| Profiling | pprof server on `localhost:6060`, block/mutex profiling enabled | No-op (zero overhead, code eliminated by compiler) |
| Binary | Uncompressed, debug symbols | Obfuscated + UPX compressed, stripped (`-s -w`) |
| Version | `dev` (default) | Set via `LDFLAGS` from git tag/commit |
| Frontend | Vite dev server with HMR | Embedded in binary via `//go:embed all:frontend/dist` |
**Code Generation:**
```bash
make generate # Runs: go generate ./...
```
Triggers:
- `backend/app.go`: `//go:generate go tool templ generate` (compiles `.templ``*_templ.go`)
- `backend/database/database.go`: `//go:generate go tool sqlc generate` (compiles SQL → Go in `backend/database/sql/sqlcgen/`)
**Git Hooks (lefthook):**
- Pre-commit: `go vet`, `golangci-lint`, codegen freshness check, frontend TypeScript typecheck
- Pre-push: protect main branch, `go test`, `go mod verify`
## Platform Requirements
**Development:**
- Go 1.25+
- pnpm (for frontend package management)
- Linux: WebKitGTK development headers (webkit2gtk-4.1)
- All Go commands require `-tags webkit2_41` build tag
**Production (Linux):**
- WebKitGTK 4.1 runtime libraries
- D-Bus session bus (for MPRIS2 media controls)
**Cross-Platform Support:**
- Linux: Full support (MPRIS2 media controls via D-Bus)
- macOS/Windows: Supported via Wails; media controls use no-op stub (`backend/mediacontrols/stub.go`)
- User data paths: `~/.local/share/yellowjacket/` (Linux), `~/Library/Application Support/yellowjacket/` (macOS), `%LOCALAPPDATA%\yellowjacket\` (Windows)
---
*Stack analysis: 2026-02-26*
+377
View File
@@ -0,0 +1,377 @@
# Codebase Structure
**Analysis Date:** 2026-02-26
## Directory Layout
```
yellowjacket/
├── backend/ # Go backend — all application logic
│ ├── app.go # Main app struct, lifecycle hooks, dependency wiring
│ ├── assets/ # Custom HTTP asset handler for Wails webview
│ ├── config/ # Application config (TOML persistence, event emission)
│ ├── coverart/ # Cover art extraction, thumbnail generation, HTTP serving
│ ├── database/ # SQLite database layer with sqlc-generated queries
│ │ └── sql/ # SQL source files and generated code
│ │ ├── schemas/ # CREATE TABLE DDL (embedded at build time)
│ │ ├── queries/ # sqlc query definitions
│ │ └── sqlcgen/ # Auto-generated Go code (DO NOT EDIT)
│ ├── events/ # Centralized event name constants (must match frontend)
│ ├── favorites/ # Favorites config types
│ ├── ffmpeg/ # FFmpeg binary embedding (Linux/Windows)
│ │ └── bin/
│ ├── frontendutil/ # Frontend-bound utility functions (dialogs)
│ ├── library/ # Music library scanning, querying, cover art management
│ ├── logging/ # Wails logger adapter for slog
│ ├── mediacontrols/ # OS media controls (MPRIS on Linux, stub elsewhere)
│ ├── metadata/ # Audio file metadata extraction (tags, duration, decoding)
│ ├── player/ # Audio playback engine (beep library)
│ ├── playlist/ # Playlist management, M3U8 import/export, phantom resolution
│ ├── profiling/ # Dev-only pprof server and timing utilities
│ ├── queue/ # Playback queue with shuffle/repeat/persistence
│ ├── system/ # OS-specific utilities (user dirs, disk type detection)
│ ├── theme/ # Theme config types (accent color, background shade)
│ ├── tracklist/ # Track list column config types
│ └── ui/ # UI-related backend types
├── frontend/ # TypeScript/Lit frontend
│ ├── index.html # Main HTML entry point
│ ├── index.css # Global styles
│ ├── package.json # Node dependencies (Lit, Vite, WebAwesome)
│ ├── tsconfig.json # TypeScript config with path aliases
│ ├── vite.config.mts # Vite build config with alias resolution
│ ├── dist/ # Built frontend assets (gitignored)
│ ├── src/ # Source code
│ │ ├── events.ts # Event name constants (must match backend)
│ │ ├── assets/ # Static assets (fonts, images, icons)
│ │ ├── components/ # Lit Web Components (UI)
│ │ ├── store/ # Singleton stores (backend state mirrors)
│ │ │ ├── index.ts # Barrel exports for stores
│ │ │ └── controllers/ # ReactiveControllers connecting stores to components
│ │ └── utils/ # Shared frontend utilities
│ └── wailsjs/ # Auto-generated Wails bindings (DO NOT EDIT)
│ ├── go/ # Go function bindings for TypeScript
│ └── runtime/ # Wails runtime API (events, window, etc.)
├── internal/ # Internal Go packages
│ └── dev/ # Build-tag-based dev/prod detection
├── pkg/ # Shared Go packages
│ └── templcomp/ # Shared templ component utilities
├── test_data/ # Test fixtures (audio files for testing)
│ └── music_library_test/ # Mock music library directory
├── build/ # Build artifacts
│ └── bin/ # Compiled binaries
├── scripts/ # Development scripts (profiling)
├── docs/ # Documentation
│ └── dev/ # Developer docs
├── .github/ # GitHub Actions workflows
│ └── workflows/
├── main.go # Application entry point
├── go.mod # Go module definition
├── go.sum # Go dependency checksums
├── Makefile # Build commands (dev, build, test, lint, generate)
├── wails.json # Wails project config
├── .golangci.yml # golangci-lint v2 config
├── lefthook.yml # Git hooks config
├── .releaserc.yml # Semantic release config
├── renovate.json5 # Dependency update automation
└── AGENTS.md # AI coding agent guidelines
```
## Directory Purposes
**`backend/`:**
- Purpose: All Go server-side application logic
- Contains: Domain packages, infrastructure, data access
- Key files: `app.go` (main app struct and lifecycle)
**`backend/player/`:**
- Purpose: Audio playback engine using the beep library
- Contains: Player struct, volume management, state persistence/restoration, track info emission
- Key files: `player.go` (main player logic, ~1105 lines), `volume.go` (volume type conversions)
**`backend/queue/`:**
- Purpose: Playback queue management — ordering, navigation, shuffle, repeat, persistence
- Contains: Queue struct, track management, auto-advance logic, shuffle/repeat navigation, event emission, DB persistence
- Key files: `queue.go` (main queue logic), `navigation.go` (next/previous/shuffle), `handlers.go` (playback finished), `emit.go` (event emission), `persistence.go` (DB save/restore)
**`backend/library/`:**
- Purpose: Music library scanning, metadata extraction pipeline, query interface
- Contains: Library struct, concurrent scan pipeline, cover art processing, database queries for tracks/albums/artists/genres
- Key files: `library.go` (scan pipeline), `query.go` (data access methods for frontend), `rescan.go` (full rescan with clear), `coverart.go` (cover art extraction/thumbnails), `config.go` (library config types), `metrics.go` (scan metrics)
**`backend/playlist/`:**
- Purpose: Playlist CRUD, M3U8 file management, phantom track resolution
- Contains: Playlist service, M3U8 parser/writer, track matching/scoring for phantom resolution
- Key files: `playlist.go` (main service, ~1779 lines), `m3u.go` (M3U8 parsing/writing), `match.go` (phantom track scoring), `favorites.go` (default playlist management)
**`backend/database/`:**
- Purpose: SQLite database access layer
- Contains: DB wrapper, schema management, migrations, FTS5 search
- Key files: `database.go` (connection, schema, migrations), `search.go` (FTS5 full-text search queries)
**`backend/databasekom/sql/schemas/`:**
- Purpose: SQLite CREATE TABLE statements embedded at build time
- Contains: 17 `.sql` files defining all tables
- Key tables: `audio_files`, `recordings`, `artists`, `artist_credit`, `release_groups`, `cover_art`, `genres`, `playlists`, `playlist_tracks`, `queue`, `queue_tracks`, `player_state`, `search_index` (FTS5)
**`backend/database/sql/queries/`:**
- Purpose: sqlc query definitions that generate type-safe Go code
- Contains: 13 `.sql` files with named queries
- Key files: `audio_files.sql`, `recordings.sql`, `playlists.sql`, `queue.sql`, `player_state.sql`
**`backend/database/sql/sqlcgen/`:**
- Purpose: Auto-generated Go code from sqlc (DO NOT EDIT)
- Contains: Type-safe query functions, model structs
- Regenerate: `make generate` or `go generate ./...`
**`backend/events/`:**
- Purpose: Centralized event name string constants for Go side
- Contains: Single file with const groups for playback, queue, config, playlist, library events
- Key file: `events.go`
**`backend/config/`:**
- Purpose: Application configuration management
- Contains: Config struct (TOML-backed), getter/setter methods that validate + save + emit events
- Key files: `config.go` (main config), `window.go` (window size config)
- Sub-configs: Library, Theme, Window, TrackList, Favorites — each defined in their own packages
**`backend/metadata/`:**
- Purpose: Audio file metadata extraction — tags, duration, genre parsing, decoding
- Contains: Tag extraction, custom MP3/FLAC duration parsers, audio file decoder
- Key files: `metadata.go` (tag extraction), `decoder.go` (audio format decoding), `duration.go` (duration calculation), `genre.go` (genre string parsing), `mp3duration.go`, `flacduration.go`
**`backend/coverart/`:**
- Purpose: Cover art storage, thumbnail generation, HTTP serving
- Contains: Cover art handler (HTTP), file management, sized variant generation
- Key files: `coverart.go` (path/URL resolution), `handler.go` (HTTP handler)
**`backend/assets/`:**
- Purpose: Custom HTTP asset handler wrapping Wails' default handler
- Contains: ServeMux-based routing with fallback to Wails asset handler
- Key file: `handler.go`
**`backend/mediacontrols/`:**
- Purpose: OS media control integration (MPRIS2 on Linux)
- Contains: Handler interface, Linux MPRIS implementation, no-op stub for other platforms
- Key files: `mediacontrols.go` (interface), `mpris_linux.go` (Linux), `stub.go` (fallback)
**`backend/system/`:**
- Purpose: OS-specific system utilities
- Contains: User directory paths (config/data), disk type detection
- Key files: `userdata.go` (user dir paths), `disktype_linux.go` / `disktype_other.go`
**`backend/profiling/`:**
- Purpose: Dev-only profiling (pprof server, operation timing)
- Contains: Build-tagged profiling code — dev builds start pprof on :6060, prod builds are no-ops
- Key files: `profiling.go` (dev), `profiling_prod.go` (prod no-op), `timing.go` / `timing_prod.go`
**`backend/logging/`:**
- Purpose: Wails logger adapter that routes Wails log calls to slog
- Key file: `logging.go`
**`backend/frontendutil/`:**
- Purpose: Utility Go functions bound to the frontend (file/directory dialogs)
- Key file: `frontendutil.go`
**`backend/theme/`:**
- Purpose: Theme configuration types (accent color, background shade)
- Key file: `config.go`
**`backend/tracklist/`:**
- Purpose: Track list column configuration types
- Key file: `config.go`
**`backend/favorites/`:**
- Purpose: Favorites/default playlist configuration types
- Key file: `config.go`
**`frontend/src/components/`:**
- Purpose: All Lit Web Components (custom elements)
- Contains: Each component in its own subdirectory with `.ts` file(s)
- Key components:
- `audio-player/` — Player controls, seekbar, volume control
- `track-list/` — Main track listing table with column config and search ranking
- `queue-panel/` — Queue display and management
- `sidebar/` — Navigation sidebar
- `cover-grid/` — Album cover grid with virtual scrolling
- `now-playing/` — Current track info display
- `config-page/` — Settings UI
- `playlist-view/` — Playlist display and management
- `artists-view/` — Artist listing
- `genres-view/` — Genre listing
- `search-bar/` — Search input
**`frontend/src/store/`:**
- Purpose: Singleton state stores mirroring backend state
- Contains: Store classes with event bridge, state access, actions (delegated to backend), subscription system
- Key files: `player-store.ts`, `queue-store.ts`, `library-store.ts`, `playlist-store.ts`, `theme-store.ts`, `search-store.ts`, `favorites-store.ts`, `tracklist-store.ts`
- Barrel: `index.ts` re-exports stores and types
**`frontend/src/store/controllers/`:**
- Purpose: ReactiveControllers connecting Lit components to stores
- Contains: Controller classes that subscribe on `hostConnected()` and unsubscribe on `hostDisconnected()`
- Pattern: `new PlayerController(this)` in component constructor
- Key files: `player-controller.ts`, `queue-controller.ts`, `library-controller.ts`, `playlist-controller.ts`, `theme-controller.ts`, `search-controller.ts`, `favorites-controller.ts`, `tracklist-controller.ts`
**`frontend/src/utils/`:**
- Purpose: Shared frontend utility functions and controllers
- Key files: `format.ts` (display formatting), `time.ts` (time formatting), `context-menu-controller.ts`, `drag-controller.ts`, `selection-controller.ts`, `drag-image.ts`
**`frontend/src/assets/`:**
- Purpose: Static assets (fonts, images, icons)
- Contains: Font files, SVG icons organized by category (`icons/music/`, `icons/ui/`)
**`frontend/wailsjs/`:**
- Purpose: Auto-generated Wails bindings (DO NOT EDIT)
- Contains: TypeScript wrappers for Go functions and Wails runtime API
- Key directories: `go/` (bindings for each bound Go package), `runtime/` (Wails runtime API)
- Regenerated automatically by Wails on build
**`internal/dev/`:**
- Purpose: Build-tag-based dev/prod detection
- Contains: Two files with opposite build tags
- Key files: `devbuild.go` (`//go:build dev``IsDev = true`), `nondevbuild.go` (`//go:build !dev``IsDev = false`)
**`test_data/`:**
- Purpose: Test fixtures for audio file tests
- Contains: Sample audio files in `music_library_test/` directory
- Used by: `*_test.go` files that need real audio data
## Key File Locations
**Entry Points:**
- `main.go`: Application entry point — logger setup, asset handler, app creation, `wails.Run()`
- `backend/app.go`: Main app struct `YellowJacketApp`, lifecycle hooks, dependency wiring
- `frontend/index.html`: Frontend HTML entry point loaded by Wails webview
**Configuration:**
- `wails.json`: Wails project config (name, frontend commands)
- `frontend/tsconfig.json`: TypeScript config with strict mode and path aliases
- `frontend/vite.config.mts`: Vite build config with path alias resolution
- `frontend/package.json`: Node.js dependencies and scripts
- `.golangci.yml`: golangci-lint v2 configuration
- `Makefile`: Build commands (dev, build-dev, build-prod, test, lint, generate)
- `go.mod`: Go module definition and dependencies
- `lefthook.yml`: Git hook configuration
**Core Logic:**
- `backend/player/player.go`: Audio playback engine (~1105 lines)
- `backend/queue/queue.go`: Queue management (~1169 lines)
- `backend/library/library.go`: Library scan pipeline (~1329 lines)
- `backend/playlist/playlist.go`: Playlist service (~1779 lines)
- `backend/database/database.go`: Database connection and schema management
- `backend/database/search.go`: FTS5 search implementation
- `backend/config/config.go`: Application config management
**Event Contracts:**
- `backend/events/events.go`: Go event name constants
- `frontend/src/events.ts`: TypeScript event name constants (must match Go)
**Frontend State:**
- `frontend/src/store/player-store.ts`: Player state mirror
- `frontend/src/store/queue-store.ts`: Queue state mirror with delta event handling
- `frontend/src/store/index.ts`: Barrel exports for all stores
## Naming Conventions
**Files:**
- Go: `snake_case.go` — e.g., `player.go`, `queue_tracks.go`, `cover_art.go`
- Go tests: `*_test.go` co-located with source — e.g., `player_test.go`
- TypeScript: `kebab-case.ts` — e.g., `player-store.ts`, `audio-player.ts`
- SQL schemas: `snake_case.sql` — e.g., `audio_files.sql`, `player_state.sql`
**Directories:**
- Go packages: `lowercase` single word — e.g., `player`, `queue`, `library`, `metadata`
- Multi-word Go: `lowercase` concatenated — e.g., `frontendutil`, `mediacontrols`, `coverart`
- Frontend components: `kebab-case` — e.g., `audio-player/`, `track-list/`, `queue-panel/`
- Frontend stores: flat in `store/` directory
## Where to Add New Code
**New Backend Feature/Package:**
- Create directory: `backend/{feature}/`
- Add package doc comment
- Wire into `backend/app.go` — create in `NewYellowJacketApp()`, call `SetContext()` in `OnStartup()`
- If frontend-callable: add to `FEBindings` slice in `backend/app.go`
- If emitting events: add event names to `backend/events/events.go` AND `frontend/src/events.ts`
**New Frontend Component:**
- Create directory: `frontend/src/components/{component-name}/`
- Create main file: `{component-name}.ts`
- Use `@customElement('{component-name}')` decorator
- Connect to store via controller: `private player = new PlayerController(this);`
- Use path aliases for imports: `@store/*`, `@components/*`, `@go/*`, `@utils/*`
**New Frontend Store:**
- Create file: `frontend/src/store/{name}-store.ts`
- Create matching controller: `frontend/src/store/controllers/{name}-controller.ts`
- Export from `frontend/src/store/index.ts`
- Subscribe to backend events in constructor
- Delegate actions to Go via Wails bindings
**New Database Table:**
- Add schema: `backend/database/sql/schemas/{table_name}.sql`
- Add queries: `backend/database/sql/queries/{table_name}.sql`
- Run `make generate` to regenerate `backend/database/sql/sqlcgen/`
- Never edit files in `sqlcgen/` directly
**New SQL Query:**
- Add to appropriate file in `backend/database/sql/queries/`
- Run `make generate`
- Use generated methods via `db.Queries.{MethodName}()`
**New Event:**
- Add Go constant: `backend/events/events.go`
- Add TypeScript constant: `frontend/src/events.ts` (must match exactly)
- Emit in Go: `runtime.EventsEmit(ctx, events.EventName, payload)`
- Subscribe in TypeScript store: `EventsOn(Events.EventName, handler)`
**Utilities:**
- Go shared helpers: `pkg/` for cross-package utilities
- Go internal helpers: `internal/` for project-internal utilities
- Frontend shared helpers: `frontend/src/utils/`
## Special Directories
**`frontend/wailsjs/`:**
- Purpose: Auto-generated Wails TypeScript bindings for Go functions
- Generated: Yes — by Wails build tooling
- Committed: Yes
- DO NOT EDIT — regenerated on every build
**`backend/database/sql/sqlcgen/`:**
- Purpose: Auto-generated Go code from sqlc query definitions
- Generated: Yes — by `go tool sqlc generate` via `make generate`
- Committed: Yes
- DO NOT EDIT — regenerate with `make generate`
**`frontend/dist/`:**
- Purpose: Built frontend assets (Vite output)
- Generated: Yes — by `pnpm build`
- Committed: No (gitignored)
**`build/bin/`:**
- Purpose: Compiled application binaries
- Generated: Yes — by Wails build
- Committed: No
**`*_templ.go` files:**
- Purpose: Auto-generated Go code from templ templates
- Generated: Yes — by `go tool templ generate` via `make generate`
- Committed: Yes
- DO NOT EDIT — regenerate with `make generate`
**`test_data/`:**
- Purpose: Audio test fixtures for unit tests
- Generated: No — manually curated test files
- Committed: Yes
**`internal/dev/`:**
- Purpose: Build-tag-based dev/prod detection flag
- Generated: No
- Committed: Yes
- `devbuild.go` (`//go:build dev`): `IsDev = true`
- `nondevbuild.go` (`//go:build !dev`): `IsDev = false`
---
*Structure analysis: 2026-02-26*
+491
View File
@@ -0,0 +1,491 @@
# Testing Patterns
**Analysis Date:** 2026-02-26
## Test Framework
**Runner:**
- Go standard `testing` package
- No external test frameworks (no testify assertions — uses raw `t.Errorf`/`t.Fatalf`)
- golangci-lint `testifylint` is enabled but unused (no testify dependency)
**Assertion Library:**
- Standard library only — `t.Errorf`, `t.Fatalf`, `t.Fatal`, `t.Logf`
- Custom equality helpers in test files (e.g., `slicesEqual`)
**Run Commands:**
```bash
make test # All tests (preferred)
go test -tags webkit2_41 -race -count=1 -timeout 120s ./... # All tests manually
go test -tags webkit2_41 ./backend/player/ # Single package
go test -tags webkit2_41 -run TestFunctionName ./backend/player/ # Single test
go test -tags webkit2_41 -v -run TestFunctionName ./backend/... # Verbose single test
```
## Build Tags Requirement
**Critical:** All `go test` invocations require `-tags webkit2_41`. The Makefile handles this automatically. Without this tag, compilation fails because the Wails v2 framework depends on WebKit bindings.
```bash
# Correct:
go test -tags webkit2_41 ./...
# Wrong — will fail to compile:
go test ./...
```
The `Makefile` test target includes all recommended flags:
```makefile
test:
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
```
- `-race` — Race detector enabled
- `-count=1` — Disable test caching (always run)
- `-timeout 120s` — 2-minute timeout
## Test File Organization
**Location:** Colocated with source as `*_test.go` in the same package:
```
backend/player/player.go
backend/player/player_test.go
backend/metadata/genre.go
backend/metadata/genre_test.go
backend/metadata/mp3duration.go
backend/metadata/mp3duration_test.go
backend/metadata/flacduration.go
backend/metadata/flacduration_test.go
backend/coverart/coverart.go
backend/coverart/coverart_test.go
backend/playlist/m3u.go
backend/playlist/m3u_test.go
backend/playlist/match.go
backend/playlist/match_test.go
```
**Exception:** `backend/coverart/coverart_test.go` uses `package coverart_test` (external test package) to test only the exported API.
**All other test files** use the same package as the source (internal tests), allowing access to unexported functions:
```go
package metadata // internal test — can call unexported getMP3Duration()
package playlist // internal test — can call unexported sanitizeFilename()
```
## Test Fixtures
**Location:** `test_data/` at the project root.
**Contents:** Real audio files (MP3, FLAC) used by metadata and player tests.
**Access pattern:** Tests use relative paths from the package directory:
```go
// From backend/player/player_test.go
var testQueue = []string{
"../../test_data/music_library_test/other_music/03 PONPONPON.mp3",
"../../test_data/music_library_test/01 Some Chords.mp3",
"../../test_data/music_library_test/03 anything.mp3",
}
// From backend/metadata/mp3duration_test.go
root := filepath.Join("..", "..", "test_data")
```
**Test helper functions** scan the fixture directory for files of the right type:
```go
// backend/metadata/mp3duration_test.go
func testMP3Files(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "test_data")
var files []string
err := filepath.Walk(root, func(
path string, info os.FileInfo, err error,
) error {
if !info.IsDir() && filepath.Ext(path) == ".mp3" {
files = append(files, path)
}
return nil
})
if len(files) == 0 {
t.Skip("no .mp3 test fixtures found in test_data/")
}
return files
}
// backend/metadata/flacduration_test.go
func testFlacFiles(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "test_data")
// same pattern for .flac files
}
```
**`t.TempDir()`** is used for tests that write files:
```go
dir := t.TempDir()
tmpPath := filepath.Join(dir, "multi_id3v2.mp3")
os.WriteFile(tmpPath, out, 0o644)
```
## Hardware-Dependent Test Skipping
### Integration Tests (Audio Device + Wails Runtime)
The player test requires both a Wails runtime context and an audio output device. It skips unless explicitly opted in:
```go
// backend/player/player_test.go
func TestPlayer(t *testing.T) {
if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
t.Skip(
"skipping: integration test requires Wails runtime and audio device " +
"(set YELLOWJACKET_INTEGRATION=1 to run)",
)
}
// ...
}
```
**To run integration tests:**
```bash
YELLOWJACKET_INTEGRATION=1 go test -tags webkit2_41 -v ./backend/player/
```
### Fixture-Dependent Tests
Tests that need audio fixtures skip gracefully when none are found:
```go
if len(files) == 0 {
t.Skip("no .mp3 test fixtures found in test_data/")
}
```
## Test Structure Patterns
### Table-Driven Tests
The predominant pattern across the codebase. Use a slice of anonymous structs with `t.Run` subtests:
```go
// backend/metadata/genre_test.go
func TestParseGenres(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
want []string
}{
{
name: "single genre",
raw: "Rock",
want: []string{"Rock"},
},
{
name: "semicolon separated",
raw: "Rock; Electronic",
want: []string{"Rock", "Electronic"},
},
// ...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ParseGenres(tt.raw)
if !slicesEqual(got, tt.want) {
t.Errorf(
"ParseGenres(%q) = %v, want %v",
tt.raw, got, tt.want,
)
}
})
}
}
```
### Parallel Tests
Use `t.Parallel()` at both the suite and subtest level. All unit tests use parallel execution:
```go
func TestSanitizeFilename(t *testing.T) {
t.Parallel() // top-level parallel
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // subtest parallel
// ...
})
}
}
```
### File-Iteration Tests
For tests that iterate over real fixture files, use `t.Run` with the filename:
```go
// backend/metadata/mp3duration_test.go
func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
for _, path := range testMP3Files(t) {
t.Run(filepath.Base(path), func(t *testing.T) {
// compare fast parser vs full decode
refMS, err := GetTrackLengthMillis(path)
// ...
if diffMS > toleranceMS {
t.Errorf(
"duration mismatch: beep=%dms fast=%dms "+
"(diff %dms exceeds %dms tolerance)",
refMS, fastMS, diffMS, toleranceMS,
)
}
})
}
}
```
### Integration Test Pattern
The player integration test creates a real player instance and exercises it:
```go
// backend/player/player_test.go
func TestPlayer(t *testing.T) {
if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
t.Skip("skipping: integration test requires ...")
}
p := NewPlayer(slog.Default(), nil)
if err := p.InitSpeaker(); err != nil {
t.Fatalf("could not initialize speaker: %s", err.Error())
}
p.SetContext(t.Context())
for _, track := range testQueue {
if err := p.LoadFile(track); err != nil {
t.Fatalf("could not load file %s: %s", track, err.Error())
}
if err := p.Play(); err != nil {
t.Fatalf("could not play file %s: %s", track, err.Error())
}
}
}
```
## Mocking
**No mocking framework is used.** The codebase relies on:
1. **Interfaces for injection:** The `TrackLoader` interface in `backend/queue/queue.go` allows the queue to work with any player implementation:
```go
type TrackLoader interface {
LoadFile(filePath string) error
Play() error
IsPlaying() bool
CurrentPositionSeconds() (int, error)
UnloadTrack()
}
```
2. **`nil` dependencies:** Tests pass `nil` for dependencies not needed:
```go
p := NewPlayer(slog.Default(), nil) // nil database
```
3. **Real implementations:** Most tests exercise real code against test fixtures rather than mocks.
4. **Callback injection:** Cross-cutting behavior uses function callbacks rather than interface mocks:
```go
// Injected callback avoids queue→player circular dependency:
p.SetPlaybackFinishedHandler(handler func())
// Hook-based coordination:
l.SetRescanHooks(library.RescanHooks{
PreClear: yj.queue.Clear,
PostScan: yj.playlist.RestoreAllPlaylists,
})
```
## Test Helpers
### Custom Equality Functions
Since no assertion library is used, test files include local equality helpers:
```go
// backend/metadata/genre_test.go
func slicesEqual(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// backend/playlist/match_test.go
func stringSliceEqual(a, b []string) bool {
// identical implementation
}
```
### Test File Builders
The `buildID3v2Header` helper in `backend/metadata/flacduration_test.go` creates synthetic audio file structures for testing:
```go
func buildID3v2Header(payloadSize int) []byte {
header := []byte{
'I', 'D', '3', // signature
3, 0, // version 2.3.0
0, // flags
0, 0, 0, 0, // size (syncsafe, filled below)
}
header[6] = byte((payloadSize >> 21) & 0x7F)
header[7] = byte((payloadSize >> 14) & 0x7F)
header[8] = byte((payloadSize >> 7) & 0x7F)
header[9] = byte(payloadSize & 0x7F)
return header
}
```
### `t.Helper()` Usage
Test helper functions call `t.Helper()` so failure line numbers point to the caller:
```go
func testMP3Files(t *testing.T) []string {
t.Helper()
// ...
}
```
### `t.Context()` Usage
Integration tests use `t.Context()` for the test context (enforced by `usetesting` linter):
```go
p.SetContext(t.Context())
```
### `//nolint` Annotations
Tests use `//nolint:mnd` for magic numbers in test data construction:
```go
//nolint:mnd // synthetic tag construction.
tag1Size := 1024
tag2Size := 2048
//nolint:mnd // expected offset after first tag.
expectedFirst := int64(10 + 100)
//nolint:mnd // byte values from manual FLAC spec packing.
var si [streamInfoLength]byte
si[10] = 0x0A
```
## Error Assertion Patterns
### Fatal vs Error
- `t.Fatalf` for setup failures that prevent the test from continuing
- `t.Errorf` for check failures that should be reported but allow remaining checks to run
```go
// Setup failure — stop immediately:
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Assertion failure — continue checking other fields:
if got != tt.want {
t.Errorf(
"SizedFilename(%q, %q) = %q, want %q",
tt.filename, tt.suffix, got, tt.want,
)
}
```
### Error Expectation
Tests that expect errors check for `nil`/`non-nil`:
```go
func TestWriteM3U8EmptyDir(t *testing.T) {
t.Parallel()
err := writeM3U8("", 1, "test", nil)
if err == nil {
t.Fatal("expected error for empty dir path")
}
}
```
## Frontend Type Checking
No frontend test framework is configured. TypeScript correctness is verified via type checking:
```bash
cd frontend && pnpm exec tsc --noEmit
```
This validates all TypeScript files against the strict `tsconfig.json` settings without producing output files.
## Test Coverage
**Requirements:** No enforced coverage target.
**Coverage command:**
```bash
go test -tags webkit2_41 -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
## Test Types Summary
**Unit Tests:**
- All tests in `backend/metadata/`, `backend/coverart/`, `backend/playlist/`
- Test pure functions with table-driven patterns
- Use `t.Parallel()` for concurrent execution
- No external dependencies (except test fixtures)
**Integration Tests:**
- `backend/player/player_test.go`
- Requires audio hardware and Wails runtime
- Gated behind `YELLOWJACKET_INTEGRATION=1` env var
- Not run in CI
**E2E Tests:**
- Not implemented
**Frontend Tests:**
- Not implemented (type checking only via `tsc --noEmit`)
---
*Testing analysis: 2026-02-26*