docs(12): create phase plan
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user