Files
yellowjacket/.planning/research/ARCHITECTURE.md
T

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)

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:

  1. Tag Edit Flow: Frontend collects edits → Wails binding → tageditor.SaveTags() → write file tags → update DB records → emit TagsEdited event → frontend refreshes affected views
  2. Scan Cancel Flow: Frontend sends cancel request → Wails binding → library.CancelScan() → cancel context propagation → scan goroutines check ctx.Done() → emit LibraryScanCancelled event
  3. Smart Playlist Flow: User defines rules via frontend → Wails binding → smartplaylist.Create() → persist rules to DB → evaluate rules → emit SmartPlaylistChanged event → frontend refreshes
  4. Gapless Flow: Player pre-decodes next track in background → when current track ends, swap streamer chains without speaker interruption → seamless transition
  5. 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:

  1. Update recordings.name (title)
  2. Upsert artist_credit + artists + link table (if artist changed)
  3. Upsert release_groups (if album changed)
  4. Re-link release_group_recordings
  5. 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:

  1. A dedicated context.CancelFunc stored on the Library struct
  2. All scan phases checking ctx.Done() (most already do via select in 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:

  1. A crossfadeDuration config setting (default 0 = disabled, range 1-12 seconds)
  2. A crossfade mixer that handles the volume ramping
  3. 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 OnPlaybackFinished auto-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 BufferedStreamer read-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, permissions
  • main.js — frontend code (Lit component)
  • backend.go (optional) — Go plugin via plugin package 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:

  1. Plugin directory scanning + manifest parsing
  2. JS plugin loading mechanism
  3. Core API surface (events, player, queue)
  4. 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:

  1. Scan Cancellation — Smallest change. Modifies existing code minimally. Tests scan pipeline resilience.
  2. 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:

  1. Tag Editing — New dependency (bogem/id3v2), new DB queries, new package. Validates that tag write → DB update → event → frontend refresh pipeline works.
  2. Smart Playlists — New table, new package, dynamic SQL. Independent of tag editing but benefits from validated DB migration patterns.

Phase 3: Complex Backend Changes

  1. Gapless Playback — Core audio pipeline modification. Start with gapless, add crossfade later. Most technically risky feature.
  2. 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:

  1. Layout Customization — Requires all existing components to be working well. Modifies index.html structure.
  2. 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.ReadFrom only, no write methods)
  • SQLite WAL mode + single writer: Existing database.go configuration confirmed
  • Wails v2 binding generation: Existing app.go FEBindings pattern confirmed

Architecture research: 2026-03-06