chore: remove stale planning and config files
This commit is contained in:
@@ -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
|
|
||||||
@@ -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<library.Track[]>` — 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 |
|
|
||||||
Reference in New Issue
Block a user