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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-library-views-phantom-tracks | 02 | execute | 2 |
|
|
false |
|
|
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
<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/13-library-views-phantom-tracks/13-CONTEXT.md @.planning/phases/13-library-views-phantom-tracks/13-01-SUMMARY.mdFrom backend/library/query.go — new methods added by Plan 13-01:
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:
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:
import { GetAllTracks, GetAllAlbums, GetAllArtists, GetAllGenresWithCounts, GetAlbumsByArtist } from '@go/library/Library';
After Plan 13-01 + Wails binding regen, these will also be available:
import { GetAllTracksByLibrary, GetAllAlbumsByLibrary, GetAllArtistsByLibrary,
GetAllGenresWithCountsByLibrary, GetAlbumsByArtistByLibrary,
GetTracksByGenreByLibrary, GetAlbumTracksByLibrary,
SearchTracksByLibrary } from '@go/library/Library';
From backend/library/query.go — library info for dropdown:
func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error)
// Info struct: { ID int64, Name string, Path string, TrackCount int64 }
Already available as Wails binding:
import { GetAllLibrariesWithTrackCounts } from '@go/library/Library';
From frontend/index.html — top bar structure:
<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:
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:
import { SearchTracks } from '@go/library/Library';
// loadTracks() calls libraryCtrl.getTracks() for browse
// handleSearchResult() calls SearchTracks(term) for search
Add a selectedLibraryId: number | null field to LibraryStore (null = "All Libraries"). Add methods:
getSelectedLibraryId(): number | null— returns current filtersetSelectedLibrary(id: number | null): void— sets filter, callsinvalidate()which clears caches, resets scroll positions, and triggerseagerFetch(). The existing invalidation + eager refetch pattern handles everything.getLibraries(): Promise<library.Info[]>— callsGetAllLibrariesWithTrackCounts(). Cache the result in aprivate libraries: library.Info[] | nullfield. Invalidate onLibraryAdded,LibraryRenamed,LibraryRemovedevents (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 | nullsetSelectedLibrary(id: number | null): voidgetLibraries(): 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
LibraryControllerto 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
connectedCallbackvialibraryCtrl.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>:
<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:
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
SearchTracksByLibraryfrom Wails bindings - When
selectedLibraryIdis set on the library controller, callSearchTracksByLibrary(term, selectedLibraryId)instead ofSearchTracks(term) - Access the library filter via the existing
libraryCtrlinstance
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
GetTracksByGenreByLibraryfrom Wails bindings - Import
libraryStore(or use a LibraryController) - When
selectedLibraryIdis set, callGetTracksByGenreByLibrary(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.trackscontains 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
overridekeyword on all Lit lifecycle methods - Use
import typefor type-only imports (verbatimModuleSyntax) - Use design tokens from
../../styles/tokens.css(importdesignTokens) - Arrow function event handlers (auto-bound
this) - Register component in HTMLElementTagNameMap
- Lines under 100 chars where possible cd /mnt/vault/dev/golang/yellowjacket && wails build -tags webkit2_41 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.
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 Human verification — all checklist items above pass 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)
<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>