Files
yellowjacket/.planning/milestones/v1.1-phases/12-library-crud-data-integrity/12-01-PLAN.md
T
2026-03-16 16:08:27 -04:00

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12-library-crud-data-integrity 01 execute 1
backend/library/crud.go
backend/events/events.go
frontend/src/events.ts
backend/queue/queue.go
true
LIB-01
LIB-02
LIB-03
DATA-02
DATA-03
PLAY-04
truths artifacts key_links
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
path provides exports
backend/library/crud.go AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact methods
AddLibrary
RenameLibrary
RemoveLibrary
GetRemovalImpact
RemovalSummary
RemovalImpact
path provides contains
backend/events/events.go LibraryAdded, LibraryRenamed, LibraryRemoved event constants LibraryAdded
path provides contains
frontend/src/events.ts Regenerated event constants LibraryAdded
from to via pattern
backend/library/crud.go backend/library/scan_queue.go ScanLibrary call after AddLibrary l.ScanLibrary
from to via pattern
backend/library/crud.go backend/database/search.go RebuildSearchIndex after removal RebuildSearchIndex
from to via pattern
backend/library/crud.go backend/queue/queue.go Queue compaction after cascade delete CompactAfterLibraryRemoval
Implement the backend Library CRUD API (AddLibrary, RenameLibrary, RemoveLibrary) with full data integrity: orphan cleanup, phantom track conversion, FTS5 rebuild, queue compaction, and event emission.

Purpose: This is the core backend for Phase 12 — all frontend library management UI depends on these Wails-bound methods. Output: backend/library/crud.go with all CRUD methods, updated events, queue compaction method.

<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>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/12-library-crud-data-integrity/12-RESEARCH.md @.planning/phases/12-library-crud-data-integrity/12-CONTEXT.md @.planning/phases/11-per-library-scan-pipeline/11-01-SUMMARY.md @.planning/phases/10-schema-migration/10-01-SUMMARY.md

@backend/library/library.go @backend/library/scan_queue.go @backend/library/rescan.go @backend/library/query.go @backend/events/events.go @backend/database/search.go @backend/queue/queue.go @backend/database/sql/queries/libraries.sql @backend/database/sql/schemas/_libraries.sql @backend/database/sql/schemas/audio_files.sql @backend/database/sql/schemas/playlist_tracks.sql @backend/player/player.go

From backend/library/scan_queue.go:

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:

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:

func (d *DB) RebuildSearchIndex() error

From backend/database/sql/queries/libraries.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:

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:

// Library events.
const (
    LibraryScanStarted  = "LibraryScanStarted"
    LibraryScanProgress = "LibraryScanProgress"
    LibraryScanComplete = "LibraryScanComplete"
)

From backend/player/player.go:

func (p *Player) IsPlaying() bool
func (p *Player) UnloadTrack()
Task 1: Implement Library CRUD methods and orphan cleanup pipeline backend/library/crud.go backend/events/events.go frontend/src/events.ts Create `backend/library/crud.go` with the following methods on the `Library` struct:

Types:

// 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 transactionl.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_filesDELETE FROM audio_files WHERE library_id = ?. This triggers CASCADE on queue_tracks and SET NULL on playlist_tracks.audio_file_id.
  7. Delete orphaned recordingsDELETE FROM recordings WHERE id NOT IN (SELECT DISTINCT recording_id FROM audio_files)
  8. Delete orphaned recording_genresDELETE FROM recording_genres WHERE recording_id NOT IN (SELECT id FROM recordings)
  9. Delete orphaned release_group_recordingsDELETE FROM release_group_recordings WHERE recording_id NOT IN (SELECT id FROM recordings)
  10. Delete orphaned release_groupsDELETE 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_artistsDELETE FROM artist_credit_artist WHERE credit_id NOT IN (SELECT id FROM artist_credit)
  13. Delete orphaned artistsDELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM artist_credit_artist)
  14. Delete orphaned genresDELETE FROM genres WHERE id NOT IN (SELECT DISTINCT genre_id FROM recording_genres)
  15. Collect orphaned cover_art file pathsSELECT 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 rowsDELETE 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 rowDELETE FROM libraries WHERE id = ?
  18. Commit transaction
  19. Post-commit: Rebuild FTS5l.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 eventsevents.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:

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:

