docs(12): create phase plan

This commit is contained in:
2026-03-12 17:46:08 -04:00
parent de5ee4c301
commit 721442d1cb
3 changed files with 702 additions and 1 deletions
+4 -1
View File
@@ -92,7 +92,10 @@ Plans:
3. User can remove a library — its tracks are deleted, shared artists/albums/genres used only by that library are cleaned up, but entities shared with other libraries survive intact
4. Removing a library cleans up FTS5 search index entries for that library's tracks (no stale search results)
5. Queue tracks from a removed library are cascade-deleted; the queue continues playing from the next valid track
**Plans:** TBD
**Plans:** 2 plans
Plans:
- [ ] 12-01-PLAN.md — Backend CRUD API + orphan cleanup + queue compaction + events
- [ ] 12-02-PLAN.md — Frontend library management UI in settings + sidebar cleanup
### Phase 13: Library Views & Phantom Tracks
**Goal:** Users experience a unified multi-library presentation with optional filtering and graceful playlist preservation
@@ -0,0 +1,408 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
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()
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement Library CRUD methods and orphan cleanup pipeline</name>
<files>
backend/library/crud.go
backend/events/events.go
frontend/src/events.ts
</files>
<action>
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.
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./backend/... && go vet ./backend/library/... && golangci-lint run ./backend/library/crud.go ./backend/events/events.go</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="auto">
<name>Task 2: Add queue compaction method and wire removal hooks in app.go</name>
<files>
backend/queue/queue.go
backend/app.go
backend/library/crud.go
</files>
<action>
**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).
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/queue/... ./backend/library/... && golangci-lint run ./backend/queue/queue.go ./backend/app.go</automated>
</verify>
<done>
- 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)
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/12-library-crud-data-integrity/12-01-SUMMARY.md`
</output>
@@ -0,0 +1,290 @@
---
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)"
---
<objective>
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.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Backend Wails bindings available after Plan 01 -->
From backend/library/crud.go (via Wails auto-generated bindings):
```typescript
// @go/library/Library
export function AddLibrary(path: string): Promise<library.Library>;
export function RenameLibrary(id: number, newName: string): Promise<void>;
export function RemoveLibrary(id: number): Promise<library.RemovalSummary>;
export function GetRemovalImpact(id: number): Promise<library.RemovalImpact>;
```
From backend/library/query.go (existing bindings):
```typescript
export function GetAllLibraries(): Promise<sqlcgen.Library[]>; // 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.
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Replace config-page library section with library management UI</name>
<files>
frontend/src/components/config-page/config-page.ts
</files>
<action>
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 `<config-section heading="Libraries">`.
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 `<span class="library-name">${lib.name}</span>`, `<span class="library-path">${lib.path}</span>`, `<span class="library-count">${lib.trackCount} tracks</span>`, 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
<button class="btn-primary" @click=${this.handleAddLibrary}>Add Library</button>
```
`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`<div class="toast">${this.toastMessage}</div>` : 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.).
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && npx tsc --noEmit</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="auto">
<name>Task 2: Remove Libraries sidebar nav item and view routing</name>
<files>
frontend/src/components/sidebar/app-sidebar.ts
frontend/index.ts
</files>
<action>
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 = '<library-manager></library-manager>'`
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.
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && npx tsc --noEmit</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify library management UI end-to-end</name>
<files>frontend/src/components/config-page/config-page.ts</files>
<action>
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
</action>
<verify>Manual verification — all 9 checks pass</verify>
<done>Library management UI works end-to-end: add, rename, remove with correct data lifecycle</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/12-library-crud-data-integrity/12-02-SUMMARY.md`
</output>