diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 5496a62..865a8e0 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 diff --git a/.planning/phases/12-library-crud-data-integrity/12-01-PLAN.md b/.planning/phases/12-library-crud-data-integrity/12-01-PLAN.md new file mode 100644 index 0000000..bceae36 --- /dev/null +++ b/.planning/phases/12-library-crud-data-integrity/12-01-PLAN.md @@ -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" +--- + + +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 + + + +After completion, create `.planning/phases/12-library-crud-data-integrity/12-01-SUMMARY.md` + diff --git a/.planning/phases/12-library-crud-data-integrity/12-02-PLAN.md b/.planning/phases/12-library-crud-data-integrity/12-02-PLAN.md new file mode 100644 index 0000000..754ea9f --- /dev/null +++ b/.planning/phases/12-library-crud-data-integrity/12-02-PLAN.md @@ -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)" +--- + + +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 + + + +After completion, create `.planning/phases/12-library-crud-data-integrity/12-02-SUMMARY.md` +