// 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:

removalHooks struct {
    stopPlayback func()
    compactQueue func()
}

Wire in app.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:

// Library CRUD events.
const (
    LibraryAdded   = "LibraryAdded"
    LibraryRenamed = "LibraryRenamed"
    LibraryRemoved = "LibraryRemoved"
)

Then run go generate ./backend/events/... to regenerate frontend/src/events.ts.

Use the SAFETY comment convention for ALL hand-crafted SQL (every ExecContext/QueryContext/QueryRowContext call). Follow error sentinel convention (err113): define var errLibraryNameEmpty, var errLibraryNameTooLong, var errLibraryNameDuplicate, var errLibraryPathNotExist as package-level vars. Follow nlreturn convention: blank line after early return blocks. Follow godot convention: doc comments end with periods. Follow wsl convention: blank line before var/const declarations. cd /mnt/vault/dev/golang/yellowjacket && go build ./backend/... && go vet ./backend/library/... && golangci-lint run ./backend/library/crud.go ./backend/events/events.go - crud.go exists with AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact, cancelLibraryScan methods - All hand-crafted SQL has SAFETY comments - RemoveLibrary follows exact order: phantom populate → delete audio_files → orphan cleanup → delete library → commit → FTS5 rebuild → cover art file cleanup → queue compact → events - events.go has LibraryAdded, LibraryRenamed, LibraryRemoved constants - events.ts is regenerated - go build ./backend/... passes

Task 2: Add queue compaction method and wire removal hooks in app.go backend/queue/queue.go backend/app.go backend/library/crud.go **Queue compaction method** — Add to `backend/queue/queue.go`:
// 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:

yj.library.SetRemovalHooks(library.RemovalHooks{
    StopPlayback: func() { yj.player.UnloadTrack() },
    CompactQueue: func() { yj.queue.CompactAfterLibraryRemoval() },
})

Add RemovalHooks type to crud.go (or library.go):

// RemovalHooks contains callbacks invoked during library removal.
// These break circular dependencies between library, player, and queue packages.
type RemovalHooks struct {
    // StopPlayback stops the currently-playing track.
    StopPlayback func()
    // CompactQueue reloads queue state after cascade deletes.
    CompactQueue func()
}

func (l *Library) SetRemovalHooks(h RemovalHooks) {
    l.removalHooks = h
}

Add removalHooks RemovalHooks field to the Library struct in library.go.

Make sure RemoveLibrary in crud.go calls these hooks at the appropriate points (StopPlayback before the transaction if current track belongs to the library, CompactQueue after the transaction commits). cd /mnt/vault/dev/golang/yellowjacket && go build ./... && go vet ./backend/queue/... ./backend/library/... && golangci-lint run ./backend/queue/queue.go ./backend/app.go - CompactAfterLibraryRemoval method exists on Queue - RemovalHooks type exists with StopPlayback and CompactQueue callbacks - app.go wires removal hooks in OnStartup - Library struct has removalHooks field - go build ./... passes (full build including frontend binding generation)

1. `go build ./...` — full project builds with no errors 2. `go vet ./backend/...` — no vet issues 3. `golangci-lint run ./backend/library/ ./backend/queue/ ./backend/events/` — no lint issues 4. `go test ./backend/database/... -count=1` — existing database tests still pass 5. `go test ./backend/queue/... -count=1` — existing queue tests still pass 6. `go test ./backend/library/... -count=1` — existing library tests still pass 7. Verify events.ts was regenerated with new event constants

<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>
After completion, create `.planning/phases/12-library-crud-data-integrity/12-01-SUMMARY.md`