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 |
|
true |
|
|
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()
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.LibraryAddedevent 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.LibraryRenamedwithmap[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 = ?
- Track count:
RemoveLibrary(id int64) (*RemovalSummary, error): This is the critical method. Follow the exact order from RESEARCH.md to avoid the phantom metadata pitfall:
- Cancel active scan — If this library is currently scanning, cancel it and remove from queue. Call
l.cancelLibraryScan(id)(new unexported helper that checksl.currentScanLibraryIDand scan queue). - 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 froml.player.GetCurrentFilePath(). Need to expose a way to check — add acurrentTrackBelongsToLibraryhelper that uses the Queue to get the current track's file path and checks it against the library. If it matches, calll.player.UnloadTrack(). - Pre-count for summary (track count, queue items affected, playlists affected).
- Begin transaction —
l.db.DB().BeginTx(l.ctx, nil) - 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.
- 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. - Delete orphaned recordings —
DELETE FROM recordings WHERE id NOT IN (SELECT DISTINCT recording_id FROM audio_files) - Delete orphaned recording_genres —
DELETE FROM recording_genres WHERE recording_id NOT IN (SELECT id FROM recordings) - Delete orphaned release_group_recordings —
DELETE FROM release_group_recordings WHERE recording_id NOT IN (SELECT id FROM recordings) - Delete orphaned release_groups —
DELETE FROM release_groups WHERE id NOT IN (SELECT DISTINCT release_group_id FROM release_group_recordings) - 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) - Delete orphaned artist_credit_artists —
DELETE FROM artist_credit_artist WHERE credit_id NOT IN (SELECT id FROM artist_credit) - Delete orphaned artists —
DELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM artist_credit_artist) - Delete orphaned genres —
DELETE FROM genres WHERE id NOT IN (SELECT DISTINCT genre_id FROM recording_genres) - 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. - 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) - Delete library row —
DELETE FROM libraries WHERE id = ? - Commit transaction
- Post-commit: Rebuild FTS5 —
l.db.RebuildSearchIndex()(cannot run inside transaction) - Post-commit: Delete orphaned cover art files — iterate collected paths,
os.Remove(), log warnings on failure - Post-commit: Compact queue — Call the new
l.queue.CompactAfterLibraryRemoval()method (see Task 2) - Emit events —
events.LibraryRemovedwithmap[string]any{"id": id, "summary": summary} - 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
// 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:
- Acquire
q.mu - Call
q.db.Queries.GetQueueTracks(q.db.Ctx)to get the surviving queue tracks from DB - Rebuild
q.tracksfrom the DB rows - 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
- Set
- Else: find the current track in the new list and update
q.currentIndex - Clear
q.shuffleOrder = nil(will be regenerated on next shuffle toggle) - Call
q.commitMutation(false)to persist the compacted state - 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)
<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>