diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 280d324..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,241 +0,0 @@ -# AGENTS.md - YellowJacket - -Guidelines for AI coding agents working in this repository. - -## Project Overview - -YellowJacket is a cross-platform desktop music player built with: -- **Backend**: Go 1.25 with Wails v2 framework -- **Frontend**: TypeScript with Lit Web Components -- **Database**: SQLite (pure-Go driver via `modernc.org/sqlite`) -- **Build Tools**: Make, Wails CLI, Vite, pnpm - -## Build Commands - -```bash -make dev # Development with hot-reload -make build-dev # Debug build -make build-prod # Production build (obfuscated + UPX compressed) -make generate # Run all code generators (sqlc, templ) -make clean # Clean frontend build artifacts -make lint # Run golangci-lint -make test # Run all Go tests (race detector, no cache, 2min timeout) -``` - -### Frontend Only -```bash -cd frontend && pnpm install # Install dependencies -cd frontend && pnpm dev # Vite dev server -cd frontend && pnpm build # Production build -``` - -## Testing - -**Important**: Tests require the `-tags webkit2_41` build tag. - -```bash -make test # All tests (preferred) -go test -tags webkit2_41 ./... # All tests manually -go test -tags webkit2_41 ./backend/player/ # Single package -go test -tags webkit2_41 -run TestFunctionName ./backend/player/ # Single test -go test -tags webkit2_41 -v -run TestFunctionName ./backend/player/ # Verbose single test -``` - -Test files are colocated with source as `*_test.go`. Test fixtures live in `test_data/`. Some tests skip in CI when they require hardware (audio device, Wails runtime). - -## Linting - -golangci-lint v2 config (`.golangci.yml`) with strict rules. Key linters: -- `gocritic`, `errorlint`, `err113`, `godot`, `revive`, `sloglint`, `nlreturn`, `wsl` -- Formatters: `gci`, `gofmt`, `gofumpt`, `goimports`, `golines` - -```bash -make lint # Lint all Go code -golangci-lint run --build-tags webkit2_41 ./... # With build tags explicitly -``` - -Frontend type checking: `cd frontend && pnpm exec tsc --noEmit` - -### Avoiding Common Linting Errors - -Always run `make lint` before considering a task complete. Below are the most common linting violations and how to avoid them. - -**Line length (`golines`)**: Keep lines under 100 characters. Break long function calls, especially `slog` calls, across multiple lines: -```go -// Bad — over 100 characters: -q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks)) - -// Good — broken across lines: -q.logger.Warn( - "Current index out of range", - "index", q.currentIndex, "trackCount", len(q.tracks), -) -``` - -**Stuttering type names (`revive`)**: Exported types must not repeat the package name. Consumers would write `queue.Track`, not `queue.QueueTrack`: -```go -// Bad — stutters as queue.QueueTrack: -type QueueTrack struct { ... } - -// Good: -type Track struct { ... } -``` - -**Cuddled declarations (`wsl`)**: `var` and `const` declarations must be separated from the preceding statement by a blank line: -```go -// Bad: -wasEmpty := len(q.tracks) == 0 -var newTracks []Track - -// Good: -wasEmpty := len(q.tracks) == 0 - -var newTracks []Track -``` - -**Blank line after early returns (`nlreturn`)**: An `if` block that ends with `return`, `continue`, or `break` must be followed by a blank line: -```go -if err != nil { - return err -} - -doNextThing() -``` - -**Error sentinels (`err113`)**: Never use `errors.New(...)` or `fmt.Errorf("...")` inline in return statements. Define package-level sentinel errors instead: -```go -var errNotFound = errors.New("not found") -``` - -**Doc comments (`godot`)**: All doc comments on exported types and functions must end with a period: -```go -// Track represents a track in the queue with its metadata. -type Track struct { ... } -``` - -**Import order (`gci`)**: Three groups separated by blank lines — stdlib, third-party, internal (`yellowjacket/...`). Let the formatter handle this, but be aware of the expected grouping. - -## Code Generation - -`go:generate` directives live in `backend/app.go` (templ) and `backend/database/database.go` (sqlc). After modifying `.templ` files or SQL in `backend/database/sql/`, run `make generate`. **Never edit files in `backend/database/sql/sqlcgen/` or `*_templ.go` — they are generated.** - -## Go Code Style - -### Package Documentation -Every package must have a doc comment ending with a period: -```go -// Package player provides audio playback functionality. -package player -``` - -### Import Organization -Three groups separated by blank lines (enforced by `gci`): stdlib, third-party, internal. -```go -import ( - "context" - "fmt" - - "github.com/wailsapp/wails/v2/pkg/runtime" - - "yellowjacket/backend/events" -) -``` - -### Error Handling -- Wrap errors with context: `fmt.Errorf("failed to open file: %w", err)` -- Sentinel errors as package-level vars (enforced by `err113`): - ```go - var ErrUnsupportedFileType = errors.New("unsupported file type") - ``` -- Unexported sentinels for internal use: `var errNotDirectory = errors.New("not a directory")` -- Use `errors.Join()` for accumulating multiple errors -- Return early on errors; blank line required after early returns (`nlreturn`) - -### Naming Conventions -- Structs/exported: `PascalCase` — Unexported: `camelCase` -- Constants: `PascalCase` for exported, grouped with `const (...)` -- Custom domain types: `type PlayerState string`, `type UserVolume int`, `type AudioFileExtension string` - -### Logging -`log/slog` with structured key-value pairs. Logger injected via constructors, scoped with `logger.WithGroup("player")`: -```go -p.logger.Info("File loaded", "file", filePath) -p.logger.Error("Failed to decode", "path", filePath, "err", err) -``` - -### Comments & Formatting -- Doc comments on all exported functions/types, ending with periods (enforced by `godot`) -- Blank line after early returns (enforced by `nlreturn`) - -### Constructor Pattern -```go -func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) { - player := &Player{ctx: ctx, logger: logger.WithGroup("player"), state: Stopped} - return player, nil -} -``` - -### SetContext Pattern (Two-Phase Initialization) -Components needing Wails runtime use two phases (runtime unavailable until `OnStartup`): -1. `New*()` constructor — created before Wails runtime is available -2. `SetContext(ctx context.Context)` — called after runtime starts; registers event handlers, restores state - -### Build Tags -Dev/prod detection via `internal/dev/`: `//go:build dev` → `IsDev = true`, `//go:build !dev` → `IsDev = false`. - -## TypeScript/Lit Code Style - -### Import Organization -Use path aliases from `tsconfig.json`. Use `import type` for type-only imports (`verbatimModuleSyntax`). -```typescript -import { EventsOn, EventsEmit } from '@runtime/runtime'; -import type { TrackInfo } from '@store/player-store'; -``` -Aliases: `@go/*`, `@components/*`, `@store/*`, `@runtime/*`, `@utils/*`, `@assets/*`, `@pages/*` - -### Lit Component Pattern -```typescript -@customElement('component-name') -export class ComponentName extends LitElement { - @state() private someState: Type = initialValue; - static override styles = css`...`; - override connectedCallback() { super.connectedCallback(); } - override disconnectedCallback() { super.disconnectedCallback(); } - override render() { return html`...`; } -} -``` -- `override` keyword required (`noImplicitOverride: true`) -- Private event handlers as arrow functions: `private handleClick = () => { ... }` -- `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`, `experimentalDecorators: true`, `noUnusedLocals: true`, `noUnusedParameters: true` -- Singleton stores in `frontend/src/store/` (backend is source of truth). `ReactiveController` pattern connects Lit components to stores — subscribe in `hostConnected()`, unsubscribe in `hostDisconnected()`. - -## Frontend-Backend Communication - -### Event System -Events are the primary communication mechanism. **Event names must match exactly** in both files: -- Go: `backend/events/events.go` — TypeScript: `frontend/src/events.ts` - -```go -runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo) -runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) { p.Play() }) -``` -```typescript -EventsEmit(Events.RequestPlay); -EventsOn(Events.TrackChanged, (trackInfo: TrackInfo) => { ... }); -``` - -### HTMX -The config page uses HTMX for HTML fragment loading. Backend serves fragments via templ templates (`backend/config/config-form.templ`, `backend/library/config.templ`). Config has a separate entry point (`src/pages/config/`). - -## Database - -SQLite with sqlc for type-safe queries. Schemas in `backend/database/sql/schemas/`, queries in `backend/database/sql/queries/`, generated code in `backend/database/sql/sqlcgen/`. SQLite opened with WAL mode and `SetMaxOpenConns(1)` (single-writer). After modifying SQL files, run `make generate`. - -## Directory Structure - -- `backend/` — Go: `config/`, `database/`, `events/`, `library/`, `metadata/`, `models/`, `player/`, `queue/`, `system/`, `logging/`, `frontendutil/`, `assets/` -- `frontend/src/` — TypeScript/Lit: `components/`, `pages/`, `store/`, `utils/` -- `frontend/wailsjs/` — Auto-generated Wails bindings (do not edit) -- `internal/dev/` — Build-tag-based dev/prod detection -- `pkg/templcomp/` — Shared templ component utilities -- `test_data/` — Audio test fixtures diff --git a/PLAN-fts-search-and-genre-query.md b/PLAN-fts-search-and-genre-query.md deleted file mode 100644 index cf9b322..0000000 --- a/PLAN-fts-search-and-genre-query.md +++ /dev/null @@ -1,352 +0,0 @@ -# Plan: Track List FTS Search (#1) & Genre Details Query (#4) - -## Feature #1: Track List FTS Search - -### Goal - -When the user types in the track list search bar, delegate to the backend -FTS5 index instead of filtering all tracks in-memory in JavaScript. -Backend-only search with debounce. FTS5 index stays as-is (title, artist, -album, file_path — no expansion). - -### Current flow - -1. All tracks fetched once via `Library.GetAllTracks()` → cached in - `libraryStore` -2. On each keystroke, `computeFilteredTracks()` in `track-list.ts` runs - `toLowerCase().includes(term)` across every track's active columns -3. Virtual scrolling renders only visible rows - -### Proposed flow - -1. All tracks still fetched and cached (needed for empty-search display, - sorting, column rendering) -2. When search term is non-empty, call new backend method - `Library.SearchTracks(query)` which uses FTS5 internally -3. Backend returns `[]library.Track` (same 16-field type as `GetAllTracks`) -4. Frontend uses these results directly instead of client-side filtering -5. Frontend debounces the backend call (~200-250ms) to avoid excessive - round-trips on fast typing - -### Backend changes - -#### 1. `backend/database/search.go` — New method `SearchFTSTracks` - -Add `SearchFTSTracks(query string, limit int)` method on `*DB`. - -- Uses `buildFTSQuery(query)` to tokenise the user input -- Runs FTS5 MATCH against `search_index` -- JOINs to all the same tables as `GetAllTracksWithFullMetadata`: - `audio_files`, `recordings`, `artist_credit`, `release_group_recordings`, - `release_groups`, `file_types` -- Includes the `GROUP_CONCAT` subquery for genres -- Returns all 16 columns needed for `library.Track` -- Returns a new `SearchTrackRow` struct (or reuse generated types if - practical) - -Query shape: - -```sql -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM search_index si -JOIN audio_files af ON af.id = si.rowid -JOIN recordings r ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE search_index MATCH ? -ORDER BY rank -LIMIT ? -``` - -Define a `SearchTrackRow` struct with all 16 fields (using `sql.NullInt64` -for track_number, disc_number, year; `sql.NullString` for composer). - -#### 2. `backend/library/query.go` — New Wails-bound method `SearchTracks` - -```go -func (l *Library) SearchTracks(query string) ([]Track, error) -``` - -- Calls `l.db.SearchFTSTracks(query, 200)` (cap at 200 results) -- Maps each `SearchTrackRow` to `library.Track` using the same logic as - `GetAllTracks` (splitGenres, NullInt64 unwrap, etc.) -- Reuse or extract common row-mapping into a shared helper to avoid - duplication with `GetAllTracks` - -### Frontend changes - -#### 3. `frontend/src/store/library-store.ts` — Add search method + state - -Add to `LibraryStore`: - -- `async searchTracks(query: string): Promise` — calls - the Wails-bound `Library.SearchTracks(query)` and returns results -- Clear any cached search results on `invalidate()` (library scan) - -#### 4. `frontend/src/components/track-list/track-list.ts` — Switch to backend search - -Changes to the search flow: - -- Remove `computeFilteredTracks()` (the in-memory filter) -- Add `@state() private searchResults: library.Track[] | null = null` -- Add `@state() private searchLoading = false` -- Add a debounced method `debouncedSearch(term: string)` (~200ms) that: - - If term is empty → sets `searchResults = null` (show all tracks) - - Otherwise → calls `libraryStore.searchTracks(term)`, stores results in - `searchResults` -- In `recomputeTrackCaches()` (or `willUpdate`): if `searchResults` is - non-null, use it as the filtered track set; otherwise use `this.tracks` -- Trigger `debouncedSearch` from the `SearchController` when the term - changes -- The sort step (`computeSortedTracks`) still runs on the filtered set - -#### 5. Wails bindings — Auto-regenerated - -After adding the Go method, run `wails generate` (or `make dev` / build) -to regenerate `frontend/wailsjs/go/library/Library.js` and `.d.ts`. - ---- - -## Feature #4: Genre Details Query - -### Goal - -Replace the fetch-all-then-filter pattern in `genre-details.ts` with a -dedicated SQL query. Also add a `GetAllGenresWithCounts` query to eliminate -the other fetch-all-tracks dependency in `genres-view.ts`. - -### Current flow (genre details) - -1. `genre-details.ts` calls `libraryCtrl.getTracks()` → fetches ALL tracks -2. Filters in JS: `tracks.filter(t => t.Genre.includes(genreName))` - -### Proposed flow (genre details) - -1. `genre-details.ts` calls new `Library.GetTracksByGenre(genreName)` -2. Backend runs a JOIN query filtered by genre name -3. Returns `[]library.Track` — same 16-field type - -### Current flow (genre list) - -1. `genres-view.ts` calls `libraryCtrl.getTracks()` → fetches ALL tracks -2. `extractGenres()` iterates every track, counts genre occurrences, - returns sorted `Genre[]` - -### Proposed flow (genre list) - -1. `genres-view.ts` calls new `Library.GetAllGenresWithCounts()` -2. Backend runs a simple GROUP BY query -3. Returns `[]GenreWithCount` (name + track count) - -### Backend changes - -#### 6. `backend/database/sql/queries/genres.sql` — Two new sqlc queries - -**Query 1: `GetTracksByGenre`** - -```sql --- name: GetTracksByGenre :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g2.name, '||') - FROM recording_genres rg2 - JOIN genres g2 ON rg2.genre_id = g2.id - WHERE rg2.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE g.name = ? -ORDER BY r.name; -``` - -Uses `idx_recording_genres_genre_id` for the initial genre lookup. - -**Query 2: `GetAllGenresWithCounts`** - -```sql --- name: GetAllGenresWithCounts :many -SELECT g.name, COUNT(rg.recording_id) AS track_count -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -GROUP BY g.id, g.name -ORDER BY g.name; -``` - -#### 7. `backend/library/query.go` — Two new Wails-bound methods - -**Method 1: `GetTracksByGenre`** - -```go -func (l *Library) GetTracksByGenre(genreName string) ([]Track, error) -``` - -- Calls the sqlc-generated `l.db.Queries.GetTracksByGenre(ctx, genreName)` -- Maps rows to `[]Track` using the same row-mapping helper as - `GetAllTracks` and `SearchTracks` - -**Method 2: `GetAllGenresWithCounts`** - -```go -type GenreWithCount struct { - Name string `json:"Name"` - TrackCount int64 `json:"TrackCount"` -} - -func (l *Library) GetAllGenresWithCounts() ([]GenreWithCount, error) -``` - -- Calls the sqlc-generated - `l.db.Queries.GetAllGenresWithCounts(ctx)` -- Maps rows to `[]GenreWithCount` - -#### 8. Run `make generate` to regenerate sqlc output - -After adding the queries to `genres.sql`, run `make generate` to produce -the Go types and query methods in `backend/database/sql/sqlcgen/`. - -### Frontend changes - -#### 9. `frontend/src/components/genre-details/genre-details.ts` — Use new endpoint - -Replace `loadTracks()`: - -```typescript -private async loadTracks() { - if (!this.genreName) return; - try { - this.tracks = await GetTracksByGenre(this.genreName); - } catch (error) { - console.error('Error loading genre tracks:', error); - this.tracks = []; - } finally { - this.loading = false; - } -} -``` - -- Import `GetTracksByGenre` from `@go/library/Library` -- Remove `libraryCtrl.getTracks()` call and in-memory filter -- Remove the `lastTracksRef` cache-invalidation pattern (no longer - needed — each call fetches fresh data for the specific genre) -- Still listen for `LibraryScanComplete` to re-trigger `loadTracks()` - if the genre details view is open during a rescan - -#### 10. `frontend/src/components/genres-view/genres-view.ts` — Use new endpoint - -Replace `loadGenres()`: - -- Call `Library.GetAllGenresWithCounts()` instead of fetching all tracks -- Map results directly to the local `Genre[]` array (name + trackCount) -- Remove `extractGenres()` method -- Remove `this.allTracks` state (no longer needed for genre extraction) -- Note: `allTracks` may still be needed for other purposes in the - component — check if it's used elsewhere (e.g. for passing to - genre-details). If genre-details fetches its own tracks, this - dependency chain can be fully removed. - -#### 11. Wails bindings — Auto-regenerated - -Run `wails generate` to produce the new TypeScript bindings for -`GetTracksByGenre`, `GetAllGenresWithCounts`, and `SearchTracks`. - ---- - -## Shared refactoring: Row-mapping helper - -`GetAllTracks`, `SearchTracks`, and `GetTracksByGenre` all map database -rows with the same 16 columns into `library.Track`. Currently this logic -lives inline in `GetAllTracks`. Extract it into a shared helper: - -```go -func mapTrackRow( - filePath string, - lengthMs int64, - title, artistName string, - trackNumber, discNumber sql.NullInt64, - album, genre string, - year sql.NullInt64, - composer, fileType string, - sampleRate, bitDepth, channels, bitrate, fileSize int64, -) Track -``` - -This avoids tripling the row-mapping code across three methods. - ---- - -## Implementation order - -1. Backend: extract row-mapping helper in `query.go` -2. Backend: add `SearchFTSTracks` to `search.go` + `SearchTracks` to - `query.go` -3. Backend: add sqlc queries to `genres.sql` + `make generate` -4. Backend: add `GetTracksByGenre` + `GetAllGenresWithCounts` to `query.go` -5. Verify: `make lint && make test` -6. Frontend: update `genre-details.ts` to use `GetTracksByGenre` -7. Frontend: update `genres-view.ts` to use `GetAllGenresWithCounts` -8. Frontend: update `library-store.ts` with `searchTracks` method -9. Frontend: update `track-list.ts` with debounced backend search -10. Verify: `pnpm exec tsc --noEmit` -11. Full verify: `make lint && make test` - ---- - -## Files touched (summary) - -| File | Action | -|---|---| -| `backend/database/search.go` | Add `SearchFTSTracks`, `SearchTrackRow` | -| `backend/library/query.go` | Add `SearchTracks`, `GetTracksByGenre`, `GetAllGenresWithCounts`, `GenreWithCount`, extract `mapTrackRow` helper | -| `backend/database/sql/queries/genres.sql` | Add `GetTracksByGenre`, `GetAllGenresWithCounts` | -| `backend/database/sql/sqlcgen/*` | Regenerated via `make generate` | -| `frontend/src/store/library-store.ts` | Add `searchTracks` method | -| `frontend/src/components/track-list/track-list.ts` | Replace in-memory filter with debounced backend FTS search | -| `frontend/src/components/genre-details/genre-details.ts` | Replace fetch-all-then-filter with `GetTracksByGenre` | -| `frontend/src/components/genres-view/genres-view.ts` | Replace `extractGenres` with `GetAllGenresWithCounts` | -| `frontend/wailsjs/go/library/Library.js` + `.d.ts` | Auto-regenerated |