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

248 lines
12 KiB
Markdown

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