diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 968af53..e650b05 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -108,7 +108,10 @@ Plans: 3. Search results respect the active library filter — searching with a library selected returns only matches from that library; with "All Libraries" selected, searches everything 4. Playlists can contain tracks from multiple libraries — adding tracks from different libraries to the same playlist works naturally 5. When a library is removed, its tracks in playlists become phantom entries — visually distinguished (greyed out / icon) with preserved title, artist, album metadata instead of disappearing -**Plans:** TBD +**Plans:** 2 plans +Plans: +- [ ] 13-01-PLAN.md — Backend library-filtered sqlc queries + Go methods + FTS search +- [ ] 13-02-PLAN.md — Frontend library filter store + dropdown UI + all view/search wiring + verification ### Phase 14: Performance Optimization **Goal:** Scrolling, navigation, and rendering are as smooth and fast as possible — scrolling feels like a native animation, navigation is instant, no unnecessary re-renders @@ -143,7 +146,7 @@ Plans: | 10. Schema & Migration | 2/2 | Complete | 2026-03-09 | - | | 11. Per-Library Scan Pipeline | 3/3 | Complete | 2026-03-09 | - | | 12. Library CRUD & Data Integrity | v1.1 | Complete | 2026-03-15 | 2026-03-15 | -| 13. Library Views & Phantom Tracks | v1.1 | 0/? | Not started | - | +| 13. Library Views & Phantom Tracks | v1.1 | 0/2 | Planned | - | | 14. Performance Optimization | 4/4 | Complete | 2026-03-15 | - | --- diff --git a/.planning/phases/13-library-views-phantom-tracks/13-01-PLAN.md b/.planning/phases/13-library-views-phantom-tracks/13-01-PLAN.md new file mode 100644 index 0000000..c0dd242 --- /dev/null +++ b/.planning/phases/13-library-views-phantom-tracks/13-01-PLAN.md @@ -0,0 +1,247 @@ +--- +phase: 13-library-views-phantom-tracks +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/database/sql/queries/audio_files.sql + - backend/database/sql/queries/release_groups.sql + - backend/database/sql/queries/artists.sql + - backend/database/sql/queries/genres.sql + - backend/database/search.go + - backend/library/query.go +autonomous: true +requirements: [VIEW-01, VIEW-02, VIEW-03, VIEW-04] + +must_haves: + truths: + - "Backend returns all tracks when no library filter is active (unified view)" + - "Backend returns only tracks from a specific library when library_id is provided" + - "Albums, artists, and genres are filtered to only show entities that have tracks in the selected library" + - "FTS5 search returns results scoped to a specific library when library_id is provided" + - "All existing unfiltered queries continue to work unchanged" + artifacts: + - path: "backend/database/sql/queries/audio_files.sql" + provides: "GetAllTracksWithFullMetadataByLibrary query" + contains: "WHERE af.library_id" + - path: "backend/database/sql/queries/release_groups.sql" + provides: "GetAllAlbumsWithDetailsByLibrary, GetAlbumsByArtistByLibrary queries" + contains: "WHERE af.library_id" + - path: "backend/database/sql/queries/artists.sql" + provides: "GetAlbumArtistsByLibrary query" + contains: "WHERE af.library_id" + - path: "backend/database/sql/queries/genres.sql" + provides: "GetAllGenresWithCountsByLibrary, GetTracksByGenreByLibrary queries" + contains: "WHERE af.library_id" + - path: "backend/database/search.go" + provides: "SearchFTSTracksByLibrary method" + contains: "AND tm.library_id" + - path: "backend/library/query.go" + provides: "GetAllTracksByLibrary, GetAllAlbumsByLibrary, GetAllArtistsByLibrary, GetAllGenresWithCountsByLibrary, GetTracksByGenreByLibrary, GetAlbumsByArtistByLibrary, GetAlbumTracksByLibrary, SearchTracksByLibrary methods" + exports: ["GetAllTracksByLibrary", "SearchTracksByLibrary"] + key_links: + - from: "backend/library/query.go" + to: "backend/database/sql/queries/*.sql" + via: "sqlc-generated Queries methods" + pattern: "l\\.db\\.Queries\\." + - from: "backend/library/query.go" + to: "backend/database/search.go" + via: "l.db.SearchFTSTracksByLibrary" + pattern: "SearchFTSTracksByLibrary" +--- + + +Add library-filtered SQL query variants for all browse views and FTS search so the frontend can request data scoped to a specific library. + +Purpose: Phase 13 requires backend filtering for 150K+ track collections (per CONTEXT.md locked decision). Every existing unfiltered query that powers a browse view needs a `ByLibrary` variant accepting a `library_id` parameter. Existing unfiltered queries remain unchanged for the "All Libraries" default view. + +Output: sqlc-generated query methods + Go wrapper methods on Library struct + filtered FTS search method on DB struct + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/13-library-views-phantom-tracks/13-CONTEXT.md + + + + +From backend/library/query.go — existing types to reuse: +```go +type Track struct { + TrackName, ArtistName, TrackLength, FilePath string + TrackNumber, DiscNumber int64 + Album string; Genre []string; Year int64 + Composer, FileType string + SampleRate, BitDepth, Channels, Bitrate, FileSize int64 +} + +type Artist struct { ID int64; Name string } + +type Album struct { + ID int64; Name, ArtistName, CoverArtPath string + CoverArtSmall, CoverArtMedium, CoverArtLarge string + Year int64 +} + +type GenreWithCount struct { Name string; TrackCount int64 } + +// Helper used by GetAllTracks, SearchTracks, GetTracksByGenre: +func mapTrackRow(...) Track +``` + +From backend/database/search.go — existing FTS search: +```go +type SearchTrackRow struct { + FilePath string; LengthMilliseconds int64 + Title, ArtistName string + TrackNumber, DiscNumber sql.NullInt64 + Album, Genre string; Year int64 + Composer, FileType string + SampleRate, BitDepth, Channels, Bitrate, FileSize int64 +} + +func (d *DB) SearchFTSTracks(query string, limit int) ([]SearchTrackRow, error) +``` + +From track_metadata VIEW (already includes library_id): +```sql +CREATE VIEW IF NOT EXISTS track_metadata AS +SELECT af.id, af.file_path, ... af.library_id +FROM audio_files af +LEFT JOIN recordings r ON ... +LEFT JOIN release_group_recordings rgr ON ... +LEFT JOIN release_groups rg ON ... +LEFT JOIN artist_credits ac ON ... +LEFT JOIN artists a ON ... +LEFT JOIN cover_art ca ON ... +``` + + + + + + + Task 1: Add library-filtered sqlc queries for all browse views + + backend/database/sql/queries/audio_files.sql + backend/database/sql/queries/release_groups.sql + backend/database/sql/queries/artists.sql + backend/database/sql/queries/genres.sql + + +Add `ByLibrary` variants of each query used by browse views. Each variant is a copy of the existing query with an added `WHERE af.library_id = ?` condition (or equivalent JOIN condition). The `track_metadata` VIEW already includes `af.library_id` as the last column. + +**audio_files.sql** — add these queries: + +1. `GetAllTracksWithFullMetadataByLibrary` — copy of `GetAllTracksWithFullMetadata` (line 75) but add `WHERE af.library_id = ?1` to the outer query. The existing query JOINs audio_files, so the filter goes on `af.library_id`. Use sqlc parameter annotation `-- :arg library_id`. + +2. `GetAudioFilesByReleaseGroupByLibrary` — copy of `GetAudioFilesByReleaseGroup` (line 139) but add `AND af.library_id = ?` alongside the existing `WHERE rgr.release_group_id = ?`. + +**release_groups.sql** — add these queries: + +3. `GetAllAlbumsWithDetailsByLibrary` — copy of `GetAllAlbumsWithDetails` (line 48). This query doesn't directly JOIN audio_files, so add an `EXISTS (SELECT 1 FROM audio_files af WHERE af.library_id = ? AND EXISTS (SELECT 1 FROM recordings r JOIN release_group_recordings rgr ON rgr.recording_id = r.id WHERE rgr.release_group_id = rg.id AND r.id = (SELECT recording_id FROM audio_files WHERE id = af.id)))` — actually simpler: add `WHERE rg.id IN (SELECT DISTINCT rgr2.release_group_id FROM release_group_recordings rgr2 JOIN recordings r2 ON r2.id = rgr2.recording_id JOIN audio_files af2 ON af2.recording_id = r2.id WHERE af2.library_id = ?)`. Check the existing query structure first and find the simplest approach. Likely: wrap the existing query body and add a subquery filter on `rg.id` to only include albums that have at least one track in the given library. + +4. `GetAlbumsByArtistByLibrary` — copy of `GetAlbumsByArtist` (line 67). Add the same album-in-library subquery filter alongside the existing artist_id filter. + +**artists.sql** — add: + +5. `GetAlbumArtistsByLibrary` — copy of `GetAlbumArtists` (line 34). Filter to only artists that have at least one album with at least one track in the given library. Use subquery: `WHERE a.id IN (SELECT DISTINCT ac2.artist_id FROM artist_credits ac2 JOIN release_groups rg2 ON rg2.artist_credit_id = ac2.id JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id JOIN recordings r2 ON r2.id = rgr2.recording_id JOIN audio_files af2 ON af2.recording_id = r2.id WHERE af2.library_id = ?)`. + +**genres.sql** — add: + +6. `GetAllGenresWithCountsByLibrary` — copy of `GetAllGenresWithCounts` (line 66). Filter track counts to only count tracks in the given library. The existing query JOINs through recording_genres → recordings → audio_files, so add `AND af.library_id = ?` to the existing JOINs. + +7. `GetTracksByGenreByLibrary` — copy of `GetTracksByGenre` (line 26). Add `AND af.library_id = ?` alongside the existing genre name filter. + +After adding all queries, run `make generate` (or `sqlc generate` from `backend/database/`) to regenerate Go code. Verify compilation with `go build -tags webkit2_41 ./...`. + +**Important:** Do NOT modify existing queries — only add new ones. The unfiltered variants serve the "All Libraries" default view. + + + cd /mnt/vault/dev/golang/yellowjacket && go generate ./backend/database/... && go build -tags webkit2_41 ./... + + Seven new sqlc queries exist (ByLibrary variants), sqlc generate succeeds, go build compiles cleanly + + + + Task 2: Add library-filtered Go query methods and FTS search + + backend/library/query.go + backend/database/search.go + + +**backend/library/query.go** — Add `ByLibrary` wrapper methods that mirror each existing method but accept `libraryID int64` and call the `ByLibrary` sqlc query variant. Reuse `mapTrackRow` and cover art URL resolution logic identically. + +Add these exported methods to the Library struct: + +1. `GetAllTracksByLibrary(libraryID int64) ([]Track, error)` — calls `l.db.Queries.GetAllTracksWithFullMetadataByLibrary(l.ctx, libraryID)`, maps via `mapTrackRow`. Do NOT return `errNoTracksInLibrary` for empty result — a library with no tracks is valid (not an error). Return empty slice. + +2. `GetAllAlbumsByLibrary(libraryID int64) ([]Album, error)` — calls `GetAllAlbumsWithDetailsByLibrary`, maps with cover art URL resolution. + +3. `GetAllArtistsByLibrary(libraryID int64) ([]Artist, error)` — calls `GetAlbumArtistsByLibrary`. + +4. `GetAlbumsByArtistByLibrary(artistID, libraryID int64) ([]Album, error)` — calls `GetAlbumsByArtistByLibrary`. + +5. `GetAllGenresWithCountsByLibrary(libraryID int64) ([]GenreWithCount, error)` — calls `GetAllGenresWithCountsByLibrary`. + +6. `GetTracksByGenreByLibrary(genreName string, libraryID int64) ([]Track, error)` — calls `GetTracksByGenreByLibrary`. + +7. `GetAlbumTracksByLibrary(albumID, libraryID int64) ([]Track, error)` — calls `GetAudioFilesByReleaseGroupByLibrary`. + +8. `SearchTracksByLibrary(query string, libraryID int64) ([]Track, error)` — calls `l.db.SearchFTSTracksByLibrary(query, searchTrackLimit, libraryID)`, maps via `mapTrackRow`. + +**backend/database/search.go** — Add `SearchFTSTracksByLibrary`: + +```go +func (d *DB) SearchFTSTracksByLibrary( + query string, limit int, libraryID int64, +) ([]SearchTrackRow, error) +``` + +Copy from `SearchFTSTracks` but add `AND tm.library_id = ?` to the WHERE clause and pass `libraryID` as the third parameter. The hand-crafted SQL already JOINs `track_metadata tm ON tm.id = si.rowid`, so the filter is trivial. Add SAFETY comment following project convention. + +All methods must follow project conventions: +- Error wrapping with `fmt.Errorf("...: %w", err)` +- slog structured logging with method context +- godot doc comments ending with period +- Lines under 100 chars (break as needed) +- nlreturn blank line after error returns + +Verify with `go build -tags webkit2_41 ./...` and `make lint`. + + + cd /mnt/vault/dev/golang/yellowjacket && go build -tags webkit2_41 ./... && make lint + + Eight new Go methods on Library struct + one new SearchFTSTracksByLibrary on DB struct; all compile cleanly, lint passes. Wails binding generation will pick up the new exported methods automatically. + + + + + +- `go build -tags webkit2_41 ./...` compiles without errors +- `make lint` passes (golangci-lint v2 with strict rules) +- `sqlc generate` succeeds in backend/database/ +- All seven new ByLibrary SQL queries exist in their respective .sql files +- All eight new Go methods exist on Library struct +- SearchFTSTracksByLibrary exists on DB struct +- Existing unfiltered queries and methods are unchanged + + + +- Backend can serve track/album/artist/genre data filtered to a specific library_id +- Backend can serve FTS5 search results filtered to a specific library_id +- All new methods are Wails-bindable (exported, on exported struct) +- No regression in existing unfiltered queries + + + +After completion, create `.planning/phases/13-library-views-phantom-tracks/13-01-SUMMARY.md` + diff --git a/.planning/phases/13-library-views-phantom-tracks/13-02-PLAN.md b/.planning/phases/13-library-views-phantom-tracks/13-02-PLAN.md new file mode 100644 index 0000000..aacab06 --- /dev/null +++ b/.planning/phases/13-library-views-phantom-tracks/13-02-PLAN.md @@ -0,0 +1,360 @@ +--- +phase: 13-library-views-phantom-tracks +plan: 02 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - frontend/src/store/library-store.ts + - frontend/src/store/controllers/library-controller.ts + - frontend/src/components/library-filter/library-filter.ts + - frontend/index.html + - frontend/index.ts + - frontend/src/components/track-list/track-list.ts + - frontend/src/components/cover-grid/cover-grid.ts + - frontend/src/components/artists-view/artists-view.ts + - frontend/src/components/genres-view/genres-view.ts + - frontend/src/components/artist-details/artist-details.ts + - frontend/src/components/genre-details/genre-details.ts + - frontend/src/components/search-bar/search-bar.ts +autonomous: false +requirements: [VIEW-01, VIEW-02, VIEW-03, VIEW-04, PLAY-01, PLAY-02, PLAY-03] + +must_haves: + truths: + - "Default view shows tracks from all libraries merged (unified presentation)" + - "User can select a specific library from a dropdown in the top bar and all views show only that library's content" + - "Search results respect the active library filter" + - "Switching library filter triggers a backend re-fetch with loading state" + - "Scroll positions reset when switching library filter" + - "Playlists always show all tracks regardless of library filter" + - "Phantom tracks appear with existing phantom styling when a library is removed" + - "Detail views (artist, genre) respect the active library filter" + - "Library filter resets to All Libraries on app restart (no persistence)" + artifacts: + - path: "frontend/src/components/library-filter/library-filter.ts" + provides: "Library filter dropdown component" + min_lines: 60 + - path: "frontend/src/store/library-store.ts" + provides: "selectedLibraryId state + filtered fetch logic" + contains: "selectedLibraryId" + - path: "frontend/src/store/controllers/library-controller.ts" + provides: "selectedLibraryId getter/setter pass-through" + contains: "selectedLibraryId" + - path: "frontend/index.html" + provides: "library-filter element in top bar" + contains: "" + key_links: + - from: "frontend/src/store/library-store.ts" + to: "@go/library/Library" + via: "GetAllTracksByLibrary / GetAllTracks conditional call" + pattern: "GetAllTracksByLibrary|GetAllTracks" + - from: "frontend/src/components/library-filter/library-filter.ts" + to: "frontend/src/store/library-store.ts" + via: "libraryStore.setSelectedLibrary()" + pattern: "setSelectedLibrary" + - from: "frontend/src/components/track-list/track-list.ts" + to: "frontend/src/store/library-store.ts" + via: "libraryCtrl.getTracks() (now library-aware)" + pattern: "getTracks" +--- + + +Add library filter state to the frontend store, a compact dropdown control in the top bar, and wire all browse views + search to respect the active library filter. + +Purpose: Users need to filter their entire music collection to a single library or view all merged. This plan adds the filter UI and connects it to all views via the existing store/controller/component pattern. Cross-library playlists and phantom tracks already work via existing infrastructure (Phase 10 schema + Phase 12 CRUD pre-populate phantom metadata + playlist-details phantom rendering) — this plan verifies they work correctly in the multi-library context. + +Output: Working library filter dropdown, all views respond to filter changes, search respects filter, playlists remain unfiltered, phantom tracks verified + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/13-library-views-phantom-tracks/13-CONTEXT.md +@.planning/phases/13-library-views-phantom-tracks/13-01-SUMMARY.md + + + + +From backend/library/query.go — new methods added by Plan 13-01: +```go +func (l *Library) GetAllTracksByLibrary(libraryID int64) ([]Track, error) +func (l *Library) GetAllAlbumsByLibrary(libraryID int64) ([]Album, error) +func (l *Library) GetAllArtistsByLibrary(libraryID int64) ([]Artist, error) +func (l *Library) GetAlbumsByArtistByLibrary(artistID, libraryID int64) ([]Album, error) +func (l *Library) GetAllGenresWithCountsByLibrary(libraryID int64) ([]GenreWithCount, error) +func (l *Library) GetTracksByGenreByLibrary(genreName string, libraryID int64) ([]Track, error) +func (l *Library) GetAlbumTracksByLibrary(albumID, libraryID int64) ([]Track, error) +func (l *Library) SearchTracksByLibrary(query string, libraryID int64) ([]Track, error) +// Existing unfiltered methods remain unchanged +``` + +From frontend/src/store/library-store.ts — current state shape: +```typescript +class LibraryStore { + private tracks: library.Track[] | null; + private albums: library.Album[] | null; + private artists: library.Artist[] | null; + private genres: library.GenreWithCount[] | null; + // Loading flags, scroll positions, changeGen, coverSize... + async getTracks(): Promise // calls GetAllTracks() + async getAlbums(): Promise // calls GetAllAlbums() + async getArtists(): Promise // calls GetAllArtists() + async getGenres(): Promise // calls GetAllGenresWithCounts() + async getAlbumsByArtist(id: number): Promise + private invalidate(): void // nulls caches + changeGen++ + eagerFetch() +} +``` + +From frontend/src/store/library-store.ts — existing imports: +```typescript +import { GetAllTracks, GetAllAlbums, GetAllArtists, GetAllGenresWithCounts, GetAlbumsByArtist } from '@go/library/Library'; +``` + +After Plan 13-01 + Wails binding regen, these will also be available: +```typescript +import { GetAllTracksByLibrary, GetAllAlbumsByLibrary, GetAllArtistsByLibrary, + GetAllGenresWithCountsByLibrary, GetAlbumsByArtistByLibrary, + GetTracksByGenreByLibrary, GetAlbumTracksByLibrary, + SearchTracksByLibrary } from '@go/library/Library'; +``` + +From backend/library/query.go — library info for dropdown: +```go +func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) +// Info struct: { ID int64, Name string, Path string, TrackCount int64 } +``` + +Already available as Wails binding: +```typescript +import { GetAllLibrariesWithTrackCounts } from '@go/library/Library'; +``` + +From frontend/index.html — top bar structure: +```html +
+
+

