35 KiB
Architecture Patterns: v1.1 Feature Integration
Domain: Desktop music player — new feature integration with existing Wails/Lit/beep/SQLite architecture Researched: 2026-03-06 Confidence: HIGH (derived from complete codebase read + official docs for beep, MusicBrainz API, dhowden/tag)
Recommended Architecture
YellowJacket v1.1 adds 8 features to an existing, well-structured codebase. The architecture approach is integration-first: each feature slots into established patterns (two-phase init, event-driven sync, mutex-protected state, sqlc codegen) rather than introducing new architectural paradigms. The one exception is the plugin system, which necessarily introduces a new extension mechanism.
High-Level Integration Map
┌─────────────────────────────────────────┐
│ app.go (wiring) │
│ New: TagEditor, SmartPlaylist, │
│ MusicBrainz, Shortcuts, Layout │
└────────────────┬────────────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ player │ │ database │ │ events │
│ │ │ │ │ │
│ +gapless│ │ +smart_pl │ │ +TagsEdit │
│ +xfade │ │ +shortcuts│ │ +ScanCanc │
│ │ │ +layout │ │ +SmartPL │
└─────────┘ └───────────┘ │ +Shortcut │
│ +Layout │
│ +MBrainz │
└───────────┘
Component Boundaries
| Component | Responsibility | New vs Modified | Communicates With |
|---|---|---|---|
backend/tageditor/ |
Read/write audio file tags, coordinate DB updates | NEW package | metadata, database, library, events |
backend/library/ |
Scan cancellation via context | MODIFIED | database, events, coverart |
backend/smartplaylist/ |
Rule-based dynamic playlists | NEW package | database, events |
backend/shortcuts/ |
Keyboard shortcut registry + dispatch | NEW package | config, events, player, queue |
backend/player/ |
Gapless playback + crossfade | MODIFIED | beep, events, queue |
backend/musicbrainz/ |
MusicBrainz API client + caching | NEW package | database (cache tables), events |
backend/layout/ |
Layout section configuration | NEW package | config, events |
backend/plugin/ |
Plugin loading, lifecycle, API surface | NEW package | all packages (via API) |
frontend/src/store/tageditor-store.ts |
Tag edit state | NEW store | backend tageditor bindings |
frontend/src/store/smartplaylist-store.ts |
Smart playlist state | NEW store | backend smartplaylist bindings |
frontend/src/store/shortcut-store.ts |
Shortcut config state | NEW store | backend shortcuts bindings |
frontend/src/store/musicbrainz-store.ts |
MB browsing state | NEW store | backend musicbrainz bindings |
frontend/src/store/layout-store.ts |
Layout config state | NEW store | backend layout bindings |
Data Flow
Existing pattern preserved: Backend is source of truth. Frontend stores are reactive mirrors. Events flow backend-to-frontend. Actions flow frontend-to-backend via Wails bindings.
New data flows:
- Tag Edit Flow: Frontend collects edits → Wails binding →
tageditor.SaveTags()→ write file tags → update DB records → emitTagsEditedevent → frontend refreshes affected views - Scan Cancel Flow: Frontend sends cancel request → Wails binding →
library.CancelScan()→ cancel context propagation → scan goroutines checkctx.Done()→ emitLibraryScanCancelledevent - Smart Playlist Flow: User defines rules via frontend → Wails binding →
smartplaylist.Create()→ persist rules to DB → evaluate rules → emitSmartPlaylistChangedevent → frontend refreshes - Gapless Flow: Player pre-decodes next track in background → when current track ends, swap streamer chains without speaker interruption → seamless transition
- MusicBrainz Flow: Frontend search query → Wails binding →
musicbrainz.Search()→ HTTP GET to MB API (rate-limited) → cache results in SQLite → return to frontend → display
Feature 1: Tag Editing
Architecture
New package: backend/tageditor/
The existing dhowden/tag library is read-only. Tag writing requires a separate library. Use bogem/id3v2 for MP3 files and go-flac/go-flac (or equivalent) for FLAC Vorbis comments. OGG and WAV tag writing can be deferred (LOW priority formats).
Confidence: HIGH — dhowden/tag has no write support (confirmed from source). bogem/id3v2 is the standard Go ID3v2 writer.
// backend/tageditor/tageditor.go
type TagEditor struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
lib *library.Library // for FTS re-indexing
}
type TagUpdate struct {
FilePath string
Title string
Artist string
Album string
// ... all editable fields
}
func (te *TagEditor) SaveTags(update TagUpdate) error {
// 1. Write tags to audio file (format-specific writer)
// 2. Update recording/artist_credit/release_group in DB
// 3. Re-index in FTS5 search_index
// 4. Emit TagsEdited event with affected file paths
}
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/app.go |
Add TagEditor to FEBindings, wire in OnStartup |
LOW — follows existing pattern |
backend/events/events.go |
Add TagsEdited, TagEditFailed events |
LOW — codegen handles sync |
backend/database/ |
New queries: UpdateRecordingMetadata, GetRecordingByAudioFileID |
LOW — sqlc pattern |
backend/metadata/tags.go |
Add WriteTags() function alongside existing ExtractTags() |
MEDIUM — new dependency |
frontend/src/components/ |
New <tag-editor> component (modal/panel) |
LOW |
| Library/Queue/Player stores | Must react to TagsEdited to refresh displayed metadata |
MEDIUM — cross-store coordination |
Critical Design Decision
Write tags to file first, then update DB. If the file write fails, the DB stays consistent. If the DB update fails after file write, the next scan will reconcile. This matches the existing pattern where the filesystem is the primary source and the DB is derived.
DB Update Strategy
Tag edits must cascade through the normalized schema:
- Update
recordings.name(title) - Upsert
artist_credit+artists+ link table (if artist changed) - Upsert
release_groups(if album changed) - Re-link
release_group_recordings - Re-index FTS5
search_index
All within a single transaction. The existing entityCache pattern from library scanning can be reused for lookups.
Feature 2: Scan Cancellation
Architecture
Modified: backend/library/library.go
The scan pipeline already uses l.ctx for context propagation. Cancellation requires:
- A dedicated
context.CancelFuncstored on the Library struct - All scan phases checking
ctx.Done()(most already do viaselectin the walk and worker phases)
type Library struct {
mu sync.Mutex
ctx context.Context
// ... existing fields ...
scanCancel context.CancelFunc // NEW: cancel function for active scan
scanning bool // NEW: flag for active scan
}
func (l *Library) CancelScan() {
l.mu.Lock()
defer l.mu.Unlock()
if l.scanCancel != nil {
l.scanCancel()
}
}
func (l *Library) Scan() (*ScanMetrics, error) {
scanCtx, cancel := context.WithCancel(l.ctx)
l.mu.Lock()
l.scanCancel = cancel
l.scanning = true
l.mu.Unlock()
defer func() {
l.mu.Lock()
l.scanCancel = nil
l.scanning = false
l.mu.Unlock()
}()
// ... existing scan code, but use scanCtx instead of l.ctx ...
}
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/library/library.go |
Add scanCancel field, CancelScan() method, wrap scan in child context |
LOW — surgical change |
backend/events/events.go |
Add LibraryScanCancelled event |
LOW |
frontend/src/store/library-store.ts |
Listen for cancelled event, update scan state | LOW |
| Frontend scan progress UI | Add cancel button | LOW |
Key Constraint
The scan's DB writer goroutine commits in batches of 50. Cancellation should allow the current batch to complete (don't leave a half-committed transaction). Check scanCtx.Done() between batches, not mid-batch.
Feature 3: Smart Playlists
Architecture
New package: backend/smartplaylist/
Smart playlists are rule-based queries that dynamically produce track lists. They are not persisted as playlist_tracks — they're evaluated on demand from the rule definition.
// backend/smartplaylist/smartplaylist.go
type Service struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
}
type Rule struct {
Field string // "genre", "year", "artist", "album", "play_count", "date_added"
Operator string // "is", "is_not", "contains", "greater_than", "less_than", "between"
Value string
Value2 string // for "between" operator
}
type SmartPlaylist struct {
ID int64
Name string
Rules []Rule
MatchAll bool // AND vs OR
OrderBy string
Limit int
}
func (s *Service) Evaluate(id int64) ([]Track, error) {
// 1. Load smart playlist rules from DB
// 2. Build SQL WHERE clause from rules
// 3. Query track_metadata VIEW with dynamic conditions
// 4. Return results
}
Database Schema
-- New table
CREATE TABLE smart_playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
rules_json TEXT NOT NULL, -- JSON-encoded []Rule
match_all BOOLEAN NOT NULL DEFAULT 1,
order_by TEXT NOT NULL DEFAULT 'title',
max_tracks INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Query Generation Strategy
Rules map to the existing track_metadata VIEW columns. Build parameterized WHERE clauses:
func buildWhereClause(rules []Rule, matchAll bool) (string, []any) {
// Each rule becomes: "column OPERATOR ?"
// Combined with AND (matchAll) or OR (!matchAll)
// All values are parameterized — no SQL injection risk
}
Use raw db.QueryContext() for dynamic queries — sqlc cannot generate dynamic WHERE clauses. Document with // SAFETY: comments per existing convention.
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/app.go |
Add SmartPlaylist service to FEBindings |
LOW |
backend/database/ |
New schema for smart_playlists table, migration 6 |
LOW |
backend/events/events.go |
Add SmartPlaylistChanged, SmartPlaylistDeleted events |
LOW |
| Frontend | New <smart-playlist-editor> component, rules builder UI |
MEDIUM — most complex frontend work |
Feature 4: Customizable Keyboard Shortcuts
Architecture
New package: backend/shortcuts/
Shortcuts are stored in the TOML config and dispatched via Wails events. The backend holds the definitive shortcut map; the frontend registers a global keydown listener that sends key combos to the backend for resolution.
// backend/shortcuts/shortcuts.go
type Service struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
bindings map[string]string // key combo → action name
actions map[string]func() // action name → handler
}
type Shortcut struct {
Action string `toml:"Action" json:"action"`
Key string `toml:"Key" json:"key"` // e.g., "Ctrl+Space", "MediaPlayPause"
}
Config Integration
Add a [Shortcuts] section to config.toml:
[Shortcuts]
PlayPause = "Space"
NextTrack = "Ctrl+Right"
PrevTrack = "Ctrl+Left"
VolumeUp = "Ctrl+Up"
VolumeDown = "Ctrl+Down"
# ...
Frontend Dispatch Pattern
// Frontend: global keydown handler
document.addEventListener('keydown', (e) => {
const combo = buildComboString(e); // e.g., "Ctrl+Space"
Shortcuts.Execute(combo); // Wails binding → backend resolves + executes
});
Why backend dispatch? The backend already owns all action handlers (player.Play, queue.Next, etc.). Having the backend resolve shortcuts avoids duplicating action dispatch logic in the frontend. The frontend's only job is translating DOM KeyboardEvents into combo strings.
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/config/config.go |
Add Shortcuts config section |
LOW |
backend/app.go |
Wire Shortcuts service, register action handlers | LOW |
frontend/index.ts |
Add global keydown listener | LOW |
| MPRIS callbacks | Already wired in app.go OnStartup — shortcut actions reuse same handler functions |
LOW |
Feature 5: Gapless Playback + Crossfade
Architecture
Modified: backend/player/player.go
This is the most architecturally complex feature because it fundamentally changes how track transitions work.
Gapless Playback
Current behavior: beep.Callback fires → onPlaybackFinished() goroutine → queue calls player.LoadFile() → decode + resample + register with speaker. This gap (file open + decode) causes audible silence.
Gapless approach: Pre-decode the next track while the current one is still playing. When the current track's streamer is near exhaustion, seamlessly swap to the pre-decoded next track.
type Player struct {
// ... existing fields ...
// Gapless pre-loading
nextFile *os.File
nextStreamer beep.StreamSeekCloser
nextFormat beep.Format
nextBuffered *BufferedStreamer
nextFilePath string
gaplessEnabled bool
}
// PreloadNext is called by the queue when it knows what track comes next.
func (p *Player) PreloadNext(filePath string) error {
p.mu.Lock()
defer p.mu.Unlock()
// Open file, decode, build resampled chain, store in next* fields
// Do NOT register with speaker yet
}
Speaker integration: Use beep.Seq() to chain current + next streamer, or use a custom GaplessStreamer that automatically drains the current streamer and transitions to the next one without the beep.Callback → goroutine → LoadFile delay.
The key insight: beep's Seq(a, b) already provides gapless transition between two streamers. The challenge is having b ready before a ends.
Crossfade
Crossfade uses beep's Mixer to overlap two tracks:
// When crossfade is enabled and we're N seconds from track end:
// 1. Start fading out current track's volume
// 2. Start next track at low volume, fade in
// 3. Mix both through beep.Mixer
This requires:
- A
crossfadeDurationconfig setting (default 0 = disabled, range 1-12 seconds) - A crossfade mixer that handles the volume ramping
- Knowing the remaining duration of the current track to trigger crossfade at the right time
Queue Integration
The queue must tell the player what's next:
// In queue.go, after track advance logic:
func (q *Queue) notifyNextTrack() {
nextIdx := q.peekNextIndex() // look ahead without advancing
if nextIdx >= 0 && nextIdx < len(q.tracks) {
q.player.PreloadNext(q.tracks[nextIdx].FilePath)
}
}
This notification happens:
- After
SetQueue(next track is known) - After
Next/Previous(new next track) - After
OnPlaybackFinishedauto-advance (next-next track)
TrackLoader Interface Change
type TrackLoader interface {
LoadFile(filePath string) error
Play() error
IsPlaying() bool
CurrentPositionSeconds() (int, error)
UnloadTrack()
PreloadNext(filePath string) error // NEW
}
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/player/player.go |
Pre-loading, gapless streamer chain, crossfade mixer | HIGH — core audio pipeline |
backend/queue/queue.go |
TrackLoader interface extension, next-track notification |
MEDIUM |
backend/config/config.go |
Crossfade duration setting | LOW |
backend/events/events.go |
Potentially CrossfadeStarted event |
LOW |
| Speaker initialization | May need larger speaker buffer for crossfade overlap | MEDIUM |
Risk Mitigation
- Start with gapless only, defer crossfade. Gapless is the higher-value feature.
- Gapless can be implemented by pre-decoding and using
beep.Seq()to chain streamers — this is well-supported by beep. - Crossfade is additive — build it on top of working gapless.
- The existing
BufferedStreamerread-ahead pattern provides a foundation for pre-loading.
Feature 6: MusicBrainz Browser
Architecture
New package: backend/musicbrainz/
Read-only catalog browsing. The MusicBrainz API is rate-limited to 1 request per second and requires a meaningful User-Agent header.
Confidence: HIGH — MusicBrainz API docs confirmed. JSON format via fmt=json or Accept: application/json.
// backend/musicbrainz/client.go
type Client struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB // for response caching
httpClient *http.Client
rateLimiter *time.Ticker // 1 req/sec
userAgent string
}
const apiBaseURL = "https://musicbrainz.org/ws/2/"
func (c *Client) SearchArtist(query string, limit, offset int) (*ArtistSearchResult, error)
func (c *Client) GetArtist(mbid string) (*Artist, error)
func (c *Client) GetArtistReleaseGroups(mbid string, limit, offset int) (*ReleaseGroupBrowse, error)
func (c *Client) GetReleaseGroup(mbid string) (*ReleaseGroup, error)
func (c *Client) GetRelease(mbid string) (*Release, error)
Rate Limiting
// Enforce 1 request per second globally
func (c *Client) doRequest(url string) ([]byte, error) {
<-c.rateLimiter.C // Block until rate limit allows
// ... execute HTTP GET with User-Agent header ...
}
Response Caching
Cache MB API responses in SQLite to avoid redundant requests:
CREATE TABLE musicbrainz_cache (
url TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Cache TTL: 24 hours for search results, 7 days for entity lookups (data changes infrequently).
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/app.go |
Add MusicBrainz client to FEBindings |
LOW |
backend/database/ |
New musicbrainz_cache table, migration |
LOW |
go.mod |
No new dependencies — use net/http from stdlib |
LOW |
| Frontend | New <musicbrainz-browser> component with search, artist, album views |
MEDIUM |
Key Constraint
The app is currently fully offline. MusicBrainz browsing introduces the first network dependency. Handle network errors gracefully — cache-first with fallback, clear "offline/error" states in the UI, timeouts on HTTP requests.
Feature 7: Layout Customization System
Architecture
New package: backend/layout/
MusicBee-style section-based UI customization. The layout defines which component renders in each section of the UI.
Section Model
The current index.html defines a fixed layout:
┌─────────────────────────────────────────┐
│ header (title + search-bar) │
├─────────┬───────────────────┬───────────┤
│ sidebar │ main-panel │ queue- │
│ │ (track-list) │ panel │
│ │ │ │
├─────────┴───────────────────┴───────────┤
│ footer (now-playing + audio-player) │
└─────────────────────────────────────────┘
Make sections configurable:
// backend/layout/layout.go
type Section struct {
ID string `toml:"ID" json:"id"`
Component string `toml:"Component" json:"component"` // component tag name
Visible bool `toml:"Visible" json:"visible"`
}
type Layout struct {
Sections []Section `toml:"Sections" json:"sections"`
}
Config Integration
[Layout]
[[Layout.Sections]]
ID = "sidebar"
Component = "app-sidebar"
Visible = true
[[Layout.Sections]]
ID = "main-panel"
Component = "track-list"
Visible = true
[[Layout.Sections]]
ID = "right-panel"
Component = "queue-panel"
Visible = true
Frontend Implementation
The layout engine lives in the frontend. It reads the layout config and dynamically instantiates components in their designated sections:
// frontend/src/layout/layout-engine.ts
class LayoutEngine {
private sectionMap: Map<string, HTMLElement>;
applyLayout(config: LayoutConfig) {
for (const section of config.sections) {
const container = this.sectionMap.get(section.id);
if (container) {
container.innerHTML = '';
if (section.visible) {
const el = document.createElement(section.component);
container.appendChild(el);
}
}
}
}
}
Component Registry
Each component declares its size constraints (min width, preferred width, etc.) so the layout engine can validate configurations:
interface LayoutComponent {
tagName: string;
displayName: string;
allowedSections: string[]; // which sections this can go in
minWidth?: number;
minHeight?: number;
}
const COMPONENT_REGISTRY: LayoutComponent[] = [
{ tagName: 'track-list', displayName: 'Track List', allowedSections: ['main-panel'] },
{ tagName: 'cover-grid', displayName: 'Album Grid', allowedSections: ['main-panel'] },
{ tagName: 'queue-panel', displayName: 'Queue', allowedSections: ['right-panel', 'main-panel'] },
// ...
];
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/config/config.go |
Add Layout config section |
LOW |
frontend/index.html |
Replace hard-coded components with section containers | MEDIUM |
frontend/index.ts |
Initialize layout engine, apply config | MEDIUM |
| All existing components | No changes — they're already self-contained Web Components | LOW |
Feature 8: Plugin System
Architecture
New package: backend/plugin/
This is the most complex architectural addition. The plugin system provides extensibility hooks for both backend logic and frontend UI.
Plugin Loading
Plugins are directories in ~/.local/share/yellowjacket/plugins/, each containing:
manifest.json— name, version, entry points, permissionsmain.js— frontend code (Lit component)backend.go(optional) — Go plugin viapluginpackage or WASM
Recommended approach for v1.1: JavaScript-only plugins. Go's plugin package has severe limitations (Linux-only, same Go version required, no unloading). WASM is possible but adds complexity. JS-only plugins can:
- Register new UI components
- Subscribe to events
- Call exposed backend APIs via Wails bindings
- Add sidebar items, context menu entries, toolbar buttons
Plugin API Surface
// frontend/src/plugin/api.ts
interface YellowJacketAPI {
// Events
on(event: string, callback: Function): void;
emit(event: string, data: any): void;
// Player
player: {
play(): void;
pause(): void;
seek(seconds: number): void;
getState(): PlayerState;
};
// Queue
queue: {
addTrack(path: string): void;
getState(): QueueState;
};
// Library
library: {
search(query: string): Promise<Track[]>;
getTrackMetadata(path: string): Promise<TrackInfo>;
};
// UI
ui: {
registerSidebarItem(item: SidebarItem): void;
registerContextMenuItem(item: ContextMenuItem): void;
registerComponent(tagName: string, component: typeof LitElement): void;
};
}
Plugin Lifecycle
1. App startup → scan plugins directory
2. Parse manifest.json for each plugin
3. Validate permissions
4. Load JS entry point in sandboxed context
5. Call plugin.init(api) with the API surface
6. Plugin registers its components/handlers
7. App shutdown → call plugin.destroy() for each
Sandboxing
Plugins run in the same webview context (no iframe sandbox — too restrictive for Lit components). Instead, use an API-mediated approach: plugins can only interact with the app through the provided API object, not by reaching into internal stores or DOM directly.
Backend Plugin Hooks
For backend extensibility, expose hooks rather than full plugin loading:
// backend/plugin/hooks.go
type Hooks struct {
OnTrackChanged []func(trackInfo player.TrackInfo)
OnLibraryScanDone []func(metrics *library.ScanMetrics)
OnConfigChanged []func(key string, value any)
}
Integration Points
| Touchpoint | Change | Risk |
|---|---|---|
backend/app.go |
Plugin loader initialization | MEDIUM |
backend/plugin/ |
New package with loader, manifest parser, hook registry | HIGH — significant new code |
frontend/src/plugin/ |
New directory with API, loader, registry | HIGH |
frontend/index.ts |
Plugin initialization after DOM ready | MEDIUM |
| Security | Plugin code is untrusted — API surface must be carefully scoped | HIGH |
v1.1 Scope Recommendation
For v1.1, implement the foundation:
- Plugin directory scanning + manifest parsing
- JS plugin loading mechanism
- Core API surface (events, player, queue)
- One example plugin demonstrating the pattern
Defer to later: backend Go plugins, WASM plugins, plugin marketplace, permissions system.
Patterns to Follow
Pattern 1: Two-Phase Initialization for New Packages
What: All new packages that need Wails runtime follow New*() + SetContext().
When: Any new struct that emits events or uses Wails dialogs.
Example:
// backend/tageditor/tageditor.go
func NewTagEditor(logger *slog.Logger, db *database.DB) *TagEditor {
return &TagEditor{
logger: logger.WithGroup("tageditor"),
db: db,
}
}
func (te *TagEditor) SetContext(ctx context.Context) {
te.mu.Lock()
defer te.mu.Unlock()
te.ctx = ctx
}
Pattern 2: Event-Driven Frontend Sync
What: Backend emits events; frontend stores subscribe.
When: Any state change that the frontend needs to reflect.
Example:
// Backend emits
runtime.EventsEmit(te.ctx, events.TagsEdited, map[string]any{
"filePaths": affectedPaths,
})
// Frontend subscribes
EventsOn(Events.TagsEdited, (data: {filePaths: string[]}) => {
// Refresh affected track displays
});
Pattern 3: Config Section Pattern
What: New config sections follow the ApplyDefaults() + Validate() pattern.
When: Any new user-configurable setting.
Example:
// backend/shortcuts/config.go
type Config struct {
Bindings []Shortcut `toml:"Bindings"`
}
func (c *Config) ApplyDefaults() { /* ... */ }
func (c *Config) Validate() error { /* ... */ }
Pattern 4: Database Migration for New Tables
What: New tables use CREATE TABLE IF NOT EXISTS in schema files + PRAGMA user_version migration for any ALTER operations.
When: Adding new persistent data.
Example: Smart playlists table in backend/database/sql/schemas/smart_playlists.sql with CREATE TABLE IF NOT EXISTS, plus Migration 6 in database.go for any column additions.
Anti-Patterns to Avoid
Anti-Pattern 1: Frontend-Owned State
What: Storing authoritative state in frontend stores rather than the backend.
Why bad: Violates the single-source-of-truth principle. State gets out of sync on refresh, loses persistence.
Instead: All state changes go through backend. Frontend stores are mirrors.
Anti-Pattern 2: Direct DB Access from New Packages
What: New packages opening their own DB connections or using raw sql.DB directly.
Why bad: Violates single-writer constraint. Bypasses sqlc type safety.
Instead: All DB access goes through the shared *database.DB instance with sqlc-generated queries. Use db.BeginTx() for transactions. Only use raw queries for dynamic SQL (smart playlists), with // SAFETY: comments.
Anti-Pattern 3: Circular Package Dependencies
What: tageditor importing library which imports tageditor.
Why bad: Go doesn't allow circular imports.
Instead: Use interface-based decoupling (like TrackLoader interface) or hook patterns (like RescanHooks). The tageditor can accept a LibraryRefresher interface rather than importing the library package.
Anti-Pattern 4: Blocking the Wails Event Loop
What: Long-running operations in Wails binding methods without goroutines.
Why bad: Freezes the UI.
Instead: Long operations (tag writing, MB API calls, scan) run in goroutines and emit progress events. The binding method returns immediately or returns a "started" acknowledgement.
Anti-Pattern 5: Uncontrolled HTTP Requests
What: MusicBrainz API calls without rate limiting.
Why bad: IP gets blocked. MusicBrainz enforces 1 req/sec strictly.
Instead: Single rate-limited HTTP client with time.Ticker. Cache all responses. Queue requests.
Build Order (Dependency-Aware)
Phase 1: Independent Foundations
These features have no inter-dependencies and can be built in any order:
- Scan Cancellation — Smallest change. Modifies existing code minimally. Tests scan pipeline resilience.
- Customizable Keyboard Shortcuts — Config + new package + frontend keydown listener. No data model changes.
Phase 2: Data Model Extensions
These features add new database tables/queries:
- Tag Editing — New dependency (
bogem/id3v2), new DB queries, new package. Validates that tag write → DB update → event → frontend refresh pipeline works. - Smart Playlists — New table, new package, dynamic SQL. Independent of tag editing but benefits from validated DB migration patterns.
Phase 3: Complex Backend Changes
- Gapless Playback — Core audio pipeline modification. Start with gapless, add crossfade later. Most technically risky feature.
- MusicBrainz Browser — First network feature. HTTP client, caching, rate limiting. Independent of other features.
Phase 4: Extensibility Foundations
These are the "foundation" features — functional but not necessarily feature-complete:
- Layout Customization — Requires all existing components to be working well. Modifies
index.htmlstructure. - Plugin System — Must be last — it depends on having a stable API surface from all other features.
Rationale for This Order
- Scan cancellation first because it's a quick win that validates context cancellation patterns used throughout.
- Shortcuts early because they're simple config + dispatch with no data model changes.
- Tag editing before smart playlists because smart playlists query against track metadata that tag editing modifies — testing both together reveals integration issues.
- Gapless after tag editing because tag editing validates the "modify player behavior → event → frontend update" pipeline at a simpler level.
- MusicBrainz after gapless because it introduces network complexity that's orthogonal to audio — building it later keeps the audio work focused.
- Layout and plugins last because they're meta-features that wrap existing features. Building them last means the thing they're wrapping is stable.
Wails Bridge Implications
New FEBindings
Every new backend service added to FEBindings in app.go generates TypeScript stubs in frontend/wailsjs/go/. After adding new bindings:
make generate # regenerates Wails bindings + sqlc + events codegen
New Events (All Features)
Estimated new events across all features:
// Tag editing
TagsEdited = "TagsEdited"
TagEditFailed = "TagEditFailed"
// Scan cancellation
LibraryScanCancelled = "LibraryScanCancelled"
// Smart playlists
SmartPlaylistCreated = "SmartPlaylistCreated"
SmartPlaylistUpdated = "SmartPlaylistUpdated"
SmartPlaylistDeleted = "SmartPlaylistDeleted"
// Shortcuts
ShortcutConfigChanged = "ShortcutConfigChanged"
// MusicBrainz
MusicBrainzSearchComplete = "MusicBrainzSearchComplete"
// Layout
LayoutConfigChanged = "LayoutConfigChanged"
// Gapless/Crossfade
CrossfadeConfigChanged = "CrossfadeConfigChanged"
All go through the existing AST-based codegen pipeline (go generate + pre-commit hook).
Database Migrations
New migration sequence (current version = 5):
| Migration | Feature | What |
|---|---|---|
| 6 | Smart Playlists | CREATE TABLE smart_playlists |
| 7 | MusicBrainz | CREATE TABLE musicbrainz_cache |
| 8 | Shortcuts | Config-based (no table needed) |
| 9 | Layout | Config-based (no table needed) |
| 10 | Plugins | CREATE TABLE plugin_state (optional, for persistent plugin data) |
Most features use config (TOML) rather than DB for their settings, keeping migrations minimal.
Scalability Considerations
| Concern | Current (~1K tracks) | At 10K tracks | At 100K tracks |
|---|---|---|---|
| Smart playlist eval | <10ms | <100ms | May need indexing |
| Tag edit (single file) | ~50ms | ~50ms | ~50ms (file-level) |
| Tag edit (batch 100) | — | ~5s (serial writes) | Same |
| FTS5 re-index (tag edit) | ~1ms | ~1ms | ~1ms (single row) |
| MB API browse | Network-bound | Same | Same |
| Layout render | ~5ms | Same | Same |
The main scalability concern is smart playlist evaluation at large library sizes. The track_metadata VIEW already has a 5-table JOIN. Adding WHERE clauses for smart playlist rules adds no extra JOINs — the VIEW handles the complexity. SQLite's query planner should handle 100K rows with proper indexes.
Sources
- Codebase analysis: Complete read of all Go packages and TypeScript sources (2026-03-06)
- beep v2.1.1 API:
pkg.go.dev/github.com/gopxl/beep/v2— Mixer, Seq, Buffer, Ctrl types confirmed - MusicBrainz API:
musicbrainz.org/doc/MusicBrainz_API— rate limiting (1 req/sec), JSON format, entity types - dhowden/tag: Read-only library confirmed from source (
tag.ReadFromonly, no write methods) - SQLite WAL mode + single writer: Existing
database.goconfiguration confirmed - Wails v2 binding generation: Existing
app.goFEBindings pattern confirmed
Architecture research: 2026-03-06