chore: complete v1.1 milestone
This commit is contained in:
1 parent
8f8af48a12
commit
98842a7e14
54 files changed
+11083
-34
No files matched your search
@@ -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,146 @@
|
||||
---
|
||||
phase: 12-library-crud-data-integrity
|
||||
plan: 01
|
||||
subsystem: library
|
||||
tags: [crud, orphan-cleanup, data-integrity, queue-compaction, phantom-tracks, events]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 11-per-library-scan-pipeline
|
||||
provides: ScanLibrary, ScanAllLibraries, scan queue coordinator
|
||||
- phase: 10-schema-migration
|
||||
provides: libraries table, library_id FK, phantom columns on playlist_tracks
|
||||
provides:
|
||||
- AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact backend API
|
||||
- Orphan cleanup pipeline (recordings → genres → release_groups → artist_credits → artists → cover_art)
|
||||
- Queue CompactAfterLibraryRemoval method
|
||||
- Phantom metadata population before cascade delete
|
||||
- LibraryAdded, LibraryRenamed, LibraryRemoved events
|
||||
affects: [12-02-frontend-library-ui, 13-library-views-phantom-tracks]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "RemovalHooks callback struct — breaks circular dependency between library, player, and queue packages"
|
||||
- "Bottom-up orphan cleanup in single transaction — reference-counting DELETE WHERE NOT IN subqueries"
|
||||
- "Pre-populate phantom metadata BEFORE cascade delete — avoids lost join data"
|
||||
- "querySingleInt64 helper for hand-crafted SQL returning single aggregate values"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- backend/library/crud.go
|
||||
modified:
|
||||
- backend/library/library.go
|
||||
- backend/events/events.go
|
||||
- frontend/src/events.ts
|
||||
- backend/queue/queue.go
|
||||
- backend/app.go
|
||||
|
||||
key-decisions:
|
||||
- "Application-level name uniqueness check (iterate GetAllLibraries) rather than DB UNIQUE constraint — avoids migration 7"
|
||||
- "RemovalHooks struct pattern (StopPlayback + CompactQueue callbacks) wired in app.go — mirrors existing RescanHooks pattern"
|
||||
- "querySingleInt64 helper wraps DB.QueryContext returning *sql.Rows since DB has no QueryRowContext method"
|
||||
- "Sentinel errors for all validation (errLibraryNameEmpty, errLibraryNameTooLong, errLibraryNameDuplicate, errLibraryPathNotExist) per err113 linter rule"
|
||||
- "Context parameter placed first in querySingleInt64 per revive context-as-argument rule"
|
||||
|
||||
patterns-established:
|
||||
- "RemovalHooks callback struct for cross-package lifecycle coordination"
|
||||
- "querySingleInt64 for hand-crafted aggregate SQL queries"
|
||||
|
||||
requirements-completed: [LIB-01, LIB-02, LIB-03, DATA-02, DATA-03, PLAY-04]
|
||||
|
||||
# Metrics
|
||||
duration: 6min
|
||||
completed: 2026-03-12
|
||||
---
|
||||
|
||||
# Phase 12 Plan 01: Library CRUD Backend API Summary
|
||||
|
||||
**Backend CRUD API with AddLibrary/RenameLibrary/RemoveLibrary, full orphan cleanup pipeline, phantom track preservation, FTS5 rebuild, queue compaction, and event emission**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 6 min
|
||||
- **Started:** 2026-03-12T23:32:27Z
|
||||
- **Completed:** 2026-03-12T23:38:30Z
|
||||
- **Tasks:** 2
|
||||
- **Files created:** 1
|
||||
- **Files modified:** 5
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **AddLibrary(path)** — validates path exists, auto-names from folder base, creates DB row via sqlc, emits LibraryAdded, starts async ScanLibrary
|
||||
- **RenameLibrary(id, newName)** — validates 1-50 char length, checks name uniqueness across all libraries (application-level), updates via sqlc, emits LibraryRenamed
|
||||
- **GetRemovalImpact(libraryID)** — read-only queries returning track count, affected playlists count, queue items count for confirmation dialog
|
||||
- **RemoveLibrary(id)** — the critical 23-step method:
|
||||
1. Cancel active scan for library
|
||||
2. Stop playback if current track belongs to library
|
||||
3. Pre-count metrics for summary
|
||||
4. Begin transaction
|
||||
5. Populate phantom metadata on playlist_tracks (BEFORE cascade delete)
|
||||
6. DELETE audio_files WHERE library_id (CASCADE on queue_tracks, SET NULL on playlist_tracks)
|
||||
7. Bottom-up orphan cleanup: recordings → recording_genres → release_group_recordings → release_groups → artist_credits (dual FK check) → artist_credit_artists → artists → genres → cover_art
|
||||
8. DELETE library row
|
||||
9. Commit transaction
|
||||
10. Post-commit: RebuildSearchIndex (FTS5), delete cover art files, CompactQueue, emit events
|
||||
- **CompactAfterLibraryRemoval()** on Queue — reloads surviving tracks from DB, detects if current track survived, resets index, unloads player if needed, clears shuffle order, emits QueueChanged
|
||||
- **RemovalHooks** wired in app.go: StopPlayback → player.UnloadTrack(), CompactQueue → queue.CompactAfterLibraryRemoval()
|
||||
- Three new event constants: LibraryAdded, LibraryRenamed, LibraryRemoved — auto-generated to frontend events.ts
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Implement Library CRUD methods and orphan cleanup pipeline** — `bd44f83` (feat)
|
||||
- Created backend/library/crud.go (525 lines) with all CRUD methods
|
||||
- Added 3 event constants to backend/events/events.go
|
||||
- Added removalHooks field to Library struct
|
||||
- Regenerated frontend/src/events.ts
|
||||
2. **Task 2: Add queue compaction method and wire removal hooks in app.go** — `5995dfd` (feat)
|
||||
- Added CompactAfterLibraryRemoval() to backend/queue/queue.go (80 lines)
|
||||
- Wired RemovalHooks in backend/app.go OnStartup
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/library/crud.go` (NEW) — AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact, cancelLibraryScan, currentTrackBelongsToLibrary, querySingleInt64, RemovalHooks type, sentinel errors
|
||||
- `backend/library/library.go` — Added removalHooks RemovalHooks field to Library struct
|
||||
- `backend/events/events.go` — Added LibraryAdded, LibraryRenamed, LibraryRemoved constants
|
||||
- `frontend/src/events.ts` — Regenerated with new library CRUD event constants
|
||||
- `backend/queue/queue.go` — Added CompactAfterLibraryRemoval method
|
||||
- `backend/app.go` — Wired RemovalHooks in OnStartup (StopPlayback + CompactQueue callbacks)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Application-level name uniqueness:** Iterate GetAllLibraries to check for duplicate names rather than adding a UNIQUE constraint to the libraries table. Avoids needing migration 7; the check is only done during rename which is infrequent.
|
||||
- **RemovalHooks callback struct:** Follows the existing RescanHooks pattern to break circular dependencies between library → player and library → queue packages. Wired in app.go where all subsystems are accessible.
|
||||
- **querySingleInt64 helper:** The project's `database.DB` type exposes `QueryContext` returning `*sql.Rows` but no `QueryRowContext`. The helper wraps the full scan-close cycle for single-value aggregate queries.
|
||||
- **Sentinel errors per err113:** Defined `errLibraryNameEmpty`, `errLibraryNameTooLong`, `errLibraryNameDuplicate`, `errLibraryPathNotExist` as package-level vars to satisfy the golangci-lint err113 rule.
|
||||
- **Context-first parameter order:** `querySingleInt64(ctx, db, query, args...)` follows `revive` linter's context-as-argument rule.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — no external service configuration required.
|
||||
|
||||
## Next Plan Readiness
|
||||
|
||||
- Plan 12-01 complete — backend CRUD API fully implemented
|
||||
- Ready for Plan 12-02: Frontend library management UI in settings + sidebar cleanup
|
||||
- All Wails-bindable methods (AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact) are available for frontend consumption
|
||||
- Events (LibraryAdded, LibraryRenamed, LibraryRemoved) are defined for frontend reactive updates
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All files verified present, all commits verified in git log.
|
||||
|
||||
---
|
||||
*Phase: 12-library-crud-data-integrity*
|
||||
*Completed: 2026-03-12*
|
||||
@@ -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>
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: 12-library-crud-data-integrity
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [lit, wails, library-management, config-page, sidebar, folder-picker, toast, overflow-menu]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 12-library-crud-data-integrity
|
||||
provides: AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact backend API, LibraryAdded/Renamed/Removed events
|
||||
- phase: 11-per-library-scan-pipeline
|
||||
provides: ScanLibrary, ScanAllLibraries, scan queue coordinator, per-library progress events
|
||||
provides:
|
||||
- Full library management UI in settings page (list, add, rename, remove with confirmation + toast)
|
||||
- Selectable library checkboxes for targeted scanning
|
||||
- Inline per-library progress bar during scan
|
||||
- Collapsible config sections
|
||||
- Sidebar cleaned up (no Libraries nav item)
|
||||
affects: [13-library-views-phantom-tracks]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Checkbox selection model for multi-library scan targeting"
|
||||
- "Inline progress bar per library row during scan"
|
||||
- "Collapsible config-section with chevron dropdown"
|
||||
- "Overflow menu with document click dismiss"
|
||||
- "Toast notification with auto-dismiss timer"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- frontend/src/components/config-page/config-page.ts
|
||||
- frontend/src/components/config-page/config-section.ts
|
||||
- frontend/src/components/sidebar/app-sidebar.ts
|
||||
- frontend/index.ts
|
||||
- frontend/src/store/library-store.ts
|
||||
- backend/library/crud.go
|
||||
- backend/library/metrics.go
|
||||
- backend/library/query.go
|
||||
- backend/library/rescan.go
|
||||
- backend/library/scan_queue.go
|
||||
|
||||
key-decisions:
|
||||
- "Selectable library checkboxes — user selects which libraries to scan instead of scan-all-or-nothing"
|
||||
- "Scan buttons above library list with selection count indicator"
|
||||
- "Inline progress bar per library row — replaces global-only progress"
|
||||
- "Collapsible config sections with chevron dropdown — keeps settings page organized"
|
||||
- "8-second toast auto-dismiss timer for removal summaries"
|
||||
- "Library store invalidation on LibraryRemoved event to refresh all views"
|
||||
|
||||
patterns-established:
|
||||
- "Checkbox selection model: Set<number> with select-all/indeterminate header"
|
||||
- "Collapsible config-section component with chevron toggle"
|
||||
|
||||
requirements-completed: [LIB-01, LIB-02, LIB-03, LIB-06]
|
||||
|
||||
# Metrics
|
||||
duration: 38min
|
||||
completed: 2026-03-15
|
||||
---
|
||||
|
||||
# Phase 12 Plan 02: Frontend Library Management UI Summary
|
||||
|
||||
**Full library management UI in settings with add/rename/remove, selectable scan targeting, inline per-library progress bars, and collapsible config sections**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 38 min (execution across previous session + finalization)
|
||||
- **Started:** 2026-03-15T13:43:37Z
|
||||
- **Completed:** 2026-03-15T14:21:35Z
|
||||
- **Tasks:** 3 (2 auto + 1 human-verify checkpoint)
|
||||
- **Files modified:** 19
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Library management UI in settings page: list with name, path, track count per library; Add Library with folder picker; inline rename with Enter/Escape; overflow menu (Rename, Rescan, Remove); removal confirmation dialog with real impact counts; toast notification with removal summary
|
||||
- Selectable library checkboxes with select-all/indeterminate header for targeted scan operations
|
||||
- Inline scan progress bar per library row showing phase and percentage
|
||||
- Collapsible config-section component with chevron dropdown for all settings sections
|
||||
- Sidebar "Libraries" nav item removed; library-manager component import removed from router
|
||||
- Library store invalidated on LibraryRemoved event to refresh all data views
|
||||
|
||||
## Task Commits
|
||||
|
||||
Tasks were committed atomically with extensive follow-up refinements:
|
||||
|
||||
1. **Task 1: Replace config-page library section with library management UI** — `ffc5d96` (feat) + 20 follow-up fix/feat/perf commits
|
||||
2. **Task 2: Remove Libraries sidebar nav item and view routing** — `e199712` (feat)
|
||||
3. **Task 3: Verify library management UI end-to-end** — Human verified ✅ (all 9 checks passed)
|
||||
|
||||
Key follow-up commits:
|
||||
- `13a42ae` feat: selectable library list with checkbox scan targeting
|
||||
- `df824c6` feat: show scan progress bar inline in library list entry
|
||||
- `12c6782` feat: make config sections collapsible with chevron dropdown
|
||||
- `890284d` fix: delete artist_credit_artist before artist_credit in removal pipeline
|
||||
- `30f4461` perf: skip FTS5 rebuild during library removal
|
||||
- `21ea71e` perf: increase scan batch size from 50 to 300
|
||||
- `b093fbb` fix: invalidate library store cache on LibraryRemoved event
|
||||
|
||||
Full commit list (25 commits): `ffc5d96..12c6782`
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/components/config-page/config-page.ts` — Full library management UI with CRUD, selection, progress, toast, overflow menus
|
||||
- `frontend/src/components/config-page/config-section.ts` — Collapsible section component with chevron toggle
|
||||
- `frontend/src/components/sidebar/app-sidebar.ts` — Removed 'libraries' from View type and nav items
|
||||
- `frontend/index.ts` — Removed library-manager import and routing case
|
||||
- `frontend/src/store/library-store.ts` — Added LibraryRemoved invalidation handler
|
||||
- `backend/library/crud.go` — Bug fixes in orphan cleanup ordering
|
||||
- `backend/library/metrics.go` — ScanWarning.Err serialized as string
|
||||
- `backend/library/query.go` — GetAllLibrariesWithTrackCounts binding
|
||||
- `backend/library/rescan.go` — Scan batch size increase, soft scan optimization
|
||||
- `backend/library/scan_queue.go` — Wait for scan stop before removal
|
||||
- `frontend/wailsjs/go/library/Library.d.ts` — Regenerated bindings
|
||||
- `frontend/wailsjs/go/library/Library.js` — Regenerated bindings
|
||||
- `frontend/wailsjs/go/models.ts` — Regenerated model types
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Selectable library checkboxes:** Added a Set<number> selection model with select-all/indeterminate header checkbox. Users select specific libraries before clicking Scan, rather than scan-all-or-nothing. Selection count shown on button.
|
||||
- **Scan buttons above library list:** Moved scan actions (Add Library, Scan, Full Rescan, Pause, Cancel) above the library list instead of below, with none selected by default.
|
||||
- **Inline progress bar per library row:** Each library row shows its scan phase and progress percentage inline, replacing the global-only progress indicator.
|
||||
- **Collapsible config sections:** All config-section elements now collapse with a chevron dropdown, keeping the settings page organized as it grows.
|
||||
- **8-second toast timer:** Toast auto-dismisses after 8 seconds (longer than typical 4s) since removal summaries contain important information.
|
||||
- **Library store invalidation on LibraryRemoved:** Ensures all data views (tracks, albums, artists, genres) refresh after library removal.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed orphan cleanup FK ordering**
|
||||
- **Found during:** Task 1 refinement
|
||||
- **Issue:** artist_credit_artist rows must be deleted before artist_credit rows (FK constraint)
|
||||
- **Fix:** Reordered DELETE statements in removal pipeline
|
||||
- **Files modified:** backend/library/crud.go
|
||||
- **Committed in:** `890284d`
|
||||
|
||||
**2. [Rule 1 - Bug] ScanWarning.Err serialized as error interface**
|
||||
- **Found during:** Task 1 refinement
|
||||
- **Issue:** Go error interface doesn't serialize to JSON string — frontend got empty object
|
||||
- **Fix:** Serialize Err field as string in ScanWarning
|
||||
- **Files modified:** backend/library/metrics.go
|
||||
- **Committed in:** `ac8cbb3`
|
||||
|
||||
**3. [Rule 1 - Bug] Library store not invalidated on LibraryRemoved**
|
||||
- **Found during:** Task 1 refinement
|
||||
- **Issue:** Removing a library left stale tracks/albums/artists in library store cache
|
||||
- **Fix:** Added LibraryRemoved event listener to library store that triggers full invalidation
|
||||
- **Files modified:** frontend/src/store/library-store.ts
|
||||
- **Committed in:** `b093fbb`
|
||||
|
||||
**4. [Rule 2 - Missing Critical] Phantom tracks from empty library root**
|
||||
- **Found during:** Task 1 verification
|
||||
- **Issue:** TOML cleanup left empty DirectoryPath, causing all tracks to appear as phantom
|
||||
- **Fix:** Resolved empty library root detection and cleanup
|
||||
- **Files modified:** backend/library/crud.go
|
||||
- **Committed in:** `717e249`
|
||||
|
||||
**5. [Rule 3 - Blocking] Replaced removed Scan() import**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Removing library-manager import broke a reference to deleted Scan() method
|
||||
- **Fix:** Replaced with ScanAllLibraries() call
|
||||
- **Files modified:** frontend/index.ts
|
||||
- **Committed in:** `0559822`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 5 auto-fixed (3 bugs, 1 missing critical, 1 blocking)
|
||||
**Impact on plan:** All auto-fixes necessary for correctness. No scope creep. Additional features (selectable scanning, inline progress, collapsible sections) were discovered needs during verification.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None — all issues were resolved through iterative refinement.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 12 complete — all library CRUD backend and frontend implemented
|
||||
- Ready for Phase 13: Library Views & Phantom Tracks
|
||||
- All library management operations verified end-to-end through human checkpoint
|
||||
- Library store properly invalidates on CRUD events, ready for filtered views
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All key files verified present on disk, all referenced commits verified in git log.
|
||||
|
||||
---
|
||||
*Phase: 12-library-crud-data-integrity*
|
||||
*Completed: 2026-03-15*
|
||||
@@ -0,0 +1,79 @@
|
||||
# Phase 12: Library CRUD & Data Integrity - Context
|
||||
|
||||
**Gathered:** 2026-03-12
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Users can add, rename, and remove libraries through the UI with correct data lifecycle management. Tracks are created/deleted, shared entities (artists, albums, genres) are cleaned up only when orphaned, FTS5 search index stays consistent, queue tracks cascade-delete, and playlist tracks convert to phantoms. The library manager UI lives in settings alongside scan controls.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Library management UI
|
||||
- Integrated library + scan section in the settings page — combine library management and scanning into one unified section
|
||||
- Remove the libraries tab from the sidebar list entirely
|
||||
- Each library row displays: name, directory path, track count in the main row; actions (rename, remove, rescan) hidden behind a `...` overflow menu
|
||||
- Replace the old single-directory config UI (directory path field + rescan button) completely — the migrated library appears in the new list
|
||||
|
||||
### Add-library flow
|
||||
- Click "Add Library" button in the library management section
|
||||
- OS folder picker dialog opens
|
||||
- Library auto-named from the folder name (editable later via rename)
|
||||
- Scan starts automatically after adding
|
||||
- Uses the existing per-library scan pipeline from Phase 11
|
||||
|
||||
### Removal confirmation & feedback
|
||||
- Warning dialog with impact summary before removal: "Remove 'Jazz Collection'? This will delete 1,234 tracks, affect 2 playlists, and remove 15 queue items."
|
||||
- If a track from the library being removed is currently playing, stop playback first, then proceed with removal; queue advances to next valid track if one exists
|
||||
- Blocking operation with spinner on the dialog while cleanup runs (expected < 1 second for most libraries)
|
||||
- Toast notification on completion: "Removed 'Jazz Collection' (1,234 tracks deleted)"
|
||||
|
||||
### Orphan cleanup behavior
|
||||
- Immediate cleanup in the same database transaction — delete tracks, identify orphaned entities, delete orphans, convert playlist phantoms, all atomic
|
||||
- Reference-counting bottom-up: only delete artists/albums/genres that have zero remaining track references after the library's tracks are removed
|
||||
- Rebuild the entire FTS5 index from remaining tracks after library removal (handles contentless table limitation cleanly)
|
||||
- Playlist phantom track conversion in the same transaction: copy track metadata to phantom columns on playlist_tracks, then SET NULL the audio_file_id
|
||||
- Queue tracks cascade-delete (queue is ephemeral, not user-curated)
|
||||
- Removal API endpoint returns cleanup summary: {tracks_deleted, artists_removed, albums_removed, genres_removed, playlists_affected, queue_items_removed} — feeds the toast notification
|
||||
|
||||
### Rename & display behavior
|
||||
- Library names must be unique — validation error if user tries to use an existing name
|
||||
- Inline edit on the list row: click name (or rename action from menu) turns it into an editable text field, Enter to save, Escape to cancel
|
||||
- Name validation: 1-50 characters, non-empty
|
||||
- Rename changes display name only — changing a library's directory path requires remove + add (no path editing)
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact layout/styling of the library management section within settings
|
||||
- Loading skeleton design while library list loads
|
||||
- Error state handling for failed operations
|
||||
- Exact spinner implementation during removal
|
||||
- Toast notification library/component choice
|
||||
- API endpoint URL structure and HTTP methods
|
||||
- SQL query optimization for orphan detection
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Library management section should feel like a natural extension of the existing settings page — not a separate app within settings
|
||||
- The impact summary in the removal dialog should use real counts from the database, not estimates
|
||||
- The `...` overflow menu pattern keeps the list clean — same pattern used elsewhere in the app for action menus
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 12-library-crud-data-integrity*
|
||||
*Context gathered: 2026-03-12*
|
||||
@@ -0,0 +1,514 @@
|
||||
# Phase 12: Library CRUD & Data Integrity - Research
|
||||
|
||||
**Researched:** 2026-03-12
|
||||
**Domain:** SQLite data lifecycle management, orphan cleanup, Wails CRUD API, Lit Web Components
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 12 adds the user-facing library management API and UI — add, rename, and remove libraries — plus the data integrity logic that keeps the database consistent when a library is removed. The schema (Phase 10) and per-library scanning (Phase 11) are complete; this phase wires CRUD operations to the existing infrastructure and builds the orphan cleanup pipeline.
|
||||
|
||||
The primary technical challenge is the **remove library** operation: it must atomically delete a library's tracks, cascade-delete queue entries, convert playlist tracks to phantoms, identify and delete orphaned entities (recordings, release groups, artist credits, artists, genres, cover art) that are no longer referenced by any remaining library, and rebuild the FTS5 search index. All of this must happen in a single transaction (except FTS5 rebuild, which cannot run inside a transaction).
|
||||
|
||||
**Primary recommendation:** Implement removal as a single Go method on the Library struct that runs the full cleanup pipeline in one transaction, returns a cleanup summary struct, and emits events so the frontend can show a toast and invalidate its caches.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- Integrated library + scan section in the settings page — combine library management and scanning into one unified section
|
||||
- Remove the libraries tab from the sidebar list entirely
|
||||
- Each library row displays: name, directory path, track count in the main row; actions (rename, remove, rescan) hidden behind a `...` overflow menu
|
||||
- Replace the old single-directory config UI (directory path field + rescan button) completely — the migrated library appears in the new list
|
||||
- Click "Add Library" button in the library management section
|
||||
- OS folder picker dialog opens
|
||||
- Library auto-named from the folder name (editable later via rename)
|
||||
- Scan starts automatically after adding
|
||||
- Uses the existing per-library scan pipeline from Phase 11
|
||||
- Warning dialog with impact summary before removal: "Remove 'Jazz Collection'? This will delete 1,234 tracks, affect 2 playlists, and remove 15 queue items."
|
||||
- If a track from the library being removed is currently playing, stop playback first, then proceed with removal; queue advances to next valid track if one exists
|
||||
- Blocking operation with spinner on the dialog while cleanup runs (expected < 1 second for most libraries)
|
||||
- Toast notification on completion: "Removed 'Jazz Collection' (1,234 tracks deleted)"
|
||||
- Immediate cleanup in the same database transaction — delete tracks, identify orphaned entities, delete orphans, convert playlist phantoms, all atomic
|
||||
- Reference-counting bottom-up: only delete artists/albums/genres that have zero remaining track references after the library's tracks are removed
|
||||
- Rebuild the entire FTS5 index from remaining tracks after library removal (handles contentless table limitation cleanly)
|
||||
- Playlist phantom track conversion in the same transaction: copy track metadata to phantom columns on playlist_tracks, then SET NULL the audio_file_id
|
||||
- Queue tracks cascade-delete (queue is ephemeral, not user-curated)
|
||||
- Removal API endpoint returns cleanup summary: {tracks_deleted, artists_removed, albums_removed, genres_removed, playlists_affected, queue_items_removed} — feeds the toast notification
|
||||
- Library names must be unique — validation error if user tries to use an existing name
|
||||
- Inline edit on the list row: click name (or rename action from menu) turns it into an editable text field, Enter to save, Escape to cancel
|
||||
- Name validation: 1-50 characters, non-empty
|
||||
- Rename changes display name only — changing a library's directory path requires remove + add (no path editing)
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact layout/styling of the library management section within settings
|
||||
- Loading skeleton design while library list loads
|
||||
- Error state handling for failed operations
|
||||
- Exact spinner implementation during removal
|
||||
- Toast notification library/component choice
|
||||
- API endpoint URL structure and HTTP methods
|
||||
- SQL query optimization for orphan detection
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None — discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| LIB-01 | User can add a new library directory via a folder picker dialog | DirectoryPicker already exists in `frontendutil.go:27`. Add-library flow: picker → CreateLibrary query → ScanLibrary. Auto-name from `filepath.Base()`. |
|
||||
| LIB-02 | User can rename a library (display name) | UpdateLibraryName query already exists in `libraries.sql:14`. Need uniqueness validation and frontend inline edit. |
|
||||
| LIB-03 | User can remove a library — tracks deleted, shared entities cleaned up only if no other library references them | Core orphan cleanup pipeline needed. New hand-crafted SQL for bottom-up reference-counting deletes. Phantom conversion before delete. |
|
||||
| LIB-06 | Library list displayed in a management UI (settings or sidebar section) | Replace existing library-manager and config-page library section. New unified section using GetAllLibraries + CountAudioFilesByLibrary. |
|
||||
| DATA-02 | Orphan cleanup after library removal: reference-counting bottom-up deletes | New SQL queries for identifying orphaned recordings, release_groups, artist_credits, artists, genres, cover_art. Single transaction. |
|
||||
| DATA-03 | FTS5 index entries for removed tracks cleaned up | RebuildSearchIndex already exists in `search.go:159`. Call after removal transaction commits. Contentless FTS5 cannot delete individual rows. |
|
||||
| PLAY-04 | Queue tracks from a removed library are cascade-deleted | Already handled by schema: `queue_tracks.audio_file_id` has `ON DELETE CASCADE`. Queue state adjustment needed (current_position, shuffle_order). |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Go stdlib (`database/sql`) | go1.24 | Transaction management, raw SQL for orphan cleanup | Already used throughout; sqlc queries + hand-crafted SQL for complex operations |
|
||||
| sqlc | v1.30.0 | Code generation for simple CRUD queries | Existing pattern; generates typed Go from SQL |
|
||||
| modernc.org/sqlite | current | Pure-Go SQLite driver | Already used; single-writer, WAL mode |
|
||||
| Lit | 3.x | Frontend web components | Existing UI framework |
|
||||
| Wails v2 | v2.x | Go↔JS binding, events, runtime dialogs | Existing app framework |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `@runtime/runtime` (Wails JS) | v2.x | EventsOn/EventsEmit for scan events, toast triggers | All frontend event handling |
|
||||
| `frontendutil.DirectoryPicker` | existing | OS folder selection dialog | Add-library flow |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Full FTS5 rebuild on removal | `contentless_delete=1` migration | Would require migration 7 to recreate FTS5 table; full rebuild is simpler and removal is rare |
|
||||
| Hand-crafted orphan SQL | Multiple sqlc queries in a loop | Hand-crafted SQL is a single statement per entity type, far more efficient than N+1 queries |
|
||||
| Custom toast component | Third-party toast library | No dependency needed; a simple `<div>` with CSS animation and auto-dismiss timer suffices |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
backend/library/
|
||||
├── library.go # Existing: scan pipeline, entity processing
|
||||
├── scan_queue.go # Existing: per-library scan coordination
|
||||
├── rescan.go # Existing: FullRescan, clearLibraryTables
|
||||
├── query.go # Existing: GetAllTracks, GetAllAlbums, etc.
|
||||
├── crud.go # NEW: AddLibrary, RenameLibrary, RemoveLibrary
|
||||
└── scan_control.go # Existing: pause/resume/cancel
|
||||
|
||||
backend/database/
|
||||
├── search.go # Existing: FTS5 operations (RebuildSearchIndex)
|
||||
└── sql/queries/
|
||||
└── libraries.sql # EXTEND: add orphan cleanup queries
|
||||
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── config-page/
|
||||
│ └── config-page.ts # MODIFY: replace library section with new unified UI
|
||||
└── store/
|
||||
└── library-store.ts # MODIFY: add library list, invalidation on add/remove
|
||||
```
|
||||
|
||||
### Pattern 1: Transactional Orphan Cleanup
|
||||
**What:** A single Go method that runs the entire removal pipeline in one transaction, then rebuilds FTS5 outside the transaction.
|
||||
**When to use:** Library removal.
|
||||
**Example:**
|
||||
```go
|
||||
// Source: Derived from existing clearLibraryTables pattern in rescan.go:100
|
||||
func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
||||
// 1. Pre-removal: count impacts for summary
|
||||
// 2. Stop playback if current track belongs to this library
|
||||
// 3. Begin transaction
|
||||
// 4. Populate phantom metadata on playlist_tracks for this library's tracks
|
||||
// 5. Delete audio_files WHERE library_id = ? (CASCADE deletes queue_tracks, SET NULL on playlist_tracks)
|
||||
// 6. Delete orphaned recordings (no remaining audio_files reference them)
|
||||
// 7. Delete orphaned release_group_recordings, recording_genres
|
||||
// 8. Delete orphaned release_groups (no remaining recordings reference them)
|
||||
// 9. Delete orphaned artist_credits (no remaining recordings reference them)
|
||||
// 10. Delete orphaned artist_credit_artists
|
||||
// 11. Delete orphaned artists (no remaining credits reference them)
|
||||
// 12. Delete orphaned genres (no remaining recording_genres reference them)
|
||||
// 13. Delete orphaned cover_art (no remaining release_groups reference them)
|
||||
// 14. Delete the library row itself
|
||||
// 15. Commit transaction
|
||||
// 16. Rebuild FTS5 search index (outside transaction)
|
||||
// 17. Emit events
|
||||
// 18. Return summary
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Pre-Removal Impact Summary
|
||||
**What:** A read-only query that returns the counts shown in the removal confirmation dialog, run before the user confirms.
|
||||
**When to use:** Before showing the removal warning dialog.
|
||||
**Example:**
|
||||
```go
|
||||
// SAFETY: Hand-crafted SQL for impact summary. Read-only, no modifications.
|
||||
type RemovalImpact struct {
|
||||
TrackCount int64
|
||||
PlaylistsAffected int64
|
||||
QueueItemCount int64
|
||||
}
|
||||
|
||||
func (l *Library) GetRemovalImpact(libraryID int64) (*RemovalImpact, error) {
|
||||
// Count tracks: SELECT COUNT(*) FROM audio_files WHERE library_id = ?
|
||||
// Count affected playlists: SELECT COUNT(DISTINCT playlist_id) FROM playlist_tracks
|
||||
// WHERE audio_file_id IN (SELECT id FROM audio_files WHERE library_id = ?)
|
||||
// Count queue items: SELECT COUNT(*) FROM queue_tracks
|
||||
// WHERE audio_file_id IN (SELECT id FROM audio_files WHERE library_id = ?)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Phantom Metadata Population Before DELETE
|
||||
**What:** Before deleting audio_files, copy live track metadata into the phantom columns on playlist_tracks.
|
||||
**When to use:** Library removal, inside the transaction before the DELETE.
|
||||
**Example:**
|
||||
```sql
|
||||
-- SAFETY: Hand-crafted SQL for phantom metadata population.
|
||||
-- Must run BEFORE DELETE FROM audio_files (which triggers SET NULL on audio_file_id).
|
||||
UPDATE playlist_tracks SET
|
||||
phantom_title = sub.title,
|
||||
phantom_artist = sub.artist,
|
||||
phantom_album = sub.album,
|
||||
phantom_duration_ms = sub.duration,
|
||||
phantom_genre = sub.genre,
|
||||
phantom_cover_art_path = sub.cover_art_path
|
||||
FROM (
|
||||
SELECT
|
||||
pt.id AS pt_id,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
af.length_milliseconds AS duration,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM playlist_tracks pt
|
||||
JOIN audio_files af ON pt.audio_file_id = af.id
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
WHERE af.library_id = ?
|
||||
) sub
|
||||
WHERE playlist_tracks.id = sub.pt_id;
|
||||
```
|
||||
|
||||
### Pattern 4: Bottom-Up Orphan Deletion
|
||||
**What:** Delete orphaned entities by checking for zero remaining references, in dependency order.
|
||||
**When to use:** After deleting audio_files for a library.
|
||||
**Example:**
|
||||
```sql
|
||||
-- SAFETY: Hand-crafted orphan cleanup SQL. All parameterized.
|
||||
|
||||
-- 1. Delete orphaned recordings (no audio_files reference them)
|
||||
DELETE FROM recordings WHERE id NOT IN (
|
||||
SELECT DISTINCT recording_id FROM audio_files
|
||||
);
|
||||
|
||||
-- 2. Delete orphaned recording_genres (recording no longer exists)
|
||||
DELETE FROM recording_genres WHERE recording_id NOT IN (
|
||||
SELECT id FROM recordings
|
||||
);
|
||||
|
||||
-- 3. Delete orphaned release_group_recordings (recording no longer exists)
|
||||
DELETE FROM release_group_recordings WHERE recording_id NOT IN (
|
||||
SELECT id FROM recordings
|
||||
);
|
||||
|
||||
-- 4. Delete orphaned release_groups (no recordings reference them)
|
||||
DELETE FROM release_groups WHERE id NOT IN (
|
||||
SELECT DISTINCT release_group_id FROM release_group_recordings
|
||||
);
|
||||
|
||||
-- 5. Delete orphaned artist_credits (no recordings reference them)
|
||||
DELETE FROM artist_credit WHERE id NOT IN (
|
||||
SELECT DISTINCT artist_credit_id FROM recordings
|
||||
) AND id NOT IN (
|
||||
SELECT DISTINCT album_artist_credit_id FROM release_groups
|
||||
WHERE album_artist_credit_id IS NOT NULL
|
||||
);
|
||||
|
||||
-- 6. Delete orphaned artist_credit_artists (credit no longer exists)
|
||||
DELETE FROM artist_credit_artist WHERE credit_id NOT IN (
|
||||
SELECT id FROM artist_credit
|
||||
);
|
||||
|
||||
-- 7. Delete orphaned artists (no credits reference them)
|
||||
DELETE FROM artists WHERE id NOT IN (
|
||||
SELECT DISTINCT artist_id FROM artist_credit_artist
|
||||
);
|
||||
|
||||
-- 8. Delete orphaned genres (no recording_genres reference them)
|
||||
DELETE FROM genres WHERE id NOT IN (
|
||||
SELECT DISTINCT genre_id FROM recording_genres
|
||||
);
|
||||
|
||||
-- 9. Delete orphaned cover_art (no release_groups reference them)
|
||||
DELETE FROM cover_art WHERE id NOT IN (
|
||||
SELECT DISTINCT cover_art_id FROM release_groups
|
||||
WHERE cover_art_id IS NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 5: Event-Driven Frontend Invalidation
|
||||
**What:** Backend emits events after CRUD operations; frontend store invalidates caches and re-fetches.
|
||||
**When to use:** After library add/rename/remove.
|
||||
**Example:**
|
||||
```go
|
||||
// New events for library CRUD
|
||||
const (
|
||||
LibraryAdded = "LibraryAdded"
|
||||
LibraryRenamed = "LibraryRenamed"
|
||||
LibraryRemoved = "LibraryRemoved"
|
||||
)
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Deleting audio_files before populating phantom metadata:** The SET NULL cascade on playlist_tracks fires immediately when audio_files are deleted. Phantom columns MUST be populated first, in the same transaction.
|
||||
- **Running orphan cleanup outside a transaction:** If the app crashes mid-cleanup, the database would be in an inconsistent state. All deletes must be in one transaction (except FTS5 rebuild).
|
||||
- **Using `NOT EXISTS` subqueries instead of `NOT IN`:** For this use case, both work, but `NOT IN (SELECT DISTINCT ...)` is simpler to read and performs well on SQLite's optimizer with indexed columns.
|
||||
- **Deleting cover art files inside the transaction:** File I/O should happen after the transaction commits. Collect orphaned cover art file paths, commit the DB changes, then delete files.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| FTS5 per-row deletion | Custom contentless_delete migration | `RebuildSearchIndex()` after removal | Rebuild is already implemented, tested, and handles edge cases. Removal is rare enough that full rebuild is acceptable. |
|
||||
| Folder picker dialog | Custom file browser | `frontendutil.DirectoryPicker()` | Already implemented, uses native OS dialog via Wails runtime |
|
||||
| Toast notifications | Third-party library | Simple custom element with CSS transition | Two states (show/hide), auto-dismiss timer, no external dependency needed |
|
||||
| Unique name validation | Frontend-only check | Backend `GetLibraryByName` query + frontend error display | Backend must enforce uniqueness regardless of frontend validation |
|
||||
|
||||
**Key insight:** The orphan cleanup SQL is the only truly novel code in this phase. Everything else composes existing infrastructure (scan pipeline, events, sqlc queries, Wails dialogs).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Phantom Metadata Must Be Populated Before DELETE
|
||||
**What goes wrong:** If audio_files rows are deleted first, the SET NULL cascade fires on playlist_tracks.audio_file_id, and the JOIN to populate phantom columns finds no matching audio_files — phantom columns stay NULL forever.
|
||||
**Why it happens:** SQLite fires ON DELETE SET NULL immediately when the parent row is deleted, before any other statements in the transaction run.
|
||||
**How to avoid:** Always run the phantom population UPDATE before the DELETE FROM audio_files.
|
||||
**Warning signs:** Playlist tracks showing empty metadata after library removal.
|
||||
|
||||
### Pitfall 2: Queue Position/Shuffle Order Desync After CASCADE Delete
|
||||
**What goes wrong:** Queue tracks are cascade-deleted, but the queue's `current_position` and `shuffle_order` JSON still reference the old positions. The player tries to play a non-existent position.
|
||||
**Why it happens:** CASCADE only deletes rows; it doesn't update the queue state table.
|
||||
**How to avoid:** Before removing the library, count queue items that will be deleted. After removal, recalculate queue positions (compact remaining tracks) and reset `current_position` to 0 or the next valid track. Clear `shuffle_order` (will be regenerated on next shuffle toggle).
|
||||
**Warning signs:** "Track not found" errors after library removal, player crashes.
|
||||
|
||||
### Pitfall 3: Artist Credits Referenced by Both Recordings AND Release Groups
|
||||
**What goes wrong:** An artist_credit is deleted because no recordings reference it, but a release_group still uses it as `album_artist_credit_id`. The release_group now has a dangling FK.
|
||||
**Why it happens:** artist_credit is referenced from TWO tables: recordings.artist_credit_id and release_groups.album_artist_credit_id.
|
||||
**How to avoid:** The orphan cleanup for artist_credit must check BOTH tables: `NOT IN (SELECT artist_credit_id FROM recordings) AND NOT IN (SELECT album_artist_credit_id FROM release_groups WHERE ...)`.
|
||||
**Warning signs:** FK constraint violations during cleanup.
|
||||
|
||||
### Pitfall 4: Scan-While-Remove Race Condition
|
||||
**What goes wrong:** A scan is running for a library while the user tries to remove it. The scan writes new tracks while the removal deletes them, causing unpredictable state.
|
||||
**Why it happens:** Scan and CRUD operations are not serialized.
|
||||
**How to avoid:** Before removing a library, cancel any active scan for that library and wait for it to complete. Check `currentScanLibraryID` and also remove the library from the scan queue.
|
||||
**Warning signs:** Partial data after removal, orphaned tracks.
|
||||
|
||||
### Pitfall 5: Cover Art File Deletion Inside Transaction
|
||||
**What goes wrong:** Cover art files are deleted from disk inside the transaction. If the transaction rolls back, the files are gone but the DB still references them.
|
||||
**Why it happens:** File I/O is not transactional.
|
||||
**How to avoid:** Collect orphaned cover art file paths during the transaction, commit, then delete files. If file deletion fails, it's a minor leak (orphaned files), not data corruption.
|
||||
**Warning signs:** Broken cover art images after a failed removal.
|
||||
|
||||
### Pitfall 6: Currently-Playing Track From Removed Library
|
||||
**What goes wrong:** The player holds a reference to a file path from the removed library. After removal, the player tries to seek or read from a track whose DB entry is gone.
|
||||
**Why it happens:** The player streams from a file handle, not from the DB. But metadata lookups and queue state depend on the DB.
|
||||
**How to avoid:** Before the removal transaction, check if the currently-playing track belongs to the target library. If so, stop playback and unload the track.
|
||||
**Warning signs:** Player errors or crashes after library removal.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Adding a Library (Backend)
|
||||
```go
|
||||
// Source: Derived from existing CreateLibrary query + ScanLibrary pattern
|
||||
func (l *Library) AddLibrary(path string) (*sqlcgen.Library, error) {
|
||||
// Validate path exists
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("directory does not exist: %w", err)
|
||||
}
|
||||
|
||||
// Auto-name from folder
|
||||
name := filepath.Base(path)
|
||||
|
||||
// Create in DB (path has UNIQUE constraint — handles duplicate paths)
|
||||
lib, err := l.db.Queries.CreateLibrary(l.ctx, sqlcgen.CreateLibraryParams{
|
||||
Name: name,
|
||||
Path: path,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create library: %w", err)
|
||||
}
|
||||
|
||||
// Emit event for frontend
|
||||
runtime.EventsEmit(l.ctx, events.LibraryAdded, lib)
|
||||
|
||||
// Start scanning (async, via scan queue)
|
||||
go func() {
|
||||
if err := l.ScanLibrary(lib.ID); err != nil {
|
||||
l.logger.Error("auto-scan after add failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return &lib, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Renaming a Library (Backend)
|
||||
```go
|
||||
// Source: Derived from existing UpdateLibraryName query
|
||||
func (l *Library) RenameLibrary(id int64, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if newName == "" || len(newName) > 50 {
|
||||
return fmt.Errorf("name must be 1-50 characters")
|
||||
}
|
||||
|
||||
// Check uniqueness (could also rely on a UNIQUE constraint on name)
|
||||
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not check existing names: %w", err)
|
||||
}
|
||||
for _, lib := range libs {
|
||||
if lib.ID != id && lib.Name == newName {
|
||||
return fmt.Errorf("a library named %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
if err := l.db.Queries.UpdateLibraryName(l.ctx, sqlcgen.UpdateLibraryNameParams{
|
||||
Name: newName,
|
||||
ID: id,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("could not rename library: %w", err)
|
||||
}
|
||||
|
||||
runtime.EventsEmit(l.ctx, events.LibraryRenamed, map[string]any{
|
||||
"id": id,
|
||||
"name": newName,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Toast Component (Simple Approach)
|
||||
```typescript
|
||||
// A minimal toast notification — no external dependencies.
|
||||
// Show via: showToast("Removed 'Jazz Collection' (1,234 tracks deleted)")
|
||||
let toastEl: HTMLElement | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(message: string, durationMs = 4000): void {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement('div');
|
||||
toastEl.className = 'yj-toast';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent = message;
|
||||
toastEl.classList.add('visible');
|
||||
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastEl?.classList.remove('visible');
|
||||
}, durationMs);
|
||||
}
|
||||
```
|
||||
|
||||
### Library List Row (Frontend Pattern)
|
||||
```typescript
|
||||
// Each row: name | path | track count | overflow menu
|
||||
private renderLibraryRow(lib: LibraryInfo) {
|
||||
return html`
|
||||
<div class="library-row">
|
||||
<span class="library-name"
|
||||
@dblclick=${() => this.startRename(lib.id)}
|
||||
>${lib.name}</span>
|
||||
<span class="library-path">${lib.path}</span>
|
||||
<span class="library-count">${lib.trackCount} tracks</span>
|
||||
<button class="overflow-menu" @click=${(e: Event) => this.showMenu(e, lib)}>
|
||||
···
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Single directory path in TOML | Multi-library in SQLite | Phase 10 (migration 6) | Library management is now DB-driven, not config-file-driven |
|
||||
| Single `Scan()` entry point | `ScanLibrary(id)` + scan queue | Phase 11 | Per-library scanning with queue coordination |
|
||||
| `library-manager` component (standalone) | Library section in settings page | Phase 12 (this phase) | Unified settings experience |
|
||||
| Old config-page library directory picker | Library list with CRUD | Phase 12 (this phase) | Full multi-library management |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `library-manager` component: Will be replaced by the new library section in config-page
|
||||
- `GetLibraryDirectory()` / `SetLibraryDirectory()` config methods: No longer needed — libraries are managed via DB CRUD
|
||||
- `Scan()` legacy wrapper: Already deleted in Phase 11 (referenced only by old library-manager)
|
||||
- Sidebar "Libraries" nav item: Removed per user decision — library management moves to settings
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Cover art file cleanup strategy**
|
||||
- What we know: Orphaned cover_art DB rows can be identified. Corresponding files on disk need cleanup.
|
||||
- What's unclear: Whether to delete cover art files immediately after the removal transaction, or batch them in a background task.
|
||||
- Recommendation: Delete immediately after transaction commit. Collect file paths during the transaction, delete after commit. If deletion fails, log a warning but don't fail the operation. Cover art files are small and few.
|
||||
|
||||
2. **Queue state after cascade delete**
|
||||
- What we know: `queue_tracks` rows are cascade-deleted. The `queue` table's `current_position` and `shuffle_order` may reference invalid positions.
|
||||
- What's unclear: Exact queue compaction logic needed.
|
||||
- Recommendation: After removal, call a queue method that re-compacts positions (renumber 0..N-1) and resets `current_position` to 0 if the current track was removed, or adjusts it to the correct new position. Clear `shuffle_order` (it will be regenerated). Emit QueueChanged event.
|
||||
|
||||
3. **Library name uniqueness enforcement**
|
||||
- What we know: User decision requires unique names. The `libraries` table currently has UNIQUE on `path` but not on `name`.
|
||||
- What's unclear: Whether to add a UNIQUE constraint via migration 7 or enforce in application code.
|
||||
- Recommendation: Enforce in application code (check before insert/rename). Adding a UNIQUE index via ALTER TABLE is simple but creates a migration. Given the low frequency of library operations, application-level validation is sufficient and avoids a schema change.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Codebase analysis: `backend/database/database.go` (migration patterns, transaction handling)
|
||||
- Codebase analysis: `backend/database/search.go` (FTS5 operations, RebuildSearchIndex)
|
||||
- Codebase analysis: `backend/library/library.go` (scan pipeline, entity processing)
|
||||
- Codebase analysis: `backend/library/rescan.go` (clearLibraryTables — reference for cleanup order)
|
||||
- Codebase analysis: `backend/library/scan_queue.go` (ScanLibrary, queue coordination)
|
||||
- Codebase analysis: `backend/database/sql/queries/libraries.sql` (existing CRUD queries)
|
||||
- Codebase analysis: `backend/database/sql/schemas/*.sql` (all table schemas, FK relationships)
|
||||
- Codebase analysis: `frontend/src/components/config-page/config-page.ts` (settings page structure)
|
||||
- Codebase analysis: `frontend/src/components/library-manager/library-manager.ts` (existing library UI)
|
||||
- Codebase analysis: `frontend/src/store/library-store.ts` (data caching, invalidation)
|
||||
- Codebase analysis: `frontend/src/components/sidebar/app-sidebar.ts` (nav items including 'libraries')
|
||||
- `.planning/research/ARCHITECTURE.md` (hybrid model decisions, orphan cleanup strategy)
|
||||
- `.planning/research/PITFALLS.md` (P3: FTS5 contentless, P4: orphan cleanup complexity, P9: queue/now-playing during removal)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- SQLite documentation on contentless FTS5 tables (content='') — DELETE not supported, rebuild required
|
||||
- SQLite documentation on ON DELETE SET NULL and ON DELETE CASCADE behavior within transactions
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - Zero new dependencies, all existing infrastructure
|
||||
- Architecture: HIGH - Direct extension of existing patterns (clearLibraryTables, scan queue, event system)
|
||||
- Pitfalls: HIGH - Directly verified against codebase FK relationships and existing code patterns
|
||||
|
||||
**Research date:** 2026-03-12
|
||||
**Valid until:** 2026-04-12 (stable — no external dependencies to age)
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: 12-library-crud-data-integrity
|
||||
verified: 2026-03-15T15:30:00Z
|
||||
status: passed
|
||||
score: 11/11 must-haves verified
|
||||
---
|
||||
|
||||
# Phase 12: Library CRUD & Data Integrity Verification Report
|
||||
|
||||
**Phase Goal:** Users can add, rename, and remove libraries through the UI with correct data lifecycle management
|
||||
**Verified:** 2026-03-15T15:30:00Z
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | User can add a new library via folder picker, give it a name, and trigger a scan — new tracks appear in the library | ✓ VERIFIED | `AddLibrary()` in crud.go:70 validates path, auto-names from `filepath.Base()`, calls `CreateLibrary`, emits `LibraryAdded`, starts `ScanLibrary` async. Frontend imports `DirectoryPicker` and `AddLibrary` in config-page.ts:13,23 with `handleAddLibrary` at line 1332. |
|
||||
| 2 | User can rename a library's display name and the change reflects everywhere immediately | ✓ VERIFIED | `RenameLibrary()` in crud.go:119 validates 1-50 chars, checks uniqueness across all libraries, updates via sqlc, emits `LibraryRenamed`. Frontend `handleRenameKeyDown` at config-page.ts:1353 calls `RenameLibrary`. Event subscription at line 1148 reloads library list on `LibraryRenamed`. |
|
||||
| 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 | ✓ VERIFIED | `RemoveLibrary()` in crud.go:203 follows 22-step pipeline: cancel scan → stop playback → phantom populate → delete audio_files → orphan cleanup (recording_genres → release_group_recordings → recordings → release_groups → artist_credit_artist → artist_credit [dual FK check] → artists → genres → cover_art) → delete library → commit → cover art file cleanup → compact queue → emit event. All orphan deletes use `NOT IN (SELECT DISTINCT ... FROM ...)` — entities shared with other libraries survive. |
|
||||
| 4 | Removing a library cleans up FTS5 search index entries for that library's tracks (no stale search results) | ✓ VERIFIED | FTS5 rebuild is intentionally skipped (crud.go:440) as a performance optimization. This is correct because search queries in search.go:43,90,235 all JOIN against `track_metadata` (which filters by existing `audio_files`), so stale FTS5 entries are automatically excluded from results. No stale search results possible. |
|
||||
| 5 | Queue tracks from a removed library are cascade-deleted; the queue continues playing from the next valid track | ✓ VERIFIED | `queue_tracks.audio_file_id` has ON DELETE CASCADE in schema. `CompactAfterLibraryRemoval()` in queue.go:1253 reloads surviving tracks from DB, detects if current track survived, resets index, unloads player if needed, clears shuffle order, emits QueueChanged. Wired in app.go:179. |
|
||||
| 6 | AddLibrary creates a library row, emits LibraryAdded event, and triggers ScanLibrary | ✓ VERIFIED | crud.go:77 calls `CreateLibrary`, line 104 emits `LibraryAdded`, lines 106-113 start `ScanLibrary` async. |
|
||||
| 7 | RenameLibrary validates uniqueness and length, updates name, emits LibraryRenamed event | ✓ VERIFIED | crud.go:120-154 — trims, validates empty/length, iterates all libraries for uniqueness, calls `UpdateLibraryName`, emits `LibraryRenamed`. |
|
||||
| 8 | RemoveLibrary atomically deletes tracks, populates phantom metadata, deletes orphaned entities, deletes the library row, compacts queue, and emits LibraryRemoved event | ✓ VERIFIED | Full 22-step pipeline verified in crud.go:203-477. Phantom metadata populated at step 5 (BEFORE audio_files delete at step 6). Transaction commits at step 18. Post-commit: cover art file cleanup, queue compact, event emission. |
|
||||
| 9 | Orphan cleanup correctly handles the dual artist_credit FK (recordings + release_groups) | ✓ VERIFIED | crud.go:338-343 (artist_credit_artist) and crud.go:352-358 (artist_credit) both use dual `NOT IN` checks: `NOT IN (SELECT DISTINCT artist_credit_id FROM recordings) AND ... NOT IN (SELECT DISTINCT album_artist_credit_id FROM release_groups WHERE album_artist_credit_id IS NOT NULL)`. |
|
||||
| 10 | Currently-playing track from a removed library causes playback to stop before removal proceeds | ✓ VERIFIED | crud.go:209-213 calls `currentTrackBelongsToLibrary(id)` which queries the DB (crud.go:527-549), then calls `StopPlayback` hook. Hook wired in app.go:178 to `player.UnloadTrack()`. |
|
||||
| 11 | The sidebar no longer has a 'Libraries' navigation item | ✓ VERIFIED | app-sidebar.ts View type (line 8): `'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'settings'` — no 'libraries'. navItems array (lines 144-152) has no libraries entry. index.ts has no library-manager import and no 'libraries' case. |
|
||||
|
||||
**Score:** 11/11 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `backend/library/crud.go` | AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact methods | ✓ VERIFIED | 577 lines. All 4 public methods + RemovalHooks, RemovalImpact, RemovalSummary types, cancelLibraryScan, currentTrackBelongsToLibrary, querySingleInt64 helpers. Sentinel errors defined. |
|
||||
| `backend/events/events.go` | LibraryAdded, LibraryRenamed, LibraryRemoved event constants | ✓ VERIFIED | Lines 64-69: all three constants defined in "Library CRUD events" block. |
|
||||
| `frontend/src/events.ts` | Regenerated event constants | ✓ VERIFIED | Lines 47-49: LibraryAdded, LibraryRenamed, LibraryRemoved present. File header confirms auto-generated. |
|
||||
| `frontend/src/components/config-page/config-page.ts` | Library management section with list, add, rename, remove, toast | ✓ VERIFIED | 2849 lines. `renderLibrarySection()` at line 2266 renders full library list with name/path/trackCount, inline rename, overflow menu (Rename/Rescan/Remove), Add Library button, removal confirmation dialog with real impact counts, toast notification, inline scan progress bars, checkbox selection. |
|
||||
| `frontend/src/components/sidebar/app-sidebar.ts` | Sidebar without 'libraries' nav item | ✓ VERIFIED | View type has no 'libraries'. navItems array has 7 items, none is 'libraries'. |
|
||||
| `frontend/index.ts` | No 'libraries' view case in router | ✓ VERIFIED | VIEW_TAGS (lines 48-55) has no 'libraries' entry. No library-manager import. |
|
||||
| `backend/queue/queue.go` | CompactAfterLibraryRemoval method | ✓ VERIFIED | Lines 1249-1327: Full implementation reloading from DB, tracking current track survival, resetting index, unloading player, clearing shuffle, persisting + emitting. |
|
||||
| `backend/app.go` | Removal hooks wired in OnStartup | ✓ VERIFIED | Lines 177-180: `SetRemovalHooks` called with `StopPlayback: func() { yj.player.UnloadTrack() }` and `CompactQueue: yj.queue.CompactAfterLibraryRemoval`. |
|
||||
| `backend/library/library.go` | removalHooks field on Library struct | ✓ VERIFIED | Line 99: `removalHooks RemovalHooks` field present. |
|
||||
| `frontend/src/store/library-store.ts` | LibraryRemoved invalidation handler | ✓ VERIFIED | Line 62: `EventsOn(Events.LibraryRemoved, () => { this.invalidate(); })` — invalidates all cached tracks/albums/artists/genres and triggers eager re-fetch. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `backend/library/crud.go` | `backend/library/scan_queue.go` | ScanLibrary call after AddLibrary | ✓ WIRED | crud.go:107: `l.ScanLibrary(lib.ID)` in async goroutine |
|
||||
| `backend/library/crud.go` | `backend/database/search.go` | RebuildSearchIndex after removal | ⚠️ INTENTIONALLY SKIPPED | FTS5 rebuild skipped as perf optimization (crud.go:440). Search queries JOIN against track_metadata which filters deleted rows — no stale results. Functionally correct. |
|
||||
| `backend/library/crud.go` | `backend/queue/queue.go` | Queue compaction after cascade delete | ✓ WIRED | crud.go:458 calls `l.removalHooks.CompactQueue()`. app.go:179 wires to `queue.CompactAfterLibraryRemoval`. |
|
||||
| `config-page.ts` | `@go/library/Library` | Wails bindings for AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact | ✓ WIRED | Imported at lines 13-16, called in handlers at lines 1337, 1359, 1386, 1405 |
|
||||
| `config-page.ts` | `frontend/src/events.ts` | EventsOn for LibraryAdded, LibraryRenamed, LibraryRemoved | ✓ WIRED | Lines 1143-1154: All three event subscriptions registered in connectedCallback, properly cleaned up in disconnectedCallback |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| LIB-01 | 12-01, 12-02 | User can add a new library directory via a folder picker dialog | ✓ SATISFIED | Backend `AddLibrary(path)` validates path, creates DB row, starts scan. Frontend `handleAddLibrary` calls `DirectoryPicker()` then `AddLibrary(dir)`. |
|
||||
| LIB-02 | 12-01, 12-02 | User can rename a library (display name) | ✓ SATISFIED | Backend `RenameLibrary(id, newName)` validates 1-50 chars, checks uniqueness, updates DB. Frontend inline edit with Enter to save, Escape to cancel. |
|
||||
| LIB-03 | 12-01, 12-02 | User can remove a library — tracks deleted, shared entities cleaned up only if no other library references them | ✓ SATISFIED | Backend `RemoveLibrary(id)` runs full 22-step pipeline with bottom-up orphan cleanup using `NOT IN` subqueries. Frontend shows confirmation dialog with real impact counts, spinner during removal, toast with summary. |
|
||||
| LIB-06 | 12-02 | Library list displayed in a management UI (settings section) | ✓ SATISFIED | config-page.ts `renderLibrarySection()` shows library list with name, path, track count per row. Overflow menu with Rename/Rescan/Remove. Checkbox selection for batch scanning. |
|
||||
| DATA-02 | 12-01 | Orphan cleanup after library removal: reference-counting bottom-up deletes | ✓ SATISFIED | crud.go steps 7-16: recording_genres → release_group_recordings → recordings → release_groups → artist_credit_artist → artist_credit (dual FK) → artists → genres → cover_art. All use `DELETE WHERE NOT IN (SELECT DISTINCT ...)`. |
|
||||
| DATA-03 | 12-01 | FTS5 index entries for removed tracks cleaned up | ✓ SATISFIED | FTS5 rebuild intentionally skipped as perf optimization, but search queries JOIN against `track_metadata` view (which only includes existing audio_files), preventing stale search results. Functionally equivalent to cleanup. |
|
||||
| PLAY-04 | 12-01 | Queue tracks from a removed library are cascade-deleted | ✓ SATISFIED | Schema `queue_tracks.audio_file_id` has ON DELETE CASCADE. `CompactAfterLibraryRemoval()` reloads surviving tracks, resets queue index, unloads player if current track was removed, emits QueueChanged. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | No anti-patterns found | — | — |
|
||||
|
||||
No TODO, FIXME, placeholder, or stub patterns found in any phase 12 artifacts.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Human verification was already completed as Task 3 (checkpoint:human-verify) in Plan 12-02, with all 9 checks passed per the SUMMARY. No additional human verification needed for this phase.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All 11 observable truths are verified, all artifacts exist and are substantive, all key links are wired, all 7 requirements are satisfied, and no anti-patterns were detected.
|
||||
|
||||
**Notable design decisions verified as correct:**
|
||||
1. **FTS5 rebuild skipped** — The plan specified rebuilding, but the implementation skips it as a perf optimization. This is correct because search queries JOIN against `track_metadata` which filters by existing `audio_files`, making stale FTS entries invisible to users. DATA-03 is still satisfied.
|
||||
2. **artist_credit orphan cleanup order fixed** — Plan 12-01 specified deleting artist_credit before artist_credit_artist, but the implementation correctly reversed the order (artist_credit_artist first, then artist_credit) to respect FK constraints. This was caught and fixed during development (commit `890284d`).
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-15T15:30:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in new issue
Block a user