YellowJacket

+

Music how it was meant to bee.

+
+ +
+``` + +From frontend/src/components/genre-details/genre-details.ts — direct Wails binding: +```typescript +import { GetTracksByGenre } from '@go/library/Library'; +// calls GetTracksByGenre(this.genreName) directly, bypasses library store +``` + +From frontend/src/components/track-list/track-list.ts — search integration: +```typescript +import { SearchTracks } from '@go/library/Library'; +// loadTracks() calls libraryCtrl.getTracks() for browse +// handleSearchResult() calls SearchTracks(term) for search +``` +
+
+ + + + + Task 1: Add library filter state to store + controller, create dropdown component, wire all views + + frontend/src/store/library-store.ts + frontend/src/store/controllers/library-controller.ts + frontend/src/components/library-filter/library-filter.ts + frontend/index.html + frontend/index.ts + frontend/src/components/track-list/track-list.ts + frontend/src/components/cover-grid/cover-grid.ts + frontend/src/components/artists-view/artists-view.ts + frontend/src/components/genres-view/genres-view.ts + frontend/src/components/artist-details/artist-details.ts + frontend/src/components/genre-details/genre-details.ts + frontend/src/components/search-bar/search-bar.ts + + +**Step 1: Library store filter state** (`library-store.ts`) + +Add a `selectedLibraryId: number | null` field to LibraryStore (null = "All Libraries"). Add methods: +- `getSelectedLibraryId(): number | null` — returns current filter +- `setSelectedLibrary(id: number | null): void` — sets filter, calls `invalidate()` which clears caches, resets scroll positions, and triggers `eagerFetch()`. The existing invalidation + eager refetch pattern handles everything. +- `getLibraries(): Promise` — calls `GetAllLibrariesWithTrackCounts()`. Cache the result in a `private libraries: library.Info[] | null` field. Invalidate on `LibraryAdded`, `LibraryRenamed`, `LibraryRemoved` events (the last two listeners already exist — extend them). + +Modify `getTracks()`: if `selectedLibraryId` is not null, call `GetAllTracksByLibrary(this.selectedLibraryId)` instead of `GetAllTracks()`. Similarly for `getAlbums()` → `GetAllAlbumsByLibrary`, `getArtists()` → `GetAllArtistsByLibrary`, `getGenres()` → `GetAllGenresWithCountsByLibrary`. + +Modify `getAlbumsByArtist(artistID)`: if `selectedLibraryId` is not null, call `GetAlbumsByArtistByLibrary(artistID, this.selectedLibraryId)` instead of `GetAlbumsByArtist(artistID)`. + +Add imports for the new Wails bindings: `GetAllTracksByLibrary`, `GetAllAlbumsByLibrary`, `GetAllArtistsByLibrary`, `GetAllGenresWithCountsByLibrary`, `GetAlbumsByArtistByLibrary`, `GetAllLibrariesWithTrackCounts`. + +Also add `getAlbumsByArtistNameCached()`: when `selectedLibraryId` is set, this should return null (force a backend query instead of client-side filtering, since cached albums are already library-filtered). + +**Step 2: Library controller pass-through** (`library-controller.ts`) + +Add pass-through methods: +- `get selectedLibraryId(): number | null` +- `setSelectedLibrary(id: number | null): void` +- `getLibraries(): Promise` + +**Step 3: Library filter dropdown component** (NEW file `library-filter.ts`) + +Create `frontend/src/components/library-filter/library-filter.ts` — a compact `` Lit component: +- Uses `LibraryController` to get library list and current selection +- Renders as a styled `