docs(13): create phase plan — library views and phantom tracks

This commit is contained in:
2026-03-16 08:46:05 -04:00
parent 963487f892
commit 66ba5ad1a6
3 changed files with 612 additions and 2 deletions
+5 -2
View File
@@ -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 | - |
---
@@ -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"
---
<objective>
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
</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/13-library-views-phantom-tracks/13-CONTEXT.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
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 ...
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add library-filtered sqlc queries for all browse views</name>
<files>
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
</files>
<action>
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.
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go generate ./backend/database/... && go build -tags webkit2_41 ./...</automated>
</verify>
<done>Seven new sqlc queries exist (ByLibrary variants), sqlc generate succeeds, go build compiles cleanly</done>
</task>
<task type="auto">
<name>Task 2: Add library-filtered Go query methods and FTS search</name>
<files>
backend/library/query.go
backend/database/search.go
</files>
<action>
**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`.
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && go build -tags webkit2_41 ./... && make lint</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/13-library-views-phantom-tracks/13-01-SUMMARY.md`
</output>
@@ -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: "<library-filter>"
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"
---
<objective>
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
</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/13-library-views-phantom-tracks/13-CONTEXT.md
@.planning/phases/13-library-views-phantom-tracks/13-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plan 13-01. Executor should use these directly. -->
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<library.Track[]> // calls GetAllTracks()
async getAlbums(): Promise<library.Album[]> // calls GetAllAlbums()
async getArtists(): Promise<library.Artist[]> // calls GetAllArtists()
async getGenres(): Promise<library.GenreWithCount[]> // calls GetAllGenresWithCounts()
async getAlbumsByArtist(id: number): Promise<library.Album[]>
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
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
<h3 class="subtitle">Music how it was meant to bee.</h3>
</hgroup>
<search-bar></search-bar>
</header>
```
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add library filter state to store + controller, create dropdown component, wire all views</name>
<files>
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
</files>
<action>
**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<library.Info[]>` — 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<library.Info[]>`
**Step 3: Library filter dropdown component** (NEW file `library-filter.ts`)
Create `frontend/src/components/library-filter/library-filter.ts` — a compact `<library-filter>` Lit component:
- Uses `LibraryController` to get library list and current selection
- Renders as a styled `<select>` dropdown (native select for simplicity and accessibility):
- First option: "All Libraries" (value="" or value="0")
- One option per library: library name (value=library.id)
- On change: calls `libraryStore.setSelectedLibrary(id)` (null for "All Libraries", numeric ID otherwise)
- Loads library list on `connectedCallback` via `libraryCtrl.getLibraries()`
- Refreshes library list on LibraryAdded/LibraryRemoved events (the store handles this — controller just needs to re-read)
- Styling: matches existing top bar aesthetic with design tokens — `var(--yj-bg-surface)` background, `var(--yj-text-primary)` text, `var(--yj-border-subtle)` border, `var(--yj-accent)` focus ring. Compact height matching search bar (32px). No animation per CONTEXT.md (Claude's discretion — keep it simple).
- Register in HTMLElementTagNameMap
**Step 4: Wire into index.html and index.ts**
In `frontend/index.html`: add `<library-filter></library-filter>` in the `<header class="top-bar">` between the `<hgroup>` and `<search-bar>`:
```html
<header class="top-bar">
<hgroup>...</hgroup>
<library-filter></library-filter>
<search-bar></search-bar>
</header>
```
In `frontend/index.ts`: add import for the new component:
```typescript
import '@components/library-filter/library-filter.js';
```
**Step 5: Wire search to respect library filter** (`track-list.ts`)
The track-list component has a `handleSearchResult` method that calls `SearchTracks(term)`. Modify this:
- Import `SearchTracksByLibrary` from Wails bindings
- When `selectedLibraryId` is set on the library controller, call `SearchTracksByLibrary(term, selectedLibraryId)` instead of `SearchTracks(term)`
- Access the library filter via the existing `libraryCtrl` instance
Find the search-related code in track-list.ts and update accordingly. The search bar itself doesn't need changes — it just sets the search term. The track-list reacts to term changes and performs the actual search.
**Step 6: Wire genre-details and artist-details to respect library filter**
`genre-details.ts` calls `GetTracksByGenre(genreName)` directly (bypasses store). Modify:
- Import `GetTracksByGenreByLibrary` from Wails bindings
- Import `libraryStore` (or use a LibraryController)
- When `selectedLibraryId` is set, call `GetTracksByGenreByLibrary(genreName, selectedLibraryId)` instead
`artist-details.ts` calls `libraryCtrl.getAlbumsByArtist(id)` which goes through the store — this is already handled by Step 1's store changes.
`cover-grid.ts` — album track expansion dropdown calls `GetAlbumTracks(albumID)` directly. Import `GetAlbumTracksByLibrary` and use it when filter is active. Check if `cover-grid.ts` has a direct `GetAlbumTracks` import and update it.
**Step 7: Ensure playlists remain unfiltered**
Verify that playlist-view and playlist-details do NOT use LibraryController or libraryStore for their data. They should use PlaylistStore / direct Wails bindings to playlist.Service — which is library-agnostic. No changes needed if confirmed.
**Step 8: Queue context — playing from filtered view**
Per CONTEXT.md locked decision: "Queue matches the filter context — playing from a filtered view populates the queue with only that library's tracks."
This already works naturally because:
- When library filter is active, `track-list.tracks` contains only filtered tracks
- Double-click sends that track's FilePath to `queueStore.setQueue([filePath], 0)`
- Context menu "Play" sends selected (filtered) file paths to `queueStore.setQueue(filePaths, 0, true)`
- The queue service resolves tracks from file paths in the DB — which includes tracks from all libraries
However, there's a subtlety: the Queue's `SetQueue` on the backend resolves track metadata by file path from the DB. Since files from other libraries still exist in the DB, this works correctly. The queue will contain whatever file paths were sent from the filtered view.
No code changes needed for queue — the filtering happens at the data source (library store → track-list), and queue just receives file paths.
Verify build compiles: `cd frontend && npm run check` (TypeScript check) and test the app with `wails dev -tags webkit2_41`.
**Important conventions:**
- Use `override` keyword on all Lit lifecycle methods
- Use `import type` for type-only imports (verbatimModuleSyntax)
- Use design tokens from `../../styles/tokens.css` (import `designTokens`)
- Arrow function event handlers (auto-bound `this`)
- Register component in HTMLElementTagNameMap
- Lines under 100 chars where possible
</action>
<verify>
<automated>cd /mnt/vault/dev/golang/yellowjacket && wails build -tags webkit2_41</automated>
</verify>
<done>Library filter dropdown appears in top bar, shows "All Libraries" by default plus all configured libraries. Selecting a library causes all browse views (tracks, albums, artists, genres) to show only that library's content. Search respects the filter. Detail views (artist-details, genre-details) respect the filter. Playlists remain unfiltered. Scroll positions reset on filter change. Filter resets on app restart.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify library filter, cross-library playlists, and phantom tracks end-to-end</name>
<action>
Run the app with `wails dev -tags webkit2_41` and verify all Phase 13 requirements:
**1. Library filter dropdown (VIEW-02)**
- [ ] Compact dropdown appears in top bar between title and search bar
- [ ] Shows "All Libraries" as default selection
- [ ] Lists all configured libraries by name
- [ ] Selecting a library immediately refreshes all views
**2. Unified view — All Libraries (VIEW-01)**
- [ ] With "All Libraries" selected, track list shows tracks from ALL libraries
- [ ] Albums view shows albums from all libraries
- [ ] Artists view shows artists from all libraries
- [ ] Genres view shows genres from all libraries
**3. Filtered view — specific library (VIEW-02, VIEW-03)**
- [ ] Selecting a specific library shows only that library's tracks
- [ ] Albums view shows only albums with tracks in selected library
- [ ] Artists view shows only artists with albums in selected library
- [ ] Genres view shows only genres with tracks in selected library
- [ ] Artist detail page (click an artist) shows only that artist's albums in selected library
- [ ] Genre detail page (click a genre) shows only that genre's tracks in selected library
**4. Search with library filter (VIEW-04)**
- [ ] With "All Libraries" selected, search returns results from all libraries
- [ ] With a specific library selected, search returns only matches from that library
**5. Cross-library playlists (PLAY-01)**
- [ ] Create a playlist and add tracks from different libraries — they all appear correctly
- [ ] Playlist view is NOT affected by library filter (shows all playlists always)
- [ ] Playlist detail view shows ALL tracks regardless of active library filter
**6. Phantom tracks (PLAY-02, PLAY-03)**
- [ ] Remove a library that has tracks in a playlist
- [ ] Those tracks become phantom entries (greyed out with warning icon)
- [ ] Phantom tracks show preserved title, artist, album metadata
- [ ] Phantom resolver (locate/remove buttons) works on the phantom entries
**7. UX details**
- [ ] Scroll positions reset when switching library filter
- [ ] Loading skeleton shows briefly during filter switch
- [ ] Filter resets to "All Libraries" on app restart
- [ ] Queue plays correctly when tracks are from filtered view
</action>
<verify>Human verification — all checklist items above pass</verify>
<done>All 7 requirement groups verified: VIEW-01 (unified), VIEW-02 (filtered), VIEW-03 (browse views filtered), VIEW-04 (search filtered), PLAY-01 (cross-library playlists), PLAY-02 (phantom preservation), PLAY-03 (phantom display)</done>
</task>
</tasks>
<verification>
- `wails build -tags webkit2_41` completes successfully
- Library filter dropdown renders in top bar
- All 7 requirements verified: VIEW-01 (unified), VIEW-02 (filtered), VIEW-03 (browse filtered), VIEW-04 (search filtered), PLAY-01 (cross-library playlists), PLAY-02 (phantom preservation), PLAY-03 (phantom display)
- No regression in existing functionality (playlists, queue, playback)
</verification>
<success_criteria>
- Library filter dropdown in top bar with "All Libraries" default + per-library options
- Track, album, artist, genre views all filter by selected library
- Search respects active library filter
- Playlists remain unfiltered (cross-library by design)
- Phantom tracks display correctly after library removal
- Queue populated from filtered context
- Human checkpoint passed
</success_criteria>
<output>
After completion, create `.planning/phases/13-library-views-phantom-tracks/13-02-SUMMARY.md`
</output>