chore: complete v1.1 milestone

This commit is contained in:
yonlu committed 2026-03-16 16:08:27 -04:00
1 parent 8f8af48a12
commit 98842a7e14
54 files changed
+11083 -34

No files matched your search

@@ -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,117 @@
---
phase: 13-library-views-phantom-tracks
plan: 01
subsystem: database, api
tags: [sqlite, sqlc, fts5, library-filtering, go]
# Dependency graph
requires:
- phase: 10-schema-migration
provides: library_id column on audio_files, libraries table
- phase: 11-per-library-scan-pipeline
provides: per-library scanning populates library_id
provides:
- Library-filtered sqlc queries for tracks, albums, artists, genres
- Library-filtered FTS5 search via SearchFTSTracksByLibrary
- Eight exported Go methods on Library struct for Wails binding
affects: [13-library-views-phantom-tracks, frontend-library-selector]
# Tech tracking
tech-stack:
added: []
patterns:
- ByLibrary query variants with subquery filtering for entity tables
- EXISTS/IN subquery pattern for cross-table library scoping
key-files:
created: []
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/sql/sqlcgen/audio_files.sql.go
- backend/database/sql/sqlcgen/release_groups.sql.go
- backend/database/sql/sqlcgen/artists.sql.go
- backend/database/sql/sqlcgen/genres.sql.go
- backend/database/search.go
- backend/library/query.go
key-decisions:
- "IN-subquery pattern for album/artist/genre library filtering — entities are global, tracks belong to libraries"
- "Empty slice return (not error) for library with no tracks — valid state"
patterns-established:
- "ByLibrary variant pattern: copy query, add WHERE af.library_id = ? or IN-subquery on entity IDs"
requirements-completed: [VIEW-01, VIEW-02, VIEW-03, VIEW-04]
# Metrics
duration: 5min
completed: 2026-03-16
---
# Phase 13 Plan 01: Library-Filtered Backend Queries Summary
**Seven ByLibrary sqlc query variants + eight Go wrapper methods + library-scoped FTS5 search enabling per-library browse views**
## Performance
- **Duration:** 5 min
- **Started:** 2026-03-16T13:24:17Z
- **Completed:** 2026-03-16T13:29:24Z
- **Tasks:** 2
- **Files modified:** 10
## Accomplishments
- Added 7 ByLibrary SQL query variants for all browse views (tracks, albums, artists, genres)
- Added 8 exported Go methods on Library struct for Wails frontend binding
- Added SearchFTSTracksByLibrary on DB struct for library-scoped full-text search
- All existing unfiltered queries remain unchanged for "All Libraries" default view
## Task Commits
Each task was committed atomically:
1. **Task 1: Add library-filtered sqlc queries for all browse views** - `5cc58ce` (feat)
2. **Task 2: Add library-filtered Go query methods and FTS search** - `5f7de50` (feat)
## Files Created/Modified
- `backend/database/sql/queries/audio_files.sql` - GetAllTracksWithFullMetadataByLibrary, GetAudioFilesByReleaseGroupByLibrary
- `backend/database/sql/queries/release_groups.sql` - GetAllAlbumsWithDetailsByLibrary, GetAlbumsByArtistByLibrary
- `backend/database/sql/queries/artists.sql` - GetAlbumArtistsByLibrary
- `backend/database/sql/queries/genres.sql` - GetTracksByGenreByLibrary, GetAllGenresWithCountsByLibrary
- `backend/database/sql/sqlcgen/*.sql.go` - sqlc-generated Go code for all new queries
- `backend/database/search.go` - SearchFTSTracksByLibrary method
- `backend/library/query.go` - Eight ByLibrary wrapper methods on Library struct
## Decisions Made
- Used IN-subquery pattern for album/artist/genre library filtering since entities are global but tracks belong to libraries
- Return empty slice (not error) when library has no tracks — empty library is valid state, not an error condition
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Backend library-filtered queries complete, ready for Plan 02 (frontend library selector UI and phantom track handling)
- All 8 new methods are Wails-bindable (exported, on exported Library struct)
## Self-Check: PASSED
- All 10 modified files exist on disk
- Both task commits found in git log (5cc58ce, 5f7de50)
- SUMMARY.md exists at expected path
- `go build -tags webkit2_41 ./...` compiles cleanly
- `make lint` passes with 0 issues
---
*Phase: 13-library-views-phantom-tracks*
*Completed: 2026-03-16*
@@ -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>
@@ -0,0 +1,216 @@
---
phase: 13-library-views-phantom-tracks
plan: 02
subsystem: ui, api
tags: [lit, wails, library-filter, phantom-tracks, playlist-resolution, typescript]
# Dependency graph
requires:
- phase: 13-library-views-phantom-tracks
plan: 01
provides: Library-filtered sqlc queries and Go methods for tracks, albums, artists, genres, search
- phase: 12-library-crud-data-integrity
provides: Library CRUD API, orphan cleanup, phantom track metadata pre-population
- phase: 10-schema-migration
provides: Libraries table, library_id FK, phantom columns on playlist_tracks
provides:
- Library filter dropdown UI in top bar with All Libraries default
- All browse views (tracks, albums, artists, genres) respect active library filter
- Search respects active library filter
- Detail views (artist-details, genre-details, album tracks) respect library filter
- Phantom track auto-resolution after library scan via ScanHooks callback
- phantom_file_path column on playlist_tracks for phantom-to-track matching
affects: [multi-library-complete, v1.1-milestone]
# Tech tracking
tech-stack:
added: []
patterns:
- "Conditional Wails binding dispatch: store methods call ByLibrary variant when filter active, default variant otherwise"
- "ScanHooks callback for cross-package phantom resolution (mirrors RemovalHooks/RescanHooks pattern)"
- "Deferred event delegation retry in updated() for virtualizer race condition"
- "phantom_file_path stored on removal for post-scan matching"
key-files:
created:
- frontend/src/components/library-filter/library-filter.ts
- backend/playlist/playlist.go (ResolvePhantomTracksAfterScan)
modified:
- frontend/src/store/library-store.ts
- frontend/src/store/controllers/library-controller.ts
- frontend/index.html
- frontend/index.ts
- frontend/src/components/cover-grid/cover-grid.ts
- frontend/src/components/cover-grid/album-selection.ts
- frontend/src/components/artists-view/artists-view.ts
- frontend/src/components/genres-view/genres-view.ts
- frontend/src/components/genre-details/genre-details.ts
- frontend/src/components/track-list/track-list.ts
- frontend/src/components/queue-panel/queue-panel.ts
- backend/library/crud.go
- backend/library/library.go
- backend/database/database.go
- backend/database/sql/schemas/playlist_tracks.sql
- backend/app.go
key-decisions:
- "Client-side search with filtered data source — rankTracks filters already-loaded tracks; no backend SearchTracksByLibrary call needed"
- "Native select for library filter dropdown — compact, accessible, matches top bar height; no custom component overhead"
- "getAlbumsByArtistNameCached returns null when filter active — avoids stale cross-library data; forces backend query"
- "ScanHooks callback pattern for phantom resolution — mirrors RemovalHooks/RescanHooks for cross-package communication"
- "phantom_file_path column on playlist_tracks — enables post-scan matching of phantoms to re-added tracks"
- "M3U8-based phantom resolution — reads playlist files to match phantoms by position and file path"
patterns-established:
- "Conditional ByLibrary dispatch: check selectedLibraryId, call ByLibrary variant or unfiltered default"
- "ScanHooks pattern: cross-package callbacks registered at app init to avoid circular imports"
- "Deferred delegation guard: retry event delegation in updated() when virtualizer not ready on firstUpdated()"
requirements-completed: [VIEW-01, VIEW-02, VIEW-03, VIEW-04, PLAY-01, PLAY-02, PLAY-03]
# Metrics
duration: 45min
completed: 2026-03-16
---
# Phase 13 Plan 02: Library Filter UI & Phantom Track Resolution Summary
**Library filter dropdown in top bar with conditional ByLibrary queries across all views, plus ScanHooks-based phantom track auto-resolution after library re-scan**
## Performance
- **Duration:** ~45 min (including checkpoint verification and bugfixes)
- **Started:** 2026-03-16T13:32:45Z
- **Completed:** 2026-03-16T14:18:00Z
- **Tasks:** 2 (1 auto + 1 checkpoint:human-verify — APPROVED)
- **Files modified:** 33 (across 4 code commits)
## Accomplishments
- Created `<library-filter>` dropdown component in top bar — shows "All Libraries" default plus all configured libraries
- Wired all browse views (tracks, albums, artists, genres) and detail views (artist-details, genre-details, album track expansion) to respect active library filter via conditional ByLibrary Wails binding calls
- Search inherits library filter automatically — client-side rankTracks operates on filtered track data
- Playlists remain unfiltered (cross-library by design) — verified during checkpoint
- Fixed pre-existing virtualizer event delegation race condition from Phase 14-03 optimization
- Added phantom track auto-resolution: ScanHooks callback triggers M3U8-based phantom matching after library scan
- Added `phantom_file_path` column (migration 7) to playlist_tracks for reliable phantom→track matching
- All 7 Phase 13 requirements verified end-to-end: VIEW-01 through VIEW-04, PLAY-01 through PLAY-03
## Task Commits
Each task was committed atomically:
1. **Task 1: Add library filter state + dropdown + wire all views** - `42b8cf9` (feat)
2. **Task 2: Verify library filter, cross-library playlists, and phantom tracks** - Checkpoint APPROVED
### Bugfix Commits (during checkpoint verification)
3. **Fix: Defer virtualizer event delegation until element exists** - `f05d2bb` (fix)
4. **Fix: Auto-resolve phantom playlist tracks after library scan** - `93262b9` (fix)
5. **Fix: Resolve phantom playlist tracks using M3U8 paths after scan** - `9f595b7` (fix)
**Plan metadata:** `70e3814` (docs: complete library filter UI plan)
## Files Created/Modified
### Frontend — Library filter UI (Task 1)
- `frontend/src/components/library-filter/library-filter.ts` — NEW: Library filter dropdown component (native select, design tokens)
- `frontend/src/store/library-store.ts` — selectedLibraryId state, conditional ByLibrary dispatch, getLibraries(), invalidation
- `frontend/src/store/controllers/library-controller.ts` — Pass-through for selectedLibraryId, setSelectedLibrary, getLibraries
- `frontend/index.html` — `<library-filter>` element added to top bar header
- `frontend/index.ts` — Import for library-filter component
- `frontend/src/components/cover-grid/cover-grid.ts` — GetAlbumTracksByLibrary for album expansion dropdown
- `frontend/src/components/cover-grid/album-selection.ts` — Library-aware fetchAlbumTracks helper
- `frontend/src/components/artists-view/artists-view.ts` — GetAlbumsByArtistByLibrary + GetAlbumTracksByLibrary
- `frontend/src/components/genres-view/genres-view.ts` — GetTracksByGenreByLibrary for context menu
- `frontend/src/components/genre-details/genre-details.ts` — GetTracksByGenreByLibrary for track loading
- `frontend/wailsjs/go/library/Library.d.ts` — Regenerated with ByLibrary bindings
- `frontend/wailsjs/go/library/Library.js` — Regenerated with ByLibrary bindings
### Frontend — Virtualizer race condition fix
- `frontend/src/components/track-list/track-list.ts` — Deferred event delegation with guard flag in updated()
- `frontend/src/components/queue-panel/queue-panel.ts` — Deferred event delegation with guard flag in updated()
### Backend — Phantom track auto-resolution
- `backend/database/database.go` — Migration 7: phantom_file_path column on playlist_tracks
- `backend/database/sql/schemas/playlist_tracks.sql` — phantom_file_path column definition
- `backend/database/sql/sqlcgen/models.go` — Generated model with PhantomFilePath field
- `backend/database/sql/sqlcgen/playlists.sql.go` — Generated query updates
- `backend/library/crud.go` — Store file_path as phantom_file_path on RemoveLibrary
- `backend/library/library.go` — ScanHooks registration, phantom resolution trigger after scan
- `backend/playlist/playlist.go` — ResolvePhantomTracksAfterScan: M3U8-based phantom matching
- `backend/app.go` — ScanHooks wiring at app initialization
- `frontend/wailsjs/go/models.ts` — Updated generated models
- `frontend/wailsjs/go/playlist/Service.d.ts` — Updated generated bindings
- `frontend/wailsjs/go/playlist/Service.js` — Updated generated bindings
## Decisions Made
- **Client-side search filtering:** rankTracks already operates on filtered track data from library store — no separate backend SearchTracksByLibrary call needed
- **Native `<select>` for library filter:** Compact, accessible, 32px height matching search bar, no custom dropdown overhead
- **getAlbumsByArtistNameCached returns null when filter active:** Forces backend query to avoid showing stale cross-library cached albums
- **ScanHooks callback for phantom resolution:** Mirrors established RemovalHooks/RescanHooks pattern — avoids circular dependency between library and playlist packages
- **M3U8-based phantom resolution over SQL-only:** Reads playlist files to match phantoms by both position and phantom_file_path — handles pre-existing phantoms (match by position) and new ones (match by stored path)
- **phantom_file_path column:** Stored at removal time so post-scan resolution can match even when M3U8 position changes
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Wails bindings not yet generated for ByLibrary methods**
- **Found during:** Task 1 (start)
- **Issue:** Plan 13-01 added Go methods but Wails bindings weren't regenerated
- **Fix:** Ran `wails generate module` before implementation
- **Files modified:** frontend/wailsjs/go/library/Library.d.ts, Library.js
- **Verification:** All ByLibrary imports resolve correctly
- **Committed in:** `42b8cf9`
**2. [Rule 1 - Bug] Info type uses lowercase property names**
- **Found during:** Task 1 (TypeScript typecheck)
- **Issue:** library-filter.ts used `lib.ID` and `lib.Name` but Wails-generated Info type uses `lib.id` and `lib.name`
- **Fix:** Changed to lowercase property access
- **Files modified:** frontend/src/components/library-filter/library-filter.ts
- **Verification:** `tsc --noEmit` passes cleanly
- **Committed in:** `42b8cf9`
**3. [Rule 1 - Bug] Virtualizer event delegation race condition**
- **Found during:** Checkpoint verification (Task 2)
- **Issue:** Pre-existing race from Phase 14-03 — event delegation on virtualizer failed when element wasn't rendered yet on firstUpdated()
- **Fix:** Added retry in updated() with guard flag; delegation happens once virtualizer exists
- **Files modified:** frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts
- **Verification:** Track list and queue panel click/context-menu events work reliably on app launch
- **Committed in:** `f05d2bb`
**4. [Rule 2 - Missing Critical] Phantom track auto-resolution after library scan**
- **Found during:** Checkpoint verification (Task 2)
- **Issue:** Phantom tracks not auto-resolved when library re-added and scanned — users would need to manually resolve each one
- **Fix:** Added phantom_file_path column (migration 7), store file_path on removal, ResolvePhantomTracksAfterScan via ScanHooks callback with M3U8 path comparison
- **Files modified:** backend/database/database.go, backend/database/sql/schemas/playlist_tracks.sql, backend/library/crud.go, backend/library/library.go, backend/playlist/playlist.go, backend/app.go
- **Verification:** Remove library → re-add → scan → phantoms automatically resolve to real tracks
- **Committed in:** `93262b9`, `9f595b7`
---
**Total deviations:** 4 auto-fixed (2 bugs, 1 blocking, 1 missing critical)
**Impact on plan:** All fixes essential for correctness. Virtualizer fix resolved pre-existing race condition exposed by multi-library testing. Phantom auto-resolution is critical UX — users should not need to manually fix playlist tracks after re-adding a library.
## Issues Encountered
None beyond the deviations documented above.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- **Phase 13 complete** — All 7 requirements verified (VIEW-01 through VIEW-04, PLAY-01 through PLAY-03)
- **v1.1 Multi-Library Support milestone complete** — Phases 9-13 all done
- **Phase 14 (Performance Optimization) already complete** — executed in parallel during v1.1 development
- All browse views, search, playlists, and phantom tracks working correctly in multi-library context
## Self-Check: PASSED
- All 10 key files exist on disk
- All 5 commits found in git log (42b8cf9, f05d2bb, 93262b9, 9f595b7, 70e3814)
- SUMMARY.md exists at expected path
---
*Phase: 13-library-views-phantom-tracks*
*Completed: 2026-03-16*
@@ -0,0 +1,70 @@
# Phase 13: Library Views & Phantom Tracks - Context
**Gathered:** 2026-03-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Filtered presentation across all views, search, and phantom track display. The default view shows all libraries merged (unified). Users can filter to a specific library via a dropdown, and all browse views (tracks, albums, artists, genres) plus search respect that filter. Playlists can contain tracks from multiple libraries. When a library is removed, its playlist tracks become phantom entries using the existing phantom infrastructure.
</domain>
<decisions>
## Implementation Decisions
### Library filter placement & interaction
- Compact dropdown select control in the top bar, next to the search bar
- Shows current selection (default: "All Libraries"), click to open list of libraries
- Filter selection persists across view changes within a session (resets on app restart — no localStorage persistence)
- Search respects the active library filter — searching with a library selected returns only matches from that library; "All Libraries" searches everything
### Filter behavior across views
- Backend filtering — new SQL queries with library_id WHERE clauses, not frontend JS filtering (matches existing roadmap decision for 150K+ track collections)
- When filtering to one library, only show artists/albums/genres that have at least one track in that library — entities with zero tracks in the selected library are hidden
- Detail views (artist detail, album detail, genre detail) also respect the active library filter
- Switching the library filter triggers a backend re-fetch with brief loading state (existing loading skeleton) — no per-library caching
- Scroll positions reset when switching library filter (new data set)
### Phantom track appearance
- Use existing phantom track infrastructure already built in playlist-details component — no new visual treatment needed
- Existing `.track-item.phantom` CSS styling, phantom-resolver component, locate/remove actions all apply
- Full phantom resolver available for library-removal phantoms (users can re-locate/re-match)
- Preserve title, artist, album metadata in phantom columns (matches existing phantom_title, phantom_artist, phantom_album schema columns)
- Phantom tracks are included in playlist track counts (total count, not split)
### Cross-library playlist behavior
- Playlists always show all tracks regardless of the active library filter — playlists are user-curated, not filtered
- No per-track library indicator in playlist views — tracks look the same regardless of source library
- Adding tracks to a playlist works identically whether from a filtered or unfiltered view — no special confirmation
- Queue matches the filter context — playing from a filtered view populates the queue with only that library's tracks
### Claude's Discretion
- Exact dropdown styling and animation (should match existing design tokens)
- How "All Libraries" vs specific library queries are structured internally (separate SQL queries vs parameterized)
- Loading skeleton behavior during filter switch transitions
- How library filter state is stored in the frontend (new store, extension of existing store, etc.)
</decisions>
<specifics>
## Specific Ideas
- Phantom track infrastructure is already built in `playlist-details.ts` — phantom CSS classes, phantom-resolver component, locate/remove buttons. Library-removal phantoms should flow through the same rendering path (is_phantom = 1 with cached metadata columns).
- The existing `GetAudioFilesByLibrary` SQL query already exists but isn't wired into the main view data path. Backend filtering will need similar filtered queries for albums, artists, genres, and FTS search.
- The `track_metadata` VIEW already includes `library_id` — can JOIN on it for filtered queries.
- Phase 12 already pre-populates phantom metadata BEFORE cascade delete, so the phantom columns should already be filled correctly when a library is removed.
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope
</deferred>
---
*Phase: 13-library-views-phantom-tracks*
*Context gathered: 2026-03-16*
@@ -0,0 +1,134 @@
---
phase: 13-library-views-phantom-tracks
verified: 2026-03-16T15:00:00Z
status: passed
score: 5/5 must-haves verified
re_verification: false
---
# Phase 13: Library Views & Phantom Tracks Verification Report
**Phase Goal:** Users experience a unified multi-library presentation with optional filtering and graceful playlist preservation
**Verified:** 2026-03-16T15:00:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths (from ROADMAP Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | The default track list shows tracks from all libraries merged — the user sees their complete collection as one unified view | ✓ VERIFIED | `library-store.ts:41` — `selectedLibraryIdValue: number | null = null` (null = all). `getTracks()` at L133-136: when `id === null`, calls `GetAllTracks()` (unfiltered). Same pattern for albums (L162-164), artists (L190-192), genres (L218-220). |
| 2 | User can select a specific library from a filter control and all views (tracks, albums, artists, genres) show only that library's content | ✓ VERIFIED | `library-filter.ts` (107 lines) — `<select>` dropdown in top bar with "All Libraries" default + per-library options. `setSelectedLibrary()` calls `invalidate()` which clears all caches + resets scroll positions + triggers `eagerFetch()`. Each store method dispatches to `ByLibrary` variant when `selectedLibraryIdValue !== null`. Detail views wire through: `cover-grid.ts:980-987` (GetAlbumTracksByLibrary), `genre-details.ts:183-193` (GetTracksByGenreByLibrary), `genres-view.ts:737-743` (GetTracksByGenreByLibrary), `artists-view.ts:939-952` (GetAlbumTracksByLibrary). |
| 3 | Search results respect the active library filter | ✓ VERIFIED | Per SUMMARY key-decision: client-side `rankTracks()` operates on already-filtered track data from library store. When library filter is active, `getTracks()` returns library-scoped data, so search inherits the filter automatically without needing a separate `SearchTracksByLibrary` backend call. Backend `SearchTracksByLibrary` method exists (query.go:784-827, search.go:292-375) as a fallback capability. |
| 4 | Playlists can contain tracks from multiple libraries — adding tracks from different libraries to the same playlist works naturally | ✓ VERIFIED | `playlist-view/` and `playlist-details/` have zero imports of `selectedLibraryId` or `setSelectedLibrary`. playlist-details imports `libraryStore` only for `getCachedTracks()` and `getCachedAlbums()` (cover art resolution in track-details dialog, L433-454). Playlist data flows from `playlist.Service` (Go) which is library-agnostic — no library_id filtering. `playlist_tracks.sql` schema has nullable `audio_file_id` with `ON DELETE SET NULL` for phantom support. |
| 5 | When a library is removed, its tracks in playlists become phantom entries — visually distinguished with preserved metadata instead of disappearing | ✓ VERIFIED | **Schema:** `playlist_tracks.sql:6-11` — phantom_title, phantom_artist, phantom_album, phantom_duration_ms, phantom_genre, phantom_cover_art_path columns. `phantom_file_path` column (L12, migration 7). **Backend:** `crud.go:248` stores `phantom_file_path` on removal. `playlist.go:1497-1504` — `ResolvePhantomTracksAfterScan()` method. `app.go:176-178` — ScanHooks wiring. **Frontend:** `playlist-details.ts` has 41 phantom-related lines — `.phantom` CSS class, phantom-resolver component import, locate/remove actions. |
**Score:** 5/5 truths verified
### Required Artifacts (Plan 13-01)
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `backend/database/sql/queries/audio_files.sql` | GetAllTracksWithFullMetadataByLibrary, GetAudioFilesByReleaseGroupByLibrary | ✓ VERIFIED | L139-169 (ByLibrary variant with `WHERE af.library_id = ?`), L204-235 (ByReleaseGroupByLibrary with `AND af.library_id = ?`) |
| `backend/database/sql/queries/release_groups.sql` | GetAllAlbumsWithDetailsByLibrary, GetAlbumsByArtistByLibrary | ✓ VERIFIED | L67-91 (ByLibrary with IN-subquery), L114-140 (ByArtistByLibrary with IN-subquery + artist filter) |
| `backend/database/sql/queries/artists.sql` | GetAlbumArtistsByLibrary | ✓ VERIFIED | L42-58 (ByLibrary with IN-subquery through artist_credit_artist → release_groups → recordings → audio_files) |
| `backend/database/sql/queries/genres.sql` | GetTracksByGenreByLibrary, GetAllGenresWithCountsByLibrary | ✓ VERIFIED | L66-104 (ByLibrary with `AND af.library_id = ?`), L113-121 (CountsByLibrary with JOIN through recordings → audio_files) |
| `backend/database/search.go` | SearchFTSTracksByLibrary | ✓ VERIFIED | L290-375 — full method with `AND tm.library_id = ?` in WHERE clause, SAFETY comment, parameterized query |
| `backend/library/query.go` | 8 ByLibrary methods on Library struct | ✓ VERIFIED | GetAllTracksByLibrary (L449-497), GetAllAlbumsByLibrary (L500-549), GetAllArtistsByLibrary (L553-587), GetAlbumsByArtistByLibrary (L591-646), GetAllGenresWithCountsByLibrary (L650-678), GetTracksByGenreByLibrary (L682-729), GetAlbumTracksByLibrary (L733-780), SearchTracksByLibrary (L784-827) — all exported, on exported struct, Wails-bindable |
### Required Artifacts (Plan 13-02)
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `frontend/src/components/library-filter/library-filter.ts` | Library filter dropdown (min 60 lines) | ✓ VERIFIED | 107 lines. Lit component with native `<select>`, design tokens, "All Libraries" default, per-library options, HTMLElementTagNameMap registration |
| `frontend/src/store/library-store.ts` | selectedLibraryId state + filtered fetch logic | ✓ VERIFIED | L41 `selectedLibraryIdValue`, L322-331 getter/setter, conditional dispatch in getTracks/getAlbums/getArtists/getGenres/getAlbumsByArtist, L333-344 getLibraries() |
| `frontend/src/store/controllers/library-controller.ts` | selectedLibraryId getter/setter pass-through | ✓ VERIFIED | L136-146 — `selectedLibraryId` getter, `setSelectedLibrary()`, `getLibraries()` |
| `frontend/index.html` | `<library-filter>` in top bar | ✓ VERIFIED | L18 — `<library-filter></library-filter>` between `<hgroup>` and `<search-bar>` |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `library-store.ts` | `@go/library/Library` | Conditional GetAllTracksByLibrary/GetAllTracks call | ✓ WIRED | L8-13 imports ByLibrary bindings; L133-136 dispatches based on `selectedLibraryIdValue` |
| `library-filter.ts` | `library-store.ts` | `libraryCtrl.setSelectedLibrary()` | ✓ WIRED | L75 calls `this.libraryCtrl.setSelectedLibrary(id)` on change event; L80 reads `this.libraryCtrl.selectedLibraryId` |
| `track-list.ts` / browse views | `library-store.ts` | `libraryCtrl.getTracks()` (library-aware) | ✓ WIRED | Store dispatches correct variant; all detail views (genre-details, cover-grid, artists-view, genres-view) check `libraryCtrl.selectedLibraryId` and call ByLibrary variants |
| `library/query.go` | `database/sql/queries/*.sql` | sqlc-generated Queries methods | ✓ WIRED | Each Go method calls `l.db.Queries.Get*ByLibrary(...)` — e.g., L452 `GetAllTracksWithFullMetadataByLibrary`, L503 `GetAllAlbumsWithDetailsByLibrary` |
| `library/query.go` | `database/search.go` | `l.db.SearchFTSTracksByLibrary` | ✓ WIRED | L787-788 calls `l.db.SearchFTSTracksByLibrary(query, searchTrackLimit, libraryID)` |
| `app.go` | `playlist/playlist.go` | ScanHooks.ResolvePhantoms | ✓ WIRED | L176-178 `yj.library.SetScanHooks(library.ScanHooks{ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan})` |
| `library/crud.go` | `playlist_tracks` | phantom_file_path storage on removal | ✓ WIRED | L248 `phantom_file_path = sub.file_path` in UPDATE during library removal |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| VIEW-01 | 13-01, 13-02 | Default view shows tracks from all libraries merged | ✓ SATISFIED | `selectedLibraryIdValue` defaults to null; `GetAllTracks()` called when null |
| VIEW-02 | 13-01, 13-02 | User can filter to a specific library | ✓ SATISFIED | `<library-filter>` dropdown + conditional ByLibrary dispatch in all store methods |
| VIEW-03 | 13-01, 13-02 | Browse views (albums, artists, genres) work filtered | ✓ SATISFIED | 7 ByLibrary SQL queries + 8 Go methods + conditional frontend dispatch in all views |
| VIEW-04 | 13-01, 13-02 | Search respects active library filter | ✓ SATISFIED | Client-side search on filtered data; backend SearchFTSTracksByLibrary exists as capability |
| PLAY-01 | 13-02 | Cross-library playlists | ✓ SATISFIED | Playlist service is library-agnostic; playlist-view/playlist-details have no library filter dependency |
| PLAY-02 | 13-02 | Phantom tracks when library removed | ✓ SATISFIED | phantom columns in schema, phantom_file_path for resolution, ScanHooks wiring, ResolvePhantomTracksAfterScan method |
| PLAY-03 | 13-02 | Phantom tracks visually distinguished | ✓ SATISFIED | `.track-item.phantom` CSS styling in playlist-details, phantom-resolver component with locate/remove actions |
**Orphaned requirements:** None. All 7 requirement IDs (VIEW-01 through VIEW-04, PLAY-01 through PLAY-03) appear in PLAN frontmatter and are traced in REQUIREMENTS.md to Phase 13.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | None found | — | No TODOs, FIXMEs, placeholders, empty implementations, or stub patterns detected in any key artifact |
### Human Verification Required
All automated checks pass. The following items require human testing for complete confidence:
### 1. Library Filter Visual Presentation
**Test:** Run app with `wails dev -tags webkit2_41`, verify dropdown appears in top bar between title and search bar
**Expected:** Compact 32px dropdown with "All Libraries" default, design-token styling
**Why human:** Visual appearance and positioning cannot be verified programmatically
### 2. Filter Responsiveness
**Test:** Select a specific library from the dropdown
**Expected:** All views (tracks, albums, artists, genres) immediately refresh with only that library's content; loading skeleton shows briefly
**Why human:** UI responsiveness, loading state timing, and data correctness require visual inspection
### 3. Phantom Track Display After Library Removal
**Test:** Add tracks from library A to a playlist, then remove library A
**Expected:** Tracks become phantom entries (greyed out, warning icon), metadata preserved, locate/remove buttons visible
**Why human:** Visual phantom styling and resolver behavior need interactive testing
### 4. Phantom Auto-Resolution After Re-Scan
**Test:** Remove library → re-add same library → scan → check playlists
**Expected:** Previously phantom tracks automatically resolve back to real tracks
**Why human:** End-to-end flow through ScanHooks callback and M3U8 path matching
### 5. Filter Reset on App Restart
**Test:** Select a library filter, restart the app
**Expected:** Filter resets to "All Libraries" (no persistence)
**Why human:** Requires app restart cycle
**Note:** Plan 13-02 included a human checkpoint (Task 2) that was marked APPROVED during execution. The SUMMARY documents comprehensive end-to-end verification was performed during development.
### Gaps Summary
No gaps found. All 5 ROADMAP success criteria are satisfied. All 7 requirement IDs (VIEW-01 through VIEW-04, PLAY-01 through PLAY-03) have implementation evidence in the codebase:
- **Backend:** 7 ByLibrary SQL queries + 8 Go wrapper methods + 1 FTS search method + phantom resolution infrastructure
- **Frontend:** Library filter dropdown component (107 lines) + store with conditional ByLibrary dispatch + controller pass-through + wiring in all browse views and detail views
- **Phantom tracks:** Schema columns + phantom_file_path storage on removal + ScanHooks-based auto-resolution + existing phantom UI in playlist-details
- **Playlists isolated:** playlist-view and playlist-details have no library filter dependency — confirmed by grep showing zero `selectedLibraryId`/`setSelectedLibrary` references
- **Scroll reset:** `invalidate()` resets all scrollPositions to 0 on filter change
- **No persistence:** `selectedLibraryIdValue` defaults to null, no localStorage/backend persistence code
All commits verified in git history: `5cc58ce`, `5f7de50`, `42b8cf9`, `f05d2bb`, `93262b9`, `9f595b7`.
---
_Verified: 2026-03-16T15:00:00Z_
_Verifier: Claude (gsd-verifier)_