Files
yellowjacket/.planning/phases/13-library-views-phantom-tracks/13-02-PLAN.md
T

361 lines
19 KiB
Markdown

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