409 lines
19 KiB
Markdown
409 lines
19 KiB
Markdown
---
|
|
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>
|