- ```
-
-5. **Add cancel confirmation dialog** — render it conditionally when `showCancelDialog` is true. Place the dialog render at the end of the library section's render method (after the metrics tree, before the closing `` tag):
-
- ```typescript
- ${this.showCancelDialog ? html`
-
-
e.stopPropagation()}>
-
Cancel Scan
-
- ${this.cancelMetrics?.added
- ? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?`
- : 'Cancel the current scan?'}
-
-
-
-
-
-
-
-
- ` : ''}
- ```
-
-6. **Update the status bar** to show paused state:
- In the existing status bar rendering, update to show "Paused" when paused:
- ```typescript
-
-
- ${this.shortcutConflict.newKey} is already bound to
- ${ConfigPage.SHORTCUT_META[this.shortcutConflict.existingAction]?.label ?? this.shortcutConflict.existingAction}.
-
-
-
-
-
-
- ` : ''}
-
- `;
- }
- ```
-
-7. **Call `renderShortcutsSection()`** from the main render method. Insert `${this.renderShortcutsSection()}` in the template — place it between "Track List Columns" and "Library" sections, or after Library. Look at the current render layout to find the best spot.
-
-8. **Add CSS styles** for the shortcuts section:
- ```css
- .shortcut-category {
- margin-bottom: 16px;
- }
- .shortcut-category-header {
- font-size: var(--yj-text-sm, 13px);
- font-weight: 600;
- color: var(--yj-text-secondary, #aaa);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin-bottom: 8px;
- padding-bottom: 4px;
- border-bottom: 1px solid var(--yj-border, #444);
- }
- .shortcut-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 0;
- gap: 16px;
- }
- .shortcut-label {
- font-size: var(--yj-text-sm, 13px);
- color: var(--yj-text-primary, #eee);
- }
- .shortcut-scope {
- font-size: var(--yj-text-xs, 11px);
- color: var(--yj-text-tertiary, #888);
- margin-left: 4px;
- }
- .shortcut-actions {
- margin-top: 16px;
- display: flex;
- justify-content: flex-end;
- }
- .conflict-banner {
- margin-top: 12px;
- padding: 12px;
- background: rgba(255, 165, 0, 0.1);
- border: 1px solid rgba(255, 165, 0, 0.4);
- border-radius: 6px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- }
- .conflict-text {
- font-size: var(--yj-text-sm, 13px);
- }
- .conflict-actions {
- display: flex;
- gap: 8px;
- flex-shrink: 0;
- }
- ```
-
-
- cd frontend && npx tsc --noEmit 2>&1 | head -20
-
- Keyboard Shortcuts section renders in the config page with shortcuts grouped by category (Player, Navigation, App). Each row shows label + shortcut-capture widget. Conflict detection warns before overwriting. "Reset All to Defaults" and per-shortcut reset work. Panel-specific shortcuts show their scope label.
-
-
-
-
-
-```bash
-cd frontend && npx tsc --noEmit
-```
-TypeScript compiles. shortcut-capture component and shortcuts section are properly wired.
-
-
-
-- `shortcut-capture` component exists and handles recording, Escape cancel, blur cancel, reset
-- Config page has a "Keyboard Shortcuts" section with category headers
-- All 16 default shortcuts are listed with their labels
-- Clicking a capture widget enters recording mode, pressing a key updates the binding
-- Conflicts are detected and shown in a warning banner with Overwrite/Cancel options
-- "Reset All to Defaults" button calls store.resetAll()
-- Per-shortcut reset icon appears on hover when binding differs from default
-- Panel-specific shortcuts show their scope (e.g., "track-list") next to the label
-
-
-
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md
deleted file mode 100644
index f2f86e5..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md
+++ /dev/null
@@ -1,121 +0,0 @@
----
-phase: 09-scan-cancellation-keyboard-shortcuts
-plan: 04
-subsystem: ui
-tags: [keyboard-shortcuts, lit, web-components, config-ui]
-
-# Dependency graph
-requires:
- - phase: 09-scan-cancellation-keyboard-shortcuts
- provides: ShortcutsStore, ShortcutsController, buildKeyString utility (from 09-02)
-provides:
- - shortcut-capture record-style key capture web component
- - Keyboard Shortcuts settings section in config page with category grouping
- - Conflict detection and resolution UI for shortcut rebinding
- - Per-shortcut and global reset functionality
-affects: [09-05-shortcuts-integration]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Record-style key capture pattern: click to record, keydown to capture, Escape/blur to cancel"
- - "Conflict detection banner with overwrite/cancel resolution"
- - "Static SHORTCUT_META metadata map for UI labels, categories, scopes, and defaults"
-
-key-files:
- created:
- - frontend/src/components/config-page/shortcut-capture.ts
- modified:
- - frontend/src/components/config-page/config-page.ts
-
-key-decisions:
- - "Place Keyboard Shortcuts as a config-section between Track List Columns and Library sections"
- - "Use static SHORTCUT_META record on ConfigPage class for action metadata rather than importing from backend"
- - "Conflict detection shows banner inline rather than dialog — simpler interaction pattern"
-
-patterns-established:
- - "shortcut-capture component: reusable record-style key binding widget"
-
-requirements-completed: [KEY-02, KEY-03]
-
-# Metrics
-duration: 5min
-completed: 2026-03-07
----
-
-# Phase 9 Plan 4: Keyboard Shortcuts Settings UI Summary
-
-**Record-style shortcut capture component with categorized settings section, inline conflict detection banner, and per-shortcut/global reset controls**
-
-## Performance
-
-- **Duration:** 5 min
-- **Started:** 2026-03-07T02:52:35Z
-- **Completed:** 2026-03-07T02:58:26Z
-- **Tasks:** 2
-- **Files modified:** 2
-
-## Accomplishments
-- shortcut-capture web component with recording mode, Escape cancel, blur cancel, and per-shortcut reset
-- Keyboard Shortcuts section in config page with Player, Navigation, App category grouping
-- All 16 default shortcuts listed with human-readable labels and scope indicators
-- Conflict detection warns before overwriting with Overwrite/Cancel resolution
-- Reset All to Defaults button for global shortcut reset
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Create shortcut-capture web component** - `3914369` (feat — bundled into 09-03 commit by concurrent agent)
-2. **Task 2: Add Keyboard Shortcuts section to config page with conflict detection** - `0451fb3` (feat)
-
-## Files Created/Modified
-- `frontend/src/components/config-page/shortcut-capture.ts` - Record-style key capture widget with buildKeyString integration
-- `frontend/src/components/config-page/config-page.ts` - Added Keyboard Shortcuts section with category grouping, conflict detection, reset controls
-
-## Decisions Made
-- Placed Keyboard Shortcuts section between Track List Columns and Library (natural position before infrastructure settings)
-- Used static `SHORTCUT_META` map on ConfigPage for label/category/scope/default metadata — keeps UI concerns local rather than pulling from backend
-- Conflict detection uses an inline banner below the shortcuts list rather than a modal dialog — simpler and less disruptive
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 3 - Blocking] shortcut-capture.ts already committed by concurrent Plan 03 agent**
-- **Found during:** Task 1 (commit attempt)
-- **Issue:** The shortcut-capture.ts file was already in the working tree when Plan 03's agent ran `git add`, so it was bundled into commit `3914369` (feat(09-03))
-- **Fix:** Verified the file content matches the plan specification exactly — no re-creation needed. Proceeded to Task 2.
-- **Files modified:** None (file already correct)
-- **Verification:** `npx tsc --noEmit` passes, file content verified
-- **Committed in:** 3914369 (09-03 commit)
-
----
-
-**Total deviations:** 1 auto-fixed (1 blocking)
-**Impact on plan:** Task 1's file was pre-committed by a concurrent agent. Content is correct; only the commit attribution differs. No scope creep.
-
-## Issues Encountered
-None
-
-## User Setup Required
-
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Shortcuts settings UI complete — users can view, rebind, and reset all keyboard shortcuts
-- Ready for Plan 05 (shortcuts integration testing) or other remaining plans
-- shortcut-capture component is reusable for any future key-binding UI needs
-
-## Self-Check: PASSED
-
-- [x] shortcut-capture.ts exists
-- [x] config-page.ts exists
-- [x] 09-04-SUMMARY.md exists
-- [x] Commit 3914369 exists (Task 1 — bundled in 09-03)
-- [x] Commit 0451fb3 exists (Task 2)
-
----
-*Phase: 09-scan-cancellation-keyboard-shortcuts*
-*Completed: 2026-03-07*
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md
deleted file mode 100644
index e8238cf..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md
+++ /dev/null
@@ -1,164 +0,0 @@
----
-phase: 09-scan-cancellation-keyboard-shortcuts
-plan: 05
-type: execute
-wave: 3
-depends_on:
- - 09-01
- - 09-02
- - 09-03
- - 09-04
-files_modified: []
-autonomous: false
-requirements:
- - SCAN-01
- - SCAN-02
- - SCAN-03
- - KEY-01
- - KEY-02
- - KEY-03
- - KEY-04
- - KEY-05
-
-must_haves:
- truths:
- - "User can start a scan, pause it, resume it, and cancel it — all via buttons in the settings page"
- - "Cancelled scan does not corrupt the database or delete unvisited files"
- - "Default keyboard shortcuts work immediately — Space, arrows, S, R, Q, M, N, P, /, Ctrl+F"
- - "Shortcuts are suppressed when typing in search box (except Escape)"
- - "User can rebind any shortcut via record-style capture in settings"
- - "Shortcut conflicts are detected and warned about"
- - "Shortcut bindings persist across app restart"
- artifacts: []
- key_links: []
----
-
-
-Verify all Phase 9 features work together end-to-end — scan control and keyboard shortcuts.
-
-Purpose: Catch integration issues before marking the phase complete.
-Output: Verification results and any integration fixes needed.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md
-
-
-
-
-
- Task 1: Build verification and automated checks
-
-
-1. Run the full build to verify everything compiles:
- ```bash
- cd backend && go build ./...
- cd ../frontend && npx tsc --noEmit
- ```
-
-2. Run existing tests to verify no regressions:
- ```bash
- cd backend && go test ./... -count=1 -timeout 120s
- ```
-
-3. Run go vet on all packages:
- ```bash
- cd backend && go vet ./...
- ```
-
-4. Verify event sync is up to date:
- ```bash
- cd backend && go generate ./events/...
- git diff --exit-code frontend/src/events.ts
- ```
-
-5. Verify the new scan control methods are Wails-bindable (exported, on a bound struct):
- ```bash
- grep -n "func (l \*Library) CancelScan\|func (l \*Library) PauseScan\|func (l \*Library) ResumeScan\|func (l \*Library) IsScanActive\|func (l \*Library) IsScanPaused" backend/library/scan_control.go
- ```
-
-6. Verify shortcuts config is accessible:
- ```bash
- grep -n "func (c \*Config) GetShortcuts\|func (c \*Config) SetShortcut" backend/config/config.go
- ```
-
-7. Fix any issues found.
-
-
- cd backend && go build ./... && go vet ./... && go test ./... -count=1 -timeout 120s 2>&1 | tail -20
-
- Full backend + frontend build passes, all existing tests pass, no regressions.
-
-
-
- Task 2: Human verification of all Phase 9 features
- Verify all scan control and keyboard shortcut features work end-to-end.
- Human confirms all 23 verification steps pass.
- All Phase 9 requirements verified: SCAN-01/02/03 and KEY-01/02/03/04/05.
-
-Complete scan cancellation and keyboard shortcuts features:
-1. Backend: CancelScan/PauseScan/ResumeScan methods with per-scan context and channel-based pause
-2. Frontend scan UI: Pause/Resume/Cancel buttons during scan, cancel confirmation dialog
-3. Keyboard shortcuts: 16 default bindings (Space, arrows, S/R/Q/M/N/P, /, Ctrl+F, Ctrl+A, Enter, Delete)
-4. Keyboard shortcut settings: Record-style key capture, conflict detection, grouped by category, reset to defaults
-5. Config persistence: Shortcuts saved to TOML config file
-
-
-**Scan Control (Settings > Library):**
-1. Open Settings, configure a library directory with many audio files
-2. Click "Soft Scan" — verify Pause and Cancel buttons appear, progress shows
-3. Click "Pause" — verify status says "Scan paused.", button changes to "Resume"
-4. Click "Resume" — verify scan continues from where it left off
-5. Start another scan, click "Cancel Scan" — verify confirmation dialog appears showing track count
-6. Click "Keep X tracks" — verify scan stops, tracks remain in library
-7. Start another scan, cancel, click "Discard" — verify scan stops with discard message
-
-**Keyboard Shortcuts:**
-8. Without any text input focused, press Space — verify play/pause toggles
-9. Press Up/Down arrows — verify volume changes
-10. Press Left/Right arrows — verify seeking (if a track is playing)
-11. Press S — verify shuffle toggles
-12. Press R — verify repeat mode cycles
-13. Press Q — verify queue panel toggles
-14. Press / or Ctrl+F — verify search box gets focus
-15. Click inside the search box, type — verify shortcuts do NOT fire while typing
-16. Press Escape while in search box — verify search box blurs and shortcuts resume
-
-**Shortcut Settings (Settings > Keyboard Shortcuts):**
-17. Scroll to Keyboard Shortcuts section — verify shortcuts grouped by Player, Navigation, App
-18. Click on a shortcut's key badge (e.g., Space for Play/Pause) — verify it enters "Press a key combo..." mode
-19. Press a new key — verify the binding updates
-20. Try binding a key that's already used — verify conflict warning appears
-21. Click "Overwrite" — verify old binding is cleared and new one is set
-22. Click "Reset All to Defaults" — verify all shortcuts return to defaults
-23. Restart the app — verify custom bindings persist
-
- Type "approved" or describe any issues found
-
-
-
-
-
-Full build passes. All existing tests pass. Human verification covers all 8 requirement IDs.
-
-
-
-- `go build ./...` and `npx tsc --noEmit` pass
-- `go test ./...` passes with no regressions
-- All 23 manual verification steps confirmed by user
-
-
-
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-SUMMARY.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-SUMMARY.md
deleted file mode 100644
index 855bec0..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-05-SUMMARY.md
+++ /dev/null
@@ -1,110 +0,0 @@
----
-phase: 09-scan-cancellation-keyboard-shortcuts
-plan: 05
-subsystem: integration
-tags: [integration-testing, verification, scan-control, keyboard-shortcuts, volume-fix]
-
-# Dependency graph
-requires:
- - phase: 09-scan-cancellation-keyboard-shortcuts
- provides: All Phase 9 features — scan control backend (09-01), keyboard shortcuts service (09-02), scan control UI (09-03), shortcuts settings UI (09-04)
-provides:
- - End-to-end verified scan cancellation with pause/resume
- - End-to-end verified keyboard shortcuts with rebinding and persistence
- - Volume data flow fix (ChangeVolume/MuteToggle emit events and persist state)
-affects: []
-
-# Tech tracking
-tech-stack:
- added: []
- patterns: []
-
-key-files:
- created: []
- modified:
- - backend/player/player.go
-
-key-decisions:
- - "ChangeVolume and MuteToggle must emit VolumeChanged event and call saveState for UI sync"
-
-patterns-established: []
-
-requirements-completed: [SCAN-01, SCAN-02, SCAN-03, KEY-01, KEY-02, KEY-03, KEY-04, KEY-05]
-
-# Metrics
-duration: 3min
-completed: 2026-03-07
----
-
-# Phase 9 Plan 05: Integration Testing & Verification Summary
-
-**End-to-end verification of scan control and keyboard shortcuts with volume data flow bug fix found and resolved during human testing**
-
-## Performance
-
-- **Duration:** ~3 min (continuation — tasks 1-2 completed across checkpoint)
-- **Started:** 2026-03-07T02:58:00Z
-- **Completed:** 2026-03-07T15:06:00Z
-- **Tasks:** 2
-- **Files modified:** 1 (bug fix during verification)
-
-## Accomplishments
-- Full build verification passed: `go build`, `npx tsc --noEmit`, `go vet`, `go test` all clean
-- Event codegen sync verified (frontend/src/events.ts matches backend)
-- All 5 scan control methods confirmed Wails-bindable (exported on Library struct)
-- All 4 shortcuts config methods confirmed Wails-bindable (exported on Config struct)
-- Human verification of all 23 test scenarios approved
-- Found and fixed volume data flow bug: ChangeVolume/MuteToggle were missing emitVolumeChanged and saveState calls
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Build verification and automated checks** - No commit (verification only, no code changes)
-2. **Task 2: Human verification of all Phase 9 features** - Approved after bug fix
-
-**Bug fix during verification:** `bb3fd20` (fix: emit VolumeChanged event and persist state in ChangeVolume and MuteToggle)
-
-## Files Created/Modified
-- `backend/player/player.go` - Added emitVolumeChanged() and saveState() calls to ChangeVolume() and MuteToggle() methods
-
-## Decisions Made
-- ChangeVolume and MuteToggle must emit VolumeChanged event and call saveState — without this, the frontend volume slider and mute icon don't update when keyboard shortcuts change volume
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 1 - Bug] ChangeVolume and MuteToggle missing event emission and state persistence**
-- **Found during:** Task 2 (human verification — volume shortcuts didn't update UI)
-- **Issue:** `ChangeVolume()` and `MuteToggle()` in `backend/player/player.go` modified volume/mute state but didn't call `emitVolumeChanged()` or `saveState()`, so the frontend volume slider and mute icon never reflected keyboard-shortcut-driven changes
-- **Fix:** Added `p.emitVolumeChanged()` and `p.saveState()` calls to both methods, matching the pattern used by `SetVolume()` and `SetMuted()`
-- **Files modified:** backend/player/player.go
-- **Verification:** Volume up/down shortcuts now update the slider; mute toggle shortcut now updates the mute icon
-- **Committed in:** bb3fd20
-
----
-
-**Total deviations:** 1 auto-fixed (1 bug)
-**Impact on plan:** Essential fix for keyboard shortcut → volume UI feedback loop. Without this, volume shortcuts worked but the UI didn't reflect changes.
-
-## Issues Encountered
-None beyond the volume data flow bug documented above.
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Phase 9 complete — all 8 requirements verified (SCAN-01/02/03, KEY-01/02/03/04/05)
-- Ready for Phase 10 (Tag Editing) or other v1.1 phases
-- Scan control and keyboard shortcuts patterns established for reuse
-
-## Self-Check: PASSED
-
-- [x] backend/player/player.go exists (modified file)
-- [x] Commit bb3fd20 exists (bug fix)
-- [x] All 4 prior plan summaries exist (09-01 through 09-04)
-
----
-*Phase: 09-scan-cancellation-keyboard-shortcuts*
-*Completed: 2026-03-07*
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
deleted file mode 100644
index 91a3d0f..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Phase 9: Scan Cancellation & Keyboard Shortcuts - Context
-
-**Gathered:** 2026-03-06
-**Status:** Ready for planning
-
-
-## Phase Boundary
-
-Users can control library scans (cancel/pause/resume) and operate the entire app via configurable keyboard shortcuts. Scans stop gracefully without database corruption, paused scans resume without re-processing. Keyboard shortcuts work out of the box with sensible defaults, are fully customizable via a settings UI, context-aware across three scopes, and suppressed during text input.
-
-
-
-
-## Implementation Decisions
-
-### Default key bindings
-- Hybrid style: Space/arrows for player controls (no modifier), Ctrl+key for app actions
-- Up/Down arrows adjust volume, Left/Right seek within track
-- Both `/` and `Ctrl+F` focus the search box
-- `Q` toggles the queue panel
-- `S` for shuffle, `R` for repeat (single-key player controls)
-- `Ctrl+A` for select-all in any multi-select context (track lists, etc.)
-- All bindings are configurable — the above are defaults
-- Claude fills in remaining defaults (mute, etc.) using common media player conventions
-
-### Shortcut settings UI
-- Record-style key capture: click a shortcut row, press the new key combo, it captures live
-- Conflicts show a warning with the conflicting action — user chooses to overwrite (old becomes unbound) or cancel
-- Shortcuts grouped by category (Player, Navigation, App) in the settings view
-- "Reset to defaults" button resets all shortcuts; individual per-shortcut reset also available
-- Lives as a "Keyboard Shortcuts" tab within the existing settings dialog
-
-### Context scoping
-- Three scopes: Global (always active), Panel-specific (when a panel has focus), Text Input (shortcuts suppressed)
-- Global scope: player controls (Space, arrows, S, R, Q, etc.) fire regardless of which panel is focused
-- Panel-specific scope: track list gets Enter-to-play and Delete-to-remove when focused
-- Text Input scope: only Escape works (blurs the text input) — all other shortcuts suppressed
-- No visual scope indicator — relies on natural browser focus behavior; users learn through use
-
-### Scan control UX
-- Pause and Cancel buttons placed next to the existing status label, above the existing progress bar in the scanner UI
-- On cancel: prompt the user — "Keep X tracks found so far, or discard?" — gives user control over partial results
-- On resume after pause: skip already-processed files and continue with remaining — no duplicate work
-- Scan control is buttons-only — no keyboard shortcuts for cancel/pause (scans are infrequent)
-
-### Claude's Discretion
-- Remaining default key assignments not explicitly discussed (mute, volume step size, etc.)
-- Scan progress detail level and error handling during scan
-- Loading/disabled states for scan control buttons
-- Visual design of the shortcut settings UI (spacing, grouping headers, etc.)
-- How the cancel confirmation dialog looks and behaves
-
-
-
-
-## Specific Ideas
-
-- Hybrid key style inspired by media players (Foobar2000/Winamp feel for player controls, standard app conventions for Ctrl+key actions)
-- Both `/` and `Ctrl+F` for search — power users get slash, everyone knows Ctrl+F
-- Record-style key capture like VS Code's keybinding editor
-- Cancel prompt on scan gives user control without losing work
-
-
-
-
-## Deferred Ideas
-
-None — discussion stayed within phase scope
-
-
-
----
-
-*Phase: 09-scan-cancellation-keyboard-shortcuts*
-*Context gathered: 2026-03-06*
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
deleted file mode 100644
index bf995ef..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
+++ /dev/null
@@ -1,555 +0,0 @@
-# Phase 9: Scan Cancellation & Keyboard Shortcuts - Research
-
-**Researched:** 2026-03-06
-**Domain:** Go context cancellation, frontend keyboard event management, Lit web component architecture
-**Confidence:** HIGH
-
-## Summary
-
-This phase adds two independent feature sets to YellowJacket: scan control (cancel/pause/resume) on the Go backend with frontend buttons, and a full keyboard shortcut system on the Lit frontend with configurable bindings persisted via the existing TOML config.
-
-**Scan cancellation** requires threading a cancellable `context.Context` through the existing scan pipeline. The current `Scan()` method already checks `l.ctx.Done()` in several `select` blocks within the directory walker and worker pool. The implementation adds a dedicated `scanCancel context.CancelFunc` field on `Library`, Pause/Resume via a sync-based mechanism (channel or mutex), and new Wails-bound methods (`CancelScan`, `PauseScan`, `ResumeScan`). The cancel confirmation dialog ("Keep X tracks found so far, or discard?") is a frontend concern — the backend simply stops and reports partial results vs rolls back.
-
-**Keyboard shortcuts** are a pure frontend feature. No external libraries are needed — the browser's `KeyboardEvent` API is sufficient for a Wails desktop app. A central `KeyboardShortcutService` singleton listens on `document.keydown`, resolves the active scope (Global, Panel-specific, Text Input), looks up the action, and dispatches it. Bindings are stored in the Go config (new `Shortcuts` TOML section) and exposed via Wails bindings. The settings UI adds a "Keyboard Shortcuts" tab to the existing `config-page` component with record-style key capture.
-
-**Primary recommendation:** Implement scan cancellation via `context.WithCancel` + a pause channel on the backend, and keyboard shortcuts as a frontend-only `KeyboardShortcutService` with Go config persistence. Both are zero-dependency — no new libraries needed on either side.
-
-
-## User Constraints (from CONTEXT.md)
-
-### Locked Decisions
-- Hybrid style: Space/arrows for player controls (no modifier), Ctrl+key for app actions
-- Up/Down arrows adjust volume, Left/Right seek within track
-- Both `/` and `Ctrl+F` focus the search box
-- `Q` toggles the queue panel
-- `S` for shuffle, `R` for repeat (single-key player controls)
-- `Ctrl+A` for select-all in any multi-select context (track lists, etc.)
-- All bindings are configurable — the above are defaults
-- Claude fills in remaining defaults (mute, etc.) using common media player conventions
-- Record-style key capture: click a shortcut row, press the new key combo, it captures live
-- Conflicts show a warning with the conflicting action — user chooses to overwrite (old becomes unbound) or cancel
-- Shortcuts grouped by category (Player, Navigation, App) in the settings view
-- "Reset to defaults" button resets all shortcuts; individual per-shortcut reset also available
-- Lives as a "Keyboard Shortcuts" tab within the existing settings dialog
-- Three scopes: Global (always active), Panel-specific (when a panel has focus), Text Input (shortcuts suppressed)
-- Global scope: player controls (Space, arrows, S, R, Q, etc.) fire regardless of which panel is focused
-- Panel-specific scope: track list gets Enter-to-play and Delete-to-remove when focused
-- Text Input scope: only Escape works (blurs the text input) — all other shortcuts suppressed
-- No visual scope indicator — relies on natural browser focus behavior; users learn through use
-- Pause and Cancel buttons placed next to the existing status label, above the existing progress bar in the scanner UI
-- On cancel: prompt the user — "Keep X tracks found so far, or discard?" — gives user control over partial results
-- On resume after pause: skip already-processed files and continue with remaining — no duplicate work
-- Scan control is buttons-only — no keyboard shortcuts for cancel/pause (scans are infrequent)
-
-### Claude's Discretion
-- Remaining default key assignments not explicitly discussed (mute, volume step size, etc.)
-- Scan progress detail level and error handling during scan
-- Loading/disabled states for scan control buttons
-- Visual design of the shortcut settings UI (spacing, grouping headers, etc.)
-- How the cancel confirmation dialog looks and behaves
-
-### Deferred Ideas (OUT OF SCOPE)
-None — discussion stayed within phase scope
-
-
-
-## Phase Requirements
-
-| ID | Description | Research Support |
-|----|-------------|-----------------|
-| SCAN-01 | User can cancel an in-progress library scan via a cancel button | Go context cancellation pattern; new `CancelScan()` Wails binding; frontend cancel button in config-page scan section |
-| SCAN-02 | Cancelled scan stops gracefully without corrupting the database | Batch-transactional writes already atomic; cancel skips orphan cleanup (STATE.md warning); partial results either kept or discarded per user choice |
-| SCAN-03 | User can pause a library scan and resume it without re-scanning processed files | Pause channel blocks worker pool goroutines; resume unblocks; existingPaths sync.Map already tracks processed files |
-| KEY-01 | Default keybindings work out of box | Frontend `KeyboardShortcutService` with hardcoded default map; Go config stores overrides |
-| KEY-02 | User can customize all keyboard shortcuts via a visual settings UI | "Keyboard Shortcuts" tab in config-page; record-style key capture component; Wails config bindings for persistence |
-| KEY-03 | Shortcut conflicts are detected and warned about when rebinding | Frontend conflict detection during key capture — compare against all bindings in same scope |
-| KEY-04 | Shortcuts are scoped — different bindings apply based on focused component | Three-scope system (Global, Panel, TextInput); scope resolved by checking `document.activeElement` shadow DOM chain |
-| KEY-05 | Shortcuts are disabled when text input has focus (except Escape to blur) | TextInput scope check: if active element is ``, `
-
-## Standard Stack
-
-### Core
-| Library | Version | Purpose | Why Standard |
-|---------|---------|---------|--------------|
-| Go `context` | stdlib | Scan cancellation via `context.WithCancel` | Standard Go cancellation pattern; already used in scan pipeline |
-| `sync` | stdlib | Pause/resume via channel or conditional variable | No external dependency needed for goroutine coordination |
-| Browser `KeyboardEvent` API | Web standard | Key capture, modifier detection, key identification | Native API, no library needed for desktop Wails app |
-| Lit 3.x | 3.2.1 (existing) | Shortcut settings UI components | Already the project's component framework |
-| BurntSushi/toml | existing | Config persistence for shortcut bindings | Already the project's config format |
-
-### Supporting
-| Library | Version | Purpose | When to Use |
-|---------|---------|---------|-------------|
-| `golang.org/x/sync/errgroup` | existing | Worker pool with context-aware cancellation | Already used in scan worker pool |
-
-### Alternatives Considered
-| Instead of | Could Use | Tradeoff |
-|------------|-----------|----------|
-| Custom key manager | `hotkeys-js` or `tinykeys` | Unnecessary dependency for a Wails app — no global OS hotkeys needed, browser events suffice |
-| TOML config for shortcuts | JSON file or SQLite | TOML is the existing config format — consistency wins |
-| sync.Cond for pause | Channel-based pause | Channels are simpler and more idiomatic in Go; sync.Cond is error-prone |
-
-## Architecture Patterns
-
-### Recommended Project Structure
-```
-backend/
-├── library/
-│ ├── library.go # Add scanCancel, scanPaused fields; modify Scan()
-│ ├── scan_control.go # New: CancelScan(), PauseScan(), ResumeScan() methods
-│ └── metrics.go # Add Cancelled bool field to ScanMetrics
-├── config/
-│ └── config.go # Add Shortcuts *shortcuts.Config section
-├── shortcuts/ # New package
-│ ├── config.go # ShortcutConfig struct, defaults, validation
-│ └── config_test.go # Unit tests for config validation
-└── events/
- └── events.go # Add ScanCancelled, ScanPaused, ScanResumed events
-
-frontend/src/
-├── services/
-│ └── keyboard-shortcut-service.ts # New: singleton, keydown listener, scope resolution, action dispatch
-├── store/
-│ └── shortcuts-store.ts # New: persisted shortcut bindings from config
-├── components/
-│ └── config-page/
-│ ├── config-page.ts # Add "Keyboard Shortcuts" tab
-│ └── shortcut-capture.ts # New: record-style key capture widget
-```
-
-### Pattern 1: Context Cancellation for Scan
-**What:** Use `context.WithCancel` to create a per-scan context that propagates cancellation to all goroutines.
-**When to use:** Every call to `Scan()` creates a child context from `l.ctx`.
-
-```go
-// In library.go — Scan() method modification
-func (l *Library) Scan() (*ScanMetrics, error) {
- // Create cancellable context for this scan
- scanCtx, cancel := context.WithCancel(l.ctx)
-
- l.mu.Lock()
- l.scanCancel = cancel
- l.scanActive = true
- l.mu.Unlock()
-
- defer func() {
- l.mu.Lock()
- l.scanCancel = nil
- l.scanActive = false
- l.mu.Unlock()
- }()
-
- // Pass scanCtx instead of l.ctx to all operations
- // Workers check scanCtx.Done() for cancellation
- // ...
-}
-```
-
-### Pattern 2: Channel-Based Pause/Resume
-**What:** Use a channel that workers check before processing each file. When paused, the channel blocks; when resumed, it's replaced with a closed channel (always readable).
-**When to use:** Pause/resume scan control.
-
-```go
-type Library struct {
- // ...
- scanPauseCh chan struct{} // nil = not paused, non-nil closed = running, non-nil open = paused
-}
-
-// Workers call this before processing each file:
-func (l *Library) waitIfPaused(ctx context.Context) error {
- l.mu.Lock()
- ch := l.scanPauseCh
- l.mu.Unlock()
-
- if ch == nil {
- return nil
- }
-
- select {
- case <-ch: // channel closed = unpaused, proceed
- return nil
- case <-ctx.Done():
- return ctx.Err()
- }
-}
-```
-
-### Pattern 3: Frontend Keyboard Shortcut Service
-**What:** A singleton service that listens on `document.keydown`, resolves scope, looks up binding, and dispatches action.
-**When to use:** The service is created once at app startup and never destroyed.
-
-```typescript
-// keyboard-shortcut-service.ts
-class KeyboardShortcutService {
- private bindings: Map;
-
- constructor() {
- document.addEventListener('keydown', this.handleKeydown);
- }
-
- private handleKeydown = (e: KeyboardEvent) => {
- // 1. Check if text input focused — suppress all except Escape
- if (this.isTextInputFocused()) {
- if (e.key === 'Escape') {
- (document.activeElement as HTMLElement)?.blur();
- e.preventDefault();
- }
- return;
- }
-
- // 2. Build key string: "Ctrl+Shift+K" format
- const keyStr = this.buildKeyString(e);
-
- // 3. Check panel-specific bindings first, then global
- const scope = this.resolveScope();
- const action = this.findAction(keyStr, scope);
-
- if (action) {
- e.preventDefault();
- this.dispatch(action);
- }
- };
-
- private isTextInputFocused(): boolean {
- const el = this.getDeepActiveElement();
- if (!el) return false;
-
- const tag = el.tagName.toLowerCase();
- if (tag === 'input' || tag === 'textarea') return true;
- if ((el as HTMLElement).isContentEditable) return true;
-
- return false;
- }
-
- // Shadow DOM aware active element resolution
- private getDeepActiveElement(): Element | null {
- let el = document.activeElement;
- while (el?.shadowRoot?.activeElement) {
- el = el.shadowRoot.activeElement;
- }
- return el;
- }
-}
-```
-
-### Pattern 4: Config Extension for Shortcuts
-**What:** Add a `Shortcuts` section to the existing TOML config following the same pattern as Theme, TrackList, Favorites.
-**When to use:** Persisting user-customized keyboard shortcuts.
-
-```go
-// backend/shortcuts/config.go
-type Config struct {
- Bindings map[string]string `toml:"Bindings"` // action -> key combo
-}
-
-func (c *Config) ApplyDefaults() {
- if c.Bindings == nil {
- c.Bindings = DefaultBindings()
- }
-}
-
-// backend/config/config.go — add to Config struct
-type Config struct {
- // ... existing fields
- Shortcuts *shortcuts.Config `toml:"Shortcuts"`
-}
-```
-
-### Anti-Patterns to Avoid
-- **Anti-pattern: Global mutable state for pause:** Don't use a global variable. Keep pause state on the Library struct, protected by the existing mutex.
-- **Anti-pattern: Keyboard listeners on individual components:** Don't add `keydown` handlers to every component. Use a single document-level listener that delegates based on scope.
-- **Anti-pattern: Storing shortcuts in localStorage:** Don't bypass the Go config system. All persistent config flows through the TOML config file via Wails bindings, consistent with existing patterns (theme, tracklist columns, favorites).
-- **Anti-pattern: Using `e.keyCode` or `e.which`:** Use `e.key` and `e.code` — they're the modern standard and handle international keyboards correctly.
-- **Anti-pattern: Cancelling scan inside a transaction:** The batch commit is already atomic. Cancellation should happen between batches, not mid-transaction.
-
-## Don't Hand-Roll
-
-| Problem | Don't Build | Use Instead | Why |
-|---------|-------------|-------------|-----|
-| Key event normalization | Custom key string builder from scratch | `e.key` + modifier booleans (`e.ctrlKey`, `e.shiftKey`, etc.) | The browser API is sufficient; `e.key` returns the logical key value |
-| Context cancellation | Custom goroutine signaling | `context.WithCancel` | Standard Go pattern, already partially in use in the scan pipeline |
-| Goroutine pause | Manual sync.Mutex lock/unlock cycling | Channel-based blocking | Channels compose naturally with `select` and context cancellation |
-
-**Key insight:** Both features (scan control and keyboard shortcuts) are well-served by standard library/platform capabilities. No external dependencies are needed.
-
-## Common Pitfalls
-
-### Pitfall 1: Orphan Cleanup After Cancelled Scan
-**What goes wrong:** The scan's orphan cleanup phase (Phase 5) iterates `existingPaths` and deletes DB entries for files not found on disk. If a scan is cancelled mid-way, `existingPaths` still contains files that weren't visited yet — they'd be incorrectly deleted as "orphans."
-**Why it happens:** The scan loads all existing files into `existingPaths` at the start, then removes entries as they're found during the walk. A cancelled walk leaves legitimate files in the map.
-**How to avoid:** Skip orphan cleanup entirely when the scan is cancelled. This is already called out as a warning in STATE.md: "Scan cancellation: skip orphan cleanup on cancelled scans."
-**Warning signs:** Tracks disappearing from the library after cancelling a scan.
-
-### Pitfall 2: Shadow DOM Active Element Detection
-**What goes wrong:** `document.activeElement` returns the host element of a shadow root, not the actual focused element inside. Shortcut suppression during text input would fail because the check sees `` not ``.
-**Why it happens:** Lit components use Shadow DOM. The focused `` inside `` shadow root isn't directly visible to `document.activeElement`.
-**How to avoid:** Walk the `shadowRoot.activeElement` chain recursively until reaching the leaf focused element (shown in Pattern 3 above).
-**Warning signs:** Keyboard shortcuts firing while typing in the search box.
-
-### Pitfall 3: Race Between Cancel and Batch Commit
-**What goes wrong:** Calling `CancelScan()` while a batch transaction is in progress could leave the database in an inconsistent state if the context is cancelled during `tx.Commit()`.
-**Why it happens:** SQLite `Commit()` with modernc.org/sqlite checks context cancellation.
-**How to avoid:** The scan context should be checked between batches, not during a commit. Use a separate check: after each `flushBatch()` call, check if `scanCtx` is done before processing more results. The batch commit itself should use the parent `l.ctx` (not the scan-specific cancellable context) so in-flight transactions always complete.
-**Warning signs:** "database is locked" errors or partial batch commits.
-
-### Pitfall 4: Key Combo String Normalization
-**What goes wrong:** Different representations of the same key combo: "ctrl+f" vs "Ctrl+F" vs "Control+f" — lookups fail.
-**Why it happens:** No consistent normalization of key strings.
-**How to avoid:** Define a canonical format: modifiers in fixed order (Ctrl+Alt+Shift+Meta) + lowercase key name. Always normalize both when storing and when matching.
-**Warning signs:** Shortcuts not firing after reassignment, or duplicate entries in settings.
-
-### Pitfall 5: Space Key Conflicts with Scrollable Areas
-**What goes wrong:** Space is the default browser scroll-down key. If Space is bound to play/pause globally, scrollable panels may stop scrolling.
-**Why it happens:** `e.preventDefault()` on Space prevents the browser's native scroll behavior.
-**How to avoid:** The scope system handles this — when a scrollable panel has focus and the user intends to scroll, the panel-specific scope should not have Space bound. The Global scope's Space binding calls `preventDefault()` which is acceptable since this is a desktop app (not a web page), and the primary use of Space is play/pause.
-**Warning signs:** Users unable to scroll with keyboard in track lists.
-
-### Pitfall 6: Partial Results Handling on Cancel
-**What goes wrong:** When user cancels and chooses "discard," the backend has already committed batches to the database. Rolling back multiple committed transactions is complex.
-**Why it happens:** Scan writes in batches of 50 that are committed as they go.
-**How to avoid:** "Discard" means "delete the tracks added during this scan." Track which audio file IDs were added during the current scan (via the `added` counter mechanism — extend to track IDs). On discard, delete those specific records. Alternatively, simpler: "discard" triggers a FullRescan minus the cancel-interrupted data. Given complexity, the simpler approach is: "Keep" is the default, "Discard" just clears the entire library (same as FullRescan clear phase) since partial state is unreliable.
-**Warning signs:** Stale or duplicate entries after cancel-and-discard.
-
-## Code Examples
-
-### Scan Control — Backend Methods
-
-```go
-// scan_control.go
-
-// CancelScan cancels an in-progress scan. Returns immediately;
-// the scan goroutines will stop at their next check point.
-func (l *Library) CancelScan() {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if l.scanCancel != nil {
- l.scanCancel()
- }
-}
-
-// PauseScan pauses an in-progress scan. Workers block at their
-// next pause checkpoint until ResumeScan is called.
-func (l *Library) PauseScan() {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if !l.scanActive || l.scanPaused {
- return
- }
-
- l.scanPaused = true
- l.scanPauseCh = make(chan struct{})
-
- runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
-}
-
-// ResumeScan unblocks a paused scan.
-func (l *Library) ResumeScan() {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if !l.scanPaused {
- return
- }
-
- l.scanPaused = false
- close(l.scanPauseCh) // unblocks all waiting workers
-
- runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
-}
-
-// IsScanActive returns the current scan state for the frontend.
-func (l *Library) IsScanActive() bool {
- l.mu.Lock()
- defer l.mu.Unlock()
- return l.scanActive
-}
-
-// IsScanPaused returns whether the scan is currently paused.
-func (l *Library) IsScanPaused() bool {
- l.mu.Lock()
- defer l.mu.Unlock()
- return l.scanPaused
-}
-```
-
-### Key String Builder
-
-```typescript
-// keyboard-shortcut-service.ts
-function buildKeyString(e: KeyboardEvent): string {
- const parts: string[] = [];
-
- if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
- if (e.altKey) parts.push('Alt');
- if (e.shiftKey) parts.push('Shift');
-
- // Normalize key name
- let key = e.key;
-
- // Skip standalone modifier presses
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
- return '';
- }
-
- // Normalize common key names
- if (key === ' ') key = 'Space';
- if (key === 'ArrowUp') key = 'Up';
- if (key === 'ArrowDown') key = 'Down';
- if (key === 'ArrowLeft') key = 'Left';
- if (key === 'ArrowRight') key = 'Right';
-
- // Single character keys: uppercase for display
- if (key.length === 1) key = key.toUpperCase();
-
- parts.push(key);
-
- return parts.join('+');
-}
-```
-
-### Default Bindings Map
-
-```typescript
-// Based on user decisions + common media player conventions
-const DEFAULT_BINDINGS: Record = {
- // Player controls (Global scope, no modifier)
- 'player.playPause': { key: 'Space', scope: 'global', category: 'Player' },
- 'player.volumeUp': { key: 'Up', scope: 'global', category: 'Player' },
- 'player.volumeDown': { key: 'Down', scope: 'global', category: 'Player' },
- 'player.seekForward': { key: 'Right', scope: 'global', category: 'Player' },
- 'player.seekBack': { key: 'Left', scope: 'global', category: 'Player' },
- 'player.shuffle': { key: 'S', scope: 'global', category: 'Player' },
- 'player.repeat': { key: 'R', scope: 'global', category: 'Player' },
- 'player.mute': { key: 'M', scope: 'global', category: 'Player' },
- 'player.next': { key: 'N', scope: 'global', category: 'Player' },
- 'player.previous': { key: 'P', scope: 'global', category: 'Player' },
-
- // Navigation (Global scope)
- 'nav.search': { key: '/', scope: 'global', category: 'Navigation' },
- 'nav.searchAlt': { key: 'Ctrl+F', scope: 'global', category: 'Navigation' },
- 'nav.queue': { key: 'Q', scope: 'global', category: 'Navigation' },
-
- // App actions (Global scope, Ctrl modifier)
- 'app.selectAll': { key: 'Ctrl+A', scope: 'global', category: 'App' },
-
- // Panel-specific (track list focused)
- 'tracklist.play': { key: 'Enter', scope: 'panel:track-list', category: 'Navigation' },
- 'tracklist.delete': { key: 'Delete', scope: 'panel:track-list', category: 'Navigation' },
-};
-```
-
-### Shortcut Settings Tab — Key Capture Widget
-
-```typescript
-// shortcut-capture.ts — Record-style key capture (VS Code inspired)
-@customElement('shortcut-capture')
-class ShortcutCapture extends LitElement {
- @property() action = '';
- @property() currentKey = '';
- @state() private recording = false;
- @state() private pendingKey = '';
-
- private handleClick = () => {
- this.recording = true;
- this.pendingKey = '';
- };
-
- private handleKeydown = (e: KeyboardEvent) => {
- if (!this.recording) return;
-
- e.preventDefault();
- e.stopPropagation();
-
- const keyStr = buildKeyString(e);
- if (!keyStr) return; // bare modifier press
-
- if (keyStr === 'Escape') {
- // Cancel recording
- this.recording = false;
- this.pendingKey = '';
- return;
- }
-
- this.pendingKey = keyStr;
- this.recording = false;
-
- // Dispatch event for parent to handle conflict check + save
- this.dispatchEvent(new CustomEvent('shortcut-change', {
- detail: { action: this.action, key: keyStr },
- bubbles: true, composed: true,
- }));
- };
-
- override render() {
- return html`
-
- `;
- }
-}
-```
-
-## State of the Art
-
-| Old Approach | Current Approach | When Changed | Impact |
-|--------------|------------------|--------------|--------|
-| `KeyboardEvent.keyCode` | `KeyboardEvent.key` / `.code` | Deprecated for years | Use `.key` for logical key, `.code` for physical position |
-| Manual goroutine cancellation with channels | `context.WithCancel` | Standard since Go 1.7 (2016) | Composes with existing context-aware APIs |
-| Global keyboard shortcut libraries (mousetrap, hotkeys.js) | Native KeyboardEvent API | N/A | Desktop Wails app doesn't need library overhead |
-
-**Deprecated/outdated:**
-- `KeyboardEvent.keyCode` / `KeyboardEvent.which`: Deprecated. Use `.key` for the logical key value.
-- `KeyboardEvent.charCode`: Removed. Not relevant for this use case.
-
-## Open Questions
-
-1. **Volume step size for arrow keys**
- - What we know: Up/Down arrows should adjust volume. Player.SetVolume accepts 0-100 integer.
- - What's unclear: Step size per keypress (5? 10?)
- - Recommendation: Default to 5 units per keypress (matches common media player conventions). This is a Claude's Discretion item.
-
-2. **Seek step size for arrow keys**
- - What we know: Left/Right arrows should seek. Player.Seek accepts seconds.
- - What's unclear: How many seconds per keypress.
- - Recommendation: Default to 5 seconds per keypress. This is a Claude's Discretion item.
-
-3. **"Discard" implementation on scan cancel**
- - What we know: User can choose "Keep X tracks" or "Discard." Keeping is straightforward (do nothing).
- - What's unclear: Precise discard mechanism — delete individual added IDs vs clear-and-rescan approach.
- - Recommendation: Track added audio file IDs during the scan. On discard, batch-delete those IDs within a transaction. This avoids the nuclear option of a full library clear while being precise. If this proves too complex, a simpler fallback is to trigger the library clear tables operation (existing `clearLibraryTables()`) and leave the user with an empty library that they can rescan.
-
-4. **N and P for next/previous vs typing**
- - What we know: Single-key shortcuts (S, R, Q) work in global scope. N/P follow the same pattern.
- - What's unclear: Whether N/P could conflict with other planned features (e.g., future search-as-you-type).
- - Recommendation: Include N/P as defaults but since all bindings are configurable, users can remap if conflicts arise. The text input scope suppression ensures they don't fire during typing.
-
-## Sources
-
-### Primary (HIGH confidence)
-- **Codebase analysis** — Direct reading of all scanner, config, events, and frontend component source files
-- **Go `context` package** — Standard library documentation for `WithCancel` pattern
-- **MDN `KeyboardEvent`** — `e.key`, `e.code`, modifier properties (`ctrlKey`, `altKey`, `shiftKey`, `metaKey`)
-
-### Secondary (MEDIUM confidence)
-- **VS Code keybinding UX** — Reference for record-style key capture interaction pattern (widely adopted UX pattern)
-- **Wails v2 event system** — `runtime.EventsEmit` / `EventsOn` patterns verified from existing codebase usage
-
-## Metadata
-
-**Confidence breakdown:**
-- Standard stack: HIGH — no new dependencies, all patterns verified from existing codebase and Go/Web standards
-- Architecture: HIGH — extends existing patterns (config sections, Wails bindings, Lit components, event system)
-- Pitfalls: HIGH — identified from direct codebase analysis (shadow DOM, orphan cleanup, batch commits)
-
-**Research date:** 2026-03-06
-**Valid until:** 2026-04-06 (stable domain — no rapidly changing dependencies)
diff --git a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-VERIFICATION.md b/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-VERIFICATION.md
deleted file mode 100644
index a3cf6af..0000000
--- a/.planning/milestones/v1.1-phases/09-scan-cancellation-keyboard-shortcuts/09-VERIFICATION.md
+++ /dev/null
@@ -1,137 +0,0 @@
----
-phase: 09-scan-cancellation-keyboard-shortcuts
-verified: 2026-03-07T15:30:00Z
-status: passed
-score: 7/7 must-haves verified
-re_verification: false
-human_verification:
- - test: "Start a library scan with a large folder, click Pause, verify progress freezes, click Resume, verify scan continues"
- expected: "Scan pauses immediately at next worker checkpoint, status bar shows 'Scan paused.', Resume continues from where it left off"
- why_human: "Requires running the app with a real audio library directory to observe real-time scan behavior"
- - test: "Start a scan, click Cancel, verify confirmation dialog shows track count and Keep/Discard/Continue options"
- expected: "Dialog shows 'Keep X tracks found so far, or discard?', clicking Keep stops the scan but preserves partial results, clicking Discard cancels and shows informational message"
- why_human: "Dialog rendering, track count accuracy, and database state after cancel require runtime verification"
- - test: "Press Space/N/P/Up/Down/Left/Right/S/R/Q/M keys without any text input focused"
- expected: "Each key triggers its mapped action (play/pause, next, previous, volume up/down, seek fwd/back, shuffle, repeat, queue toggle, mute)"
- why_human: "Keyboard event dispatch to actual player/queue requires live playback context"
- - test: "Click into search box, type text, verify shortcuts don't fire. Press Escape, verify focus returns to body and shortcuts work again"
- expected: "Text appears in search box without triggering player actions. Escape blurs the input."
- why_human: "Shadow DOM focus behavior and text input suppression require browser runtime"
- - test: "Open Settings > Keyboard Shortcuts, click a shortcut badge, press a new key, verify binding updates. Try a conflicting key, verify warning appears"
- expected: "Badge shows 'Press a key combo…', captures new key, saves it. Conflict banner shows with Overwrite/Cancel options."
- why_human: "Visual capture UI behavior and conflict resolution flow require interactive testing"
- - test: "Rebind a shortcut, restart the app, verify the custom binding persists"
- expected: "After restart, the shortcut settings show the custom binding, and pressing the custom key triggers the correct action"
- why_human: "TOML persistence across app restart requires full app lifecycle"
----
-
-# Phase 9: Scan Cancellation & Keyboard Shortcuts Verification Report
-
-**Phase Goal:** Users can control library scans (cancel/pause/resume) and operate the entire app via keyboard
-**Verified:** 2026-03-07T15:30:00Z
-**Status:** passed
-**Re-verification:** No — initial verification
-
-## Goal Achievement
-
-### Observable Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 1 | CancelScan/PauseScan/ResumeScan methods stop/pause/resume scan workers | ✓ VERIFIED | `scan_control.go`: CancelScan calls `cancel()` on scanCtx, PauseScan creates blocking channel, ResumeScan closes it. `library.go:508`: workers call `waitIfPaused(scanCtx)` before processing. Three `scanCtx.Done()` select cases (lines 329, 356, 532). |
-| 2 | Cancelled scans don't corrupt DB — orphan cleanup skipped, batch commits use l.ctx | ✓ VERIFIED | `library.go:587-594`: `cancelled := scanCtx.Err() != nil`, orphan cleanup wrapped in `if !cancelled` block. `library.go:650`: variant generation also skipped on cancel. DB ops use `l.ctx` (app context), not `scanCtx`. |
-| 3 | Default keyboard shortcuts work immediately (Space, arrows, S, R, Q, M, N, P) | ✓ VERIFIED | `keyboard-shortcut-service.ts`: singleton registers `document.keydown` listener. `dispatch()` maps all 16 actions to store/Wails calls. `shortcuts/config.go:13-38`: DefaultBindings returns all 16 bindings. Service imported at `frontend/index.ts:28`. |
-| 4 | Shortcuts suppressed in text inputs (except Escape to blur) | ✓ VERIFIED | `keyboard-shortcut-service.ts:313-321`: `if (scope === 'text-input')` returns early for all keys except Escape which calls `blur()`. `isTextInputFocused` checks INPUT (text types), TEXTAREA, contentEditable. |
-| 5 | User can rebind shortcuts via record-style capture in settings | ✓ VERIFIED | `shortcut-capture.ts`: full record-style component — click enters recording, `handleKeydown` captures via `buildKeyString`, dispatches `shortcut-change` event. `config-page.ts:1717-1810`: `renderShortcutsSection()` renders all 16 shortcuts grouped by category with capture widgets. |
-| 6 | Shortcut conflicts detected and warned about | ✓ VERIFIED | `config-page.ts:1245-1270`: `handleShortcutChange` calls `shortcutsStore.findConflict()`. Conflict shows inline banner with Overwrite/Cancel. `handleConflictOverwrite` unbinds old action then sets new one. |
-| 7 | Shortcut bindings persist to TOML via Wails bindings | ✓ VERIFIED | `config/config.go:600-696`: `GetShortcuts`, `SetShortcut`, `SetShortcuts`, `ResetShortcuts` methods exist with Save() calls and event emission. `shortcuts/config.go` with `Bindings map[string]string \`toml:"Bindings"\``. Config struct has `Shortcuts *shortcuts.Config \`toml:"Shortcuts"\`` at line 34. |
-
-**Score:** 7/7 truths verified
-
-### Required Artifacts
-
-| Artifact | Expected | Status | Details |
-|----------|----------|--------|---------|
-| `backend/library/scan_control.go` | CancelScan, PauseScan, ResumeScan, IsScanActive, IsScanPaused methods | ✓ VERIFIED | 89 lines. All 5 exported methods + unexported `waitIfPaused`. Proper mutex locking, channel coordination. |
-| `backend/events/events.go` | LibraryScanCancelled/Paused/Resumed events | ✓ VERIFIED | Lines 51-56: all 3 new scan control event constants. ShortcutsConfigChanged at line 31. |
-| `frontend/src/events.ts` | Generated TypeScript event constants in sync | ✓ VERIFIED | Lines 38-40: LibraryScanCancelled/Paused/Resumed. Line 22: ShortcutsConfigChanged. |
-| `backend/library/metrics.go` | Cancelled bool field on ScanMetrics | ✓ VERIFIED | Line 54: `Cancelled bool \`json:"cancelled"\`` |
-| `backend/shortcuts/config.go` | Config, ApplyDefaults, Validate, DefaultBindings | ✓ VERIFIED | 65 lines. Config struct, 16 default bindings, ApplyDefaults preserves user customizations, Validate is well-formed. |
-| `backend/config/config.go` | Shortcuts field, GetShortcuts/SetShortcuts/SetShortcut/ResetShortcuts | ✓ VERIFIED | Shortcuts field at line 34. Four Wails-bound methods (lines 601-696). applyDefaults at lines 202-206. Validate at lines 91-95. |
-| `frontend/src/services/keyboard-shortcut-service.ts` | Singleton service with scope resolution | ✓ VERIFIED | 356 lines. buildKeyString, getDeepActiveElement, isTextInputFocused, resolveScope, dispatch (16 actions), KeyboardShortcutService class with document keydown listener. Exported singleton at line 351. |
-| `frontend/src/store/shortcuts-store.ts` | Store with Wails persistence and event sync | ✓ VERIFIED | 190 lines. ShortcutsStore class with getBindings, getKeyForAction, getActionForKey (scope-aware), findConflict, updateBinding, resetAll, setAll. Loads from GetShortcuts, listens to ShortcutsConfigChanged. queueMicrotask coalescing. |
-| `frontend/src/store/controllers/shortcuts-controller.ts` | ReactiveController for Lit components | ✓ VERIFIED | 61 lines. Implements ReactiveController with hostConnected/Disconnected, state getter, bindings getter, updateBinding, resetAll. |
-| `frontend/src/components/config-page/shortcut-capture.ts` | Record-style key capture component | ✓ VERIFIED | 165 lines. LitElement with recording state, click/keydown/blur handlers, buildKeyString integration, Escape cancel, per-shortcut reset button, CSS with pulse animation. |
-| `frontend/src/components/config-page/config-page.ts` | Scan control UI + Shortcuts settings section | ✓ VERIFIED | Scan buttons (Pause/Resume/Cancel) at lines 1905-1941. Cancel dialog at lines 1978+. Shortcuts section via renderShortcutsSection() at line 1717. SHORTCUT_META with all 16 actions at line 221. Conflict detection at line 1245. |
-| `frontend/src/store/index.ts` | Shortcuts store and controller exports | ✓ VERIFIED | Lines 12-14: shortcutsStore, ShortcutsState, ShortcutsController exported. |
-
-### Key Link Verification
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| `scan_control.go` | `library.go` | `l.scanCancel`, `l.scanPauseCh` fields on Library struct | ✓ WIRED | Library struct has scan control fields (lines 88-92). scan_control.go reads/writes them with mutex. Scan() initializes them (lines 185-209). |
-| `library.go` | `events.go` | EventsEmit for scan lifecycle events | ✓ WIRED | `LibraryScanCancelled` emitted at line 684, `LibraryScanPaused/Resumed` emitted in scan_control.go:36,51. |
-| `keyboard-shortcut-service.ts` | `shortcuts-store.ts` | Service reads bindings from store | ✓ WIRED | Line 13: imports shortcutsStore. Line 329: `shortcutsStore.getActionForKey(keyStr, scope)`. |
-| `shortcuts-store.ts` | `config/config.go` | Wails bindings GetShortcuts/SetShortcut/ResetShortcuts | ✓ WIRED | Lines 3-7: imports GetShortcuts, SetShortcut, SetShortcuts, ResetShortcuts. Used in loadFromBackend (line 57), updateBinding (line 152), setAll (line 159), resetAll (line 164). |
-| `keyboard-shortcut-service.ts` | `player-store.ts` / `queue-store.ts` | Action dispatch calls store methods | ✓ WIRED | Lines 14-15: imports playerStore, queueStore. Line 16: imports Player Wails bindings. dispatch() calls togglePlayback, next, previous, ChangeVolume, Seek, toggleShuffle, cycleRepeat, MuteToggle. |
-| `config-page.ts` | `scan_control.go` | Wails bindings CancelScan/PauseScan/ResumeScan | ✓ WIRED | Lines 8-10: imports CancelScan, PauseScan, ResumeScan. Used in handlePauseScan (line 996), handleResumeScan (line 1000), handleCancelKeep (line 1013), handleCancelDiscard (line 1019). |
-| `config-page.ts` | `events.go` | EventsOn for scan lifecycle events | ✓ WIRED | Lines 892-903: EventsOn for LibraryScanPaused/Resumed/Cancelled registered in connectedCallback. |
-| `shortcut-capture.ts` | `keyboard-shortcut-service.ts` | Uses buildKeyString for key normalization | ✓ WIRED | Line 3: `import { buildKeyString } from '../../services/keyboard-shortcut-service'`. Used in handleKeydown (line 85). |
-| `config-page.ts` | `shortcuts-store.ts` | ShortcutsController + store methods | ✓ WIRED | Line 36-37: imports shortcutsStore and ShortcutsController. Line 218: creates controller instance. Lines 1252, 1269, 1277, 1282, 1294: calls findConflict, updateBinding, resetAll. |
-| Service → App startup | `frontend/index.ts` | Import triggers instantiation | ✓ WIRED | `frontend/index.ts:28`: `import './src/services/keyboard-shortcut-service'` — side-effect import initializes singleton. |
-
-### Requirements Coverage
-
-| Requirement | Source Plan | Description | Status | Evidence |
-|-------------|-----------|-------------|--------|----------|
-| SCAN-01 | 09-01, 09-03 | User can cancel an in-progress library scan via a cancel button | ✓ SATISFIED | Backend: CancelScan() cancels scanCtx. Frontend: Cancel Scan button calls CancelScan() Wails binding after confirmation dialog. |
-| SCAN-02 | 09-01, 09-03 | Cancelled scan stops gracefully without corrupting the database | ✓ SATISFIED | Orphan cleanup skipped on cancel (`library.go:591-594`). Variant generation skipped (`library.go:650`). Batch commits use `l.ctx` not `scanCtx` — in-flight transactions complete. `ScanMetrics.Cancelled` set to true. |
-| SCAN-03 | 09-01, 09-03 | User can pause a library scan and resume it without re-scanning processed files | ✓ SATISFIED | PauseScan creates blocking channel, workers block at `waitIfPaused`. ResumeScan closes channel, workers continue. Frontend Pause/Resume buttons toggle correctly. Already-processed files remain processed. |
-| KEY-01 | 09-02 | Default keybindings work out of box | ✓ SATISFIED | 16 default bindings in `shortcuts/config.go`. Service dispatches all actions: Space, N, P, Up, Down, Left, Right, S, R, M, Q, /, Ctrl+F, Ctrl+A, Enter, Delete. Singleton auto-initialized at app startup. |
-| KEY-02 | 09-04 | User can customize all keyboard shortcuts via a visual settings UI | ✓ SATISFIED | Config page has "Keyboard Shortcuts" section with shortcut-capture widgets for all 16 actions. Record-style capture, per-shortcut reset. |
-| KEY-03 | 09-04 | Shortcut conflicts are detected and warned about when rebinding | ✓ SATISFIED | `handleShortcutChange` calls `findConflict`. Conflict banner shows with Overwrite/Cancel. Overwrite unbinds old action. |
-| KEY-04 | 09-02 | Shortcuts are scoped — different bindings apply based on focused component | ✓ SATISFIED | `resolveScope()` returns text-input/panel:X/global. `getActionForKey` checks panel-specific bindings first, then global. `data-shortcut-scope` attribute pattern established. Tracklist actions scoped to `panel:track-list`. |
-| KEY-05 | 09-02 | Shortcuts are disabled when text input has focus (except Escape to blur) | ✓ SATISFIED | `handleKeydown`: if scope is text-input, only Escape passes through (blurs active element). All other keys suppressed. `isTextInputFocused` checks INPUT, TEXTAREA, contentEditable. |
-
-### Anti-Patterns Found
-
-| File | Line | Pattern | Severity | Impact |
-|------|------|---------|----------|--------|
-| — | — | No anti-patterns found | — | — |
-
-No TODOs, FIXMEs, placeholders, stubs, or empty implementations found in any phase 9 files.
-
-### Build Verification
-
-| Check | Status | Details |
-|-------|--------|---------|
-| `go build ./...` | ✓ PASS | Backend compiles with zero errors |
-| `go vet ./...` | ✓ PASS | No vet warnings |
-| `npx tsc --noEmit` | ✓ PASS | Frontend TypeScript compiles with zero errors |
-| Events sync | ✓ PASS | `events.ts` matches `events.go` (generated) |
-
-### Bug Fix Verified
-
-The volume data flow bug found during Plan 05 human verification has been fixed:
-- `backend/player/player.go:680-689`: `ChangeVolume()` calls `emitVolumeChanged()` and `saveState()`
-- `backend/player/player.go:696-705`: `MuteToggle()` calls `emitVolumeChanged()` and `saveState()`
-
-### Human Verification Required
-
-6 items require human testing to fully confirm runtime behavior. All automated/structural checks pass. See frontmatter for detailed test procedures.
-
-1. **Scan pause/resume flow** — Real-time pause behavior with actual audio files
-2. **Cancel confirmation dialog** — Dialog rendering, track count accuracy, database state
-3. **Default keyboard shortcuts** — Key dispatch to actual player/queue in live context
-4. **Text input suppression** — Shadow DOM focus behavior in browser runtime
-5. **Shortcut rebinding UI** — Visual capture and conflict resolution flow
-6. **Shortcut persistence** — TOML persistence across full app restart
-
-### Gaps Summary
-
-No gaps found. All 7 observable truths verified. All 12 artifacts exist, are substantive (not stubs), and are properly wired. All 10 key links verified with grep evidence. All 8 requirements (SCAN-01/02/03, KEY-01/02/03/04/05) satisfied. Backend and frontend build cleanly. No anti-patterns detected.
-
----
-
-_Verified: 2026-03-07T15:30:00Z_
-_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-01-PLAN.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-01-PLAN.md
deleted file mode 100644
index 2c35052..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-01-PLAN.md
+++ /dev/null
@@ -1,592 +0,0 @@
----
-phase: 10-schema-migration
-plan: 01
-type: execute
-wave: 1
-depends_on: []
-files_modified:
- - backend/database/sql/schemas/libraries.sql
- - backend/database/sql/schemas/audio_files.sql
- - backend/database/sql/schemas/playlist_tracks.sql
- - backend/database/sql/schemas/track_metadata_view.sql
- - backend/database/database.go
-autonomous: true
-requirements:
- - DATA-01
- - DATA-04
- - LSCAN-05
-
-must_haves:
- truths:
- - "Fresh database creates libraries table with name, path, created_at columns"
- - "Fresh database creates audio_files with library_id FK column"
- - "Fresh database creates playlist_tracks with nullable audio_file_id and phantom metadata columns"
- - "Fresh database creates track_metadata VIEW including library_id"
- - "Existing v5 database is migrated to v6 atomically — backup created first, all changes in transaction"
- - "Existing audio_files rows get library_id pointing to the auto-created default library"
- - "Migration reads TOML DirectoryPath to create the default library row"
- artifacts:
- - path: "backend/database/sql/schemas/libraries.sql"
- provides: "Libraries table DDL for fresh installs"
- contains: "CREATE TABLE IF NOT EXISTS libraries"
- - path: "backend/database/sql/schemas/audio_files.sql"
- provides: "Updated audio_files DDL with library_id FK"
- contains: "library_id"
- - path: "backend/database/sql/schemas/playlist_tracks.sql"
- provides: "Updated playlist_tracks DDL with nullable audio_file_id and phantom columns"
- contains: "phantom_title"
- - path: "backend/database/sql/schemas/track_metadata_view.sql"
- provides: "Updated VIEW with library_id in SELECT"
- contains: "af.library_id"
- - path: "backend/database/database.go"
- provides: "migration6MultiLibrary function + backup logic"
- contains: "migration6MultiLibrary"
- key_links:
- - from: "backend/database/database.go"
- to: "backend/database/sql/schemas/libraries.sql"
- via: "embedded SQL schema execution in NewDB"
- pattern: "schemas.ReadDir.*sql/schemas"
- - from: "backend/database/database.go migration6"
- to: "TOML config file"
- via: "system.GetUserConfigDirPath + toml decode"
- pattern: "toml\\.Decode"
----
-
-
-Create the database schema definitions and migration 6 for multi-library support.
-
-Purpose: This is the foundational schema change that all subsequent multi-library phases depend on. Fresh installs get the new schema directly; existing databases are migrated atomically with a pre-migration backup.
-
-Output: Updated SQL schema files for fresh databases + migration 6 implementation in database.go
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/10-schema-migration/10-CONTEXT.md
-@.planning/research/ARCHITECTURE.md
-@.planning/research/PITFALLS.md
-
-
-
-
-From backend/database/database.go:
-```go
-// DB wraps the SQLite database connection and queries.
-type DB struct {
- db *sql.DB
- Ctx context.Context
- Queries *sqlcgen.Queries
- logger *slog.Logger
-}
-
-// NewDB opens the database and applies schema migrations.
-func NewDB(logger *slog.Logger) (*DB, error)
-
-// runMigrations applies incremental schema changes using SQLite's
-// PRAGMA user_version as the version tracker.
-func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger) error
-
-// isDuplicateColumnErr returns true when the error is SQLite's
-// "duplicate column name" error.
-func isDuplicateColumnErr(err error) bool
-
-// Current migration count: 5 (user_version = 5)
-// Migration 5 pattern: table rebuild with FK OFF, DROP VIEW, rebuild, recreate VIEW, FK ON
-```
-
-From backend/database/sql/schemas/audio_files.sql (current):
-```sql
-CREATE TABLE IF NOT EXISTS audio_files (
- id integer PRIMARY KEY,
- file_path text NOT NULL UNIQUE,
- length_milliseconds int NOT NULL,
- file_type_id int NOT NULL,
- recording_id int NOT NULL,
- sample_rate int NOT NULL DEFAULT 0,
- bit_depth int NOT NULL DEFAULT 0,
- channels int NOT NULL DEFAULT 0,
- bitrate int NOT NULL DEFAULT 0,
- file_size int NOT NULL DEFAULT 0,
- basename text NOT NULL DEFAULT '',
- FOREIGN KEY(file_type_id) REFERENCES file_types(id),
- FOREIGN KEY(recording_id) REFERENCES recordings(id)
-);
-```
-
-From backend/database/sql/schemas/playlist_tracks.sql (current):
-```sql
-CREATE TABLE IF NOT EXISTS playlist_tracks (
- id INTEGER PRIMARY KEY,
- playlist_id INTEGER NOT NULL,
- audio_file_id INTEGER NOT NULL,
- position INTEGER NOT NULL,
- FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
- FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
-);
-```
-
-From backend/database/sql/schemas/queue_tracks.sql (current — CASCADE stays):
-```sql
-CREATE TABLE IF NOT EXISTS queue_tracks (
- id INTEGER PRIMARY KEY,
- audio_file_id INTEGER NOT NULL,
- position INTEGER NOT NULL,
- FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
-);
-```
-
-From backend/library/config.go:
-```go
-type Config struct {
- DirectoryPath Directory `toml:"DirectoryPath"`
- ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"`
-}
-```
-
-From backend/system/userdata.go:
-```go
-func GetUserDataDirPath() (string, error)
-func GetUserConfigDirPath() (string, error)
-```
-
-
-
-
-
-
- Task 1: Update SQL schema files for fresh installs
-
- backend/database/sql/schemas/libraries.sql
- backend/database/sql/schemas/audio_files.sql
- backend/database/sql/schemas/playlist_tracks.sql
- backend/database/sql/schemas/track_metadata_view.sql
-
-
-Create the schema files that define the target state for fresh database installs. These files are executed via `go:embed` in `NewDB()` — they use `CREATE TABLE IF NOT EXISTS` / `CREATE VIEW IF NOT EXISTS` so they're idempotent.
-
-**1. Create `libraries.sql` (NEW FILE):**
-```sql
-CREATE TABLE IF NOT EXISTS libraries (
- id INTEGER PRIMARY KEY,
- name TEXT NOT NULL,
- path TEXT NOT NULL UNIQUE,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-```
-Per user decision: minimal table — name, path, created_at only. No scan metadata columns (Phase 11 adds those). No scan_concurrency column (global default fallback for now).
-
-**2. Update `audio_files.sql`:**
-Add `library_id` column with FK to libraries table. For fresh databases the column should be `NOT NULL` with no DEFAULT (fresh installs always create a library first). However, since the CREATE TABLE runs before any libraries exist, use `DEFAULT 0` to allow the table creation to succeed — the migration and scan pipeline will always set the correct value.
-
-Add after the `basename` column:
-```sql
- library_id int NOT NULL DEFAULT 0,
-```
-Add FK constraint:
-```sql
- FOREIGN KEY(library_id) REFERENCES libraries(id)
-```
-Add index after the table:
-```sql
-CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
- ON audio_files(library_id);
-```
-
-**3. Update `playlist_tracks.sql`:**
-Change `audio_file_id` from `NOT NULL` to nullable (remove NOT NULL). Change FK from `ON DELETE CASCADE` to `ON DELETE SET NULL`. Add phantom metadata columns with NULL defaults:
-```sql
-CREATE TABLE IF NOT EXISTS playlist_tracks (
- id INTEGER PRIMARY KEY,
- playlist_id INTEGER NOT NULL,
- audio_file_id INTEGER,
- position INTEGER NOT NULL,
- phantom_title TEXT,
- phantom_artist TEXT,
- phantom_album TEXT,
- phantom_duration_ms INTEGER,
- phantom_genre TEXT,
- phantom_cover_art_path TEXT,
- FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
- FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
-);
-```
-Keep the existing indexes on playlist_id and audio_file_id.
-
-**4. Update `track_metadata_view.sql`:**
-Add `af.library_id` to the SELECT list — insert it after `af.file_size` (last column). The JOIN structure stays identical:
-```sql
- af.file_size,
- af.library_id
-FROM audio_files af
-```
-
-**IMPORTANT:** The `libraries.sql` file must sort BEFORE `audio_files.sql` alphabetically so it's executed first (the FK depends on it). Verify: "libraries" < "audio_files" — NO, "a" < "l" so audio_files runs first. This is a problem because audio_files references libraries. Solutions:
-- Rename to `001_libraries.sql` — but this changes naming convention
-- Use the migration to handle existing DBs and rely on SQLite's deferred FK check for fresh DBs — since `PRAGMA foreign_keys = ON` is set AFTER schema files run? No — PRAGMAs run BEFORE schemas in `NewDB()`.
-
-Actually, check the code: `applyPRAGMAs()` runs `PRAGMA foreign_keys = ON` before schema files execute. So `audio_files.sql` will fail FK check if `libraries` table doesn't exist yet. The fix: name the file so it sorts before audio_files. Use `_libraries.sql` (underscore sorts before 'a' in ASCII). Or better: just create the libraries table inside audio_files.sql as a preceding statement? No, that's messy.
-
-Best approach: Name the file so it executes first. Files are read from embedded FS sorted by `ReadDir` (alphabetical). Prefix: `00_libraries.sql` ensures it runs before any other schema. This is clean and explicit.
-
-Actually — re-read the `NewDB()` code. It iterates `dirEntries` from `ReadDir` which returns entries sorted by name. So we need `libraries.sql` to sort before `audio_files.sql`. Since 'l' > 'a', we can't use `libraries.sql` directly. Use `_libraries.sql` (underscore = 0x5F sorts after uppercase letters in ASCII but before lowercase... actually in Go's `ReadDir`, entries are sorted case-sensitively). Since all existing files are lowercase, and '_' (0x5F) < 'a' (0x61), `_libraries.sql` will sort first.
-
-Wait — there are other options. The simplest: just temporarily disable FK checks in `NewDB` during schema file execution, then re-enable. But that changes production behavior.
-
-Simplest correct approach: just make the FK constraint on audio_files use a CREATE INDEX rather than inline FK, and handle the FK check in the migration. But that loses FK enforcement on fresh DBs.
-
-Actually the cleanest fix: since `libraries.sql` needs to exist before `audio_files.sql`, and Go's `ReadDir` sorts alphabetically, and `_` < `a`: name it `_libraries.sql`. This runs before all other schema files.
-
-Alternative: just don't add the FK constraint to the CREATE TABLE DDL and instead add it during migration / via a separate step. But SQLite doesn't support ALTER TABLE ADD CONSTRAINT.
-
-Go with `_libraries.sql`. It's a minor naming convention change but ensures correct execution order.
-
-
- cd backend/database && go build ./...
-
-
- - `_libraries.sql` exists with CREATE TABLE IF NOT EXISTS libraries
- - `audio_files.sql` includes library_id column and FK
- - `playlist_tracks.sql` has nullable audio_file_id, SET NULL FK, and all 6 phantom columns
- - `track_metadata_view.sql` includes af.library_id in SELECT
- - Package compiles successfully
-
-
-
-
- Task 2: Implement migration 6 and pre-migration backup
-
- backend/database/database.go
-
-
-Add migration 6 to the `runMigrations()` function in `database.go`. This is the most complex migration yet — follow the established patterns from migration 5 (table rebuild with FK OFF).
-
-**Step 1: Add backup function.**
-
-Create `backupDatabase()` function that copies the database file before migration 6 runs. Per user decision: timestamp-based naming (e.g., `yj.db.bak.20260309`), no automatic cleanup, logged at INFO level.
-
-```go
-// backupDatabase copies the database file to a timestamped backup
-// before running a destructive migration. Returns the backup path.
-func backupDatabase(
- dbPath string, logger *slog.Logger,
-) (string, error) {
- backupPath := dbPath + ".bak." + time.Now().Format("20060102")
- // Use io.Copy from source to destination
- // Log at INFO: "database backup created", "path", backupPath
- // Return backupPath, nil on success
-}
-```
-
-The `dbPath` must be passed to `runMigrations`. Update the signature:
-```go
-func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error
-```
-Update the call site in `NewDB()` to pass `sqliteDBFilePath`.
-
-**Step 2: Add migration 6 block in runMigrations.**
-
-After the `version < 5` block, add:
-
-```go
-// Migration 6: multi-library support.
-if version < 6 {
- if err := migration6MultiLibrary(
- ctx, db, logger, dbPath,
- ); err != nil {
- return err
- }
-}
-```
-
-**Step 3: Implement `migration6MultiLibrary()` function.**
-
-This is a large function — follow migration 5's pattern. The steps MUST execute in this exact order inside a single transaction (DATA-04: atomic):
-
-```go
-func migration6MultiLibrary(
- ctx context.Context,
- db *sql.DB,
- logger *slog.Logger,
- dbPath string,
-) error {
- logger.Info("applying migration 6: multi-library support")
-
- // 1. Backup database BEFORE any changes.
- backupPath, err := backupDatabase(dbPath, logger)
- // Handle error — if backup fails, abort migration.
- logger.Info("pre-migration backup created", "path", backupPath)
-
- // 2. Read TOML config to get existing library directory.
- // Use system.GetUserConfigDirPath() to find config.toml.
- // Parse ONLY the [Library] section to get DirectoryPath.
- // If no config or no DirectoryPath, existingDir = "" (fresh install).
- configDir, err := system.GetUserConfigDirPath()
- // Read config.toml, decode [Library].DirectoryPath
- // Use a minimal struct: struct{ Library struct{ DirectoryPath string } }
-
- // 3. Disable FK checks for table rebuild.
- _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
-
- // 4. Create libraries table.
- _, err = db.ExecContext(ctx, `
- CREATE TABLE IF NOT EXISTS libraries (
- id INTEGER PRIMARY KEY,
- name TEXT NOT NULL,
- path TEXT NOT NULL UNIQUE,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
- )
- `)
-
- // 5. Insert default library from TOML (if existingDir is not empty).
- var defaultLibID int64
- if existingDir != "" {
- // Derive library name from directory basename.
- // e.g., "/home/user/Music" -> "Music"
- libName := filepath.Base(existingDir)
- result, err := db.ExecContext(ctx,
- "INSERT INTO libraries (name, path) VALUES (?, ?)",
- libName, existingDir,
- )
- defaultLibID, _ = result.LastInsertId()
- logger.Info("migrated existing library",
- "name", libName,
- "path", existingDir,
- "id", defaultLibID,
- )
- }
-
- // 6. Add library_id column to audio_files.
- // Use DEFAULT with the actual library ID so existing rows are backfilled.
- // Per P1: NOT NULL column added via ALTER TABLE requires DEFAULT.
- stmt := fmt.Sprintf(
- "ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT %d",
- defaultLibID,
- )
- if _, err := db.ExecContext(ctx, stmt); err != nil {
- if !isDuplicateColumnErr(err) { return ... }
- }
-
- // 7. Create index on library_id.
- _, err = db.ExecContext(ctx, `
- CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
- ON audio_files(library_id)
- `)
-
- // 8. Drop track_metadata VIEW (references audio_files which we're about to rebuild playlist_tracks against).
- _, err = db.ExecContext(ctx, "DROP VIEW IF EXISTS track_metadata")
-
- // 9. Rebuild playlist_tracks for SET NULL FK + phantom columns.
- // Per P2: audit ALL CASCADE FKs — playlist_tracks changes to SET NULL,
- // queue_tracks keeps CASCADE (ephemeral).
- _, err = db.ExecContext(ctx, `
- CREATE TABLE playlist_tracks_new (
- id INTEGER PRIMARY KEY,
- playlist_id INTEGER NOT NULL,
- audio_file_id INTEGER,
- position INTEGER NOT NULL,
- phantom_title TEXT,
- phantom_artist TEXT,
- phantom_album TEXT,
- phantom_duration_ms INTEGER,
- phantom_genre TEXT,
- phantom_cover_art_path TEXT,
- FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
- FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
- )
- `)
-
- // Copy existing data (phantom columns get NULL).
- _, err = db.ExecContext(ctx, `
- INSERT INTO playlist_tracks_new (id, playlist_id, audio_file_id, position)
- SELECT id, playlist_id, audio_file_id, position FROM playlist_tracks
- `)
-
- // Drop old table.
- _, err = db.ExecContext(ctx, "DROP TABLE playlist_tracks")
-
- // Rename.
- _, err = db.ExecContext(ctx, "ALTER TABLE playlist_tracks_new RENAME TO playlist_tracks")
-
- // Recreate indexes.
- _, err = db.ExecContext(ctx, `
- CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id
- ON playlist_tracks(playlist_id)
- `)
- _, err = db.ExecContext(ctx, `
- CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id
- ON playlist_tracks(audio_file_id)
- `)
-
- // 10. Backfill phantom metadata on existing playlist_tracks from audio_files JOINs.
- // Per user decision: eager population — fill metadata now, not lazily.
- _, err = db.ExecContext(ctx, `
- UPDATE playlist_tracks SET
- phantom_title = sub.title,
- phantom_artist = sub.artist,
- phantom_album = sub.album,
- phantom_duration_ms = sub.duration,
- phantom_genre = sub.genre,
- phantom_cover_art_path = sub.cover_art_path
- FROM (
- SELECT
- pt.id AS pt_id,
- COALESCE(r.name, '') AS title,
- COALESCE(ac.text, '') AS artist,
- COALESCE(rg.name, '') AS album,
- af.length_milliseconds AS duration,
- CAST(COALESCE(
- (SELECT GROUP_CONCAT(g.name, '||')
- FROM recording_genres rg_sub
- JOIN genres g ON rg_sub.genre_id = g.id
- WHERE rg_sub.recording_id = r.id),
- ''
- ) AS TEXT) AS genre,
- COALESCE(ca.file_path, '') AS cover_art_path
- FROM playlist_tracks pt
- JOIN audio_files af ON pt.audio_file_id = af.id
- LEFT JOIN recordings r ON af.recording_id = r.id
- LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
- LEFT JOIN (
- SELECT recording_id, MIN(release_group_id) AS release_group_id
- FROM release_group_recordings
- GROUP BY recording_id
- ) rgr ON r.id = rgr.recording_id
- LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
- LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
- ) sub
- WHERE playlist_tracks.id = sub.pt_id
- `)
-
- // 11. Recreate track_metadata VIEW with library_id.
- _, err = db.ExecContext(ctx, `
- CREATE VIEW IF NOT EXISTS track_metadata AS
- SELECT
- af.id,
- af.file_path,
- af.length_milliseconds,
- COALESCE(r.name, '') AS title,
- COALESCE(ac.text, '') AS artist_name,
- r.track_number,
- r.disc_number,
- COALESCE(rg.name, '') AS album,
- CAST(COALESCE(
- (SELECT GROUP_CONCAT(g.name, '||')
- FROM recording_genres rg_sub
- JOIN genres g ON rg_sub.genre_id = g.id
- WHERE rg_sub.recording_id = r.id),
- ''
- ) AS TEXT) AS genre,
- COALESCE(r.year, 0) AS year,
- COALESCE(r.composer, '') AS composer,
- COALESCE(ft.extension, '') AS file_type,
- af.sample_rate,
- af.bit_depth,
- af.channels,
- af.bitrate,
- af.file_size,
- af.library_id
- FROM audio_files af
- LEFT JOIN recordings r ON af.recording_id = r.id
- LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
- LEFT JOIN (
- SELECT recording_id,
- MIN(release_group_id) AS release_group_id
- FROM release_group_recordings
- GROUP BY recording_id
- ) rgr ON r.id = rgr.recording_id
- LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
- LEFT JOIN file_types ft ON af.file_type_id = ft.id
- `)
-
- // 12. Re-enable FK checks.
- _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
-
- // 13. Remove music_directory from TOML config.
- // Read the full config, nil out the Library.DirectoryPath, write back.
- // Per user decision: old key ignored if still present (no crash).
- // Use BurntSushi/toml for read/write consistency.
- // Only do this if existingDir was non-empty (migration actually ran).
- if existingDir != "" {
- removeLibraryDirFromTOML(configDir, logger)
- }
-
- // 14. Set version.
- _, err = db.ExecContext(ctx, "PRAGMA user_version = 6")
-
- logger.Info("migration 6 complete")
- return nil
-}
-```
-
-**Step 4: Implement `removeLibraryDirFromTOML()` helper.**
-
-Read the TOML file, set DirectoryPath to empty string, write back. Use the same `os.WriteFile` with `0o644` permissions pattern from the config package. If the file doesn't exist or the section is missing, no-op (per user decision: old config key ignored).
-
-**IMPORTANT notes for the executor:**
-- Import `path/filepath` for `filepath.Base()` and `time` for backup timestamp.
-- Import `io` for `io.Copy` in backup function.
-- Import `os` for file operations.
-- Import `github.com/BurntSushi/toml` for TOML read/write in migration.
-- Add `// SAFETY:` comments on all hand-crafted SQL (consistent with Phase 6 convention).
-- The backup runs OUTSIDE the transaction (you can't copy a file inside a SQL transaction). The migration SQL steps should be wrapped in a transaction for atomicity. Use `db.BeginTx()` around steps 3-12.
-- Actually, PRAGMA foreign_keys cannot run inside a transaction. Structure: backup → PRAGMA FK OFF → BEGIN TX → steps 4-11 → COMMIT → PRAGMA FK ON → PRAGMA user_version = 6.
-- Wait — PRAGMA user_version also can't run inside a transaction reliably on all SQLite versions. Follow migration 5's pattern: no explicit transaction, just sequential statements with PRAGMA FK OFF/ON wrapping.
-- For fresh installs with no TOML config: existingDir="" and defaultLibID=0. The ALTER TABLE ADD COLUMN with DEFAULT 0 is fine — there are no audio_files rows on a fresh install anyway. The schema files handle fresh DB creation.
-- The `library_id NOT NULL DEFAULT 0` on audio_files in the schema file means fresh-install audio_files don't require a library to exist yet. The scan pipeline (Phase 11) will set library_id correctly. DEFAULT 0 is a placeholder that won't satisfy the FK constraint, but since `PRAGMA foreign_keys` only checks on INSERT/UPDATE, and the CREATE TABLE runs before any data, this is safe.
-
-Actually, that FK constraint with DEFAULT 0 is problematic. If FK checks are on and someone inserts a row without a library, it'll fail. For fresh installs the scan pipeline (Phase 11) will always set a real library_id. But to be safe, DON'T add a FK constraint in the CREATE TABLE for audio_files — add it only via the migration where we control the value. Wait, no — we want FK enforcement on fresh DBs too.
-
-Better approach: Use `DEFAULT 1` in the schema file — but library ID 1 may not exist on fresh installs. Actually for fresh installs per user decision: "empty libraries table, user adds their first library when they want to scan." So there's no library to FK-reference. The scan pipeline in Phase 11 will create a library first, then scan.
-
-The safest approach: keep the FK constraint and `NOT NULL DEFAULT 0` in the schema file. Since `PRAGMA foreign_keys = ON` is set, any INSERT into audio_files without a valid library_id will fail — which is correct behavior. The DEFAULT 0 only matters for the ALTER TABLE ADD COLUMN during migration where it backfills existing rows. We immediately set all rows to the correct library_id in the same migration.
-
-Wait — for the ALTER TABLE ADD COLUMN in migration 6, the DEFAULT value must match the actual library ID. That's `defaultLibID` (dynamic). So the schema file's DEFAULT 0 is fine for CREATE TABLE (fresh DBs), and the migration uses a dynamic DEFAULT.
-
-One more thing: on fresh DBs, audio_files will have `library_id INTEGER NOT NULL DEFAULT 0` with a FK to libraries. If someone tries to INSERT an audio_file with library_id=0 and no library with id=0 exists, the FK check will fail. This is actually CORRECT — you must create a library first. Good.
-
-Let the executor figure out the exact DEFAULT handling. The key instruction is clear.
-
-
- cd backend/database && go build ./... && go vet ./...
-
-
- - `runMigrations` signature updated to accept dbPath
- - `backupDatabase()` creates timestamped copy of .db file
- - `migration6MultiLibrary()` implements all 14 steps in order
- - TOML DirectoryPath is read and used to create default library
- - Library name derived from directory basename
- - playlist_tracks rebuilt with SET NULL FK and 6 phantom columns
- - Phantom metadata backfilled from audio_files JOINs on existing rows
- - track_metadata VIEW recreated with library_id column
- - TOML config cleaned up (DirectoryPath removed after migration)
- - All hand-crafted SQL has SAFETY comments
- - Package compiles and passes vet
-
-
-
-
-
-
-- `go build ./...` passes from project root
-- `go vet ./...` passes from backend/database
-- No linting errors on new code: `golangci-lint run ./backend/database/...`
-
-
-
-- Fresh database creates all tables including libraries and updated audio_files/playlist_tracks
-- Migration 6 function exists with complete implementation
-- Backup function creates timestamped database copy
-- All schema changes follow established migration patterns
-- TOML config reading works for default library creation
-
-
-
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-01-SUMMARY.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-01-SUMMARY.md
deleted file mode 100644
index c9343a6..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-01-SUMMARY.md
+++ /dev/null
@@ -1,151 +0,0 @@
----
-phase: 10-schema-migration
-plan: 01
-subsystem: database
-tags: [sqlite, migration, multi-library, phantom-tracks, schema]
-
-# Dependency graph
-requires: []
-provides:
- - libraries table (name, path, created_at)
- - audio_files.library_id FK column with index
- - playlist_tracks phantom metadata columns (6 fields)
- - playlist_tracks SET NULL FK (was CASCADE)
- - track_metadata VIEW with library_id
- - migration 6 function (multi-library upgrade)
- - pre-migration backup function
- - TOML config cleanup (DirectoryPath removal)
-affects: [11-per-library-scan, 12-library-crud, 13-library-views]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Underscore prefix for schema file ordering (_libraries.sql sorts before audio_files.sql)"
- - "Sentinel library row (id=0) in test DB for FK satisfaction"
- - "Dynamic DEFAULT in ALTER TABLE ADD COLUMN for backfill"
- - "TOML read/write with generic map[string]any to preserve unknown sections"
-
-key-files:
- created:
- - backend/database/sql/schemas/_libraries.sql
- modified:
- - backend/database/database.go
- - backend/database/sql/schemas/audio_files.sql
- - backend/database/sql/schemas/playlist_tracks.sql
- - backend/database/sql/schemas/track_metadata_view.sql
- - backend/database/sql/sqlcgen/audio_files.sql.go
- - backend/database/sql/sqlcgen/models.go
- - backend/database/sql/sqlcgen/playlists.sql.go
- - backend/database/testhelper.go
- - backend/playlist/playlist.go
-
-key-decisions:
- - "Underscore prefix _libraries.sql for embedded FS sort order (libraries table must exist before audio_files FK)"
- - "Sentinel library id=0 in NewTestDB so existing tests using DEFAULT library_id=0 continue working"
- - "TOML cleanup uses generic map[string]any to preserve all config sections, only deletes DirectoryPath"
- - "Backup skipped for in-memory databases (test environments)"
-
-patterns-established:
- - "_libraries.sql naming convention for schema ordering"
- - "sql.NullInt64 for nullable FK columns in playlist_tracks"
-
-requirements-completed: [DATA-01, DATA-04, LSCAN-05]
-
-# Metrics
-duration: 11min
-completed: 2026-03-09
----
-
-# Phase 10 Plan 1: Schema & Migration Summary
-
-**Libraries table, audio_files.library_id FK, playlist_tracks phantom columns with SET NULL FK, migration 6 with pre-backup and TOML config cleanup**
-
-## Performance
-
-- **Duration:** 11 min
-- **Started:** 2026-03-09T13:29:50Z
-- **Completed:** 2026-03-09T13:41:26Z
-- **Tasks:** 2
-- **Files modified:** 10
-
-## Accomplishments
-- Created libraries table schema with name, path, created_at columns
-- Added library_id FK to audio_files with index for filter performance
-- Rebuilt playlist_tracks with nullable audio_file_id (SET NULL FK) and 6 phantom metadata columns
-- Implemented migration 6 with 14-step process: backup, TOML read, FK OFF, create table, insert default library, add column, rebuild playlist_tracks, backfill phantom metadata, recreate VIEW, FK ON, TOML cleanup, version bump
-- Updated track_metadata VIEW to include library_id
-- Regenerated sqlc code and fixed all callers for nullable AudioFileID
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Update SQL schema files for fresh installs** - `535855b` (feat)
-2. **Task 2: Implement migration 6 and pre-migration backup** - `1179f56` (feat)
-
-## Files Created/Modified
-- `backend/database/sql/schemas/_libraries.sql` - New libraries table DDL
-- `backend/database/sql/schemas/audio_files.sql` - Added library_id column and FK
-- `backend/database/sql/schemas/playlist_tracks.sql` - Nullable audio_file_id, SET NULL FK, 6 phantom columns
-- `backend/database/sql/schemas/track_metadata_view.sql` - Added af.library_id to SELECT
-- `backend/database/database.go` - migration6MultiLibrary(), backupDatabase(), TOML helpers
-- `backend/database/sql/sqlcgen/models.go` - Library struct, updated AudioFile and PlaylistTrack
-- `backend/database/sql/sqlcgen/audio_files.sql.go` - Updated queries for library_id column
-- `backend/database/sql/sqlcgen/playlists.sql.go` - sql.NullInt64 for AudioFileID, phantom fields
-- `backend/database/testhelper.go` - Sentinel library row, updated runMigrations call
-- `backend/playlist/playlist.go` - sql.NullInt64 wrapping for AddPlaylistTrack calls
-
-## Decisions Made
-- Used underscore prefix `_libraries.sql` to ensure correct embedded FS sort order (libraries must exist before audio_files FK reference)
-- Sentinel library row at id=0 in NewTestDB for backward compatibility with existing test data using DEFAULT library_id=0
-- TOML config cleanup uses generic `map[string]any` decode to preserve all config sections when removing only DirectoryPath
-- Backup function skips for in-memory databases (`:memory:` path check) to support test environments
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 3 - Blocking] Regenerated sqlc code and fixed compilation errors**
-- **Found during:** Task 1 (SQL schema updates)
-- **Issue:** Pre-commit hook auto-ran `sqlc generate` which updated generated code — AudioFileID changed from `int64` to `sql.NullInt64`, breaking 4 call sites in playlist.go
-- **Fix:** Added `database/sql` import to playlist.go and wrapped all AudioFileID assignments with `sql.NullInt64{Int64: id, Valid: true}`
-- **Files modified:** backend/database/sql/sqlcgen/{models,audio_files.sql,playlists.sql}.go, backend/playlist/playlist.go
-- **Verification:** `go build ./...` passes
-- **Committed in:** 535855b (Task 1 commit)
-
-**2. [Rule 3 - Blocking] Fixed test FK constraint failures**
-- **Found during:** Task 2 (migration implementation)
-- **Issue:** Existing tests insert audio_files with DEFAULT library_id=0 but no library with id=0 exists after schema changes — FK constraint violated
-- **Fix:** Added sentinel library row (id=0, name='Test', path='/test') in NewTestDB() so all tests have a valid FK target
-- **Files modified:** backend/database/testhelper.go
-- **Verification:** `go test ./backend/database/... -count=1` passes (all 10+ test functions)
-- **Committed in:** 1179f56 (Task 2 commit)
-
-**3. [Rule 1 - Bug] Fixed unchecked error returns on file Close()**
-- **Found during:** Task 2 (linter pre-commit check)
-- **Issue:** `src.Close()` and `dst.Close()` in backupDatabase() had unchecked error returns, caught by errcheck linter
-- **Fix:** Changed to `defer func() { _ = src.Close() }()` pattern (explicit discard)
-- **Files modified:** backend/database/database.go
-- **Verification:** `golangci-lint` passes with 0 issues
-- **Committed in:** 1179f56 (Task 2 commit)
-
----
-
-**Total deviations:** 3 auto-fixed (2 blocking, 1 bug)
-**Impact on plan:** All fixes necessary for correctness and build health. No scope creep — sqlc regeneration and test fixes are direct consequences of the schema changes.
-
-## Issues Encountered
-None — migration 6 follows established patterns from migration 5.
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Schema foundation complete for multi-library support
-- Ready for Plan 02 (sqlc query updates, if applicable) or Phase 11 (per-library scan pipeline)
-- All existing tests pass with new schema
-
----
-*Phase: 10-schema-migration*
-*Completed: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-02-PLAN.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-02-PLAN.md
deleted file mode 100644
index ba4f2ec..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-02-PLAN.md
+++ /dev/null
@@ -1,592 +0,0 @@
----
-phase: 10-schema-migration
-plan: 02
-type: execute
-wave: 2
-depends_on:
- - 10-01
-files_modified:
- - backend/database/sql/queries/libraries.sql
- - backend/database/sql/queries/audio_files.sql
- - backend/database/sql/queries/playlists.sql
- - backend/database/sql/sqlcgen/db.go
- - backend/database/sql/sqlcgen/models.go
- - backend/database/sql/sqlcgen/querier.go
- - backend/database/sql/sqlcgen/libraries.sql.go
- - backend/database/sql/sqlcgen/audio_files.sql.go
- - backend/database/sql/sqlcgen/playlists.sql.go
- - backend/database/testhelper.go
- - backend/database/database_test.go
-autonomous: true
-requirements:
- - LIB-04
- - LIB-05
-
-must_haves:
- truths:
- - "sqlc-generated queries exist for library CRUD (create, get, list, delete)"
- - "Playlist track queries handle nullable audio_file_id and phantom columns"
- - "Audio file queries accept library_id parameter"
- - "Migration tests verify upgrade path from v5 to v6"
- - "Migration tests verify fresh database creates correct schema"
- - "Migration tests verify TOML config is read and default library created"
- - "Test helper NewTestDB creates v6 schema including libraries table"
- artifacts:
- - path: "backend/database/sql/queries/libraries.sql"
- provides: "sqlc query definitions for libraries CRUD"
- contains: "CreateLibrary"
- - path: "backend/database/sql/queries/playlists.sql"
- provides: "Updated playlist queries with phantom column support"
- contains: "phantom_title"
- - path: "backend/database/sql/sqlcgen/libraries.sql.go"
- provides: "Generated Go code for library queries"
- contains: "func.*CreateLibrary"
- - path: "backend/database/database_test.go"
- provides: "Migration 6 integration tests"
- contains: "TestMigration6"
- key_links:
- - from: "backend/database/sql/queries/libraries.sql"
- to: "backend/database/sql/schemas/_libraries.sql"
- via: "sqlc schema awareness"
- pattern: "libraries"
- - from: "backend/database/database_test.go"
- to: "backend/database/database.go migration6"
- via: "NewTestDB runs all migrations"
- pattern: "runMigrations"
----
-
-
-Add sqlc query definitions for the new schema, regenerate Go code, and write migration integration tests.
-
-Purpose: Plan 01 created the schema and migration. This plan makes the new tables usable via type-safe sqlc queries, updates existing playlist queries for phantom support, and verifies the migration works correctly on both fresh and existing databases.
-
-Output: sqlc queries + generated code for libraries and updated playlists + comprehensive migration tests
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/10-schema-migration/10-CONTEXT.md
-@.planning/phases/10-schema-migration/10-01-SUMMARY.md
-
-
-
-
-From backend/database/sql/schemas/_libraries.sql (created by Plan 01):
-```sql
-CREATE TABLE IF NOT EXISTS libraries (
- id INTEGER PRIMARY KEY,
- name TEXT NOT NULL,
- path TEXT NOT NULL UNIQUE,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-```
-
-From backend/database/sql/schemas/playlist_tracks.sql (updated by Plan 01):
-```sql
-CREATE TABLE IF NOT EXISTS playlist_tracks (
- id INTEGER PRIMARY KEY,
- playlist_id INTEGER NOT NULL,
- audio_file_id INTEGER, -- nullable for phantom tracks
- position INTEGER NOT NULL,
- phantom_title TEXT,
- phantom_artist TEXT,
- phantom_album TEXT,
- phantom_duration_ms INTEGER,
- phantom_genre TEXT,
- phantom_cover_art_path TEXT,
- FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
- FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
-);
-```
-
-From backend/database/sql/schemas/audio_files.sql (updated by Plan 01):
-```sql
--- Now includes: library_id int NOT NULL DEFAULT 0
--- FK: FOREIGN KEY(library_id) REFERENCES libraries(id)
--- Index: idx_audio_files_library_id
-```
-
-From backend/database/database.go (updated by Plan 01):
-```go
-func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error
-func backupDatabase(dbPath string, logger *slog.Logger) (string, error)
-func migration6MultiLibrary(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error
-```
-
-From backend/database/sqlc.yaml:
-```yaml
-version: "2"
-sql:
- - name: "yellowjacket"
- engine: "sqlite"
- queries: "./sql/queries"
- schema: "./sql/schemas"
- gen:
- go:
- package: "sqlcgen"
- out: "./sql/sqlcgen"
-```
-
-Existing sqlc query patterns from playlists.sql:
-```sql
--- name: AddPlaylistTrack :one
-INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?)
-RETURNING *;
-
--- name: GetPlaylistTracksWithMetadata :many
-SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position,
- af.file_path, af.length_milliseconds, ...
-FROM playlist_tracks pt
-JOIN audio_files af ON pt.audio_file_id = af.id
-...
-```
-
-Existing test patterns from testhelper.go:
-```go
-func NewTestDB(t *testing.T) *DB // runs all schemas + migrations
-```
-
-Existing test patterns from search_test.go:
-```go
-func seedSearchData(t *testing.T, db *DB) // creates full entity graph
-```
-
-
-
-
-
-
- Task 1: Add sqlc queries for libraries and update playlist queries for phantom support
-
- backend/database/sql/queries/libraries.sql
- backend/database/sql/queries/audio_files.sql
- backend/database/sql/queries/playlists.sql
- backend/database/sql/sqlcgen/db.go
- backend/database/sql/sqlcgen/models.go
- backend/database/sql/sqlcgen/querier.go
- backend/database/sql/sqlcgen/libraries.sql.go
- backend/database/sql/sqlcgen/audio_files.sql.go
- backend/database/sql/sqlcgen/playlists.sql.go
-
-
-**1. Create `backend/database/sql/queries/libraries.sql` (NEW FILE):**
-
-Define the core CRUD queries for the libraries table. These will be consumed by Phase 12 (Library CRUD API) but the type-safe generated code is needed now for migration tests and any early usage.
-
-```sql
--- name: CreateLibrary :one
-INSERT INTO libraries (name, path) VALUES (?, ?)
-RETURNING *;
-
--- name: GetLibrary :one
-SELECT * FROM libraries WHERE id = ? LIMIT 1;
-
--- name: GetLibraryByPath :one
-SELECT * FROM libraries WHERE path = ? LIMIT 1;
-
--- name: GetAllLibraries :many
-SELECT * FROM libraries ORDER BY name;
-
--- name: UpdateLibraryName :exec
-UPDATE libraries SET name = ? WHERE id = ?;
-
--- name: DeleteLibrary :exec
-DELETE FROM libraries WHERE id = ?;
-
--- name: CountLibraries :one
-SELECT COUNT(*) AS count FROM libraries;
-```
-
-**2. Update `backend/database/sql/queries/playlists.sql`:**
-
-The existing queries need updates for the new playlist_tracks schema:
-
-a) **`AddPlaylistTrack`** — Add phantom metadata columns to the INSERT. The caller populates phantom data eagerly on every insert (per user decision):
-```sql
--- name: AddPlaylistTrack :one
-INSERT INTO playlist_tracks (
- playlist_id, audio_file_id, position,
- phantom_title, phantom_artist, phantom_album,
- phantom_duration_ms, phantom_genre, phantom_cover_art_path
-) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
-RETURNING *;
-```
-
-b) **`GetPlaylistTracks`** — Change JOIN to LEFT JOIN on audio_files (audio_file_id is now nullable). Include phantom columns in output so callers can display either live or phantom data:
-```sql
--- name: GetPlaylistTracks :many
-SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position,
- COALESCE(af.file_path, '') AS file_path,
- pt.phantom_title, pt.phantom_artist, pt.phantom_album,
- pt.phantom_duration_ms, pt.phantom_genre, pt.phantom_cover_art_path
-FROM playlist_tracks pt
-LEFT JOIN audio_files af ON pt.audio_file_id = af.id
-WHERE pt.playlist_id = ?
-ORDER BY pt.position;
-```
-
-c) **`GetPlaylistTracksWithMetadata`** — Same LEFT JOIN change, and include phantom fallback columns. When audio_file_id is NULL (phantom), the live metadata JOINs return NULL and callers use phantom_* columns instead:
-```sql
--- name: GetPlaylistTracksWithMetadata :many
-SELECT
- pt.id,
- pt.playlist_id,
- pt.audio_file_id,
- pt.position,
- COALESCE(af.file_path, '') AS file_path,
- COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
- COALESCE(r.name, pt.phantom_title, '') AS title,
- COALESCE(ac.text, pt.phantom_artist, '') AS artist,
- COALESCE(rg.name, pt.phantom_album, '') AS album,
- COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
- CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom
-FROM playlist_tracks pt
-LEFT JOIN audio_files af ON pt.audio_file_id = af.id
-LEFT JOIN recordings r ON af.recording_id = r.id
-LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
-LEFT JOIN (
- SELECT recording_id, MIN(release_group_id) AS release_group_id
- FROM release_group_recordings
- GROUP BY recording_id
-) rgr ON r.id = rgr.recording_id
-LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
-LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
-WHERE pt.playlist_id = ?
-ORDER BY pt.position;
-```
-
-d) **`GetAllPlaylistTracksWithMetadata`** — Same LEFT JOIN and phantom fallback pattern, without WHERE clause.
-
-e) **`IsTrackInPlaylist`** — Change JOIN to LEFT JOIN (audio_file_id may be NULL for phantom tracks).
-
-f) **`RemovePlaylistTrackByPath`** — Change subquery JOIN to handle nullable audio_file_id.
-
-g) **`GetPlaylistTrackFilePaths`** — Change to LEFT JOIN, filter out NULLs:
-```sql
--- name: GetPlaylistTrackFilePaths :many
-SELECT COALESCE(af.file_path, '') AS file_path
-FROM playlist_tracks pt
-LEFT JOIN audio_files af ON pt.audio_file_id = af.id
-WHERE pt.playlist_id = ? AND pt.audio_file_id IS NOT NULL
-ORDER BY pt.position;
-```
-
-**3. Update `backend/database/sql/queries/audio_files.sql`:**
-
-Add a query to get audio files filtered by library:
-```sql
--- name: GetAudioFilesByLibrary :many
-SELECT * FROM audio_files WHERE library_id = ?;
-
--- name: CountAudioFilesByLibrary :one
-SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
-```
-
-**4. Regenerate sqlc code:**
-
-Run from `backend/database/`:
-```bash
-go generate ./...
-```
-
-This regenerates all files in `sql/sqlcgen/` from the updated schemas and queries.
-
-**5. Fix any compilation errors** in the generated code or in callers of the changed query signatures (particularly `AddPlaylistTrack` which now has 9 parameters instead of 3). Check all callers:
-- `backend/playlist/playlist.go` — calls `AddPlaylistTrack`. Update to pass phantom metadata.
-- Any other callers of changed queries.
-
-For `AddPlaylistTrack` callers: pass the phantom metadata alongside the audio_file_id. The caller should resolve the metadata at insert time (eager population per user decision). Look at how `playlist.go` currently calls it and add the phantom fields. For now, populate phantom data from the track metadata that the caller already has available.
-
-**IMPORTANT:** The playlist package's `AddTrack`/`AddTracks` methods need to resolve phantom metadata before inserting. Look at how `GetPlaylistTracksWithMetadata` resolves metadata — the same JOIN pattern should be used to fetch phantom data before insert. Or simpler: the caller already has the file path → look up metadata from DB → pass as phantom columns.
-
-Create a helper query to resolve phantom metadata for a given audio_file_id:
-```sql
--- name: GetTrackPhantomMetadata :one
-SELECT
- COALESCE(r.name, '') AS title,
- COALESCE(ac.text, '') AS artist,
- COALESCE(rg.name, '') AS album,
- af.length_milliseconds AS duration_ms,
- CAST(COALESCE(
- (SELECT GROUP_CONCAT(g.name, '||')
- FROM recording_genres rg_sub
- JOIN genres g ON rg_sub.genre_id = g.id
- WHERE rg_sub.recording_id = r.id),
- ''
- ) AS TEXT) AS genre,
- COALESCE(ca.file_path, '') AS cover_art_path
-FROM audio_files af
-LEFT JOIN recordings r ON af.recording_id = r.id
-LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
-LEFT JOIN (
- SELECT recording_id, MIN(release_group_id) AS release_group_id
- FROM release_group_recordings
- GROUP BY recording_id
-) rgr ON r.id = rgr.recording_id
-LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
-LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
-WHERE af.id = ?;
-```
-
-Add this to `playlists.sql`.
-
-After regenerating, verify compilation:
-```bash
-cd backend && go build ./...
-```
-
-Fix any broken callers of `AddPlaylistTrack` — the signature change from 3 args to 9 args will cause compile errors in the playlist package. Update each caller to:
-1. Look up phantom metadata via `GetTrackPhantomMetadata` query
-2. Pass all 9 params to `AddPlaylistTrack`
-
-
- cd backend/database && go generate ./... && cd ../.. && go build ./... && go vet ./...
-
-
- - `libraries.sql` query file exists with 7 CRUD queries
- - `playlists.sql` updated with phantom column support in all track queries
- - `audio_files.sql` has library-filtered query
- - sqlc regenerated successfully (all files in sql/sqlcgen/ updated)
- - `AddPlaylistTrack` callers updated for new 9-param signature
- - `GetTrackPhantomMetadata` helper query exists for eager phantom population
- - `go build ./...` passes from project root
-
-
-
-
- Task 2: Migration integration tests and NewTestDB update
-
- backend/database/testhelper.go
- backend/database/database_test.go
-
-
-Write integration tests that verify migration 6 works correctly on both fresh and existing databases. Also update `NewTestDB` for the new schema.
-
-**1. Update `testhelper.go`:**
-
-The `NewTestDB` helper runs all schemas + migrations. Since migration 6 reads a TOML config file, and the test helper uses `:memory:` database with no file path, the migration will skip the TOML reading (existingDir = ""). The test helper needs to handle the updated `runMigrations` signature that now takes `dbPath`:
-
-```go
-// Pass empty string for dbPath — in-memory DBs don't need backup.
-if err := runMigrations(ctx, db, slog.Default(), ""); err != nil {
- t.Fatalf("could not run migrations: %v", err)
-}
-```
-
-The backup function should no-op when dbPath is empty. Verify this is handled in the migration 6 code (Plan 01 should have handled it — if not, add a guard).
-
-Also add a `NewTestDBWithLibrary` helper that creates a test DB with a pre-populated library, useful for tests in other packages:
-
-```go
-// NewTestDBWithLibrary returns a test DB with a library row pre-inserted.
-// Returns the DB and the library ID.
-func NewTestDBWithLibrary(t *testing.T, name, path string) (*DB, int64) {
- t.Helper()
- db := NewTestDB(t)
- lib, err := db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{
- Name: name,
- Path: path,
- })
- if err != nil {
- t.Fatalf("could not create test library: %v", err)
- }
- return db, lib.ID
-}
-```
-
-**2. Create/update `database_test.go`:**
-
-Write these test cases:
-
-a) **TestMigration6FreshDB** — Verify that a fresh database (no prior data) creates all expected tables including libraries, and that the schema matches expectations:
-```go
-func TestMigration6FreshDB(t *testing.T) {
- db := NewTestDB(t)
-
- // Verify libraries table exists
- var tableCount int
- err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='libraries'").Scan(&tableCount)
- // assert tableCount == 1
-
- // Verify audio_files has library_id column
- // Query PRAGMA table_info(audio_files), check for library_id
-
- // Verify playlist_tracks has phantom columns and nullable audio_file_id
- // Query PRAGMA table_info(playlist_tracks), check columns
-
- // Verify track_metadata VIEW includes library_id
- // Query PRAGMA table_info(track_metadata), check for library_id — wait, VIEWs don't work with table_info
- // Instead: SELECT sql FROM sqlite_master WHERE name='track_metadata'
- // Assert contains 'library_id'
-
- // Verify user_version is current (>= 6)
- var version int
- err = db.QueryRow("PRAGMA user_version").Scan(&version)
- // assert version >= 6
-
- // Verify libraries table is empty on fresh DB
- count, err := db.Queries.CountLibraries(db.Ctx)
- // assert count == 0
-}
-```
-
-b) **TestMigration6LibraryQueries** — Verify CRUD operations on libraries table work:
-```go
-func TestMigration6LibraryQueries(t *testing.T) {
- db := NewTestDB(t)
-
- // Create a library
- lib, err := db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{
- Name: "Music",
- Path: "/home/user/Music",
- })
- // assert lib.Name == "Music", lib.Path == "/home/user/Music"
- // assert lib.ID > 0
-
- // Get by ID
- got, err := db.Queries.GetLibrary(db.Ctx, lib.ID)
- // assert got matches lib
-
- // Get by path
- gotByPath, err := db.Queries.GetLibraryByPath(db.Ctx, "/home/user/Music")
- // assert gotByPath matches lib
-
- // Unique path constraint
- _, err = db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{
- Name: "Duplicate",
- Path: "/home/user/Music",
- })
- // assert IsUniqueViolation(err)
-
- // List libraries
- libs, err := db.Queries.GetAllLibraries(db.Ctx)
- // assert len(libs) == 1
-
- // Update name
- err = db.Queries.UpdateLibraryName(db.Ctx, sqlcgen.UpdateLibraryNameParams{
- Name: "My Music",
- ID: lib.ID,
- })
- // Verify name changed
-
- // Delete
- err = db.Queries.DeleteLibrary(db.Ctx, lib.ID)
- count, _ := db.Queries.CountLibraries(db.Ctx)
- // assert count == 0
-}
-```
-
-c) **TestMigration6PhantomPlaylistTracks** — Verify playlist tracks work with phantom columns:
-```go
-func TestMigration6PhantomPlaylistTracks(t *testing.T) {
- db, libID := NewTestDBWithLibrary(t, "Test", "/test/music")
-
- // Create prerequisite data: file_type, recording, audio_file
- // (use pattern from existing seedSearchData or seedAudioFiles)
-
- // Create playlist
- playlist, _ := db.Queries.CreatePlaylist(db.Ctx, "Test Playlist")
-
- // Add track with phantom metadata (eager population)
- track, err := db.Queries.AddPlaylistTrack(db.Ctx, sqlcgen.AddPlaylistTrackParams{
- PlaylistID: playlist.ID,
- AudioFileID: sql.NullInt64{Int64: audioFileID, Valid: true},
- Position: 0,
- PhantomTitle: sql.NullString{String: "Test Song", Valid: true},
- PhantomArtist: sql.NullString{String: "Test Artist", Valid: true},
- PhantomAlbum: sql.NullString{String: "Test Album", Valid: true},
- PhantomDurationMs: sql.NullInt64{Int64: 180000, Valid: true},
- PhantomGenre: sql.NullString{String: "Rock", Valid: true},
- PhantomCoverArtPath: sql.NullString{String: "", Valid: false},
- })
- // assert track created
-
- // Delete the audio_file — should SET NULL on audio_file_id
- // (not CASCADE delete the playlist_track)
- _, err = db.ExecContext("DELETE FROM audio_files WHERE id = ?", audioFileID)
-
- // Verify playlist track still exists with NULL audio_file_id
- tracks, _ := db.Queries.GetPlaylistTracksWithMetadata(db.Ctx, playlist.ID)
- // assert len(tracks) == 1
- // assert tracks[0].AudioFileID is NULL/invalid
- // assert tracks[0].Title == "Test Song" (from phantom)
- // assert tracks[0].IsPhantom == 1
-}
-```
-
-d) **TestMigration6AudioFilesLibraryFK** — Verify library_id FK enforcement:
-```go
-func TestMigration6AudioFilesLibraryFK(t *testing.T) {
- db, libID := NewTestDBWithLibrary(t, "Test", "/test")
-
- // Insert audio_file with valid library_id — should succeed
- // Insert audio_file with invalid library_id (999) — should fail FK check
-
- // Count files by library
- count, _ := db.Queries.CountAudioFilesByLibrary(db.Ctx, libID)
- // assert count == 1
-}
-```
-
-e) **TestMigration6TrackMetadataViewHasLibraryID** — Verify the VIEW includes library_id:
-```go
-func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
- db, libID := NewTestDBWithLibrary(t, "Test", "/test")
- // Insert an audio file with test data
- // Query track_metadata VIEW
- // Verify library_id column is present and has correct value
-}
-```
-
-**Test patterns to follow:**
-- Use `NewTestDB(t)` or `NewTestDBWithLibrary(t, ...)` for setup
-- Use `t.Helper()` in helpers
-- Use `t.Context()` — NOT `context.Background()`
-- Table-driven subtests where appropriate
-- Use `database.IsUniqueViolation(err)` for constraint checks
-- Follow existing test naming convention: `Test{Feature}{Behavior}`
-
-
- cd backend/database && go test -v -run "TestMigration6" -count=1 ./...
-
-
- - `NewTestDB` updated for new runMigrations signature (passes empty dbPath)
- - `NewTestDBWithLibrary` helper exists for tests needing a pre-created library
- - TestMigration6FreshDB verifies all tables, columns, and VIEW exist
- - TestMigration6LibraryQueries verifies CRUD and unique constraint
- - TestMigration6PhantomPlaylistTracks verifies SET NULL FK + phantom metadata preservation
- - TestMigration6AudioFilesLibraryFK verifies FK enforcement
- - TestMigration6TrackMetadataViewHasLibraryID verifies VIEW includes library_id
- - All tests pass
-
-
-
-
-
-
-- `go generate ./...` succeeds in backend/database
-- `go build ./...` succeeds from project root
-- `go test ./backend/database/... -count=1` — all tests pass including new migration tests
-- `go test ./backend/playlist/... -count=1` — playlist package still compiles and tests pass (updated AddPlaylistTrack callers)
-- `golangci-lint run ./backend/...` — no new lint errors
-
-
-
-- All 7 library CRUD queries generated and working
-- Playlist queries correctly handle phantom tracks (nullable audio_file_id, phantom columns)
-- Audio file queries support library filtering
-- Migration tests verify both fresh install and upgrade paths
-- SET NULL FK behavior verified: deleting audio_file preserves playlist_track with phantom metadata
-- NewTestDBWithLibrary helper available for downstream test usage
-
-
-
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-02-SUMMARY.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-02-SUMMARY.md
deleted file mode 100644
index 35e06dd..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-02-SUMMARY.md
+++ /dev/null
@@ -1,132 +0,0 @@
----
-phase: 10-schema-migration
-plan: 02
-subsystem: database
-tags: [sqlite, sqlc, queries, phantom-tracks, migration-tests, multi-library]
-
-# Dependency graph
-requires:
- - phase: 10-schema-migration plan 01
- provides: libraries table, audio_files.library_id, playlist_tracks phantom columns, migration 6
-provides:
- - sqlc CRUD queries for libraries table (7 queries)
- - Updated playlist queries with phantom metadata support and LEFT JOINs
- - GetTrackPhantomMetadata helper query for eager phantom population
- - Audio file queries filtered by library_id
- - Migration 6 integration tests (5 test functions)
- - NewTestDBWithLibrary helper for downstream test usage
-affects: [11-per-library-scan, 12-library-crud, 13-library-views]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "LEFT JOIN for nullable FK columns in sqlc queries"
- - "COALESCE fallback chain: live metadata → phantom metadata → empty string"
- - "is_phantom computed column via CASE WHEN for phantom track detection"
- - "NewTestDBWithLibrary helper for tests needing pre-populated library"
-
-key-files:
- created:
- - backend/database/sql/queries/libraries.sql
- - backend/database/sql/sqlcgen/libraries.sql.go
- - backend/database/database_test.go
- modified:
- - backend/database/sql/queries/audio_files.sql
- - backend/database/sql/queries/playlists.sql
- - backend/database/sql/sqlcgen/audio_files.sql.go
- - backend/database/sql/sqlcgen/playlists.sql.go
- - backend/database/testhelper.go
-
-key-decisions:
- - "COALESCE fallback chain for phantom metadata: prefer live data over phantom data over empty string"
- - "Computed is_phantom column via CASE WHEN rather than requiring callers to check audio_file_id"
- - "GetPlaylistTrackFilePaths filters out NULLs with audio_file_id IS NOT NULL"
-
-patterns-established:
- - "LEFT JOIN + COALESCE pattern for nullable FK queries"
- - "is_phantom computed column pattern for phantom track detection"
- - "NewTestDBWithLibrary(t, name, path) for integration tests needing libraries"
-
-requirements-completed: [LIB-04, LIB-05]
-
-# Metrics
-duration: 5min
-completed: 2026-03-09
----
-
-# Phase 10 Plan 2: sqlc Queries & Migration Tests Summary
-
-**Library CRUD queries, phantom-aware playlist queries with LEFT JOIN + COALESCE fallback, and 5 migration 6 integration tests**
-
-## Performance
-
-- **Duration:** 5 min
-- **Started:** 2026-03-09T13:45:05Z
-- **Completed:** 2026-03-09T13:50:34Z
-- **Tasks:** 2
-- **Files modified:** 9
-
-## Accomplishments
-- Created 7 library CRUD queries (create, get, get-by-path, list, update, delete, count) with sqlc-generated Go code
-- Updated all playlist track queries to use LEFT JOIN for nullable audio_file_id, with COALESCE fallback chain from live metadata to phantom metadata
-- Added GetTrackPhantomMetadata helper query for eager phantom population at insert time
-- Added is_phantom computed column to GetPlaylistTracksWithMetadata and GetAllPlaylistTracksWithMetadata
-- Added GetAudioFilesByLibrary and CountAudioFilesByLibrary queries
-- Created 5 comprehensive migration 6 integration tests covering fresh DB, CRUD, phantom tracks, FK enforcement, and VIEW validation
-- Added NewTestDBWithLibrary helper for downstream test usage
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Add sqlc queries for libraries and update playlist queries** - `02548dd` (feat)
-2. **Task 2: Migration integration tests and NewTestDB update** - `bc15189` (feat)
-
-## Files Created/Modified
-- `backend/database/sql/queries/libraries.sql` - 7 CRUD queries for libraries table
-- `backend/database/sql/queries/playlists.sql` - Updated with phantom support, LEFT JOINs, GetTrackPhantomMetadata
-- `backend/database/sql/queries/audio_files.sql` - Added GetAudioFilesByLibrary, CountAudioFilesByLibrary
-- `backend/database/sql/sqlcgen/libraries.sql.go` - Generated Go code for library queries
-- `backend/database/sql/sqlcgen/playlists.sql.go` - Regenerated with phantom columns, is_phantom, LEFT JOINs
-- `backend/database/sql/sqlcgen/audio_files.sql.go` - Regenerated with library filter queries
-- `backend/database/database_test.go` - 5 migration 6 integration tests
-- `backend/database/testhelper.go` - Added NewTestDBWithLibrary helper
-
-## Decisions Made
-- COALESCE fallback chain: live data → phantom data → empty string ensures callers always get usable values regardless of whether a track is phantom or not
-- Added `is_phantom` as a computed column (`CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END`) to eliminate null-checking logic in callers
-- GetPlaylistTrackFilePaths now filters `WHERE audio_file_id IS NOT NULL` to exclude phantom tracks from file path lists
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 1 - Bug] Fixed NewTestDBWithLibrary path collision with sentinel library**
-- **Found during:** Task 2 (migration tests)
-- **Issue:** Tests using `NewTestDBWithLibrary(t, "Test", "/test")` collided with the sentinel library at `(0, 'Test', '/test')` from NewTestDB, causing UNIQUE constraint violation
-- **Fix:** Changed test paths to unique values (`/test/music`, `/test/fk-lib`, `/test/view-lib`) to avoid collision with sentinel
-- **Files modified:** backend/database/database_test.go
-- **Verification:** All 5 TestMigration6 tests pass
-- **Committed in:** bc15189 (Task 2 commit)
-
----
-
-**Total deviations:** 1 auto-fixed (1 bug)
-**Impact on plan:** Minor path collision fix in tests. No scope creep.
-
-## Issues Encountered
-None
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Phase 10 complete: schema files, migration 6, sqlc queries, and migration tests all in place
-- Ready for Phase 11 (per-library scan pipeline) — libraries table and library_id queries available
-- Ready for Phase 12 (library CRUD API) — all 7 library queries generated and tested
-- Ready for Phase 13 (library views & phantom tracks) — phantom metadata queries with is_phantom column available
-
----
-*Phase: 10-schema-migration*
-*Completed: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-CONTEXT.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-CONTEXT.md
deleted file mode 100644
index 3e23819..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-CONTEXT.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Phase 10: Schema & Migration - Context
-
-**Gathered:** 2026-03-09
-**Status:** Ready for planning
-
-
-## Phase Boundary
-
-The database supports multiple libraries and phantom tracks — existing users upgrade seamlessly. Delivers: `libraries` table, `audio_files.library_id` FK, `playlist_tracks` phantom metadata columns, config migration from TOML to SQLite, and atomic migration guarantees. No UI, no CRUD API, no scan pipeline changes — just schema and migration.
-
-Requirements: DATA-01, DATA-04, LIB-04, LIB-05, LSCAN-05
-
-
-
-
-## Implementation Decisions
-
-### Migration experience
-- Silent auto-migrate on startup — no user interaction, no progress indicator, no confirmation dialog
-- Migration runs automatically when the app detects the schema version is behind
-- On migration failure: show error dialog and refuse to start — no degraded/read-only mode
-- Automatic database backup before migration runs (copy .db file before any schema changes)
-- Schema version tracked via integer (SQLite `user_version` pragma or schema_version table) — app checks on startup, runs pending migrations sequentially
-
-### Default library identity
-- Migrated library name derived from the directory name (e.g., `/home/user/Music` becomes "Music")
-- `music_directory` key removed from TOML config after successful migration — libraries table is the sole source of truth
-- Old config key ignored if still present (no crash on stale config)
-- Fresh installs start with an empty libraries table — no default library auto-created, user adds their first library when they want to scan
-- Libraries table is minimal: name, path, created_at — no scan metadata columns yet (Phase 11 can add those)
-
-### Phantom track schema
-- Rich cached metadata on `playlist_tracks`: title, artist, album, duration, genre, cover art path
-- Eager population: metadata columns filled on every playlist_tracks insert (not lazily on library removal)
-- Phantom tracks identified by NULL `audio_file_id` — no separate `is_phantom` boolean column needed
-- Migration adds new columns via ALTER TABLE ADD COLUMN (not table rebuild) — existing playlist_tracks rows get NULL metadata columns, backfilled from audio_files data
-
-### Migration rollback strategy
-- One-way migration — downgrade to pre-multi-library versions is unsupported
-- Pre-migration backup is the user's safety net for rollback
-- Backup file naming is timestamp-based (e.g., `yellowjacket.db.bak.20260309`) — multiple backups can coexist
-- No automatic backup cleanup — user manages old backup files
-- Migration events (start, success, backup path, errors) logged at INFO level to standard app log
-
-### Claude's Discretion
-- Exact column types and constraints for the libraries table
-- Index strategy for library_id FK on audio_files
-- Whether to use SQLite `user_version` pragma vs a dedicated schema_version table
-- Migration transaction boundaries (single transaction vs per-step)
-- Backfill query strategy for populating phantom metadata on existing playlist_tracks rows
-
-
-
-
-## Specific Ideas
-
-No specific requirements — open to standard approaches
-
-
-
-
-## Deferred Ideas
-
-None — discussion stayed within phase scope
-
-
-
----
-
-*Phase: 10-schema-migration*
-*Context gathered: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/10-schema-migration/10-VERIFICATION.md b/.planning/milestones/v1.1-phases/10-schema-migration/10-VERIFICATION.md
deleted file mode 100644
index 4ffa42b..0000000
--- a/.planning/milestones/v1.1-phases/10-schema-migration/10-VERIFICATION.md
+++ /dev/null
@@ -1,125 +0,0 @@
----
-phase: 10-schema-migration
-verified: 2026-03-09T09:55:00Z
-status: passed
-score: 14/14 must-haves verified
----
-
-# Phase 10: Schema & Migration Verification Report
-
-**Phase Goal:** The database supports multiple libraries and phantom tracks — existing users upgrade seamlessly
-**Verified:** 2026-03-09T09:55:00Z
-**Status:** passed
-**Re-verification:** No — initial verification
-
-## Goal Achievement
-
-### Observable Truths
-
-#### Plan 01 Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 1 | Fresh database creates libraries table with name, path, created_at columns | ✓ VERIFIED | `_libraries.sql` contains `CREATE TABLE IF NOT EXISTS libraries` with all 3 columns + id PK |
-| 2 | Fresh database creates audio_files with library_id FK column | ✓ VERIFIED | `audio_files.sql` line 13: `library_id int NOT NULL DEFAULT 0`, line 16: `FOREIGN KEY(library_id) REFERENCES libraries(id)`, index at line 22-23 |
-| 3 | Fresh database creates playlist_tracks with nullable audio_file_id and phantom metadata columns | ✓ VERIFIED | `playlist_tracks.sql` line 4: `audio_file_id INTEGER` (nullable), lines 6-11: all 6 phantom columns, line 13: `ON DELETE SET NULL` |
-| 4 | Fresh database creates track_metadata VIEW including library_id | ✓ VERIFIED | `track_metadata_view.sql` line 26: `af.library_id` in SELECT |
-| 5 | Existing v5 database is migrated to v6 atomically — backup created first, all changes in transaction | ✓ VERIFIED | `database.go` lines 718-1031: `migration6MultiLibrary()` — backup at line 728, FK OFF/ON wrapping, all 14 steps in order, `PRAGMA user_version = 6` at line 1021 |
-| 6 | Existing audio_files rows get library_id pointing to the auto-created default library | ✓ VERIFIED | `database.go` lines 794-806: `ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT %d` with dynamic `defaultLibID` |
-| 7 | Migration reads TOML DirectoryPath to create the default library row | ✓ VERIFIED | `database.go` line 736: `readLibraryDirFromTOML(logger)`, lines 1035-1077: full TOML decode with `Library.DirectoryPath`; line 769: `filepath.Base(existingDir)` for library name |
-
-#### Plan 02 Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 8 | sqlc-generated queries exist for library CRUD (create, get, list, delete) | ✓ VERIFIED | `libraries.sql` has 7 queries (CreateLibrary, GetLibrary, GetLibraryByPath, GetAllLibraries, UpdateLibraryName, DeleteLibrary, CountLibraries); `libraries.sql.go` has generated Go functions for all 7 |
-| 9 | Playlist track queries handle nullable audio_file_id and phantom columns | ✓ VERIFIED | `playlists.sql`: AddPlaylistTrack has 9 params including phantom columns; GetPlaylistTracksWithMetadata uses LEFT JOIN + COALESCE fallback chain + is_phantom computed column |
-| 10 | Audio file queries accept library_id parameter | ✓ VERIFIED | `audio_files.sql` lines 131-134: GetAudioFilesByLibrary and CountAudioFilesByLibrary queries |
-| 11 | Migration tests verify upgrade path from v5 to v6 | ✓ VERIFIED | `database_test.go`: TestMigration6FreshDB (201 lines), TestMigration6LibraryQueries, TestMigration6PhantomPlaylistTracks, TestMigration6AudioFilesLibraryFK, TestMigration6TrackMetadataViewHasLibraryID — all 5 tests PASS |
-| 12 | Migration tests verify fresh database creates correct schema | ✓ VERIFIED | TestMigration6FreshDB checks: libraries table exists, audio_files has library_id, playlist_tracks has all 6 phantom columns + nullable audio_file_id, track_metadata VIEW has library_id, user_version >= 6 |
-| 13 | Migration tests verify TOML config is read and default library created | ✓ VERIFIED | TestMigration6LibraryQueries tests full CRUD lifecycle; in-memory DBs skip TOML read (correct for test env — TOML read path verified by code inspection: `readLibraryDirFromTOML` returns "" for missing config) |
-| 14 | Test helper NewTestDB creates v6 schema including libraries table | ✓ VERIFIED | `testhelper.go` line 60: `runMigrations(ctx, db, slog.Default(), ":memory:")`, line 66-71: sentinel library at id=0; `NewTestDBWithLibrary` helper at lines 87-107 |
-
-**Score:** 14/14 truths verified
-
-### Required Artifacts
-
-#### Plan 01 Artifacts
-
-| Artifact | Expected | Status | Details |
-|----------|----------|--------|---------|
-| `backend/database/sql/schemas/_libraries.sql` | Libraries table DDL for fresh installs | ✓ VERIFIED | 7 lines, CREATE TABLE with id, name, path (UNIQUE), created_at |
-| `backend/database/sql/schemas/audio_files.sql` | Updated audio_files DDL with library_id FK | ✓ VERIFIED | 24 lines, library_id column + FK + index |
-| `backend/database/sql/schemas/playlist_tracks.sql` | Updated playlist_tracks DDL with nullable audio_file_id and phantom columns | ✓ VERIFIED | 21 lines, nullable audio_file_id, SET NULL FK, 6 phantom columns, 2 indexes |
-| `backend/database/sql/schemas/track_metadata_view.sql` | Updated VIEW with library_id in SELECT | ✓ VERIFIED | 38 lines, af.library_id as last column in SELECT |
-| `backend/database/database.go` | migration6MultiLibrary function + backup logic | ✓ VERIFIED | 1155 lines total, migration6MultiLibrary (lines 718-1031), backupDatabase (lines 678-710), readLibraryDirFromTOML (lines 1035-1077), removeLibraryDirFromTOML (lines 1083-1154) |
-
-#### Plan 02 Artifacts
-
-| Artifact | Expected | Status | Details |
-|----------|----------|--------|---------|
-| `backend/database/sql/queries/libraries.sql` | sqlc query definitions for libraries CRUD | ✓ VERIFIED | 22 lines, 7 queries: CreateLibrary, GetLibrary, GetLibraryByPath, GetAllLibraries, UpdateLibraryName, DeleteLibrary, CountLibraries |
-| `backend/database/sql/queries/playlists.sql` | Updated playlist queries with phantom column support | ✓ VERIFIED | 149 lines, AddPlaylistTrack with 9 params, LEFT JOINs, COALESCE fallback chains, is_phantom, GetTrackPhantomMetadata helper |
-| `backend/database/sql/sqlcgen/libraries.sql.go` | Generated Go code for library queries | ✓ VERIFIED | 131 lines, auto-generated with all 7 query functions |
-| `backend/database/database_test.go` | Migration 6 integration tests | ✓ VERIFIED | 589 lines, 5 test functions all PASS |
-
-### Key Link Verification
-
-#### Plan 01 Key Links
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| `database.go` | `_libraries.sql` | embedded SQL schema execution in NewDB | ✓ WIRED | `schemas.ReadDir("sql/schemas")` at line 68 iterates all .sql files; `_libraries.sql` sorts before `audio_files.sql` alphabetically (`_` < `a`), ensuring FK order |
-| `database.go migration6` | TOML config file | `system.GetUserConfigDirPath + toml decode` | ✓ WIRED | `readLibraryDirFromTOML()` at line 736 calls `system.GetUserConfigDirPath()`, reads config.toml, uses `toml.Decode` with Library.DirectoryPath struct |
-
-#### Plan 02 Key Links
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| `queries/libraries.sql` | `schemas/_libraries.sql` | sqlc schema awareness | ✓ WIRED | sqlc.yaml configures schema dir as `./sql/schemas` — generated code in `libraries.sql.go` proves sqlc successfully processes both schema and queries |
-| `database_test.go` | `database.go migration6` | NewTestDB runs all migrations | ✓ WIRED | `testhelper.go` line 60: `runMigrations(ctx, db, slog.Default(), ":memory:")` — all 5 migration 6 tests pass confirming migration executes correctly |
-
-### Requirements Coverage
-
-| Requirement | Source Plan | Description | Status | Evidence |
-|-------------|-----------|-------------|--------|----------|
-| DATA-01 | 10-01 | Schema migration adds `libraries` table and `library_id` FK on `audio_files` | ✓ SATISFIED | `_libraries.sql` creates table; `audio_files.sql` has `library_id` FK; `migration6MultiLibrary` adds column to existing DBs |
-| DATA-04 | 10-01 | All library operations are transactional — no partial state on failure | ✓ SATISFIED | Migration 6 wraps all changes between `PRAGMA foreign_keys = OFF/ON`, error handling returns on every step, backup created before changes |
-| LSCAN-05 | 10-01 | Audio files are associated with their library via `library_id` foreign key | ✓ SATISFIED | `audio_files.sql` line 16: `FOREIGN KEY(library_id) REFERENCES libraries(id)`; index at line 22-23; migration backfills existing rows |
-| LIB-04 | 10-02 | Libraries are stored in SQLite (not TOML config) with CRUD through the UI | ✓ SATISFIED | 7 CRUD queries in `libraries.sql`, generated Go code in `libraries.sql.go`, Library model in `models.go` line 60-65 |
-| LIB-05 | 10-02 | Existing single-directory config is migrated seamlessly to the libraries table on first run after upgrade | ✓ SATISFIED | `readLibraryDirFromTOML` reads existing config; `migration6MultiLibrary` step 5 creates default library; `removeLibraryDirFromTOML` cleans up config |
-
-No orphaned requirements found — all 5 requirement IDs (DATA-01, DATA-04, LIB-04, LIB-05, LSCAN-05) are claimed by plans and satisfied.
-
-### Anti-Patterns Found
-
-| File | Line | Pattern | Severity | Impact |
-|------|------|---------|----------|--------|
-| — | — | — | — | No anti-patterns found |
-
-No TODO/FIXME/PLACEHOLDER/HACK/XXX markers found in any database package files. No empty implementations or stub patterns detected.
-
-### Human Verification Required
-
-### 1. Migration on Real v5 Database
-
-**Test:** Run the application against a real existing v5 database with audio files and playlists
-**Expected:** Migration 6 runs silently — backup file created, libraries table populated from TOML config, all audio_files get correct library_id, playlist_tracks rebuilt with phantom metadata backfilled, app starts normally
-**Why human:** In-memory test DBs skip backup and TOML reading; real filesystem paths, file permissions, and TOML parsing edge cases can only be verified with a real database
-
-### 2. TOML Config Cleanup
-
-**Test:** After migration, check that `config.toml` no longer has `DirectoryPath` under `[Library]` section
-**Expected:** DirectoryPath removed, other config sections preserved intact
-**Why human:** TOML marshaling with `map[string]any` may reorder keys or change formatting — verify config file is still valid and readable
-
-### Gaps Summary
-
-No gaps found. All 14 must-have truths verified, all 9 artifacts exist and are substantive, all 4 key links are wired, and all 5 requirements are satisfied. The build compiles cleanly (`go build ./...`), all tests pass (`go test ./backend/database/... ./backend/playlist/...`), and no anti-patterns were detected.
-
-The migration implementation is thorough: 14-step migration function with SAFETY comments, pre-migration backup, TOML config read/cleanup, table rebuild with FK OFF/ON wrapping, phantom metadata backfill, and VIEW recreation. The sqlc queries are properly generated with LEFT JOINs, COALESCE fallback chains, and is_phantom computed columns.
-
----
-
-_Verified: 2026-03-09T09:55:00Z_
-_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-PLAN.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-PLAN.md
deleted file mode 100644
index a4ccd47..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-PLAN.md
+++ /dev/null
@@ -1,358 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 01
-type: execute
-wave: 1
-depends_on: []
-files_modified:
- - backend/library/scan_queue.go
- - backend/library/library.go
- - backend/library/scan_control.go
- - backend/library/config.go
- - backend/library/rescan.go
- - backend/library/metrics.go
- - backend/events/events.go
- - frontend/src/events.ts
- - backend/database/sql/queries/audio_files.sql
- - backend/database/sql/sqlcgen/audio_files.sql.go
- - backend/database/sql/sqlcgen/models.go
-autonomous: true
-requirements: [LSCAN-01, LSCAN-02, LSCAN-04]
-
-must_haves:
- truths:
- - "ScanLibrary(id) scans only the directory associated with that library ID"
- - "Only one library scans at a time — additional requests are silently queued"
- - "Duplicate scan requests for the same library are silently ignored"
- - "Cancel/pause/resume work per-library — cancelling one library starts the next queued"
- - "Pausing freezes both the current scan AND the queue"
- - "ScanAllLibraries queries all libraries and queues them sequentially"
- artifacts:
- - path: "backend/library/scan_queue.go"
- provides: "Scan queue coordinator with sequential execution"
- exports: ["ScanLibrary", "ScanAllLibraries", "CancelCurrentScan", "CancelAllScans"]
- - path: "backend/library/library.go"
- provides: "Updated Scan() accepting library ID and path"
- - path: "backend/events/events.go"
- provides: "Updated scan events with library identification"
- - path: "backend/database/sql/queries/audio_files.sql"
- provides: "CreateAudioFile with library_id parameter"
- key_links:
- - from: "backend/library/scan_queue.go"
- to: "backend/library/library.go"
- via: "scanQueue calls scanLibrary which calls internal scan pipeline"
- pattern: "l\\.scanInternal"
- - from: "backend/library/scan_queue.go"
- to: "backend/database/sql/sqlcgen/libraries.sql.go"
- via: "GetLibrary query to resolve library path from ID"
- pattern: "Queries\\.GetLibrary"
- - from: "backend/library/library.go"
- to: "backend/database/sql/sqlcgen/audio_files.sql.go"
- via: "CreateAudioFile now includes library_id"
- pattern: "CreateAudioFileParams.*LibraryID"
----
-
-
-Refactor the scan pipeline from scanning a single hardcoded directory to scanning individual libraries by database ID, with a sequential scan queue coordinator.
-
-Purpose: Enable per-library scanning (LSCAN-01), sequential coordination (LSCAN-02), and per-library cancel/pause scope (LSCAN-04) at the backend level.
-Output: `ScanLibrary(id)` and `ScanAllLibraries()` Wails-bound methods, scan queue coordinator, updated events with library identification, `CreateAudioFile` with `library_id`.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/11-per-library-scan-pipeline/11-CONTEXT.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md
-@.planning/phases/10-schema-migration/10-02-SUMMARY.md
-
-
-
-
-From backend/library/library.go:
-```go
-type Library struct {
- mu sync.Mutex
- ctx context.Context
- logger *slog.Logger
- conf *Config
- db *database.DB
- rescanHooks RescanHooks
- scanActive bool
- scanCancel context.CancelFunc
- scanPaused bool
- scanPauseCh chan struct{}
-}
-
-func (l *Library) Scan() (*ScanMetrics, error)
-func (l *Library) SetContext(ctx context.Context)
-func (l *Library) CancelScan()
-func (l *Library) PauseScan()
-func (l *Library) ResumeScan()
-func (l *Library) IsScanActive() bool
-func (l *Library) IsScanPaused() bool
-```
-
-From backend/library/config.go:
-```go
-type Config struct {
- DirectoryPath Directory `toml:"DirectoryPath"`
- ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"`
-}
-```
-
-From backend/library/metrics.go:
-```go
-type ScanProgress struct {
- Phase string `json:"phase"`
- Total int64 `json:"total"`
- Processed int64 `json:"processed"`
- Added int64 `json:"added"`
- Skipped int64 `json:"skipped"`
- Updated int64 `json:"updated"`
-}
-
-type ScanMetrics struct { ... Cancelled bool ... }
-```
-
-From backend/events/events.go:
-```go
-const (
- LibraryScanStarted = "LibraryScanStarted"
- LibraryScanProgress = "LibraryScanProgress"
- LibraryScanComplete = "LibraryScanComplete"
- LibraryScanCancelled = "LibraryScanCancelled"
- LibraryScanPaused = "LibraryScanPaused"
- LibraryScanResumed = "LibraryScanResumed"
-)
-```
-
-From backend/database/sql/sqlcgen/libraries.sql.go:
-```go
-func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error)
-func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error)
-```
-
-From backend/database/sql/sqlcgen/audio_files.sql.go:
-```go
-type CreateAudioFileParams struct {
- FilePath string
- LengthMilliseconds int64
- FileTypeID int64
- RecordingID int64
- SampleRate int64
- BitDepth int64
- Channels int64
- Bitrate int64
- FileSize int64
- Basename string
- // NOTE: library_id NOT included — uses DEFAULT 0
-}
-
-func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error)
-func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error)
-```
-
-
-
-
-
-
- Task 1: Add library_id to CreateAudioFile + update events and progress types
-
- backend/database/sql/queries/audio_files.sql
- backend/database/sql/sqlcgen/audio_files.sql.go
- backend/database/sql/sqlcgen/models.go
- backend/events/events.go
- frontend/src/events.ts
- backend/library/metrics.go
-
-
-1. **Update CreateAudioFile SQL query** in `backend/database/sql/queries/audio_files.sql`:
- - Add `library_id` to the INSERT column list and VALUES: `INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
- - This adds the `library_id` parameter so scans can associate files with their library.
-
-2. **Run `sqlc generate`** to regenerate Go code:
- ```bash
- sqlc generate
- ```
- This will update `CreateAudioFileParams` to include `LibraryID int64`.
-
-3. **Add new event constants** to `backend/events/events.go` — add a "Scan queue events" group:
- ```go
- // Scan queue events.
- const (
- LibraryScanQueued = "LibraryScanQueued"
- LibraryScanQueueDrained = "LibraryScanQueueDrained"
- )
- ```
-
-4. **Regenerate TypeScript events** via `go generate ./backend/events/...` (uses the genevents tool).
-
-5. **Add library identification fields** to `ScanProgress` and `ScanMetrics` in `backend/library/metrics.go`:
- - Add to `ScanProgress`: `LibraryID int64 \`json:"libraryId"\`` and `LibraryName string \`json:"libraryName"\``
- - Add to `ScanProgress`: `QueuedCount int \`json:"queuedCount"\`` (number of libraries still queued after this one)
- - Add to `ScanMetrics`: `LibraryID int64 \`json:"libraryId"\`` and `LibraryName string \`json:"libraryName"\``
-
-6. **Fix compilation** — update the `CreateAudioFile` call in `library.go` `saveAudioFile()` method to include `LibraryID` field. The library ID will be threaded through as a parameter to `Scan`/`scanInternal` (done in Task 2), so for now add the field but use a placeholder `0` value that Task 2 will replace. Actually — since Task 2 immediately follows and both are in the same plan, add `libraryID int64` as a field on the `Library` struct (or better: pass it through the scan methods). For the compilation fix, add `LibraryID: 0` to the CreateAudioFileParams in saveAudioFile — Task 2 will thread the real value.
-
-Verify the generated code compiles: `go build ./backend/...`
-
-
- cd /mnt/vault/dev/golang/yellowjacket && sqlc generate && go generate ./backend/events/... && go build ./backend/...
-
- CreateAudioFileParams includes LibraryID field. ScanProgress and ScanMetrics include library identification fields. New scan queue events exist in both Go and TypeScript. Code compiles.
-
-
-
- Task 2: Create scan queue coordinator and refactor Library for per-library scanning
-
- backend/library/scan_queue.go
- backend/library/library.go
- backend/library/scan_control.go
- backend/library/config.go
- backend/library/rescan.go
-
-
-**Create `backend/library/scan_queue.go`** — the scan queue coordinator. This is the core of Phase 11.
-
-Design:
-- The `Library` struct gains scan queue fields (protected by `mu`):
- - `scanQueue []scanQueueEntry` — FIFO queue of library IDs to scan
- - `currentScanLibraryID int64` — the library currently being scanned (0 if none)
- - `currentScanLibraryName string` — for event payloads
-- `scanQueueEntry` struct: `libraryID int64`, `libraryName string`, `libraryPath string`
-
-**Wails-bound methods** (exported, on `*Library`):
-
-1. `ScanLibrary(id int64) error`:
- - Query `l.db.Queries.GetLibrary(l.ctx, id)` to get library name and path
- - If library not found, return error
- - Acquire `l.mu`:
- - If this library ID is already `currentScanLibraryID` or already in `scanQueue`, return nil (silent dedup per CONTEXT.md)
- - If no scan is active (`!l.scanActive`), set `currentScanLibraryID = id` and start scanning in a goroutine
- - If a scan is active, append to `scanQueue` and emit `LibraryScanQueued` event with library name and queue length
- - Release `l.mu`
- - Return nil
-
-2. `ScanAllLibraries() error`:
- - Query `l.db.Queries.GetAllLibraries(l.ctx)` to get all libraries
- - For each library, call `ScanLibrary(lib.ID)` (reuses dedup logic)
- - Return nil
-
-3. `CancelCurrentScan()` — cancels only the current library's scan (replaces old `CancelScan`):
- - Cancel the scan context (existing `l.scanCancel()` call)
- - The scan completion handler (`drainQueue`) will automatically start the next queued library
-
-4. `CancelAllScans()` — cancels current and clears queue:
- - Acquire `l.mu`, clear `l.scanQueue`, release `l.mu`
- - Then cancel the current scan context
-
-5. `GetScanQueueLength() int` — returns length of scan queue (for UI)
-
-**Internal scan orchestration:**
-
-- `startScan(entry scanQueueEntry)` — goroutine entry point:
- - Calls `l.scanInternal(entry.libraryID, entry.libraryName, entry.libraryPath)`
- - On completion, calls `l.drainQueue()`
-
-- `drainQueue()` — called after each scan completes:
- - Acquire `l.mu`
- - If `scanQueue` is not empty, pop first entry, set as `currentScanLibraryID`, release lock, call `startScan` in new goroutine
- - If `scanQueue` is empty, set `currentScanLibraryID = 0`, `scanActive = false`, emit `LibraryScanQueueDrained`, release lock
-
-**Refactor `Library.Scan()` → `scanInternal()`:**
-
-- Rename current `Scan()` to `scanInternal(libraryID int64, libraryName string, libraryPath string)` (unexported)
-- Remove the `l.conf.DirectoryPath` dependency — use the `libraryPath` parameter instead
-- Replace `l.db.Queries.GetAllAudioFiles(l.ctx)` with `l.db.Queries.GetAudioFilesByLibrary(l.ctx, libraryID)` in Phase 1 (load existing)
-- Pass `libraryID` through to `saveAudioFile` so `CreateAudioFileParams.LibraryID` is set correctly
-- Update all `ScanProgress` emissions to include `LibraryID`, `LibraryName`, and `QueuedCount` (read queue length under lock)
-- Update `ScanMetrics` to include `LibraryID` and `LibraryName` before emitting `LibraryScanComplete`/`LibraryScanCancelled`
-- The `workerCount` should use `resolveScanWorkerCount(ScanConcurrencyAuto, libraryPath)` — no longer from config (each library path may be on different storage)
-
-**Keep backward-compatible `Scan()` method** — public method that scans using the legacy `l.conf.DirectoryPath` for `handleConfigUpdate`. Mark it deprecated. It should:
-- Look up or create a library for `l.conf.DirectoryPath` using `GetLibraryByPath`
-- Call `ScanLibrary(lib.ID)`
-
-**Update `scan_control.go`:**
-
-- Rename `CancelScan()` to an internal helper `cancelCurrentScan()` (unexported)
-- Keep `PauseScan()` and `ResumeScan()` as-is — they operate on the current scan which is correct
-- `IsScanActive()` unchanged
-- Add `QueuedLibraryNames() []string` — returns names of queued libraries (for UI display)
-
-**Update `config.go`:**
-- The `Config` struct keeps `DirectoryPath` and `ScanConcurrency` for backward compatibility, but `DirectoryPath` is now unused for normal scanning (libraries come from DB). `ScanConcurrency` is still useful as a global default.
-
-**Update `rescan.go`:**
-- `FullRescan()` needs updating — it should accept a library ID. For now, keep it working with `l.conf.DirectoryPath` (it's used from the config page). Phase 12 will add per-library rescan.
-
-**Thread `libraryID` through the scan pipeline:**
-- Add `libraryID int64` field to `scanWork` struct (or pass it via closure)
-- In `saveAudioFile`, use `LibraryID: libraryID` in `CreateAudioFileParams`
-- In the `commitBatch` → `saveAudioFile` call chain, thread the library ID through. Simplest: add `libraryID int64` as a parameter to `commitBatch` and `saveAudioFile` and `updateAudioFileMetadata`.
-
-**Linting notes:**
-- All exported methods need doc comments ending with period (godot)
-- No stuttering (revive) — method names don't repeat "Library"
-- Sentinel errors as package vars (err113)
-- Blank line after early returns (nlreturn)
-- Keep lines under 100 chars (golines)
-
-
- cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/library/...
-
-
-- `ScanLibrary(id)` scans a specific library's directory, associating files with that library_id
-- `ScanAllLibraries()` queues all libraries for sequential scanning
-- Scan queue coordinator ensures only one scan runs at a time, with silent dedup
-- Cancel: `CancelCurrentScan()` cancels current and starts next; `CancelAllScans()` cancels current and clears queue
-- Pause freezes current scan AND queue (existing behavior — drainQueue is only called on scan completion, which doesn't happen while paused)
-- All scan events include library name and queue count
-- `go build ./...` passes
-
-
-
-
-
-
-```bash
-# Build passes
-go build ./...
-
-# Vet passes
-go vet ./backend/library/...
-
-# Generated code is up to date
-sqlc generate && go generate ./backend/events/...
-
-# Existing tests still pass (scan_test.go uses the old Scan() path)
-go test ./backend/library/... -count=1 -timeout 60s
-
-# Events synced
-diff <(grep -oP '"[A-Z][a-zA-Z]+"' backend/events/events.go | sort) <(grep -oP '"[A-Z][a-zA-Z]+"' frontend/src/events.ts | sort)
-```
-
-
-
-- ScanLibrary(id) resolves library path from DB and scans only that directory
-- CreateAudioFile includes library_id — new files are associated with their library
-- Only one scan runs at a time — queue coordinates sequential execution
-- Duplicate requests are silently ignored
-- CancelCurrentScan stops current library, next queued starts automatically
-- CancelAllScans stops current and clears queue
-- Pause freezes scan AND queue
-- All scan events include library name and queue count
-- go build ./... passes, go test ./backend/library/... passes
-
-
-
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-SUMMARY.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
deleted file mode 100644
index 470f2bd..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
+++ /dev/null
@@ -1,127 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 01
-subsystem: library
-tags: [scan-queue, per-library, wails-bindings, sqlc, events]
-
-# Dependency graph
-requires:
- - phase: 10-schema-migration
- provides: libraries table, library_id column on audio_files, GetLibrary/GetAllLibraries/GetLibraryByPath queries
-provides:
- - ScanLibrary(id) Wails-bound method for per-library scanning
- - ScanAllLibraries() Wails-bound method for bulk sequential scanning
- - Scan queue coordinator with FIFO sequential execution and silent dedup
- - CancelCurrentScan() and CancelAllScans() for queue-aware cancellation
- - GetScanQueueLength() and QueuedLibraryNames() for UI display
- - Library-aware ScanProgress and ScanMetrics with libraryId, libraryName, queuedCount
- - LibraryScanQueued and LibraryScanQueueDrained events
- - CreateAudioFile with library_id parameter
-affects: [12-library-crud-data-integrity, 13-library-views-phantom-tracks]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Scan queue coordinator pattern: FIFO queue with single-active-scan mutex"
- - "scanInternal() as reusable per-library scan engine"
- - "Silent dedup for scan requests (no-op if already scanning or queued)"
-
-key-files:
- created:
- - backend/library/scan_queue.go
- modified:
- - backend/library/library.go
- - backend/library/scan_control.go
- - backend/library/metrics.go
- - backend/events/events.go
- - backend/database/sql/queries/audio_files.sql
- - backend/database/sql/sqlcgen/audio_files.sql.go
- - frontend/src/events.ts
- - frontend/wailsjs/go/library/Library.d.ts
- - frontend/wailsjs/go/library/Library.js
-
-key-decisions:
- - "Library identification threaded through importResult.libraryID rather than adding field to Library struct"
- - "scanInternal returns *ScanMetrics instead of (*ScanMetrics, error) — errors are logged and warnings accumulated"
- - "Worker count auto-detected per library path (ScanConcurrencyAuto) rather than using global config value"
- - "Backward-compatible Scan() retained as deprecated wrapper for handleConfigUpdate"
-
-patterns-established:
- - "Scan queue coordinator: scanQueue []scanQueueEntry + drainQueue() pattern for sequential execution"
- - "mkProgress closure for DRY ScanProgress event construction with library identification"
-
-requirements-completed: [LSCAN-01, LSCAN-02, LSCAN-04]
-
-# Metrics
-duration: 7min
-completed: 2026-03-09
----
-
-# Phase 11 Plan 01: Per-Library Scan Pipeline Summary
-
-**ScanLibrary(id) with FIFO queue coordinator, per-library file association via library_id, and queue-aware cancel/pause controls**
-
-## Performance
-
-- **Duration:** 7 min
-- **Started:** 2026-03-09T19:56:10Z
-- **Completed:** 2026-03-09T20:03:14Z
-- **Tasks:** 2
-- **Files modified:** 11
-
-## Accomplishments
-- `ScanLibrary(id)` resolves library path from DB and scans only that directory, associating files with library_id
-- FIFO scan queue ensures only one scan runs at a time, with silent dedup for duplicate requests
-- `ScanAllLibraries()` queries all libraries and queues them sequentially
-- `CancelCurrentScan()` stops current library and auto-starts next queued; `CancelAllScans()` clears queue too
-- Pause freezes current scan AND queue (drainQueue only runs on scan completion)
-- All scan events (progress, started, complete, cancelled) include library name and queue count
-
-## Task Commits
-
-Each task was committed atomically (note: lint fix amend merged both into single commit):
-
-1. **Task 1: Add library_id to CreateAudioFile + update events and progress types** - `943db1c` (feat)
-2. **Task 2: Create scan queue coordinator and refactor Library for per-library scanning** - `943db1c` (feat)
-
-_Note: Tasks were merged into a single commit due to lint fix amend during pre-commit hook._
-
-## Files Created/Modified
-- `backend/library/scan_queue.go` - Scan queue coordinator: ScanLibrary, ScanAllLibraries, CancelCurrentScan, CancelAllScans, drainQueue
-- `backend/library/library.go` - Refactored Scan() → scanInternal() with library ID/name/path parameters, per-library DB queries
-- `backend/library/scan_control.go` - Deprecated CancelScan() in favor of queue-aware methods
-- `backend/library/metrics.go` - Added LibraryID, LibraryName to ScanMetrics; LibraryID, LibraryName, QueuedCount to ScanProgress
-- `backend/events/events.go` - Added LibraryScanQueued and LibraryScanQueueDrained constants
-- `backend/database/sql/queries/audio_files.sql` - Added library_id to CreateAudioFile INSERT
-- `backend/database/sql/sqlcgen/audio_files.sql.go` - Regenerated with LibraryID in CreateAudioFileParams
-- `frontend/src/events.ts` - Regenerated with scan queue events
-- `frontend/wailsjs/go/library/Library.d.ts` - Auto-generated Wails bindings for new methods
-- `frontend/wailsjs/go/library/Library.js` - Auto-generated Wails bindings for new methods
-- `frontend/wailsjs/go/models.ts` - Auto-generated model updates
-
-## Decisions Made
-- **Library ID threading via importResult:** Rather than adding a libraryID field to the Library struct, the ID is threaded through the scan pipeline via the importResult struct and set in the DB writer goroutine. This keeps the data flow explicit and avoids mutation of shared state.
-- **scanInternal returns only metrics:** Changed signature from `(*ScanMetrics, error)` to `*ScanMetrics` since the scan queue coordinator calls it in a goroutine where error return is impractical. Errors are logged and accumulated in ScanMetrics.Warnings.
-- **Auto worker count per library:** Each library path may reside on different storage (SSD vs HDD), so worker count uses `ScanConcurrencyAuto` with per-path detection rather than the global config value.
-- **Backward-compatible Scan():** Retained as deprecated wrapper that resolves the library from `l.conf.DirectoryPath` via `GetLibraryByPath`. This keeps `handleConfigUpdate` and `FullRescan` working without changes.
-
-## Deviations from Plan
-
-None - plan executed exactly as written.
-
-## Issues Encountered
-None
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Per-library scan pipeline complete, ready for Phase 11 Plan 02 (if exists) or Phase 12 (Library CRUD & Data Integrity)
-- Frontend can now call `ScanLibrary(id)`, `ScanAllLibraries()`, `CancelCurrentScan()`, `CancelAllScans()`
-- Progress events include library identification for UI display
-- Phase 12 can build library management UI on top of these Wails bindings
-
----
-*Phase: 11-per-library-scan-pipeline*
-*Completed: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-PLAN.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-PLAN.md
deleted file mode 100644
index fddd372..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-PLAN.md
+++ /dev/null
@@ -1,245 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 02
-type: execute
-wave: 2
-depends_on: ["11-01"]
-files_modified:
- - frontend/src/components/config-page/config-page.ts
- - frontend/src/components/library-manager/library-manager.ts
- - frontend/wailsjs/go/library/Library.d.ts
- - frontend/wailsjs/go/library/Library.js
-autonomous: true
-requirements: [LSCAN-03, LSCAN-04]
-
-must_haves:
- truths:
- - "Progress UI shows which library is currently being scanned by name"
- - "Progress UI shows queue count when libraries are queued"
- - "Cancel during queued multi-scan shows modal with 'Cancel This Library' and 'Cancel All Scanning' choices"
- - "Cancelling one library automatically starts scanning the next queued library"
- - "Scan All Libraries button exists and triggers ScanAllLibraries binding"
- artifacts:
- - path: "frontend/src/components/config-page/config-page.ts"
- provides: "Updated cancel dialog with scope choice, progress with library name"
- - path: "frontend/src/components/library-manager/library-manager.ts"
- provides: "Scan All Libraries button, per-library progress display"
- - path: "frontend/wailsjs/go/library/Library.d.ts"
- provides: "TypeScript declarations for ScanLibrary, ScanAllLibraries, CancelCurrentScan, CancelAllScans"
- key_links:
- - from: "frontend/src/components/config-page/config-page.ts"
- to: "@go/library/Library"
- via: "Wails binding calls for CancelCurrentScan, CancelAllScans"
- pattern: "CancelCurrentScan|CancelAllScans"
- - from: "frontend/src/components/library-manager/library-manager.ts"
- to: "@go/library/Library"
- via: "Wails binding calls for ScanAllLibraries"
- pattern: "ScanAllLibraries"
----
-
-
-Update the frontend scan UI to display per-library progress (library name + queue count), add a "Scan All Libraries" button, and implement the cancel scope modal dialog for queued scans.
-
-Purpose: Fulfill LSCAN-03 (progress identifies which library) and LSCAN-04 frontend (cancel/pause work per-library with clear scope).
-Output: Updated config-page with library-aware cancel dialog, library-manager with Scan All button, Wails binding stubs.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/phases/11-per-library-scan-pipeline/11-CONTEXT.md
-@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
-@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md
-
-
-
-
-Updated ScanProgress payload (from backend/library/metrics.go after Plan 01):
-```typescript
-interface ScanProgress {
- phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails';
- total: number;
- processed: number;
- added: number;
- skipped: number;
- updated: number;
- libraryId: number; // NEW — which library is scanning
- libraryName: string; // NEW — display name
- queuedCount: number; // NEW — libraries still queued
-}
-```
-
-New Wails-bound methods (from Plan 01):
-```typescript
-// These will need stubs in Library.d.ts and Library.js
-export function ScanLibrary(id: number): Promise;
-export function ScanAllLibraries(): Promise;
-export function CancelCurrentScan(): Promise;
-export function CancelAllScans(): Promise;
-export function GetScanQueueLength(): Promise;
-```
-
-New events (from Plan 01):
-```typescript
-LibraryScanQueued: "LibraryScanQueued",
-LibraryScanQueueDrained: "LibraryScanQueueDrained",
-```
-
-Existing cancel dialog pattern from config-page.ts:
-- Modal overlay with stopPropagation
-- Three button choices
-- handleCancelKeep / handleCancelDiscard / handleCancelDialogDismiss
-
-
-
-
-
-
- Task 1: Add Wails binding stubs and update progress/cancel UI in config-page
-
- frontend/wailsjs/go/library/Library.d.ts
- frontend/wailsjs/go/library/Library.js
- frontend/src/components/config-page/config-page.ts
-
-
-1. **Add Wails binding stubs** to `frontend/wailsjs/go/library/Library.d.ts`:
- ```typescript
- export function ScanLibrary(id: number): Promise;
- export function ScanAllLibraries(): Promise;
- export function CancelCurrentScan(): Promise;
- export function CancelAllScans(): Promise;
- export function GetScanQueueLength(): Promise;
- export function QueuedLibraryNames(): Promise;
- ```
-
- And corresponding runtime implementations in `Library.js`:
- ```javascript
- export function ScanLibrary(id) { return window['go']['library']['Library']['ScanLibrary'](id); }
- export function ScanAllLibraries() { return window['go']['library']['Library']['ScanAllLibraries'](); }
- export function CancelCurrentScan() { return window['go']['library']['Library']['CancelCurrentScan'](); }
- export function CancelAllScans() { return window['go']['library']['Library']['CancelAllScans'](); }
- export function GetScanQueueLength() { return window['go']['library']['Library']['GetScanQueueLength'](); }
- export function QueuedLibraryNames() { return window['go']['library']['Library']['QueuedLibraryNames'](); }
- ```
-
-2. **Update config-page.ts ScanProgress interface** to include the new fields:
- - Add `libraryId: number`, `libraryName: string`, `queuedCount: number` to the `ScanProgress` interface
-
-3. **Update imports** — replace `CancelScan` import with `CancelCurrentScan, CancelAllScans` from `@go/library/Library`
-
-4. **Update progress display** (`renderScanProgress` method or equivalent):
- - When `scanProgress.libraryName` is non-empty, show "Scanning: [Library Name]" as the progress label instead of just "Scanning"
- - When `scanProgress.queuedCount > 0`, add a line below: "[N] libraries queued" in tertiary text color
- - Format: `Scanning: My Music (245/1200 files)` with `2 libraries queued` below
-
-5. **Update cancel dialog** — replace the current three-option dialog with the per-library-aware version per CONTEXT.md:
- - Add `@state() private scanQueuedCount = 0;` to track queue state
- - Update `handleScanProgress` to also save `queuedCount`
- - **When `queuedCount > 0`** (multi-scan in progress): show modal dialog with TWO buttons:
- - "Cancel This Library" — calls `CancelCurrentScan()` (stops current, next starts)
- - "Cancel All Scanning" — calls `CancelAllScans()` (stops everything)
- - No default — user must pick (per CONTEXT.md: "no default, user must pick")
- - **When `queuedCount === 0`** (single scan): keep existing cancel behavior but call `CancelCurrentScan()` instead of `CancelScan()`. Can use the existing Keep/Discard/Continue dialog pattern.
- - Update `handleCancelKeep` → call `CancelCurrentScan()` instead of `CancelScan()`
- - Update `handleCancelDiscard` → call `CancelCurrentScan()` instead of `CancelScan()`
-
-6. **Handle new events** in `connectedCallback`:
- - Listen for `LibraryScanQueued` — update `scanQueuedCount` from event payload
- - Listen for `LibraryScanQueueDrained` — set `scanQueuedCount = 0`, reset scan state
-
-7. **Update scan buttons section** — when not scanning, show "Scan All Libraries" as an additional button alongside Soft Scan and Full Rescan. It calls `ScanAllLibraries()`.
-
-**Styling notes:**
-- Use existing design tokens (`--yj-text-primary`, `--yj-text-tertiary`, `--yj-accent`)
-- Queue count text: `.progress-detail` style (smaller, tertiary color)
-- Library name in progress: bold, primary text color
-- Cancel modal buttons: "Cancel This Library" gets `btn-warning`, "Cancel All Scanning" gets `btn-danger`
-- Keep `.cancel-dialog` CSS class pattern from Phase 9
-
-**TypeScript strictness:**
-- `override` keyword on lifecycle methods
-- `import type` for type-only imports
-- Private event handlers as arrow functions
-
-
- cd /mnt/vault/dev/golang/yellowjacket/frontend && npx tsc --noEmit
-
-
-- ScanProgress interface includes libraryId, libraryName, queuedCount
-- Progress UI shows "Scanning: [Library Name]" and queue count
-- Cancel dialog shows scope choice when multiple scans queued
-- CancelCurrentScan/CancelAllScans called instead of CancelScan
-- Scan All Libraries button exists in scan actions
-- TypeScript compiles cleanly
-
-
-
-
- Task 2: Update library-manager component for per-library scan display
-
- frontend/src/components/library-manager/library-manager.ts
-
-
-1. **Update ScanProgress interface** in library-manager.ts to match the new fields: add `libraryId: number`, `libraryName: string`, `queuedCount: number`.
-
-2. **Update progress rendering** in `renderScanProgress()`:
- - Show library name: "Scanning: [Library Name]" as the progress label
- - Show queued count when > 0: "[N] libraries queued" in tertiary text
-
-3. **Update imports** — add `ScanAllLibraries` import from `@go/library/Library`
-
-4. **Add "Scan All Libraries" button** to the scan actions section:
- - Place it alongside existing "Soft Scan" and "Full Rescan" buttons
- - Style: `btn-primary` class, disabled when scanning
- - Handler: `private handleScanAll = async (): Promise => { await ScanAllLibraries(); }`
- - Label: "Scan All Libraries" (or "Scanning..." when active)
-
-5. **Listen for LibraryScanQueued and LibraryScanQueueDrained events**:
- - In `connectedCallback`, add event subscriptions
- - In `disconnectedCallback`, clean up subscriptions
- - These events update scanning state for the UI
-
-6. **Update handleScanComplete** to handle per-library scan completion:
- - The `LibraryScanComplete` event now includes `libraryName` in the metrics
- - If queue is still draining, don't reset scanning state (wait for `LibraryScanQueueDrained`)
- - Only fully reset `scanning = false` on `LibraryScanQueueDrained` or when `queuedCount === 0` in the complete event
-
-
- cd /mnt/vault/dev/golang/yellowjacket/frontend && npx tsc --noEmit
-
-
-- Library-manager shows library name in scan progress
-- "Scan All Libraries" button exists and calls ScanAllLibraries
-- Scan state properly tracks queue draining (doesn't reset early)
-- TypeScript compiles cleanly
-
-
-
-
-
-
-```bash
-# TypeScript compiles
-cd frontend && npx tsc --noEmit
-
-# Full project builds (backend + frontend)
-cd .. && go build ./...
-```
-
-
-
-- Progress bar shows "Scanning: [Library Name] (N/M files)" during scan
-- Queue count visible when libraries are queued
-- Cancel modal offers "Cancel This Library" / "Cancel All Scanning" during queued scans
-- "Scan All Libraries" button exists in both config-page and library-manager
-- TypeScript compiles cleanly
-
-
-
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-SUMMARY.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-SUMMARY.md
deleted file mode 100644
index 142abcf..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-02-SUMMARY.md
+++ /dev/null
@@ -1,113 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 02
-subsystem: ui
-tags: [lit-element, scan-progress, cancel-dialog, per-library, wails-bindings]
-
-# Dependency graph
-requires:
- - phase: 11-per-library-scan-pipeline
- provides: ScanLibrary, ScanAllLibraries, CancelCurrentScan, CancelAllScans, queue-aware ScanProgress with libraryId/libraryName/queuedCount, LibraryScanQueued/LibraryScanQueueDrained events
-provides:
- - Per-library progress display showing library name and queue count in config-page and library-manager
- - Queue-aware cancel dialog with "Cancel This Library" / "Cancel All Scanning" scope choice
- - "Scan All Libraries" button in both config-page and library-manager
-affects: [12-library-crud-data-integrity, 13-library-views-phantom-tracks]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Queue-aware cancel dialog: scope choice when queuedCount > 0, single-scan dialog otherwise"
- - "Library name in progress label: baseLabel + libraryName from ScanProgress"
- - "Queue draining guard: handleScanComplete defers full reset when queue still has entries"
-
-key-files:
- created: []
- modified:
- - frontend/src/components/config-page/config-page.ts
- - frontend/src/components/library-manager/library-manager.ts
-
-key-decisions:
- - "Cancel dialog shows two-option scope choice (Cancel This Library / Cancel All) only when queuedCount > 0; single-scan uses existing Keep/Discard/Continue pattern"
- - "handleScanComplete defers scanning=false when queue has entries, relying on ScanQueueDrained for final reset"
- - "Wails binding stubs already generated by Plan 01 auto-generation; no manual stubs needed"
-
-patterns-established:
- - "Queue-aware cancel dialog: conditional dialog content based on scanQueuedCount > 0"
- - "Progress library prefix: libraryName from ScanProgress displayed in progress-label"
-
-requirements-completed: [LSCAN-03, LSCAN-04]
-
-# Metrics
-duration: 4min
-completed: 2026-03-09
----
-
-# Phase 11 Plan 02: Frontend Scan UI Summary
-
-**Per-library progress display with library name and queue count, queue-aware cancel dialog with scope choice, and Scan All Libraries button in both config-page and library-manager**
-
-## Performance
-
-- **Duration:** 4 min
-- **Started:** 2026-03-09T20:07:11Z
-- **Completed:** 2026-03-09T20:11:36Z
-- **Tasks:** 2
-- **Files modified:** 2
-
-## Accomplishments
-- Config-page and library-manager both show "Scanning: [Library Name]" in progress bar during scans
-- Queue count displayed as "[N] libraries queued" below progress bar when libraries are queued
-- Cancel dialog in config-page shows "Cancel This Library" / "Cancel All Scanning" scope choice when multiple scans queued
-- "Scan All Libraries" button added alongside Soft Scan and Full Rescan in both components
-- ScanProgress interface updated with libraryId, libraryName, queuedCount in both components
-- Event subscriptions for LibraryScanQueued and LibraryScanQueueDrained properly managed
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Add Wails binding stubs and update progress/cancel UI in config-page** - `d01591d` (feat)
-2. **Task 2: Update library-manager component for per-library scan display** - `d61f122` (feat)
-
-## Files Created/Modified
-- `frontend/src/components/config-page/config-page.ts` - Updated ScanProgress interface, replaced CancelScan with CancelCurrentScan/CancelAllScans, added queue-aware cancel dialog with scope choice, progress shows library name and queue count, Scan All Libraries button added
-- `frontend/src/components/library-manager/library-manager.ts` - Updated ScanProgress interface, progress shows library name and queue count, Scan All Libraries button added, queue event subscriptions, scan complete defers reset when queue draining
-
-## Decisions Made
-- **Cancel dialog scope choice:** When queuedCount > 0, show "Cancel This Library" (btn-warning) and "Cancel All Scanning" (btn-danger) — no default, user must pick. When queuedCount === 0, keep existing three-option Keep/Discard/Continue pattern but calling CancelCurrentScan instead of deprecated CancelScan.
-- **Queue drain guard:** handleScanComplete checks scanQueuedCount before resetting scanning=false. If queue has entries, only metrics are updated; full reset waits for LibraryScanQueueDrained event.
-- **Wails binding stubs already present:** Plan 01's auto-generation already created all needed stubs (ScanLibrary, ScanAllLibraries, CancelCurrentScan, CancelAllScans, GetScanQueueLength, QueuedLibraryNames) — no manual stub additions needed.
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 3 - Blocking] Unstaged backend files in git index**
-- **Found during:** Task 2 commit
-- **Issue:** Backend Go files (app.go, library.go, rescan.go) were staged in the git index from prior work, causing golangci-lint failures in the pre-commit hook on unrelated code
-- **Fix:** Unstaged the backend files before committing the frontend-only change
-- **Files modified:** None (git index manipulation only)
-- **Verification:** Commit succeeded with frontend-typecheck passing
-- **Committed in:** d61f122 (Task 2 commit)
-
----
-
-**Total deviations:** 1 auto-fixed (1 blocking)
-**Impact on plan:** Minor git workflow issue, no scope creep.
-
-## Issues Encountered
-None
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Per-library scan UI complete — progress identifies library by name, queue count visible, cancel has scope choice
-- Ready for Phase 11 Plan 03 (if exists) or Phase 12 (Library CRUD & Data Integrity)
-- Frontend fully wired to backend scan queue API from Plan 01
-
----
-*Phase: 11-per-library-scan-pipeline*
-*Completed: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-PLAN.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-PLAN.md
deleted file mode 100644
index 0a06c46..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-PLAN.md
+++ /dev/null
@@ -1,182 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 03
-type: execute
-wave: 2
-depends_on: ["11-01"]
-files_modified:
- - backend/app.go
- - backend/library/library.go
-autonomous: true
-requirements: [LSCAN-01, LSCAN-02]
-
-must_haves:
- truths:
- - "App auto-scans all libraries on launch using ScanAllLibraries"
- - "Legacy LibraryConfigChanged event handler is removed or updated for multi-library"
- - "Library struct no longer requires Config.DirectoryPath to function"
- artifacts:
- - path: "backend/app.go"
- provides: "Updated OnDomReady or OnStartup to trigger ScanAllLibraries on launch"
- - path: "backend/library/library.go"
- provides: "Updated NewLibrary constructor — Config no longer required"
- key_links:
- - from: "backend/app.go"
- to: "backend/library/scan_queue.go"
- via: "ScanAllLibraries call on startup"
- pattern: "library\\.ScanAllLibraries"
----
-
-
-Wire the per-library scan pipeline into app startup and clean up legacy single-directory scanning paths.
-
-Purpose: Ensure auto-scan on launch uses `ScanAllLibraries()` (same codepath as the UI button per CONTEXT.md), and remove/update legacy `LibraryConfigChanged` handler that assumed a single directory.
-Output: Updated app.go startup wiring, cleaned-up Library constructor.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/phases/11-per-library-scan-pipeline/11-CONTEXT.md
-@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
-
-
-
-From backend/library/scan_queue.go (created in Plan 01):
-```go
-func (l *Library) ScanLibrary(id int64) error
-func (l *Library) ScanAllLibraries() error
-func (l *Library) CancelCurrentScan()
-func (l *Library) CancelAllScans()
-```
-
-From backend/app.go (current):
-```go
-func (yj *YellowJacketApp) OnStartup(ctx context.Context)
-// Currently: yj.library.SetContext(ctx)
-// Currently: library is created with appConfig.Library (Config with DirectoryPath)
-
-func NewYellowJacketApp(...) {
- lib, err := library.NewLibrary(
- yjApp.appContext,
- yjApp.logger,
- yjApp.appConfig.Library, // Config with DirectoryPath
- yjApp.database,
- )
-}
-```
-
-From backend/library/library.go (current event handler):
-```go
-func (l *Library) registerEventHandlers() {
- runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
- // Parses DirectoryPath from event data, calls l.handleConfigUpdate
- })
-}
-```
-
-
-
-
-
-
- Task 1: Wire auto-scan on startup and clean up legacy single-directory code
-
- backend/app.go
- backend/library/library.go
-
-
-1. **Update `NewLibrary` constructor** in `backend/library/library.go`:
- - Make `*Config` parameter optional/removable. The Library no longer needs a pre-configured DirectoryPath because scan paths come from the database.
- - Keep the `*Config` parameter for backward compatibility but don't require `DirectoryPath` to be set.
- - Update validation: if `conf` is nil, create a default config with empty DirectoryPath (already handled).
-
-2. **Update `registerEventHandlers`** in `backend/library/library.go`:
- - Remove the `LibraryConfigChanged` event handler entirely. This handler assumed a single-directory model where changing the config triggers a scan. In the multi-library model:
- - Libraries are added/removed through the library CRUD API (Phase 12)
- - Scanning is triggered explicitly via `ScanLibrary()` or `ScanAllLibraries()`
- - The `LibraryConfigChanged` event and `handleConfigUpdate` method can be deleted or marked deprecated
- - Delete `handleConfigUpdate` method
- - Delete `errLibraryDirNotConfigured` sentinel error (no longer needed)
-
-3. **Update `NewYellowJacketApp` in `backend/app.go`**:
- - Change the `library.NewLibrary(...)` call. The Config parameter is less important now since DirectoryPath is ignored. Pass `yjApp.appConfig.Library` as before (it still has ScanConcurrency which is useful as a default).
-
-4. **Add auto-scan on startup** in `backend/app.go`:
- - In `OnDomReady` (or via a goroutine started in `OnStartup` that waits for DOM ready), trigger auto-scan.
- - Best approach: In `OnDomReady`, after the startup error check, launch a goroutine:
- ```go
- go func() {
- if err := yj.library.ScanAllLibraries(); err != nil {
- yj.logger.Error("auto-scan failed", "err", err)
- }
- }()
- ```
- - This uses the same `ScanAllLibraries()` codepath as the UI button (per CONTEXT.md: "Auto-scan on launch should use the same ScanAllLibraries() codepath as the UI button — single implementation").
- - It runs in a goroutine so it doesn't block the DOM ready callback.
- - Only run if there are libraries in the DB: check `l.db.Queries.CountLibraries(l.ctx)` first (or let ScanAllLibraries handle the empty case gracefully by returning immediately when GetAllLibraries returns an empty slice).
-
-5. **Clean up legacy `Scan()` method**:
- - In Plan 01, the old `Scan()` was kept as backward-compatible wrapper. Now review: since we're removing `handleConfigUpdate` which was the only caller of the legacy `Scan()` via `l.handleConfigUpdate → l.Scan()`, we can either:
- - Keep `Scan()` for tests (it's used in `scan_test.go`)
- - Update it to call `scanInternal` with the library from `l.conf.DirectoryPath` if set, or return early if not set
- - Keep `FullRescan()` — it's still called from the config-page UI. It should work with the first/default library. Update it to look up the default library from DB rather than using `l.conf.DirectoryPath`.
-
-6. **Update `FullRescan()`** in `backend/library/rescan.go`:
- - Instead of using `l.conf.DirectoryPath`, look up the first library from DB: `libs, err := l.db.Queries.GetAllLibraries(l.ctx)` and use `libs[0]`.
- - If no libraries exist, return an error.
- - Call `scanInternal(lib.ID, lib.Name, lib.Path)` instead of `l.Scan()`.
- - Per-library FullRescan will be added in Phase 12 — for now this rescans the first/only library.
-
-**Linting requirements:**
-- Doc comments ending with period
-- Blank line after early returns
-- Lines under 100 chars
-
-
- cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/... && go test ./backend/library/... -count=1 -timeout 60s
-
-
-- Auto-scan on startup calls ScanAllLibraries (same codepath as UI button)
-- Legacy LibraryConfigChanged handler removed
-- Legacy handleConfigUpdate removed
-- FullRescan uses library from DB instead of config DirectoryPath
-- go build passes, go vet passes, existing tests pass
-
-
-
-
-
-
-```bash
-# Full build
-go build ./...
-
-# Vet
-go vet ./backend/...
-
-# Tests pass (including scan_test.go)
-go test ./backend/library/... -count=1 -timeout 60s
-
-# No references to removed handler
-grep -rn "LibraryConfigChanged" backend/library/ | grep -v "_test.go"
-# Should return no hits (only events.go constant definition, not handler registration)
-```
-
-
-
-- App auto-scans all libraries on launch via ScanAllLibraries
-- LibraryConfigChanged handler removed from library package
-- handleConfigUpdate removed
-- FullRescan works with DB-sourced library (not config DirectoryPath)
-- All tests pass, build passes
-
-
-
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-SUMMARY.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-SUMMARY.md
deleted file mode 100644
index 0cf8c7d..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-03-SUMMARY.md
+++ /dev/null
@@ -1,117 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-plan: 03
-subsystem: library
-tags: [scan-pipeline, startup, auto-scan, legacy-cleanup]
-
-# Dependency graph
-requires:
- - phase: 11-per-library-scan-pipeline
- provides: ScanLibrary, ScanAllLibraries, scanInternal, scan queue coordinator
-provides:
- - Auto-scan all libraries on app launch via ScanAllLibraries in OnDomReady
- - FullRescan using DB-sourced library (not config DirectoryPath)
- - Cleaned-up Library with no legacy single-directory handler
-affects: [12-library-crud-data-integrity]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Auto-scan goroutine in OnDomReady — non-blocking startup scan"
- - "FullRescan resolves library from DB via GetAllLibraries"
-
-key-files:
- created: []
- modified:
- - backend/app.go
- - backend/library/library.go
- - backend/library/rescan.go
-
-key-decisions:
- - "FullRescan uses first library from GetAllLibraries — per-library rescan deferred to Phase 12"
- - "LibraryConfigChanged handler removed entirely rather than updated — multi-library model uses CRUD API"
- - "Scan() wrapper deleted — only callers were handleConfigUpdate and FullRescan, both updated"
-
-patterns-established:
- - "Auto-scan pattern: goroutine in OnDomReady calling ScanAllLibraries"
-
-requirements-completed: [LSCAN-01, LSCAN-02]
-
-# Metrics
-duration: 10min
-completed: 2026-03-09
----
-
-# Phase 11 Plan 03: Wire Auto-Scan and Clean Up Legacy Code Summary
-
-**Auto-scan all libraries on app launch via ScanAllLibraries goroutine, FullRescan from DB-sourced library, legacy single-directory handlers removed**
-
-## Performance
-
-- **Duration:** 10 min
-- **Started:** 2026-03-09T20:07:03Z
-- **Completed:** 2026-03-09T20:17:10Z
-- **Tasks:** 1
-- **Files modified:** 3
-
-## Accomplishments
-- Auto-scan on startup calls `ScanAllLibraries()` in a goroutine from `OnDomReady` — same codepath as UI button
-- Legacy `LibraryConfigChanged` event handler removed from `registerEventHandlers`
-- Legacy `handleConfigUpdate` method deleted (single-directory model)
-- Deprecated `Scan()` wrapper deleted (replaced by `ScanLibrary`/`ScanAllLibraries`)
-- `errLibraryDirNotConfigured` sentinel error removed
-- `FullRescan` now resolves library from DB via `GetAllLibraries` instead of config DirectoryPath
-- `FullRescan` calls `scanInternal` directly instead of the removed `Scan()` wrapper
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Wire auto-scan on startup and clean up legacy single-directory code** - `1aaf536` (feat)
-
-_Note: Code changes were included in the 11-02 metadata commit due to staging overlap. All changes are verified present and correct._
-
-## Files Created/Modified
-- `backend/app.go` - Added ScanAllLibraries goroutine in OnDomReady, added early return after startupErr
-- `backend/library/library.go` - Removed LibraryConfigChanged handler, handleConfigUpdate, Scan(), errLibraryDirNotConfigured; updated NewLibrary doc comment
-- `backend/library/rescan.go` - FullRescan resolves first library from DB, calls scanInternal directly, added errNoLibrariesConfigured sentinel
-
-## Decisions Made
-- **FullRescan uses first library from DB:** Per-library full rescan will be added in Phase 12. For now, `FullRescan()` takes the first library from `GetAllLibraries()` — this preserves backward compatibility for the config-page "Rescan" button in the single-library case.
-- **Complete removal of LibraryConfigChanged handler:** Rather than updating the handler for multi-library, it was removed entirely. In the multi-library model, libraries are managed through the CRUD API (Phase 12) and scanning is triggered explicitly via `ScanLibrary`/`ScanAllLibraries`.
-- **Scan() wrapper deleted:** The only callers were `handleConfigUpdate` (deleted) and `FullRescan` (updated to use `scanInternal` directly). No backward-compatible wrapper needed.
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 3 - Blocking] Fixed golangci-lint wsl and err113 violations**
-- **Found during:** Task 1 (commit attempt)
-- **Issue:** Pre-commit hook flagged: (1) wsl — block ending with comment in registerEventHandlers, (2) err113 — dynamic errors.New in rescan.go
-- **Fix:** (1) Moved comment to function doc comment, removed empty return before close brace. (2) Created static `errNoLibrariesConfigured` sentinel error variable.
-- **Files modified:** backend/library/library.go, backend/library/rescan.go
-- **Verification:** golangci-lint passes with 0 issues
-- **Committed in:** 1aaf536 (part of task commit)
-
----
-
-**Total deviations:** 1 auto-fixed (blocking — lint compliance)
-**Impact on plan:** Necessary for pre-commit hook compliance. No scope creep.
-
-## Issues Encountered
-None
-
-## User Setup Required
-None - no external service configuration required.
-
-## Next Phase Readiness
-- Phase 11 complete — all 3 plans executed
-- Per-library scan pipeline fully wired: ScanLibrary(id), ScanAllLibraries(), auto-scan on launch
-- Ready for Phase 12: Library CRUD & Data Integrity
-- Frontend already has per-library progress display and queue-aware cancel dialog (Plan 02)
-- Phase 12 can build library management UI (add/rename/remove) on top of existing scan infrastructure
-
----
-*Phase: 11-per-library-scan-pipeline*
-*Completed: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-CONTEXT.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-CONTEXT.md
deleted file mode 100644
index 1326ea0..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-CONTEXT.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# Phase 11: Per-Library Scan Pipeline - Context
-
-**Gathered:** 2026-03-09
-**Status:** Ready for planning
-
-
-## Phase Boundary
-
-Refactor the scan pipeline from scanning a single hardcoded directory to scanning individual libraries by ID. Add sequential scan coordination (queue) so only one library scans at a time. Update progress UI to identify which library is scanning. Existing cancel/pause/resume controls work per-library with clear scope when multiple scans are queued.
-
-Library CRUD UI is Phase 12. Library-filtered views are Phase 13. This phase only changes how scans are triggered, coordinated, and displayed.
-
-
-
-
-## Implementation Decisions
-
-### Concurrent scan policy
-- Queue silently when a scan is requested while another is running — no confirmation dialog, no toast
-- Ignore duplicate scan requests silently (if library is already scanning or already queued, no-op)
-- Unbounded queue — no cap on queued scans (realistic library counts are low, 2-10)
-- Seamless transition between queued scans — progress UI updates to next library name, no notification
-
-### Scan trigger model
-- Auto-scan all libraries on app launch (current single-directory behavior extended to all libraries)
-- `ScanLibrary(id int64)` Wails-bound method — scans a specific library by database ID
-- `ScanAllLibraries()` Wails-bound method — queries all libraries and queues them sequentially; used by both app startup and the UI "Scan All" button
-- "Scan All Libraries" button in the UI in addition to per-library scan buttons
-
-### Progress identification
-- Library name shown in existing progress bar area: "Scanning: [Library Name] (245/1200 files)"
-- When libraries are queued, show queue count: "N libraries queued" alongside the active scan progress
-- Progress UI disappears/collapses when all scans complete (matches current behavior)
-
-### Cancel/pause scope
-- Cancel button during a queued multi-scan shows a **modal dialog** with two choices: "Cancel This Library" and "Cancel All Scanning" — no default, user must pick
-- If user cancels just the current library, the next queued library starts automatically
-- Pause freezes the current scan AND the queue — resume continues the paused library, then the queue proceeds
-- No partial scan indication needed — partially-scanned library keeps whatever files were processed, user can re-scan later
-
-### Claude's Discretion
-- Event payload format (whether scan events include library name or just ID)
-- Internal queue data structure implementation
-- Exact progress bar label formatting and layout
-- How "Scan All" button is placed in the UI (this phase focuses on the button existing; Phase 12 designs the full library management UI)
-
-
-
-
-## Specific Ideas
-
-- The scan queue coordinator should be a separate concern from the scan execution itself — clean separation between "what to scan next" and "how to scan"
-- Cancel dialog should feel similar to the existing cancel confirmation from Phase 9, extended with the scope choice
-- Auto-scan on launch should use the same `ScanAllLibraries()` codepath as the UI button — single implementation
-
-
-
-
-## Deferred Ideas
-
-None — discussion stayed within phase scope
-
-
-
----
-
-*Phase: 11-per-library-scan-pipeline*
-*Context gathered: 2026-03-09*
diff --git a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-VERIFICATION.md b/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-VERIFICATION.md
deleted file mode 100644
index 4c6016f..0000000
--- a/.planning/milestones/v1.1-phases/11-per-library-scan-pipeline/11-VERIFICATION.md
+++ /dev/null
@@ -1,112 +0,0 @@
----
-phase: 11-per-library-scan-pipeline
-verified: 2026-03-09T20:30:00Z
-status: passed
-score: 12/12 must-haves verified
----
-
-# Phase 11: Per-Library Scan Pipeline Verification Report
-
-**Phase Goal:** Users can scan individual libraries independently with proper sequential coordination
-**Verified:** 2026-03-09T20:30:00Z
-**Status:** passed
-**Re-verification:** No — initial verification
-
-## Goal Achievement
-
-### Observable Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 1 | ScanLibrary(id) scans only the directory associated with that library ID | ✓ VERIFIED | `scan_queue.go:22-69` — `ScanLibrary` queries `GetLibrary(id)` from DB, passes `lib.Path` to `scanInternal()` |
-| 2 | Only one library scans at a time — additional requests are silently queued | ✓ VERIFIED | `scan_queue.go:49-66` — if `scanActive`, appends to `scanQueue`, emits `LibraryScanQueued` |
-| 3 | Duplicate scan requests for the same library are silently ignored | ✓ VERIFIED | `scan_queue.go:31-41` — checks `currentScanLibraryID` and iterates `scanQueue` for dedup |
-| 4 | Cancel/pause/resume work per-library — cancelling one library starts the next queued | ✓ VERIFIED | `scan_queue.go:96-117` — `CancelCurrentScan()` cancels context, `drainQueue()` at line 152 pops next; `CancelAllScans()` clears queue first |
-| 5 | Pausing freezes both the current scan AND the queue | ✓ VERIFIED | `scan_control.go:28-40` — `PauseScan` sets `scanPaused=true`, creates blocking channel. `drainQueue` only runs after `scanInternal` returns, which blocks on pause. |
-| 6 | ScanAllLibraries queries all libraries and queues them sequentially | ✓ VERIFIED | `scan_queue.go:73-91` — queries `GetAllLibraries`, iterates calling `ScanLibrary(lib.ID)` |
-| 7 | Progress UI shows which library is currently being scanned by name | ✓ VERIFIED | `config-page.ts:2173-2214` and `library-manager.ts:931-972` — both render `Scanning: ${p.libraryName}` in progress labels |
-| 8 | Progress UI shows queue count when libraries are queued | ✓ VERIFIED | `config-page.ts:2185-2191,2257-2263` and `library-manager.ts:943-949,1014-1020` — render `${p.queuedCount} libraries queued` |
-| 9 | Cancel during queued multi-scan shows modal with 'Cancel This Library' and 'Cancel All Scanning' choices | ✓ VERIFIED | `config-page.ts:2050-2097` — when `scanQueuedCount > 0`, renders two-button dialog: "Cancel This Library" (`btn-warning`, calls `CancelCurrentScan`) and "Cancel All Scanning" (`btn-danger`, calls `CancelAllScans`) |
-| 10 | Cancelling one library automatically starts scanning the next queued library | ✓ VERIFIED | `scan_queue.go:152-173` — `drainQueue()` pops next entry and calls `startScan` in goroutine |
-| 11 | Scan All Libraries button exists and triggers ScanAllLibraries binding | ✓ VERIFIED | `config-page.ts:1997-2002` — "Scan All Libraries" button with `btn-primary`, calls `handleScanAll → ScanAllLibraries()`. Also `library-manager.ts:1291-1298` — identical button |
-| 12 | App auto-scans all libraries on launch using ScanAllLibraries | ✓ VERIFIED | `app.go:273-277` — goroutine in `OnDomReady` calls `yj.library.ScanAllLibraries()` |
-
-**Score:** 12/12 truths verified
-
-### Required Artifacts
-
-| Artifact | Expected | Status | Details |
-|----------|----------|--------|---------|
-| `backend/library/scan_queue.go` | Scan queue coordinator | ✓ VERIFIED | 174 lines. Exports: `ScanLibrary`, `ScanAllLibraries`, `CancelCurrentScan`, `CancelAllScans`, `GetScanQueueLength`, `QueuedLibraryNames`. Internal: `startScan`, `drainQueue`, `scanQueueEntry` |
-| `backend/library/library.go` | Updated scan pipeline with `scanInternal` | ✓ VERIFIED | 1534 lines. `scanInternal(libraryID, libraryName, libraryPath)` uses `GetAudioFilesByLibrary(ctx, libraryID)` for per-library file loading, threads `libraryID` through `importResult`. `mkProgress` closure includes library identification. |
-| `backend/library/scan_control.go` | Deprecated CancelScan, per-library controls | ✓ VERIFIED | 92 lines. `CancelScan()` deprecated with doc comment pointing to queue-aware methods. `PauseScan`/`ResumeScan`/`IsScanActive`/`IsScanPaused` unchanged. |
-| `backend/library/metrics.go` | Library identification in ScanProgress/ScanMetrics | ✓ VERIFIED | `ScanProgress` has `LibraryID`, `LibraryName`, `QueuedCount`. `ScanMetrics` has `LibraryID`, `LibraryName`. |
-| `backend/events/events.go` | Scan queue event constants | ✓ VERIFIED | `LibraryScanQueued` and `LibraryScanQueueDrained` constants present |
-| `frontend/src/events.ts` | Regenerated TypeScript events | ✓ VERIFIED | Generated file includes `LibraryScanQueued` and `LibraryScanQueueDrained` |
-| `backend/database/sql/queries/audio_files.sql` | CreateAudioFile with library_id | ✓ VERIFIED | INSERT includes `library_id` as 11th parameter |
-| `backend/database/sql/sqlcgen/audio_files.sql.go` | Generated CreateAudioFileParams with LibraryID | ✓ VERIFIED | `CreateAudioFileParams` includes `LibraryID int64` field |
-| `frontend/src/components/config-page/config-page.ts` | Cancel dialog with scope, progress with library name | ✓ VERIFIED | 2425 lines. ScanProgress interface with `libraryId`, `libraryName`, `queuedCount`. Queue-aware cancel dialog renders when `scanQueuedCount > 0`. |
-| `frontend/src/components/library-manager/library-manager.ts` | Scan All button, per-library progress | ✓ VERIFIED | 1337 lines. Imports `ScanAllLibraries`, renders "Scan All Libraries" button, progress shows library name and queue count. |
-| `frontend/wailsjs/go/library/Library.d.ts` | TypeScript declarations for new methods | ✓ VERIFIED | Declares `ScanLibrary`, `ScanAllLibraries`, `CancelCurrentScan`, `CancelAllScans`, `GetScanQueueLength`, `QueuedLibraryNames` |
-| `frontend/wailsjs/go/library/Library.js` | Runtime implementations for new methods | ✓ VERIFIED | All 6 new methods implemented with correct `window['go']` paths |
-| `backend/app.go` | Auto-scan on startup via ScanAllLibraries | ✓ VERIFIED | `OnDomReady` goroutine calls `yj.library.ScanAllLibraries()` |
-| `backend/library/rescan.go` | FullRescan using DB-sourced library | ✓ VERIFIED | `FullRescan()` queries `GetAllLibraries()`, uses `libs[0]`, calls `scanInternal(lib.ID, lib.Name, lib.Path)` |
-
-### Key Link Verification
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| `scan_queue.go` | `library.go` | `scanQueue calls scanInternal` | ✓ WIRED | `startScan` at line 145 calls `l.scanInternal(entry.libraryID, entry.libraryName, entry.libraryPath)` |
-| `scan_queue.go` | `sqlcgen/libraries.sql.go` | `GetLibrary query` | ✓ WIRED | `ScanLibrary` at line 23 calls `l.db.Queries.GetLibrary(l.ctx, id)` |
-| `library.go` | `sqlcgen/audio_files.sql.go` | `CreateAudioFile with LibraryID` | ✓ WIRED | `saveAudioFile` at line 955 sets `LibraryID: result.libraryID` in `CreateAudioFileParams` |
-| `config-page.ts` | `@go/library/Library` | `CancelCurrentScan/CancelAllScans` | ✓ WIRED | Lines 8-9 import `CancelCurrentScan, CancelAllScans`. Used in handlers at lines 1052, 1058, 1066 |
-| `library-manager.ts` | `@go/library/Library` | `ScanAllLibraries` | ✓ WIRED | Line 7 imports `ScanAllLibraries`. Called in `handleScanAll` at line 801 |
-| `app.go` | `scan_queue.go` | `ScanAllLibraries on startup` | ✓ WIRED | Line 274 calls `yj.library.ScanAllLibraries()` in goroutine |
-
-### Requirements Coverage
-
-| Requirement | Source Plan | Description | Status | Evidence |
-|-------------|------------|-------------|--------|----------|
-| LSCAN-01 | 11-01, 11-03 | User can trigger a scan for a specific library (not all-or-nothing) | ✓ SATISFIED | `ScanLibrary(id)` resolves library from DB, scans that directory only. `ScanAllLibraries()` queues all. Both Wails-bound. |
-| LSCAN-02 | 11-01, 11-03 | Scanning is sequential — only one library scans at a time (SQLite single-writer) | ✓ SATISFIED | `scanQueue` + `scanActive` mutex ensures one-at-a-time. `drainQueue()` pops next after current completes. |
-| LSCAN-03 | 11-02 | Scan progress UI shows which library is being scanned | ✓ SATISFIED | Both `config-page.ts` and `library-manager.ts` show `Scanning: [Library Name]` in progress, plus queue count. |
-| LSCAN-04 | 11-01, 11-02 | Existing scan cancellation and pause/resume work per-library | ✓ SATISFIED | `CancelCurrentScan()` cancels current, next starts automatically. `CancelAllScans()` clears queue. Pause freezes current + queue. Cancel dialog offers scope choice when queued. |
-
-### Anti-Patterns Found
-
-| File | Line | Pattern | Severity | Impact |
-|------|------|---------|----------|--------|
-| — | — | No TODOs, FIXMEs, placeholders, or empty implementations found | — | — |
-
-**Note:** The Wails-generated bindings (`Library.d.ts`, `Library.js`) still include a `Scan()` method stub even though the Go method was deleted. This is a stale binding — calling it from the frontend would fail at runtime. However, the `config-page.ts` and `library-manager.ts` still import and call `Scan()` from their soft scan handlers (`handleSoftScan`). This is a pre-existing pattern that was intentionally left for backward compatibility (the config-page's "Soft Scan" button calls `Scan()` which no longer exists). This is an ℹ️ Info-level note — the soft scan button will fail at runtime until Phase 12 addresses it, but it is outside Phase 11's scope (Phase 11's goal is per-library scanning, not removing legacy UI buttons).
-
-### Human Verification Required
-
-### 1. Scan All Libraries End-to-End
-
-**Test:** Add 2+ libraries via the database, click "Scan All Libraries" button
-**Expected:** Libraries scan sequentially, progress shows each library name in turn, queue count decrements, final QueueDrained resets UI
-**Why human:** Requires multiple libraries in DB and visual verification of progress transitions
-
-### 2. Cancel Scope Dialog
-
-**Test:** Start "Scan All Libraries" with 2+ libraries. While scanning, click "Cancel Scan" in config-page
-**Expected:** Modal dialog shows "Cancel This Library" and "Cancel All Scanning" buttons. "Cancel This Library" stops current, next starts. "Cancel All Scanning" stops everything.
-**Why human:** Visual dialog behavior and queue state transitions need runtime verification
-
-### 3. Pause Freezes Queue
-
-**Test:** Start "Scan All Libraries" with 2+ libraries. Pause the scan.
-**Expected:** Current scan pauses. No queued library starts until resume. Resume continues current scan, then queue proceeds.
-**Why human:** Requires observing real-time pause/resume behavior with queue coordination
-
-### 4. Auto-Scan on Launch
-
-**Test:** Add a library to the database, restart the application
-**Expected:** Scan starts automatically on DOM ready, progress shows library name
-**Why human:** Requires application restart and observing startup behavior
-
----
-
-_Verified: 2026-03-09T20:30:00Z_
-_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-PLAN.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-PLAN.md
deleted file mode 100644
index bceae36..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-PLAN.md
+++ /dev/null
@@ -1,408 +0,0 @@
----
-phase: 12-library-crud-data-integrity
-plan: 01
-type: execute
-wave: 1
-depends_on: []
-files_modified:
- - backend/library/crud.go
- - backend/events/events.go
- - frontend/src/events.ts
- - backend/queue/queue.go
-autonomous: true
-requirements: [LIB-01, LIB-02, LIB-03, DATA-02, DATA-03, PLAY-04]
-
-must_haves:
- truths:
- - "AddLibrary creates a library row, emits LibraryAdded event, and triggers ScanLibrary"
- - "RenameLibrary validates uniqueness and length, updates name, emits LibraryRenamed event"
- - "RemoveLibrary atomically deletes tracks, populates phantom metadata on playlist_tracks, deletes orphaned entities, deletes the library row, rebuilds FTS5 index, and emits LibraryRemoved event"
- - "Orphan cleanup correctly handles the dual artist_credit FK (recordings + release_groups)"
- - "Queue tracks from a removed library are cascade-deleted and queue state is compacted"
- - "Currently-playing track from a removed library causes playback to stop before removal proceeds"
- artifacts:
- - path: "backend/library/crud.go"
- provides: "AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact methods"
- exports: ["AddLibrary", "RenameLibrary", "RemoveLibrary", "GetRemovalImpact", "RemovalSummary", "RemovalImpact"]
- - path: "backend/events/events.go"
- provides: "LibraryAdded, LibraryRenamed, LibraryRemoved event constants"
- contains: "LibraryAdded"
- - path: "frontend/src/events.ts"
- provides: "Regenerated event constants"
- contains: "LibraryAdded"
- key_links:
- - from: "backend/library/crud.go"
- to: "backend/library/scan_queue.go"
- via: "ScanLibrary call after AddLibrary"
- pattern: "l\\.ScanLibrary"
- - from: "backend/library/crud.go"
- to: "backend/database/search.go"
- via: "RebuildSearchIndex after removal"
- pattern: "RebuildSearchIndex"
- - from: "backend/library/crud.go"
- to: "backend/queue/queue.go"
- via: "Queue compaction after cascade delete"
- pattern: "CompactAfterLibraryRemoval"
----
-
-
-Implement the backend Library CRUD API (AddLibrary, RenameLibrary, RemoveLibrary) with full data integrity: orphan cleanup, phantom track conversion, FTS5 rebuild, queue compaction, and event emission.
-
-Purpose: This is the core backend for Phase 12 — all frontend library management UI depends on these Wails-bound methods.
-Output: `backend/library/crud.go` with all CRUD methods, updated events, queue compaction method.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/12-library-crud-data-integrity/12-RESEARCH.md
-@.planning/phases/12-library-crud-data-integrity/12-CONTEXT.md
-@.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md
-@.planning/phases/10-schema-migration/10-01-SUMMARY.md
-
-@backend/library/library.go
-@backend/library/scan_queue.go
-@backend/library/rescan.go
-@backend/library/query.go
-@backend/events/events.go
-@backend/database/search.go
-@backend/queue/queue.go
-@backend/database/sql/queries/libraries.sql
-@backend/database/sql/schemas/_libraries.sql
-@backend/database/sql/schemas/audio_files.sql
-@backend/database/sql/schemas/playlist_tracks.sql
-@backend/player/player.go
-
-
-
-
-From backend/library/scan_queue.go:
-```go
-func (l *Library) ScanLibrary(id int64) error
-func (l *Library) ScanAllLibraries() error
-func (l *Library) CancelCurrentScan()
-func (l *Library) CancelAllScans()
-```
-
-From backend/library/library.go:
-```go
-type Library struct {
- ctx context.Context
- db *database.DB
- conf *config.Config
- logger *slog.Logger
- // ... scan state fields, mu sync.Mutex
-}
-```
-
-From backend/database/search.go:
-```go
-func (d *DB) RebuildSearchIndex() error
-```
-
-From backend/database/sql/queries/libraries.sql:
-```sql
--- name: CreateLibrary :one
-INSERT INTO libraries (name, path) VALUES (?, ?) RETURNING *;
--- name: GetLibrary :one
-SELECT * FROM libraries WHERE id = ? LIMIT 1;
--- name: GetLibraryByPath :one
-SELECT * FROM libraries WHERE path = ? LIMIT 1;
--- name: GetAllLibraries :many
-SELECT * FROM libraries ORDER BY name;
--- name: UpdateLibraryName :exec
-UPDATE libraries SET name = ? WHERE id = ?;
--- name: DeleteLibrary :exec
-DELETE FROM libraries WHERE id = ?;
--- name: CountLibraries :one
-SELECT COUNT(*) AS count FROM libraries;
--- name: CountAudioFilesByLibrary :one
-SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
-```
-
-From backend/queue/queue.go:
-```go
-func (q *Queue) Clear()
-func (q *Queue) EmitCurrentState()
-func (q *Queue) GetState() State
-type TrackLoader interface {
- IsPlaying() bool
- CurrentPositionSeconds() (int, error)
- UnloadTrack()
-}
-```
-
-From backend/events/events.go:
-```go
-// Library events.
-const (
- LibraryScanStarted = "LibraryScanStarted"
- LibraryScanProgress = "LibraryScanProgress"
- LibraryScanComplete = "LibraryScanComplete"
-)
-```
-
-From backend/player/player.go:
-```go
-func (p *Player) IsPlaying() bool
-func (p *Player) UnloadTrack()
-```
-
-
-
-
-
-
- Task 1: Implement Library CRUD methods and orphan cleanup pipeline
-
- backend/library/crud.go
- backend/events/events.go
- frontend/src/events.ts
-
-
-Create `backend/library/crud.go` with the following methods on the `Library` struct:
-
-**Types:**
-```go
-// RemovalImpact contains pre-removal counts for the confirmation dialog.
-type RemovalImpact struct {
- TrackCount int64 `json:"trackCount"`
- PlaylistsAffected int64 `json:"playlistsAffected"`
- QueueItemCount int64 `json:"queueItemCount"`
-}
-
-// RemovalSummary contains post-removal counts for the toast notification.
-type RemovalSummary struct {
- TracksDeleted int64 `json:"tracksDeleted"`
- ArtistsRemoved int64 `json:"artistsRemoved"`
- AlbumsRemoved int64 `json:"albumsRemoved"`
- GenresRemoved int64 `json:"genresRemoved"`
- PlaylistsAffected int64 `json:"playlistsAffected"`
- QueueItemsRemoved int64 `json:"queueItemsRemoved"`
-}
-```
-
-**AddLibrary(path string) (\*sqlcgen.Library, error):**
-- Validate path exists with `os.Stat`
-- Auto-name from `filepath.Base(path)`
-- Call `l.db.Queries.CreateLibrary(l.ctx, ...)` (the path UNIQUE constraint prevents duplicate paths)
-- Emit `events.LibraryAdded` event with the library struct
-- Start scanning async: `go func() { l.ScanLibrary(lib.ID) }()` — log error if it fails
-- Return the created library
-
-**RenameLibrary(id int64, newName string) error:**
-- Trim and validate: 1-50 chars, non-empty
-- Check uniqueness: call `GetAllLibraries`, iterate to find conflicting name (excluding self). Use application-level validation per research recommendation (no schema migration needed).
-- Call `l.db.Queries.UpdateLibraryName(l.ctx, ...)`
-- Emit `events.LibraryRenamed` with `map[string]any{"id": id, "name": newName}`
-
-**GetRemovalImpact(libraryID int64) (\*RemovalImpact, error):**
-- Three read-only queries (all hand-crafted SQL with SAFETY comments):
- - Track count: `SELECT COUNT(*) FROM audio_files WHERE library_id = ?`
- - Playlists affected: `SELECT COUNT(DISTINCT pt.playlist_id) FROM playlist_tracks pt JOIN audio_files af ON pt.audio_file_id = af.id WHERE af.library_id = ?`
- - Queue items: `SELECT COUNT(*) FROM queue_tracks qt JOIN audio_files af ON qt.audio_file_id = af.id WHERE af.library_id = ?`
-
-**RemoveLibrary(id int64) (\*RemovalSummary, error):**
-This is the critical method. Follow the exact order from RESEARCH.md to avoid the phantom metadata pitfall:
-
-1. **Cancel active scan** — If this library is currently scanning, cancel it and remove from queue. Call `l.cancelLibraryScan(id)` (new unexported helper that checks `l.currentScanLibraryID` and scan queue).
-2. **Stop playback if needed** — Check if the currently-playing track belongs to this library via a query: `SELECT COUNT(*) FROM audio_files WHERE library_id = ? AND file_path = ?` where the file_path comes from `l.player.GetCurrentFilePath()`. Need to expose a way to check — add a `currentTrackBelongsToLibrary` helper that uses the Queue to get the current track's file path and checks it against the library. If it matches, call `l.player.UnloadTrack()`.
-3. **Pre-count** for summary (track count, queue items affected, playlists affected).
-4. **Begin transaction** — `l.db.DB().BeginTx(l.ctx, nil)`
-5. **Populate phantom metadata** — MUST run BEFORE delete. Hand-crafted SQL UPDATE that copies live track metadata into phantom columns on playlist_tracks for tracks belonging to this library. See 12-RESEARCH.md Pattern 3 for the exact SQL.
-6. **Delete audio_files** — `DELETE FROM audio_files WHERE library_id = ?`. This triggers CASCADE on queue_tracks and SET NULL on playlist_tracks.audio_file_id.
-7. **Delete orphaned recordings** — `DELETE FROM recordings WHERE id NOT IN (SELECT DISTINCT recording_id FROM audio_files)`
-8. **Delete orphaned recording_genres** — `DELETE FROM recording_genres WHERE recording_id NOT IN (SELECT id FROM recordings)`
-9. **Delete orphaned release_group_recordings** — `DELETE FROM release_group_recordings WHERE recording_id NOT IN (SELECT id FROM recordings)`
-10. **Delete orphaned release_groups** — `DELETE FROM release_groups WHERE id NOT IN (SELECT DISTINCT release_group_id FROM release_group_recordings)`
-11. **Delete orphaned artist_credits** — CRITICAL: check BOTH recordings AND release_groups: `DELETE FROM artist_credit WHERE id NOT IN (SELECT DISTINCT artist_credit_id FROM recordings) AND id NOT IN (SELECT DISTINCT album_artist_credit_id FROM release_groups WHERE album_artist_credit_id IS NOT NULL)`
-12. **Delete orphaned artist_credit_artists** — `DELETE FROM artist_credit_artist WHERE credit_id NOT IN (SELECT id FROM artist_credit)`
-13. **Delete orphaned artists** — `DELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM artist_credit_artist)`
-14. **Delete orphaned genres** — `DELETE FROM genres WHERE id NOT IN (SELECT DISTINCT genre_id FROM recording_genres)`
-15. **Collect orphaned cover_art file paths** — `SELECT file_path FROM cover_art WHERE id NOT IN (SELECT DISTINCT cover_art_id FROM release_groups WHERE cover_art_id IS NOT NULL)` — store in a slice for post-commit cleanup.
-16. **Delete orphaned cover_art rows** — `DELETE FROM cover_art WHERE id NOT IN (SELECT DISTINCT cover_art_id FROM release_groups WHERE cover_art_id IS NOT NULL)`
-17. **Delete library row** — `DELETE FROM libraries WHERE id = ?`
-18. **Commit transaction**
-19. **Post-commit: Rebuild FTS5** — `l.db.RebuildSearchIndex()` (cannot run inside transaction)
-20. **Post-commit: Delete orphaned cover art files** — iterate collected paths, `os.Remove()`, log warnings on failure
-21. **Post-commit: Compact queue** — Call the new `l.queue.CompactAfterLibraryRemoval()` method (see Task 2)
-22. **Emit events** — `events.LibraryRemoved` with `map[string]any{"id": id, "summary": summary}`
-23. **Return summary**
-
-All hand-crafted SQL statements MUST have SAFETY comments following the project convention: `// SAFETY: [reason sqlc can't handle] + [safety assurance]`.
-
-**cancelLibraryScan(id int64):**
-Unexported helper. Check if `l.currentScanLibraryID` matches `id` — if so, call `CancelCurrentScan()`. Also remove the library from the scan queue slice (filter it out under `l.scanMu` lock).
-
-**currentTrackBelongsToLibrary(libraryID int64) bool:**
-Unexported helper. Get the current track file path from the queue (need to check if queue has a method to expose this, or query via `q.GetState().Tracks[q.GetState().CurrentIndex].FilePath`). Then query `SELECT library_id FROM audio_files WHERE file_path = ?` and compare.
-
-Actually — for stopping playback: the Library struct doesn't directly hold a reference to Player. Use the existing `RescanHooks.PreClear` pattern or add a `StopPlaybackHook func()` field on Library. In `app.go` OnStartup, wire it:
-```go
-yj.library.StopPlaybackHook = func() {
- yj.player.UnloadTrack()
-}
-```
-But that's for stopping unconditionally. For checking if the current track belongs to a library, it's simpler to do the check inside `RemoveLibrary` via a hand-crafted query: `SELECT COUNT(*) FROM audio_files af JOIN queue_tracks qt ON qt.audio_file_id = af.id WHERE af.library_id = ? AND qt.position = (SELECT current_position FROM queue LIMIT 1)`. If count > 0, call the hook.
-
-Better approach: add two fields to Library:
-```go
-// StopPlaybackForLibrary is called before library removal if the
-// currently-playing track belongs to the library being removed.
-// Wired in app.go OnStartup.
-StopPlaybackForLibrary func()
-// GetQueueState returns the current queue state for library removal checks.
-// Wired in app.go OnStartup.
-GetQueueState func() (currentFilePath string, ok bool)
-```
-
-Actually, the simplest approach that follows existing patterns: Library already has a `rescanHooks RescanHooks` field. Add a new field:
-```go
-removalHooks struct {
- stopPlayback func()
- compactQueue func()
-}
-```
-Wire in app.go:
-```go
-yj.library.SetRemovalHooks(library.RemovalHooks{
- StopPlayback: func() { yj.player.UnloadTrack() },
- CompactQueue: func() { yj.queue.CompactAfterLibraryRemoval() },
-})
-```
-Then for the "does current track belong to this library" check, just use a DB query in the transaction-preparation stage.
-
-**Add to events.go:**
-```go
-// Library CRUD events.
-const (
- LibraryAdded = "LibraryAdded"
- LibraryRenamed = "LibraryRenamed"
- LibraryRemoved = "LibraryRemoved"
-)
-```
-
-Then run `go generate ./backend/events/...` to regenerate `frontend/src/events.ts`.
-
-Use the SAFETY comment convention for ALL hand-crafted SQL (every ExecContext/QueryContext/QueryRowContext call).
-Follow error sentinel convention (err113): define `var errLibraryNameEmpty`, `var errLibraryNameTooLong`, `var errLibraryNameDuplicate`, `var errLibraryPathNotExist` as package-level vars.
-Follow nlreturn convention: blank line after early return blocks.
-Follow godot convention: doc comments end with periods.
-Follow wsl convention: blank line before var/const declarations.
-
-
- cd /mnt/vault/dev/golang/yellowjacket && go build ./backend/... && go vet ./backend/library/... && golangci-lint run ./backend/library/crud.go ./backend/events/events.go
-
-
- - crud.go exists with AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact, cancelLibraryScan methods
- - All hand-crafted SQL has SAFETY comments
- - RemoveLibrary follows exact order: phantom populate → delete audio_files → orphan cleanup → delete library → commit → FTS5 rebuild → cover art file cleanup → queue compact → events
- - events.go has LibraryAdded, LibraryRenamed, LibraryRemoved constants
- - events.ts is regenerated
- - `go build ./backend/...` passes
-
-
-
-
- Task 2: Add queue compaction method and wire removal hooks in app.go
-
- backend/queue/queue.go
- backend/app.go
- backend/library/crud.go
-
-
-**Queue compaction method** — Add to `backend/queue/queue.go`:
-
-```go
-// CompactAfterLibraryRemoval reloads queue state from the database
-// after a library removal has cascade-deleted queue_tracks rows.
-// It resets currentIndex to 0 (or -1 if empty), clears shuffleOrder,
-// unloads the current track if it was removed, and emits QueueChanged.
-func (q *Queue) CompactAfterLibraryRemoval() {
-```
-
-Implementation:
-1. Acquire `q.mu`
-2. Call `q.db.Queries.GetQueueTracks(q.db.Ctx)` to get the surviving queue tracks from DB
-3. Rebuild `q.tracks` from the DB rows
-4. If the previous current track's file path is no longer in the new track list:
- - Set `q.currentIndex = 0` (or -1 if empty)
- - Call `q.player.UnloadTrack()` if player is set
-5. Else: find the current track in the new list and update `q.currentIndex`
-6. Clear `q.shuffleOrder = nil` (will be regenerated on next shuffle toggle)
-7. Call `q.commitMutation(false)` to persist the compacted state
-8. Call `q.emitQueueChanged()` to push update to frontend
-
-Need to check if `GetQueueTracks` query exists. If not, the queue persistence uses its own reload pattern. Check `backend/queue/persistence.go` for the restore pattern and reuse it. The key point is that cascade DELETE already removed the rows from `queue_tracks` — we just need to reload and reindex.
-
-**Wire removal hooks in app.go** — In `OnStartup`, after existing hook wiring, add:
-
-```go
-yj.library.SetRemovalHooks(library.RemovalHooks{
- StopPlayback: func() { yj.player.UnloadTrack() },
- CompactQueue: func() { yj.queue.CompactAfterLibraryRemoval() },
-})
-```
-
-**Add RemovalHooks type to crud.go** (or library.go):
-
-```go
-// RemovalHooks contains callbacks invoked during library removal.
-// These break circular dependencies between library, player, and queue packages.
-type RemovalHooks struct {
- // StopPlayback stops the currently-playing track.
- StopPlayback func()
- // CompactQueue reloads queue state after cascade deletes.
- CompactQueue func()
-}
-
-func (l *Library) SetRemovalHooks(h RemovalHooks) {
- l.removalHooks = h
-}
-```
-
-Add `removalHooks RemovalHooks` field to the Library struct in library.go.
-
-Make sure RemoveLibrary in crud.go calls these hooks at the appropriate points (StopPlayback before the transaction if current track belongs to the library, CompactQueue after the transaction commits).
-
-
- cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/queue/... ./backend/library/... && golangci-lint run ./backend/queue/queue.go ./backend/app.go
-
-
- - CompactAfterLibraryRemoval method exists on Queue
- - RemovalHooks type exists with StopPlayback and CompactQueue callbacks
- - app.go wires removal hooks in OnStartup
- - Library struct has removalHooks field
- - `go build ./...` passes (full build including frontend binding generation)
-
-
-
-
-
-
-1. `go build ./...` — full project builds with no errors
-2. `go vet ./backend/...` — no vet issues
-3. `golangci-lint run ./backend/library/ ./backend/queue/ ./backend/events/` — no lint issues
-4. `go test ./backend/database/... -count=1` — existing database tests still pass
-5. `go test ./backend/queue/... -count=1` — existing queue tests still pass
-6. `go test ./backend/library/... -count=1` — existing library tests still pass
-7. Verify events.ts was regenerated with new event constants
-
-
-
-- All four CRUD methods (AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact) are implemented and compile
-- RemoveLibrary follows the correct order: phantom populate → delete → orphan cleanup → commit → FTS5 rebuild
-- Queue compaction handles cascade-deleted tracks correctly
-- All events (LibraryAdded, LibraryRenamed, LibraryRemoved) are defined and auto-generated to frontend
-- Existing tests pass with no regressions
-
-
-
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-SUMMARY.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-SUMMARY.md
deleted file mode 100644
index 87e7594..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-SUMMARY.md
+++ /dev/null
@@ -1,146 +0,0 @@
----
-phase: 12-library-crud-data-integrity
-plan: 01
-subsystem: library
-tags: [crud, orphan-cleanup, data-integrity, queue-compaction, phantom-tracks, events]
-
-# Dependency graph
-requires:
- - phase: 11-per-library-scan-pipeline
- provides: ScanLibrary, ScanAllLibraries, scan queue coordinator
- - phase: 10-schema-migration
- provides: libraries table, library_id FK, phantom columns on playlist_tracks
-provides:
- - AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact backend API
- - Orphan cleanup pipeline (recordings → genres → release_groups → artist_credits → artists → cover_art)
- - Queue CompactAfterLibraryRemoval method
- - Phantom metadata population before cascade delete
- - LibraryAdded, LibraryRenamed, LibraryRemoved events
-affects: [12-02-frontend-library-ui, 13-library-views-phantom-tracks]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "RemovalHooks callback struct — breaks circular dependency between library, player, and queue packages"
- - "Bottom-up orphan cleanup in single transaction — reference-counting DELETE WHERE NOT IN subqueries"
- - "Pre-populate phantom metadata BEFORE cascade delete — avoids lost join data"
- - "querySingleInt64 helper for hand-crafted SQL returning single aggregate values"
-
-key-files:
- created:
- - backend/library/crud.go
- modified:
- - backend/library/library.go
- - backend/events/events.go
- - frontend/src/events.ts
- - backend/queue/queue.go
- - backend/app.go
-
-key-decisions:
- - "Application-level name uniqueness check (iterate GetAllLibraries) rather than DB UNIQUE constraint — avoids migration 7"
- - "RemovalHooks struct pattern (StopPlayback + CompactQueue callbacks) wired in app.go — mirrors existing RescanHooks pattern"
- - "querySingleInt64 helper wraps DB.QueryContext returning *sql.Rows since DB has no QueryRowContext method"
- - "Sentinel errors for all validation (errLibraryNameEmpty, errLibraryNameTooLong, errLibraryNameDuplicate, errLibraryPathNotExist) per err113 linter rule"
- - "Context parameter placed first in querySingleInt64 per revive context-as-argument rule"
-
-patterns-established:
- - "RemovalHooks callback struct for cross-package lifecycle coordination"
- - "querySingleInt64 for hand-crafted aggregate SQL queries"
-
-requirements-completed: [LIB-01, LIB-02, LIB-03, DATA-02, DATA-03, PLAY-04]
-
-# Metrics
-duration: 6min
-completed: 2026-03-12
----
-
-# Phase 12 Plan 01: Library CRUD Backend API Summary
-
-**Backend CRUD API with AddLibrary/RenameLibrary/RemoveLibrary, full orphan cleanup pipeline, phantom track preservation, FTS5 rebuild, queue compaction, and event emission**
-
-## Performance
-
-- **Duration:** 6 min
-- **Started:** 2026-03-12T23:32:27Z
-- **Completed:** 2026-03-12T23:38:30Z
-- **Tasks:** 2
-- **Files created:** 1
-- **Files modified:** 5
-
-## Accomplishments
-
-- **AddLibrary(path)** — validates path exists, auto-names from folder base, creates DB row via sqlc, emits LibraryAdded, starts async ScanLibrary
-- **RenameLibrary(id, newName)** — validates 1-50 char length, checks name uniqueness across all libraries (application-level), updates via sqlc, emits LibraryRenamed
-- **GetRemovalImpact(libraryID)** — read-only queries returning track count, affected playlists count, queue items count for confirmation dialog
-- **RemoveLibrary(id)** — the critical 23-step method:
- 1. Cancel active scan for library
- 2. Stop playback if current track belongs to library
- 3. Pre-count metrics for summary
- 4. Begin transaction
- 5. Populate phantom metadata on playlist_tracks (BEFORE cascade delete)
- 6. DELETE audio_files WHERE library_id (CASCADE on queue_tracks, SET NULL on playlist_tracks)
- 7. Bottom-up orphan cleanup: recordings → recording_genres → release_group_recordings → release_groups → artist_credits (dual FK check) → artist_credit_artists → artists → genres → cover_art
- 8. DELETE library row
- 9. Commit transaction
- 10. Post-commit: RebuildSearchIndex (FTS5), delete cover art files, CompactQueue, emit events
-- **CompactAfterLibraryRemoval()** on Queue — reloads surviving tracks from DB, detects if current track survived, resets index, unloads player if needed, clears shuffle order, emits QueueChanged
-- **RemovalHooks** wired in app.go: StopPlayback → player.UnloadTrack(), CompactQueue → queue.CompactAfterLibraryRemoval()
-- Three new event constants: LibraryAdded, LibraryRenamed, LibraryRemoved — auto-generated to frontend events.ts
-
-## Task Commits
-
-Each task was committed atomically:
-
-1. **Task 1: Implement Library CRUD methods and orphan cleanup pipeline** — `bd44f83` (feat)
- - Created backend/library/crud.go (525 lines) with all CRUD methods
- - Added 3 event constants to backend/events/events.go
- - Added removalHooks field to Library struct
- - Regenerated frontend/src/events.ts
-2. **Task 2: Add queue compaction method and wire removal hooks in app.go** — `5995dfd` (feat)
- - Added CompactAfterLibraryRemoval() to backend/queue/queue.go (80 lines)
- - Wired RemovalHooks in backend/app.go OnStartup
-
-## Files Created/Modified
-
-- `backend/library/crud.go` (NEW) — AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact, cancelLibraryScan, currentTrackBelongsToLibrary, querySingleInt64, RemovalHooks type, sentinel errors
-- `backend/library/library.go` — Added removalHooks RemovalHooks field to Library struct
-- `backend/events/events.go` — Added LibraryAdded, LibraryRenamed, LibraryRemoved constants
-- `frontend/src/events.ts` — Regenerated with new library CRUD event constants
-- `backend/queue/queue.go` — Added CompactAfterLibraryRemoval method
-- `backend/app.go` — Wired RemovalHooks in OnStartup (StopPlayback + CompactQueue callbacks)
-
-## Decisions Made
-
-- **Application-level name uniqueness:** Iterate GetAllLibraries to check for duplicate names rather than adding a UNIQUE constraint to the libraries table. Avoids needing migration 7; the check is only done during rename which is infrequent.
-- **RemovalHooks callback struct:** Follows the existing RescanHooks pattern to break circular dependencies between library → player and library → queue packages. Wired in app.go where all subsystems are accessible.
-- **querySingleInt64 helper:** The project's `database.DB` type exposes `QueryContext` returning `*sql.Rows` but no `QueryRowContext`. The helper wraps the full scan-close cycle for single-value aggregate queries.
-- **Sentinel errors per err113:** Defined `errLibraryNameEmpty`, `errLibraryNameTooLong`, `errLibraryNameDuplicate`, `errLibraryPathNotExist` as package-level vars to satisfy the golangci-lint err113 rule.
-- **Context-first parameter order:** `querySingleInt64(ctx, db, query, args...)` follows `revive` linter's context-as-argument rule.
-
-## Deviations from Plan
-
-None — plan executed exactly as written.
-
-## Issues Encountered
-
-None.
-
-## User Setup Required
-
-None — no external service configuration required.
-
-## Next Plan Readiness
-
-- Plan 12-01 complete — backend CRUD API fully implemented
-- Ready for Plan 12-02: Frontend library management UI in settings + sidebar cleanup
-- All Wails-bindable methods (AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact) are available for frontend consumption
-- Events (LibraryAdded, LibraryRenamed, LibraryRemoved) are defined for frontend reactive updates
-
-## Self-Check: PASSED
-
-All files verified present, all commits verified in git log.
-
----
-*Phase: 12-library-crud-data-integrity*
-*Completed: 2026-03-12*
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-PLAN.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-PLAN.md
deleted file mode 100644
index 754ea9f..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-PLAN.md
+++ /dev/null
@@ -1,290 +0,0 @@
----
-phase: 12-library-crud-data-integrity
-plan: 02
-type: execute
-wave: 2
-depends_on: [12-01]
-files_modified:
- - frontend/src/components/config-page/config-page.ts
- - frontend/src/components/sidebar/app-sidebar.ts
- - frontend/index.ts
-autonomous: false
-requirements: [LIB-01, LIB-02, LIB-03, LIB-06]
-
-must_haves:
- truths:
- - "User sees a library list in the settings page showing name, path, and track count for each library"
- - "User can click 'Add Library' to open a folder picker, library auto-names from folder and scan starts"
- - "User can rename a library inline (click name or overflow menu) with Enter to save, Escape to cancel"
- - "User sees a confirmation dialog with real impact counts before library removal"
- - "User sees a toast notification with removal summary after library is removed"
- - "The sidebar no longer has a 'Libraries' navigation item"
- artifacts:
- - path: "frontend/src/components/config-page/config-page.ts"
- provides: "Library management section with list, add, rename, remove, toast"
- contains: "renderLibraryList"
- - path: "frontend/src/components/sidebar/app-sidebar.ts"
- provides: "Sidebar without 'libraries' nav item"
- - path: "frontend/index.ts"
- provides: "No 'libraries' view case in router"
- key_links:
- - from: "frontend/src/components/config-page/config-page.ts"
- to: "@go/library/Library"
- via: "Wails bindings for AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact"
- pattern: "AddLibrary|RenameLibrary|RemoveLibrary|GetRemovalImpact"
- - from: "frontend/src/components/config-page/config-page.ts"
- to: "frontend/src/events.ts"
- via: "EventsOn for LibraryAdded, LibraryRenamed, LibraryRemoved"
- pattern: "Events\\.Library(Added|Renamed|Removed)"
----
-
-
-Replace the config-page library section with a full library management UI: library list with track counts, Add Library button with folder picker, inline rename, remove with impact dialog and toast, overflow menus. Remove sidebar "Libraries" nav item and its view routing.
-
-Purpose: Users can manage their music libraries entirely from the settings page per user decisions.
-Output: Updated config-page with library CRUD UI, cleaned-up sidebar and router.
-
-
-
-@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
-@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/phases/12-library-crud-data-integrity/12-RESEARCH.md
-@.planning/phases/12-library-crud-data-integrity/12-CONTEXT.md
-@.planning/phases/12-library-crud-data-integrity/12-01-SUMMARY.md
-
-@frontend/src/components/config-page/config-page.ts
-@frontend/src/components/sidebar/app-sidebar.ts
-@frontend/src/components/library-manager/library-manager.ts
-@frontend/index.ts
-@frontend/src/store/library-store.ts
-@frontend/src/events.ts
-
-
-
-
-From backend/library/crud.go (via Wails auto-generated bindings):
-```typescript
-// @go/library/Library
-export function AddLibrary(path: string): Promise;
-export function RenameLibrary(id: number, newName: string): Promise;
-export function RemoveLibrary(id: number): Promise;
-export function GetRemovalImpact(id: number): Promise;
-```
-
-From backend/library/query.go (existing bindings):
-```typescript
-export function GetAllLibraries(): Promise; // via database queries
-```
-
-From backend/database/sql/queries/libraries.sql (existing):
-```typescript
-// GetAllLibraries returns [{id, name, path, created_at}]
-// CountAudioFilesByLibrary returns {count}
-```
-
-From frontend/src/events.ts (regenerated in Plan 01):
-```typescript
-export const Events = {
- // ...existing events...
- LibraryAdded: "LibraryAdded",
- LibraryRenamed: "LibraryRenamed",
- LibraryRemoved: "LibraryRemoved",
-} as const;
-```
-
-From frontend/src/components/config-page/config-page.ts (existing patterns):
-```typescript
-// ConfigPage uses @state() decorators for reactive state
-// renderXxxSection() methods for each settings section
-// EventsOn() in connectedCallback for event subscriptions
-// config-field component for form fields
-// Scan state tracking: scanning, scanPaused, scanProgress, etc.
-```
-
-
-
-
-
-
- Task 1: Replace config-page library section with library management UI
-
- frontend/src/components/config-page/config-page.ts
-
-
-Replace the existing `renderLibrarySection()` method in config-page.ts with a full library management UI. The section currently shows a single directory path field + rescan button. Replace it with:
-
-**New state properties (add to class):**
-```typescript
-@state() private libraries: Array<{id: number; name: string; path: string; trackCount: number}> = [];
-@state() private editingLibraryId: number | null = null;
-@state() private editingName: string = '';
-@state() private removingLibraryId: number | null = null;
-@state() private removalImpact: {trackCount: number; playlistsAffected: number; queueItemCount: number} | null = null;
-@state() private isRemoving: boolean = false;
-@state() private toastMessage: string = '';
-@state() private toastVisible: boolean = false;
-@state() private activeMenuId: number | null = null;
-```
-
-**Load library list:**
-- In `connectedCallback` (or existing initialization), call `GetAllLibraries()` from Wails bindings, then for each library call `CountAudioFilesByLibrary(lib.id)` to get track counts (or add a new Go method that returns libraries with counts — but simpler to loop since there are typically 1-5 libraries).
-- Actually, better approach: Create a `loadLibraries()` method that calls `GetAllLibraries()` and maps results, enriching each with a `CountAudioFilesByLibrary` call. Store in `this.libraries`.
-- Call `loadLibraries()` on connectedCallback and after any CRUD event.
-
-**Event subscriptions (add to connectedCallback):**
-```typescript
-EventsOn(Events.LibraryAdded, () => this.loadLibraries());
-EventsOn(Events.LibraryRenamed, () => this.loadLibraries());
-EventsOn(Events.LibraryRemoved, () => this.loadLibraries());
-```
-
-**Remove old library config state:**
-Remove the `directoryPath` state property, `loadLibraryConfig()` method, `GetLibraryDirectory` and `SetLibraryDirectory` imports (these are legacy single-directory methods). Remove the old `config-field` for Library Directory.
-
-**renderLibrarySection() — complete replacement:**
-The section heading should be "Libraries" (not "Library"). Use ``.
-
-Content:
-1. **Library list** — For each library in `this.libraries`, render a row:
- - If `this.editingLibraryId === lib.id`: render an input field with the editing name, Enter to save (call `RenameLibrary`), Escape to cancel
- - Else: render `${lib.name}`, `${lib.path}`, `${lib.trackCount} tracks`, and an overflow `...` button
- - The overflow button toggles `this.activeMenuId` — when active, shows a dropdown with: Rename, Rescan, Remove
- - Rename: sets `this.editingLibraryId = lib.id; this.editingName = lib.name`
- - Rescan: calls `ScanLibrary(lib.id)` from existing Wails bindings
- - Remove: calls `GetRemovalImpact(lib.id)`, stores result in `this.removalImpact`, sets `this.removingLibraryId = lib.id` to show the confirmation dialog
- - Click outside overflow menu closes it (add a document click listener)
-
-2. **Add Library button** — Below the list:
- ```html
-
- ```
- `handleAddLibrary`: Call `DirectoryPicker()` from `@go/frontendutil/FrontendUtil`. If user selects a path, call `AddLibrary(path)`. The backend auto-names from folder name and triggers scan.
-
-3. **Removal confirmation dialog** — Shown when `this.removingLibraryId !== null`:
- - Overlay with dialog box (same pattern as cancel scan dialog in library-manager.ts)
- - Title: "Remove Library"
- - Message: `Remove '${libraryName}'? This will delete ${impact.trackCount} tracks, affect ${impact.playlistsAffected} playlists, and remove ${impact.queueItemCount} queue items.`
- - Two buttons: "Cancel" (closes dialog) and "Remove" (calls `RemoveLibrary(id)`)
- - When "Remove" is clicked: set `this.isRemoving = true` to show a spinner. On completion: close dialog, show toast with summary, reload libraries.
-
-4. **Toast notification** — A simple div at the bottom of the component:
- ```html
- ${this.toastVisible ? html`
${this.toastMessage}
` : nothing}
- ```
- `showToast(message: string)` method: sets `this.toastMessage`, `this.toastVisible = true`, then `setTimeout(() => this.toastVisible = false, 4000)`.
- After successful removal: `this.showToast("Removed '${name}' (${summary.tracksDeleted} tracks deleted)")`.
-
-**Scan actions integration:**
-Keep the existing scan actions (Soft Scan, Full Rescan, Scan All Libraries, Pause, Resume, Cancel) below the library list — they operate on the currently scanning library. The progress bar and scan status remain unchanged.
-
-Remove the old library directory `config-field` and `SetLibraryDirectory` logic entirely.
-
-**Styling (add to static styles):**
-- `.library-list` — flex column with gap
-- `.library-row` — flex row with items center, padding, border-bottom, hover state
-- `.library-name` — flex: 1, clickable for rename
-- `.library-path` — color: dimmed, font-size smaller, truncate with ellipsis
-- `.library-count` — color: dimmed
-- `.overflow-btn` — cursor pointer, no border, background transparent, letter-spacing for "···"
-- `.overflow-menu` — absolute position, background surface, border, shadow, z-index, list items with hover
-- `.edit-input` — styled text input for inline rename
-- `.removal-dialog-overlay` — fixed full screen, background semi-transparent
-- `.removal-dialog` — centered box, background surface, padding, rounded corners
-- `.toast` — fixed bottom center, background surface, padding, border-radius, box-shadow, animation (fade in/out via CSS transition on opacity)
-- `.spinner` — simple CSS spinner (border animation)
-
-Use design tokens where applicable (--yj-text-sm for paths/counts, etc.).
-
-
- cd /mnt/vault/dev/golang/yellowjacket && npx tsc --noEmit
-
-
- - Config-page shows library list with name, path, track count per library
- - Add Library button opens folder picker and creates library
- - Inline rename with Enter/Escape works
- - Overflow menu shows Rename, Rescan, Remove actions
- - Removal dialog shows real impact counts
- - Toast notification shows after removal
- - Old single-directory library config UI is removed
- - TypeScript compiles with no errors
-
-
-
-
- Task 2: Remove Libraries sidebar nav item and view routing
-
- frontend/src/components/sidebar/app-sidebar.ts
- frontend/index.ts
-
-
-Per user decision: "Remove the libraries tab from the sidebar list entirely."
-
-**app-sidebar.ts:**
-1. Remove `'libraries'` from the `View` type union: change `'home' | 'libraries' | 'playlists' | ...` to `'home' | 'playlists' | ...`
-2. Remove the `{ id: 'libraries', label: 'Libraries', icon: 'folder-open' }` entry from the nav items array
-
-**index.ts:**
-1. Remove the `case 'libraries':` block that sets `mainContent.innerHTML = ''`
-2. Remove the `import '@components/library-manager/library-manager.ts'` import (the component is no longer used)
-
-Note: Do NOT delete the `library-manager.ts` file itself — it may still be referenced elsewhere or useful for reference. Just remove its import and routing.
-
-
- cd /mnt/vault/dev/golang/yellowjacket && npx tsc --noEmit
-
-
- - Sidebar does not show "Libraries" nav item
- - Clicking where Libraries was no longer routes to library-manager view
- - library-manager component import removed from index.ts
- - TypeScript compiles with no errors
-
-
-
-
- Task 3: Verify library management UI end-to-end
- frontend/src/components/config-page/config-page.ts
-
-Human verification of the complete library management UI.
-
-Launch the app with `wails dev` and verify:
-1. Navigate to Settings — "Libraries" section shows existing library with name, path, and track count
-2. Click "Add Library" — folder picker opens. Select a folder with music. Library appears in list and scan starts.
-3. Click `...` overflow menu — Rename, Rescan, Remove options appear
-4. Click Rename — name becomes editable. Type new name, press Enter. Name updates.
-5. Press Escape while editing — rename is cancelled
-6. Click Remove on a test library — confirmation dialog shows real impact counts
-7. Click Remove in dialog — spinner shows, then toast notification with removal summary
-8. Sidebar no longer has "Libraries" nav item
-9. Scan controls (Soft Scan, Full Rescan, Scan All, Pause, Cancel) still work
-
- Manual verification — all 9 checks pass
- Library management UI works end-to-end: add, rename, remove with correct data lifecycle
-
-
-
-
-
-1. `npx tsc --noEmit` — TypeScript compiles with no errors
-2. `wails dev` — app launches without errors
-3. Library list shows in settings with correct data
-4. Add/rename/remove flows work end-to-end
-5. Sidebar has no "Libraries" item
-6. Scan controls still function
-
-
-
-- Library management UI replaces old single-directory config in settings page
-- All CRUD operations work: add (with folder picker + auto-scan), rename (inline edit), remove (with confirmation + toast)
-- Sidebar "Libraries" nav item is removed
-- No TypeScript compilation errors
-
-
-
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-SUMMARY.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-SUMMARY.md
deleted file mode 100644
index 79f7ac4..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-02-SUMMARY.md
+++ /dev/null
@@ -1,194 +0,0 @@
----
-phase: 12-library-crud-data-integrity
-plan: 02
-subsystem: ui
-tags: [lit, wails, library-management, config-page, sidebar, folder-picker, toast, overflow-menu]
-
-# Dependency graph
-requires:
- - phase: 12-library-crud-data-integrity
- provides: AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact backend API, LibraryAdded/Renamed/Removed events
- - phase: 11-per-library-scan-pipeline
- provides: ScanLibrary, ScanAllLibraries, scan queue coordinator, per-library progress events
-provides:
- - Full library management UI in settings page (list, add, rename, remove with confirmation + toast)
- - Selectable library checkboxes for targeted scanning
- - Inline per-library progress bar during scan
- - Collapsible config sections
- - Sidebar cleaned up (no Libraries nav item)
-affects: [13-library-views-phantom-tracks]
-
-# Tech tracking
-tech-stack:
- added: []
- patterns:
- - "Checkbox selection model for multi-library scan targeting"
- - "Inline progress bar per library row during scan"
- - "Collapsible config-section with chevron dropdown"
- - "Overflow menu with document click dismiss"
- - "Toast notification with auto-dismiss timer"
-
-key-files:
- created: []
- modified:
- - frontend/src/components/config-page/config-page.ts
- - frontend/src/components/config-page/config-section.ts
- - frontend/src/components/sidebar/app-sidebar.ts
- - frontend/index.ts
- - frontend/src/store/library-store.ts
- - backend/library/crud.go
- - backend/library/metrics.go
- - backend/library/query.go
- - backend/library/rescan.go
- - backend/library/scan_queue.go
-
-key-decisions:
- - "Selectable library checkboxes — user selects which libraries to scan instead of scan-all-or-nothing"
- - "Scan buttons above library list with selection count indicator"
- - "Inline progress bar per library row — replaces global-only progress"
- - "Collapsible config sections with chevron dropdown — keeps settings page organized"
- - "8-second toast auto-dismiss timer for removal summaries"
- - "Library store invalidation on LibraryRemoved event to refresh all views"
-
-patterns-established:
- - "Checkbox selection model: Set with select-all/indeterminate header"
- - "Collapsible config-section component with chevron toggle"
-
-requirements-completed: [LIB-01, LIB-02, LIB-03, LIB-06]
-
-# Metrics
-duration: 38min
-completed: 2026-03-15
----
-
-# Phase 12 Plan 02: Frontend Library Management UI Summary
-
-**Full library management UI in settings with add/rename/remove, selectable scan targeting, inline per-library progress bars, and collapsible config sections**
-
-## Performance
-
-- **Duration:** 38 min (execution across previous session + finalization)
-- **Started:** 2026-03-15T13:43:37Z
-- **Completed:** 2026-03-15T14:21:35Z
-- **Tasks:** 3 (2 auto + 1 human-verify checkpoint)
-- **Files modified:** 19
-
-## Accomplishments
-
-- Library management UI in settings page: list with name, path, track count per library; Add Library with folder picker; inline rename with Enter/Escape; overflow menu (Rename, Rescan, Remove); removal confirmation dialog with real impact counts; toast notification with removal summary
-- Selectable library checkboxes with select-all/indeterminate header for targeted scan operations
-- Inline scan progress bar per library row showing phase and percentage
-- Collapsible config-section component with chevron dropdown for all settings sections
-- Sidebar "Libraries" nav item removed; library-manager component import removed from router
-- Library store invalidated on LibraryRemoved event to refresh all data views
-
-## Task Commits
-
-Tasks were committed atomically with extensive follow-up refinements:
-
-1. **Task 1: Replace config-page library section with library management UI** — `ffc5d96` (feat) + 20 follow-up fix/feat/perf commits
-2. **Task 2: Remove Libraries sidebar nav item and view routing** — `e199712` (feat)
-3. **Task 3: Verify library management UI end-to-end** — Human verified ✅ (all 9 checks passed)
-
-Key follow-up commits:
-- `13a42ae` feat: selectable library list with checkbox scan targeting
-- `df824c6` feat: show scan progress bar inline in library list entry
-- `12c6782` feat: make config sections collapsible with chevron dropdown
-- `890284d` fix: delete artist_credit_artist before artist_credit in removal pipeline
-- `30f4461` perf: skip FTS5 rebuild during library removal
-- `21ea71e` perf: increase scan batch size from 50 to 300
-- `b093fbb` fix: invalidate library store cache on LibraryRemoved event
-
-Full commit list (25 commits): `ffc5d96..12c6782`
-
-## Files Created/Modified
-
-- `frontend/src/components/config-page/config-page.ts` — Full library management UI with CRUD, selection, progress, toast, overflow menus
-- `frontend/src/components/config-page/config-section.ts` — Collapsible section component with chevron toggle
-- `frontend/src/components/sidebar/app-sidebar.ts` — Removed 'libraries' from View type and nav items
-- `frontend/index.ts` — Removed library-manager import and routing case
-- `frontend/src/store/library-store.ts` — Added LibraryRemoved invalidation handler
-- `backend/library/crud.go` — Bug fixes in orphan cleanup ordering
-- `backend/library/metrics.go` — ScanWarning.Err serialized as string
-- `backend/library/query.go` — GetAllLibrariesWithTrackCounts binding
-- `backend/library/rescan.go` — Scan batch size increase, soft scan optimization
-- `backend/library/scan_queue.go` — Wait for scan stop before removal
-- `frontend/wailsjs/go/library/Library.d.ts` — Regenerated bindings
-- `frontend/wailsjs/go/library/Library.js` — Regenerated bindings
-- `frontend/wailsjs/go/models.ts` — Regenerated model types
-
-## Decisions Made
-
-- **Selectable library checkboxes:** Added a Set selection model with select-all/indeterminate header checkbox. Users select specific libraries before clicking Scan, rather than scan-all-or-nothing. Selection count shown on button.
-- **Scan buttons above library list:** Moved scan actions (Add Library, Scan, Full Rescan, Pause, Cancel) above the library list instead of below, with none selected by default.
-- **Inline progress bar per library row:** Each library row shows its scan phase and progress percentage inline, replacing the global-only progress indicator.
-- **Collapsible config sections:** All config-section elements now collapse with a chevron dropdown, keeping the settings page organized as it grows.
-- **8-second toast timer:** Toast auto-dismisses after 8 seconds (longer than typical 4s) since removal summaries contain important information.
-- **Library store invalidation on LibraryRemoved:** Ensures all data views (tracks, albums, artists, genres) refresh after library removal.
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**1. [Rule 1 - Bug] Fixed orphan cleanup FK ordering**
-- **Found during:** Task 1 refinement
-- **Issue:** artist_credit_artist rows must be deleted before artist_credit rows (FK constraint)
-- **Fix:** Reordered DELETE statements in removal pipeline
-- **Files modified:** backend/library/crud.go
-- **Committed in:** `890284d`
-
-**2. [Rule 1 - Bug] ScanWarning.Err serialized as error interface**
-- **Found during:** Task 1 refinement
-- **Issue:** Go error interface doesn't serialize to JSON string — frontend got empty object
-- **Fix:** Serialize Err field as string in ScanWarning
-- **Files modified:** backend/library/metrics.go
-- **Committed in:** `ac8cbb3`
-
-**3. [Rule 1 - Bug] Library store not invalidated on LibraryRemoved**
-- **Found during:** Task 1 refinement
-- **Issue:** Removing a library left stale tracks/albums/artists in library store cache
-- **Fix:** Added LibraryRemoved event listener to library store that triggers full invalidation
-- **Files modified:** frontend/src/store/library-store.ts
-- **Committed in:** `b093fbb`
-
-**4. [Rule 2 - Missing Critical] Phantom tracks from empty library root**
-- **Found during:** Task 1 verification
-- **Issue:** TOML cleanup left empty DirectoryPath, causing all tracks to appear as phantom
-- **Fix:** Resolved empty library root detection and cleanup
-- **Files modified:** backend/library/crud.go
-- **Committed in:** `717e249`
-
-**5. [Rule 3 - Blocking] Replaced removed Scan() import**
-- **Found during:** Task 2
-- **Issue:** Removing library-manager import broke a reference to deleted Scan() method
-- **Fix:** Replaced with ScanAllLibraries() call
-- **Files modified:** frontend/index.ts
-- **Committed in:** `0559822`
-
----
-
-**Total deviations:** 5 auto-fixed (3 bugs, 1 missing critical, 1 blocking)
-**Impact on plan:** All auto-fixes necessary for correctness. No scope creep. Additional features (selectable scanning, inline progress, collapsible sections) were discovered needs during verification.
-
-## Issues Encountered
-
-None — all issues were resolved through iterative refinement.
-
-## User Setup Required
-
-None — no external service configuration required.
-
-## Next Phase Readiness
-
-- Phase 12 complete — all library CRUD backend and frontend implemented
-- Ready for Phase 13: Library Views & Phantom Tracks
-- All library management operations verified end-to-end through human checkpoint
-- Library store properly invalidates on CRUD events, ready for filtered views
-
-## Self-Check: PASSED
-
-All key files verified present on disk, all referenced commits verified in git log.
-
----
-*Phase: 12-library-crud-data-integrity*
-*Completed: 2026-03-15*
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-CONTEXT.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-CONTEXT.md
deleted file mode 100644
index 026cf08..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-CONTEXT.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Phase 12: Library CRUD & Data Integrity - Context
-
-**Gathered:** 2026-03-12
-**Status:** Ready for planning
-
-
-## Phase Boundary
-
-Users can add, rename, and remove libraries through the UI with correct data lifecycle management. Tracks are created/deleted, shared entities (artists, albums, genres) are cleaned up only when orphaned, FTS5 search index stays consistent, queue tracks cascade-delete, and playlist tracks convert to phantoms. The library manager UI lives in settings alongside scan controls.
-
-
-
-
-## Implementation Decisions
-
-### Library management UI
-- Integrated library + scan section in the settings page — combine library management and scanning into one unified section
-- Remove the libraries tab from the sidebar list entirely
-- Each library row displays: name, directory path, track count in the main row; actions (rename, remove, rescan) hidden behind a `...` overflow menu
-- Replace the old single-directory config UI (directory path field + rescan button) completely — the migrated library appears in the new list
-
-### Add-library flow
-- Click "Add Library" button in the library management section
-- OS folder picker dialog opens
-- Library auto-named from the folder name (editable later via rename)
-- Scan starts automatically after adding
-- Uses the existing per-library scan pipeline from Phase 11
-
-### Removal confirmation & feedback
-- Warning dialog with impact summary before removal: "Remove 'Jazz Collection'? This will delete 1,234 tracks, affect 2 playlists, and remove 15 queue items."
-- If a track from the library being removed is currently playing, stop playback first, then proceed with removal; queue advances to next valid track if one exists
-- Blocking operation with spinner on the dialog while cleanup runs (expected < 1 second for most libraries)
-- Toast notification on completion: "Removed 'Jazz Collection' (1,234 tracks deleted)"
-
-### Orphan cleanup behavior
-- Immediate cleanup in the same database transaction — delete tracks, identify orphaned entities, delete orphans, convert playlist phantoms, all atomic
-- Reference-counting bottom-up: only delete artists/albums/genres that have zero remaining track references after the library's tracks are removed
-- Rebuild the entire FTS5 index from remaining tracks after library removal (handles contentless table limitation cleanly)
-- Playlist phantom track conversion in the same transaction: copy track metadata to phantom columns on playlist_tracks, then SET NULL the audio_file_id
-- Queue tracks cascade-delete (queue is ephemeral, not user-curated)
-- Removal API endpoint returns cleanup summary: {tracks_deleted, artists_removed, albums_removed, genres_removed, playlists_affected, queue_items_removed} — feeds the toast notification
-
-### Rename & display behavior
-- Library names must be unique — validation error if user tries to use an existing name
-- Inline edit on the list row: click name (or rename action from menu) turns it into an editable text field, Enter to save, Escape to cancel
-- Name validation: 1-50 characters, non-empty
-- Rename changes display name only — changing a library's directory path requires remove + add (no path editing)
-
-### Claude's Discretion
-- Exact layout/styling of the library management section within settings
-- Loading skeleton design while library list loads
-- Error state handling for failed operations
-- Exact spinner implementation during removal
-- Toast notification library/component choice
-- API endpoint URL structure and HTTP methods
-- SQL query optimization for orphan detection
-
-
-
-
-## Specific Ideas
-
-- Library management section should feel like a natural extension of the existing settings page — not a separate app within settings
-- The impact summary in the removal dialog should use real counts from the database, not estimates
-- The `...` overflow menu pattern keeps the list clean — same pattern used elsewhere in the app for action menus
-
-
-
-
-## Deferred Ideas
-
-None — discussion stayed within phase scope
-
-
-
----
-
-*Phase: 12-library-crud-data-integrity*
-*Context gathered: 2026-03-12*
diff --git a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-RESEARCH.md b/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-RESEARCH.md
deleted file mode 100644
index 6d5fdeb..0000000
--- a/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-RESEARCH.md
+++ /dev/null
@@ -1,514 +0,0 @@
-# Phase 12: Library CRUD & Data Integrity - Research
-
-**Researched:** 2026-03-12
-**Domain:** SQLite data lifecycle management, orphan cleanup, Wails CRUD API, Lit Web Components
-**Confidence:** HIGH
-
-## Summary
-
-Phase 12 adds the user-facing library management API and UI — add, rename, and remove libraries — plus the data integrity logic that keeps the database consistent when a library is removed. The schema (Phase 10) and per-library scanning (Phase 11) are complete; this phase wires CRUD operations to the existing infrastructure and builds the orphan cleanup pipeline.
-
-The primary technical challenge is the **remove library** operation: it must atomically delete a library's tracks, cascade-delete queue entries, convert playlist tracks to phantoms, identify and delete orphaned entities (recordings, release groups, artist credits, artists, genres, cover art) that are no longer referenced by any remaining library, and rebuild the FTS5 search index. All of this must happen in a single transaction (except FTS5 rebuild, which cannot run inside a transaction).
-
-**Primary recommendation:** Implement removal as a single Go method on the Library struct that runs the full cleanup pipeline in one transaction, returns a cleanup summary struct, and emits events so the frontend can show a toast and invalidate its caches.
-
-
-## User Constraints (from CONTEXT.md)
-
-### Locked Decisions
-- Integrated library + scan section in the settings page — combine library management and scanning into one unified section
-- Remove the libraries tab from the sidebar list entirely
-- Each library row displays: name, directory path, track count in the main row; actions (rename, remove, rescan) hidden behind a `...` overflow menu
-- Replace the old single-directory config UI (directory path field + rescan button) completely — the migrated library appears in the new list
-- Click "Add Library" button in the library management section
-- OS folder picker dialog opens
-- Library auto-named from the folder name (editable later via rename)
-- Scan starts automatically after adding
-- Uses the existing per-library scan pipeline from Phase 11
-- Warning dialog with impact summary before removal: "Remove 'Jazz Collection'? This will delete 1,234 tracks, affect 2 playlists, and remove 15 queue items."
-- If a track from the library being removed is currently playing, stop playback first, then proceed with removal; queue advances to next valid track if one exists
-- Blocking operation with spinner on the dialog while cleanup runs (expected < 1 second for most libraries)
-- Toast notification on completion: "Removed 'Jazz Collection' (1,234 tracks deleted)"
-- Immediate cleanup in the same database transaction — delete tracks, identify orphaned entities, delete orphans, convert playlist phantoms, all atomic
-- Reference-counting bottom-up: only delete artists/albums/genres that have zero remaining track references after the library's tracks are removed
-- Rebuild the entire FTS5 index from remaining tracks after library removal (handles contentless table limitation cleanly)
-- Playlist phantom track conversion in the same transaction: copy track metadata to phantom columns on playlist_tracks, then SET NULL the audio_file_id
-- Queue tracks cascade-delete (queue is ephemeral, not user-curated)
-- Removal API endpoint returns cleanup summary: {tracks_deleted, artists_removed, albums_removed, genres_removed, playlists_affected, queue_items_removed} — feeds the toast notification
-- Library names must be unique — validation error if user tries to use an existing name
-- Inline edit on the list row: click name (or rename action from menu) turns it into an editable text field, Enter to save, Escape to cancel
-- Name validation: 1-50 characters, non-empty
-- Rename changes display name only — changing a library's directory path requires remove + add (no path editing)
-
-### Claude's Discretion
-- Exact layout/styling of the library management section within settings
-- Loading skeleton design while library list loads
-- Error state handling for failed operations
-- Exact spinner implementation during removal
-- Toast notification library/component choice
-- API endpoint URL structure and HTTP methods
-- SQL query optimization for orphan detection
-
-### Deferred Ideas (OUT OF SCOPE)
-None — discussion stayed within phase scope
-
-
-
-## Phase Requirements
-
-| ID | Description | Research Support |
-|----|-------------|-----------------|
-| LIB-01 | User can add a new library directory via a folder picker dialog | DirectoryPicker already exists in `frontendutil.go:27`. Add-library flow: picker → CreateLibrary query → ScanLibrary. Auto-name from `filepath.Base()`. |
-| LIB-02 | User can rename a library (display name) | UpdateLibraryName query already exists in `libraries.sql:14`. Need uniqueness validation and frontend inline edit. |
-| LIB-03 | User can remove a library — tracks deleted, shared entities cleaned up only if no other library references them | Core orphan cleanup pipeline needed. New hand-crafted SQL for bottom-up reference-counting deletes. Phantom conversion before delete. |
-| LIB-06 | Library list displayed in a management UI (settings or sidebar section) | Replace existing library-manager and config-page library section. New unified section using GetAllLibraries + CountAudioFilesByLibrary. |
-| DATA-02 | Orphan cleanup after library removal: reference-counting bottom-up deletes | New SQL queries for identifying orphaned recordings, release_groups, artist_credits, artists, genres, cover_art. Single transaction. |
-| DATA-03 | FTS5 index entries for removed tracks cleaned up | RebuildSearchIndex already exists in `search.go:159`. Call after removal transaction commits. Contentless FTS5 cannot delete individual rows. |
-| PLAY-04 | Queue tracks from a removed library are cascade-deleted | Already handled by schema: `queue_tracks.audio_file_id` has `ON DELETE CASCADE`. Queue state adjustment needed (current_position, shuffle_order). |
-
-
-## Standard Stack
-
-### Core
-| Library | Version | Purpose | Why Standard |
-|---------|---------|---------|--------------|
-| Go stdlib (`database/sql`) | go1.24 | Transaction management, raw SQL for orphan cleanup | Already used throughout; sqlc queries + hand-crafted SQL for complex operations |
-| sqlc | v1.30.0 | Code generation for simple CRUD queries | Existing pattern; generates typed Go from SQL |
-| modernc.org/sqlite | current | Pure-Go SQLite driver | Already used; single-writer, WAL mode |
-| Lit | 3.x | Frontend web components | Existing UI framework |
-| Wails v2 | v2.x | Go↔JS binding, events, runtime dialogs | Existing app framework |
-
-### Supporting
-| Library | Version | Purpose | When to Use |
-|---------|---------|---------|-------------|
-| `@runtime/runtime` (Wails JS) | v2.x | EventsOn/EventsEmit for scan events, toast triggers | All frontend event handling |
-| `frontendutil.DirectoryPicker` | existing | OS folder selection dialog | Add-library flow |
-
-### Alternatives Considered
-| Instead of | Could Use | Tradeoff |
-|------------|-----------|----------|
-| Full FTS5 rebuild on removal | `contentless_delete=1` migration | Would require migration 7 to recreate FTS5 table; full rebuild is simpler and removal is rare |
-| Hand-crafted orphan SQL | Multiple sqlc queries in a loop | Hand-crafted SQL is a single statement per entity type, far more efficient than N+1 queries |
-| Custom toast component | Third-party toast library | No dependency needed; a simple `
` with CSS animation and auto-dismiss timer suffices |
-
-## Architecture Patterns
-
-### Recommended Project Structure
-```
-backend/library/
-├── library.go # Existing: scan pipeline, entity processing
-├── scan_queue.go # Existing: per-library scan coordination
-├── rescan.go # Existing: FullRescan, clearLibraryTables
-├── query.go # Existing: GetAllTracks, GetAllAlbums, etc.
-├── crud.go # NEW: AddLibrary, RenameLibrary, RemoveLibrary
-└── scan_control.go # Existing: pause/resume/cancel
-
-backend/database/
-├── search.go # Existing: FTS5 operations (RebuildSearchIndex)
-└── sql/queries/
- └── libraries.sql # EXTEND: add orphan cleanup queries
-
-frontend/src/
-├── components/
-│ └── config-page/
-│ └── config-page.ts # MODIFY: replace library section with new unified UI
-└── store/
- └── library-store.ts # MODIFY: add library list, invalidation on add/remove
-```
-
-### Pattern 1: Transactional Orphan Cleanup
-**What:** A single Go method that runs the entire removal pipeline in one transaction, then rebuilds FTS5 outside the transaction.
-**When to use:** Library removal.
-**Example:**
-```go
-// Source: Derived from existing clearLibraryTables pattern in rescan.go:100
-func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
- // 1. Pre-removal: count impacts for summary
- // 2. Stop playback if current track belongs to this library
- // 3. Begin transaction
- // 4. Populate phantom metadata on playlist_tracks for this library's tracks
- // 5. Delete audio_files WHERE library_id = ? (CASCADE deletes queue_tracks, SET NULL on playlist_tracks)
- // 6. Delete orphaned recordings (no remaining audio_files reference them)
- // 7. Delete orphaned release_group_recordings, recording_genres
- // 8. Delete orphaned release_groups (no remaining recordings reference them)
- // 9. Delete orphaned artist_credits (no remaining recordings reference them)
- // 10. Delete orphaned artist_credit_artists
- // 11. Delete orphaned artists (no remaining credits reference them)
- // 12. Delete orphaned genres (no remaining recording_genres reference them)
- // 13. Delete orphaned cover_art (no remaining release_groups reference them)
- // 14. Delete the library row itself
- // 15. Commit transaction
- // 16. Rebuild FTS5 search index (outside transaction)
- // 17. Emit events
- // 18. Return summary
-}
-```
-
-### Pattern 2: Pre-Removal Impact Summary
-**What:** A read-only query that returns the counts shown in the removal confirmation dialog, run before the user confirms.
-**When to use:** Before showing the removal warning dialog.
-**Example:**
-```go
-// SAFETY: Hand-crafted SQL for impact summary. Read-only, no modifications.
-type RemovalImpact struct {
- TrackCount int64
- PlaylistsAffected int64
- QueueItemCount int64
-}
-
-func (l *Library) GetRemovalImpact(libraryID int64) (*RemovalImpact, error) {
- // Count tracks: SELECT COUNT(*) FROM audio_files WHERE library_id = ?
- // Count affected playlists: SELECT COUNT(DISTINCT playlist_id) FROM playlist_tracks
- // WHERE audio_file_id IN (SELECT id FROM audio_files WHERE library_id = ?)
- // Count queue items: SELECT COUNT(*) FROM queue_tracks
- // WHERE audio_file_id IN (SELECT id FROM audio_files WHERE library_id = ?)
-}
-```
-
-### Pattern 3: Phantom Metadata Population Before DELETE
-**What:** Before deleting audio_files, copy live track metadata into the phantom columns on playlist_tracks.
-**When to use:** Library removal, inside the transaction before the DELETE.
-**Example:**
-```sql
--- SAFETY: Hand-crafted SQL for phantom metadata population.
--- Must run BEFORE DELETE FROM audio_files (which triggers SET NULL on audio_file_id).
-UPDATE playlist_tracks SET
- phantom_title = sub.title,
- phantom_artist = sub.artist,
- phantom_album = sub.album,
- phantom_duration_ms = sub.duration,
- phantom_genre = sub.genre,
- phantom_cover_art_path = sub.cover_art_path
-FROM (
- SELECT
- pt.id AS pt_id,
- COALESCE(r.name, '') AS title,
- COALESCE(ac.text, '') AS artist,
- COALESCE(rg.name, '') AS album,
- af.length_milliseconds AS duration,
- CAST(COALESCE(
- (SELECT GROUP_CONCAT(g.name, '||')
- FROM recording_genres rg_sub
- JOIN genres g ON rg_sub.genre_id = g.id
- WHERE rg_sub.recording_id = r.id),
- ''
- ) AS TEXT) AS genre,
- COALESCE(ca.file_path, '') AS cover_art_path
- FROM playlist_tracks pt
- JOIN audio_files af ON pt.audio_file_id = af.id
- LEFT JOIN recordings r ON af.recording_id = r.id
- LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
- LEFT JOIN (
- SELECT recording_id, MIN(release_group_id) AS release_group_id
- FROM release_group_recordings
- GROUP BY recording_id
- ) rgr ON r.id = rgr.recording_id
- LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
- LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
- WHERE af.library_id = ?
-) sub
-WHERE playlist_tracks.id = sub.pt_id;
-```
-
-### Pattern 4: Bottom-Up Orphan Deletion
-**What:** Delete orphaned entities by checking for zero remaining references, in dependency order.
-**When to use:** After deleting audio_files for a library.
-**Example:**
-```sql
--- SAFETY: Hand-crafted orphan cleanup SQL. All parameterized.
-
--- 1. Delete orphaned recordings (no audio_files reference them)
-DELETE FROM recordings WHERE id NOT IN (
- SELECT DISTINCT recording_id FROM audio_files
-);
-
--- 2. Delete orphaned recording_genres (recording no longer exists)
-DELETE FROM recording_genres WHERE recording_id NOT IN (
- SELECT id FROM recordings
-);
-
--- 3. Delete orphaned release_group_recordings (recording no longer exists)
-DELETE FROM release_group_recordings WHERE recording_id NOT IN (
- SELECT id FROM recordings
-);
-
--- 4. Delete orphaned release_groups (no recordings reference them)
-DELETE FROM release_groups WHERE id NOT IN (
- SELECT DISTINCT release_group_id FROM release_group_recordings
-);
-
--- 5. Delete orphaned artist_credits (no recordings reference them)
-DELETE FROM artist_credit WHERE id NOT IN (
- SELECT DISTINCT artist_credit_id FROM recordings
-) AND id NOT IN (
- SELECT DISTINCT album_artist_credit_id FROM release_groups
- WHERE album_artist_credit_id IS NOT NULL
-);
-
--- 6. Delete orphaned artist_credit_artists (credit no longer exists)
-DELETE FROM artist_credit_artist WHERE credit_id NOT IN (
- SELECT id FROM artist_credit
-);
-
--- 7. Delete orphaned artists (no credits reference them)
-DELETE FROM artists WHERE id NOT IN (
- SELECT DISTINCT artist_id FROM artist_credit_artist
-);
-
--- 8. Delete orphaned genres (no recording_genres reference them)
-DELETE FROM genres WHERE id NOT IN (
- SELECT DISTINCT genre_id FROM recording_genres
-);
-
--- 9. Delete orphaned cover_art (no release_groups reference them)
-DELETE FROM cover_art WHERE id NOT IN (
- SELECT DISTINCT cover_art_id FROM release_groups
- WHERE cover_art_id IS NOT NULL
-);
-```
-
-### Pattern 5: Event-Driven Frontend Invalidation
-**What:** Backend emits events after CRUD operations; frontend store invalidates caches and re-fetches.
-**When to use:** After library add/rename/remove.
-**Example:**
-```go
-// New events for library CRUD
-const (
- LibraryAdded = "LibraryAdded"
- LibraryRenamed = "LibraryRenamed"
- LibraryRemoved = "LibraryRemoved"
-)
-```
-
-### Anti-Patterns to Avoid
-- **Deleting audio_files before populating phantom metadata:** The SET NULL cascade on playlist_tracks fires immediately when audio_files are deleted. Phantom columns MUST be populated first, in the same transaction.
-- **Running orphan cleanup outside a transaction:** If the app crashes mid-cleanup, the database would be in an inconsistent state. All deletes must be in one transaction (except FTS5 rebuild).
-- **Using `NOT EXISTS` subqueries instead of `NOT IN`:** For this use case, both work, but `NOT IN (SELECT DISTINCT ...)` is simpler to read and performs well on SQLite's optimizer with indexed columns.
-- **Deleting cover art files inside the transaction:** File I/O should happen after the transaction commits. Collect orphaned cover art file paths, commit the DB changes, then delete files.
-
-## Don't Hand-Roll
-
-| Problem | Don't Build | Use Instead | Why |
-|---------|-------------|-------------|-----|
-| FTS5 per-row deletion | Custom contentless_delete migration | `RebuildSearchIndex()` after removal | Rebuild is already implemented, tested, and handles edge cases. Removal is rare enough that full rebuild is acceptable. |
-| Folder picker dialog | Custom file browser | `frontendutil.DirectoryPicker()` | Already implemented, uses native OS dialog via Wails runtime |
-| Toast notifications | Third-party library | Simple custom element with CSS transition | Two states (show/hide), auto-dismiss timer, no external dependency needed |
-| Unique name validation | Frontend-only check | Backend `GetLibraryByName` query + frontend error display | Backend must enforce uniqueness regardless of frontend validation |
-
-**Key insight:** The orphan cleanup SQL is the only truly novel code in this phase. Everything else composes existing infrastructure (scan pipeline, events, sqlc queries, Wails dialogs).
-
-## Common Pitfalls
-
-### Pitfall 1: Phantom Metadata Must Be Populated Before DELETE
-**What goes wrong:** If audio_files rows are deleted first, the SET NULL cascade fires on playlist_tracks.audio_file_id, and the JOIN to populate phantom columns finds no matching audio_files — phantom columns stay NULL forever.
-**Why it happens:** SQLite fires ON DELETE SET NULL immediately when the parent row is deleted, before any other statements in the transaction run.
-**How to avoid:** Always run the phantom population UPDATE before the DELETE FROM audio_files.
-**Warning signs:** Playlist tracks showing empty metadata after library removal.
-
-### Pitfall 2: Queue Position/Shuffle Order Desync After CASCADE Delete
-**What goes wrong:** Queue tracks are cascade-deleted, but the queue's `current_position` and `shuffle_order` JSON still reference the old positions. The player tries to play a non-existent position.
-**Why it happens:** CASCADE only deletes rows; it doesn't update the queue state table.
-**How to avoid:** Before removing the library, count queue items that will be deleted. After removal, recalculate queue positions (compact remaining tracks) and reset `current_position` to 0 or the next valid track. Clear `shuffle_order` (will be regenerated on next shuffle toggle).
-**Warning signs:** "Track not found" errors after library removal, player crashes.
-
-### Pitfall 3: Artist Credits Referenced by Both Recordings AND Release Groups
-**What goes wrong:** An artist_credit is deleted because no recordings reference it, but a release_group still uses it as `album_artist_credit_id`. The release_group now has a dangling FK.
-**Why it happens:** artist_credit is referenced from TWO tables: recordings.artist_credit_id and release_groups.album_artist_credit_id.
-**How to avoid:** The orphan cleanup for artist_credit must check BOTH tables: `NOT IN (SELECT artist_credit_id FROM recordings) AND NOT IN (SELECT album_artist_credit_id FROM release_groups WHERE ...)`.
-**Warning signs:** FK constraint violations during cleanup.
-
-### Pitfall 4: Scan-While-Remove Race Condition
-**What goes wrong:** A scan is running for a library while the user tries to remove it. The scan writes new tracks while the removal deletes them, causing unpredictable state.
-**Why it happens:** Scan and CRUD operations are not serialized.
-**How to avoid:** Before removing a library, cancel any active scan for that library and wait for it to complete. Check `currentScanLibraryID` and also remove the library from the scan queue.
-**Warning signs:** Partial data after removal, orphaned tracks.
-
-### Pitfall 5: Cover Art File Deletion Inside Transaction
-**What goes wrong:** Cover art files are deleted from disk inside the transaction. If the transaction rolls back, the files are gone but the DB still references them.
-**Why it happens:** File I/O is not transactional.
-**How to avoid:** Collect orphaned cover art file paths during the transaction, commit, then delete files. If file deletion fails, it's a minor leak (orphaned files), not data corruption.
-**Warning signs:** Broken cover art images after a failed removal.
-
-### Pitfall 6: Currently-Playing Track From Removed Library
-**What goes wrong:** The player holds a reference to a file path from the removed library. After removal, the player tries to seek or read from a track whose DB entry is gone.
-**Why it happens:** The player streams from a file handle, not from the DB. But metadata lookups and queue state depend on the DB.
-**How to avoid:** Before the removal transaction, check if the currently-playing track belongs to the target library. If so, stop playback and unload the track.
-**Warning signs:** Player errors or crashes after library removal.
-
-## Code Examples
-
-### Adding a Library (Backend)
-```go
-// Source: Derived from existing CreateLibrary query + ScanLibrary pattern
-func (l *Library) AddLibrary(path string) (*sqlcgen.Library, error) {
- // Validate path exists
- if _, err := os.Stat(path); err != nil {
- return nil, fmt.Errorf("directory does not exist: %w", err)
- }
-
- // Auto-name from folder
- name := filepath.Base(path)
-
- // Create in DB (path has UNIQUE constraint — handles duplicate paths)
- lib, err := l.db.Queries.CreateLibrary(l.ctx, sqlcgen.CreateLibraryParams{
- Name: name,
- Path: path,
- })
- if err != nil {
- return nil, fmt.Errorf("could not create library: %w", err)
- }
-
- // Emit event for frontend
- runtime.EventsEmit(l.ctx, events.LibraryAdded, lib)
-
- // Start scanning (async, via scan queue)
- go func() {
- if err := l.ScanLibrary(lib.ID); err != nil {
- l.logger.Error("auto-scan after add failed", "err", err)
- }
- }()
-
- return &lib, nil
-}
-```
-
-### Renaming a Library (Backend)
-```go
-// Source: Derived from existing UpdateLibraryName query
-func (l *Library) RenameLibrary(id int64, newName string) error {
- newName = strings.TrimSpace(newName)
- if newName == "" || len(newName) > 50 {
- return fmt.Errorf("name must be 1-50 characters")
- }
-
- // Check uniqueness (could also rely on a UNIQUE constraint on name)
- libs, err := l.db.Queries.GetAllLibraries(l.ctx)
- if err != nil {
- return fmt.Errorf("could not check existing names: %w", err)
- }
- for _, lib := range libs {
- if lib.ID != id && lib.Name == newName {
- return fmt.Errorf("a library named %q already exists", newName)
- }
- }
-
- if err := l.db.Queries.UpdateLibraryName(l.ctx, sqlcgen.UpdateLibraryNameParams{
- Name: newName,
- ID: id,
- }); err != nil {
- return fmt.Errorf("could not rename library: %w", err)
- }
-
- runtime.EventsEmit(l.ctx, events.LibraryRenamed, map[string]any{
- "id": id,
- "name": newName,
- })
-
- return nil
-}
-```
-
-### Frontend Toast Component (Simple Approach)
-```typescript
-// A minimal toast notification — no external dependencies.
-// Show via: showToast("Removed 'Jazz Collection' (1,234 tracks deleted)")
-let toastEl: HTMLElement | null = null;
-let toastTimer: ReturnType | null = null;
-
-function showToast(message: string, durationMs = 4000): void {
- if (!toastEl) {
- toastEl = document.createElement('div');
- toastEl.className = 'yj-toast';
- document.body.appendChild(toastEl);
- }
- toastEl.textContent = message;
- toastEl.classList.add('visible');
-
- if (toastTimer) clearTimeout(toastTimer);
- toastTimer = setTimeout(() => {
- toastEl?.classList.remove('visible');
- }, durationMs);
-}
-```
-
-### Library List Row (Frontend Pattern)
-```typescript
-// Each row: name | path | track count | overflow menu
-private renderLibraryRow(lib: LibraryInfo) {
- return html`
-