diff --git a/.gitignore b/.gitignore
index a197d70..b5b68b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ node_modules
build
test_data
test.db
+.aider*
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..6b8ffb6
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,38 @@
+version: "2"
+linters:
+ default: standard
+ enable:
+ - canonicalheader
+ - copyloopvar
+ - dupword
+ - err113
+ - errorlint
+ - exptostd
+ - gocritic
+ - godot
+ - goheader
+ - iface
+ - importas
+ - intrange
+ - mirror
+ - misspell
+ - nakedret
+ - nlreturn
+ - nolintlint
+ - perfsprint
+ - protogetter
+ - revive
+ - sloglint
+ - tagalign
+ - testifylint
+ - usestdlibvars
+ - usetesting
+ - whitespace
+ - wsl
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - gofumpt
+ - goimports
+ - golines
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 5992b7b..3d72bbd 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -17,7 +17,8 @@
"mode": "exec",
"program": "${workspaceFolder}/build/bin/yellowjacket",
"preLaunchTask": "build debug",
- "cwd": "${workspaceFolder}"
+ "cwd": "${workspaceFolder}",
+ "outputMode": "remote",
},
{
"name": "Wails: Dev yellowjacket",
@@ -29,4 +30,4 @@
"cwd": "${workspaceFolder}"
}
]
-}
\ No newline at end of file
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..8deaf57
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,208 @@
+# 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.24+ 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
+```
+
+### Frontend Only
+```bash
+cd frontend
+pnpm install # Install dependencies
+pnpm dev # Vite dev server
+pnpm build # Production build
+```
+
+## Testing
+
+```bash
+go test ./... # Run all tests
+go test ./backend/player/ # Run tests in a specific package
+go test -run TestFunctionName ./backend/player/ # Run a single test by name
+go test -v -run TestFunctionName ./backend/player/ # Verbose output
+```
+
+Test files are located alongside source files as `*_test.go`. Test fixtures live in `test_data/`.
+
+## Linting
+
+The project uses golangci-lint (v2 config) with strict rules. Key enabled linters:
+- `gocritic`, `errorlint`, `err113`, `godot`, `revive`, `sloglint`, `nlreturn`, `wsl`
+- Formatters: `gci`, `gofmt`, `gofumpt`, `goimports`, `golines`
+
+```bash
+golangci-lint run
+```
+
+## Code Generation
+
+`go:generate` directives live in:
+- `backend/app.go` — templ: generates `*_templ.go` from `.templ` files
+- `backend/database/database.go` — sqlc: generates type-safe DB code from SQL
+
+After modifying `.templ` files or SQL in `backend/database/sql/`, run `make generate`.
+
+## Code Style Guidelines
+
+### Go Code Style
+
+#### Package Documentation
+Every package must have a doc comment:
+```go
+// Package player provides audio playback functionality.
+package player
+```
+
+#### Import Organization
+Imports are grouped and ordered by gci/goimports (three groups separated by blank lines):
+1. Standard library 2. Third-party packages 3. Internal packages (`yellowjacket/...`)
+
+```go
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "yellowjacket/backend/events"
+ "yellowjacket/backend/metadata"
+)
+```
+
+#### Error Handling
+- Always wrap errors with context: `fmt.Errorf("failed to open file: %w", err)`
+- Define sentinel errors as package-level vars (enforced by `err113`):
+ ```go
+ var ErrUnsupportedFileType = errors.New("unsupported file type")
+ ```
+- Use `errors.Join()` for accumulating multiple errors
+- Return early on errors; blank line required after early returns (`nlreturn`)
+
+#### Naming Conventions
+- Structs: `PascalCase` (e.g., `Player`, `AudioFile`)
+- Exported methods: `PascalCase`
+- Unexported methods/fields: `camelCase`
+- Constants: `PascalCase` for exported, grouped with `const (...)`
+- Custom domain types: `type PlayerState string`, `type UserVolume int`
+
+#### Logging
+Use `log/slog` with structured key-value pairs. Logger instances are injected via constructors:
+```go
+p.logger.Info("File loaded", "file", filePath)
+p.logger.Error("Failed to decode", "path", filePath, "err", err)
+```
+Logger groups via `logger.WithGroup("player")` for component-scoped logging.
+
+#### Constructor Pattern
+```go
+func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) {
+ player := &Player{ctx: ctx, logger: logger, state: Stopped}
+ // initialization...
+ return player, nil
+}
+```
+
+#### SetContext Pattern (Two-Phase Initialization)
+Backend components that need the Wails runtime use a two-phase pattern because Wails runtime features (events, dialogs) are unavailable until `OnStartup`:
+1. Constructor (`New*`) — created before Wails runtime is available
+2. `SetContext(ctx context.Context)` — called after Wails runtime starts; registers event handlers
+
+#### Comments
+- Doc comments on all exported functions/types
+- End sentences with periods (enforced by `godot`)
+- Blank line after early returns (enforced by `nlreturn`)
+
+#### Build Tags
+Dev/prod detection uses build tags in `internal/dev/`:
+- `//go:build dev` → `IsDev = true` (used by `make dev`)
+- `//go:build !dev` → `IsDev = false` (production builds)
+
+### TypeScript/Lit Code Style
+
+#### Import Organization
+Use path aliases defined in `tsconfig.json`:
+```typescript
+import { EventsOn, EventsEmit } from '@runtime/runtime';
+import type { TrackInfo } from '@store/player-store';
+```
+Available 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;
+
+ override connectedCallback() { super.connectedCallback(); }
+ override render() { return html`...`; }
+}
+```
+
+#### TypeScript Strictness
+- `strict: true` enabled
+- `noUncheckedIndexedAccess: true` — check array/object access
+- `noImplicitOverride: true` — must use `override` keyword
+- `verbatimModuleSyntax: true` — use `import type` for type-only imports
+- `experimentalDecorators: true` — required for Lit decorators
+
+## 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) => { ... });
+```
+
+### State Management
+- Singleton stores in `frontend/src/store/` — backend is source of truth
+- ReactiveController pattern (`PlayerController`) connects Lit components to stores
+
+### HTMX
+The config page uses HTMX for HTML fragment loading. Backend serves HTML fragments via templ templates (`backend/config/config-form.templ`, `backend/library/config.templ`). Config is 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/`. After modifying SQL files, run `make generate`.
+
+## Directory Structure
+
+- `backend/` — Go backend: `config/`, `database/`, `events/`, `library/`, `metadata/`, `player/`, `system/`
+- `frontend/src/` — TypeScript/Lit: `components/`, `pages/`, `store/`, `utils/`
+- `frontend/wailsjs/` — Generated Wails bindings
+- `internal/dev/` — Build-tag-based dev/prod detection
+- `pkg/templcomp/` — Shared templ component utilities
+- `test_data/` — Audio test fixtures
+
+## Key Dependencies
+
+- **Wails v2**: Desktop app framework bridging Go and web frontend
+- **beep**: Audio playback library (custom fork `TheCodeOfCaleb/beep`)
+- **sqlc**: Type-safe SQL code generation
+- **templ**: Go HTML templating
+- **Lit**: Web component framework
+- **Web Awesome**: Web component UI library (`@awesome.me/webawesome`)
diff --git a/Makefile b/Makefile
index b2e59c4..cb54d66 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,18 @@
-dev:
- WEBKIT_DISABLE_DMABUF_RENDERER=1 wails dev -tags webkit2_41
+dev: generate clean
+ WEBKIT_DISABLE_DMABUF_RENDERER=1 wails dev -tags webkit2_41 -loglevel Debug -v 2
-build-dev:
+build-dev: generate
wails build -tags webkit2_41 -debug -clean
-build-prod:
+build-prod: generate
wails build -tags webkit2_41 -clean -obfuscated -upx
+build-frontend:
+ cd frontend && pnpm build
+
+clean:
+ rm -rf frontend/dist
+ rm -rf frontend/node_modules
+
generate:
go generate ./...
diff --git a/README.md b/README.md
index bd76822..ba3955a 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# YellowJacket
-A MusicBee inspired music player and library manager.
+How music was meant to bee.
## Install
-TODO: put release links here
+You can grab the latest release [here](https://github.com/LJ-Software/yellowjacket/releases/latest)
## Features
@@ -12,8 +12,7 @@ TODO: add feature list and screenshots here
## Development
-YellowJacket relies on [Wails](https://wails.io/docs/introduction) for development.
-Wails allows us to build a frontend with HTML/CSS/JS and backend with Golang.
+Development documentation can be found [here](./docs/dev/overview.md).
### Prerequisites
diff --git a/backend/app.go b/backend/app.go
new file mode 100644
index 0000000..f1c00db
--- /dev/null
+++ b/backend/app.go
@@ -0,0 +1,172 @@
+// Package backend contains the main application logic.
+package backend
+
+//go:generate go tool templ generate
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "yellowjacket/backend/assets"
+ "yellowjacket/backend/config"
+ "yellowjacket/backend/database"
+ "yellowjacket/backend/frontendutil"
+ "yellowjacket/backend/library"
+ "yellowjacket/backend/player"
+ "yellowjacket/backend/queue"
+)
+
+// YellowJacketApp is the main application struct for Wails.
+type YellowJacketApp struct {
+ FEBindings []any
+ FrontendUtil *frontendutil.FrontendUtil
+
+ logger *slog.Logger
+ assetHandler *assets.Handler
+ database *database.DB
+ library *library.Library
+ player *player.Player
+ queue *queue.Queue
+ appContext context.Context
+ appConfig *config.Config
+}
+
+// NewYellowJacketApp creates and initializes the application.
+func NewYellowJacketApp(
+ logger *slog.Logger,
+ assetHandler *assets.Handler,
+) (*YellowJacketApp, error) {
+ // initialize anything that does not need access to the wails runtime here
+ yjApp := &YellowJacketApp{
+ logger: logger,
+ assetHandler: assetHandler,
+ appContext: context.Background(),
+ }
+
+ // create database
+ db, err := database.NewDB(logger)
+ if err != nil {
+ return nil, fmt.Errorf("could not connect to local database: %w", err)
+ }
+
+ yjApp.database = db
+
+ // create config
+ appConfig, err := config.NewConfig(yjApp.logger)
+ if err != nil {
+ return nil, fmt.Errorf("could not get config: %w", err)
+ }
+
+ yjApp.appConfig = appConfig
+ yjApp.assetHandler.RegisterHandler("/config", yjApp.appConfig)
+
+ // create frontendUtil
+ feUtil, err := frontendutil.NewFrontendUtil()
+ if err != nil {
+ return nil, fmt.Errorf("could not create frontendUtil: %w", err)
+ }
+
+ yjApp.FrontendUtil = feUtil
+
+ lib, err := library.NewLibrary(
+ yjApp.appContext,
+ yjApp.logger,
+ yjApp.appConfig.Library,
+ yjApp.database,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("could not create library: %w", err)
+ }
+
+ yjApp.library = lib
+
+ // create cover art handler
+ coverHandler, err := library.NewCoverArtHandler()
+ if err != nil {
+ return nil, fmt.Errorf("could not create cover art handler: %w", err)
+ }
+
+ yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
+
+ yjApp.FEBindings = []any{
+ yjApp.FrontendUtil,
+ yjApp.library,
+ }
+
+ return yjApp, nil
+}
+
+var startupErr error
+
+// OnStartup initializes components that require the Wails runtime context.
+func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
+ // initialize anything that needs to use the wails runtime AFTER its been initialized
+ // you CANNOT use the wails runtime during this function
+ yj.appContext = ctx
+
+ // Set context for components that need Wails runtime for events
+ yj.appConfig.SetContext(ctx)
+ yj.FrontendUtil.SetContext(ctx)
+ yj.library.SetContext(ctx)
+
+ var err error
+ // create player
+ yj.player, err = player.NewPlayer(ctx, yj.logger.WithGroup("player"), yj.database)
+ if err != nil {
+ startupErr = errors.Join(startupErr, fmt.Errorf("could not create player: %w", err))
+ }
+
+ yj.player.SetContext(ctx)
+
+ // create queue
+ yj.queue = queue.NewQueue(yj.logger, yj.database)
+ yj.queue.SetContext(ctx)
+ yj.queue.SetPlayer(yj.player)
+ yj.queue.RestoreState()
+
+ // Register playback finished handler to drive queue auto-advance.
+ yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
+
+ // Add player to frontend bindings
+ yj.FEBindings = append(yj.FEBindings, yj.player)
+}
+
+// OnShutdown saves player state and cleans up resources before the application exits.
+func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
+ if yj.player != nil {
+ yj.player.SaveState()
+ }
+
+ if yj.queue != nil {
+ yj.queue.SaveState()
+ }
+}
+
+// OnDomReady handles post-DOM initialization and startup error reporting.
+func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
+ if startupErr != nil {
+ yj.logger.Error("startup error", "err", startupErr.Error())
+ wailsruntime.Quit(ctx)
+ }
+
+ // Push current player and queue state to the frontend. The heavy lifting
+ // (file load, seek, volume) already happened during OnStartup via
+ // RestoreState; this just emits events. A short delay ensures the
+ // frontend JS modules have loaded and registered their event listeners.
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+
+ if yj.player != nil {
+ yj.player.EmitCurrentState()
+ }
+
+ if yj.queue != nil {
+ yj.queue.EmitCurrentState()
+ }
+ }()
+}
diff --git a/backend/assets/handler.go b/backend/assets/handler.go
new file mode 100644
index 0000000..47d944d
--- /dev/null
+++ b/backend/assets/handler.go
@@ -0,0 +1,68 @@
+// Package assets handles serving frontend static files.
+package assets
+
+import (
+ "embed"
+ "log/slog"
+ "net/http"
+
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+)
+
+// Handler serves frontend assets with custom route support.
+type Handler struct {
+ Options *assetserver.Options
+ logger *slog.Logger
+ frontendDistAssets embed.FS
+ serveMux *http.ServeMux
+ wailsAssetHandler http.Handler
+}
+
+// NewAssetHandler creates a new asset handler.
+func NewAssetHandler(logger *slog.Logger, frontendDistAssets embed.FS) (*Handler, error) {
+ handler := &Handler{
+ logger: logger,
+ frontendDistAssets: frontendDistAssets,
+ serveMux: http.NewServeMux(),
+ }
+ handler.Options = &assetserver.Options{
+ Assets: handler.frontendDistAssets,
+ Middleware: handler.Middleware,
+ }
+
+ return handler, nil
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // if we dont have a custom handler defined, then use the wails asset handler
+ if _, pattern := h.serveMux.Handler(r); len(pattern) == 0 {
+ h.logger.Debug(
+ "custom handler for request not found, using wails asset handler",
+ "path",
+ *&r.URL.Path,
+ )
+ h.wailsAssetHandler.ServeHTTP(w, r)
+
+ return
+ }
+
+ h.logger.Debug(
+ "using custom handler for request",
+ "path",
+ *&r.URL.Path,
+ )
+ h.serveMux.ServeHTTP(w, r)
+}
+
+// Middleware captures the Wails asset handler for fallback routing.
+func (h *Handler) Middleware(next http.Handler) http.Handler {
+ h.wailsAssetHandler = next
+
+ return h
+}
+
+// RegisterHandler adds a custom handler for a URL pattern.
+func (h *Handler) RegisterHandler(pattern string, handler http.Handler) {
+ h.logger.Debug("registering asset handler", "pattern", pattern)
+ h.serveMux.Handle(pattern, handler)
+}
diff --git a/backend/config/config-form.templ b/backend/config/config-form.templ
new file mode 100644
index 0000000..2ee7d6b
--- /dev/null
+++ b/backend/config/config-form.templ
@@ -0,0 +1,15 @@
+package config
+
+import "yellowjacket/pkg/templcomp"
+
+templ (c *Config) form() {
+ @templcomp.ToForm(c, templ.URL("/config"), "config")
+}
+
+templ (c *Config) formSubmitError(msg string) {
+ Error: { msg }
+}
+
+templ (c *Config) formSubmitSuccess() {
+
Config saved
+}
diff --git a/backend/config/config-form_templ.go b/backend/config/config-form_templ.go
new file mode 100644
index 0000000..7e5a51f
--- /dev/null
+++ b/backend/config/config-form_templ.go
@@ -0,0 +1,113 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.865
+package config
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "yellowjacket/pkg/templcomp"
+
+func (c *Config) form() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templcomp.ToForm(c, templ.URL("/config"), "config").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func (c *Config) formSubmitError(msg string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Error: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config/config-form.templ`, Line: 10, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func (c *Config) formSubmitSuccess() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Config saved
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/backend/config/config.go b/backend/config/config.go
index bcb950f..4453f5a 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -1,145 +1,135 @@
+// Package config manages application configuration persistence.
package config
import (
+ "context"
"errors"
"fmt"
+ "log/slog"
+ "net/http"
"os"
"path"
- "yellowjacket/backend/library"
"github.com/BurntSushi/toml"
+ "yellowjacket/backend/library"
+ "yellowjacket/backend/system"
)
+// Config represents the application configuration.
type Config struct {
- filePath string // required
- *configData
+ ctx context.Context
+ logger *slog.Logger
+ serveMux *http.ServeMux
+ filePath string // required
+ Library *library.Config `form:"Library" schema:"library,required"`
}
-func newConfig(filePath string, data *configData) (*Config, error) {
+// NewConfig creates a new config by loading it from disk.
+func NewConfig(logger *slog.Logger) (*Config, error) {
+ confDir, err := system.GetUserConfigDirPath()
+ if err != nil {
+ return nil, fmt.Errorf("could not get user config directory: %w", err)
+ }
+
conf := &Config{
- filePath: filePath,
- configData: data,
+ filePath: path.Join(confDir, "config.toml"),
+ serveMux: http.NewServeMux(),
}
- // TODO make sure required fields have SOMETHING in them
- // TODO Merge existing config with read in config
- if data == nil {
- return nil, errors.New("nil config")
+ conf.logger = logger.WithGroup("config").With("config", conf)
+ conf.serveMux.HandleFunc("/", conf.handle)
+
+ if err := conf.Load(); err != nil {
+ return nil, fmt.Errorf("could not load config: %w", err)
}
- if err := data.validate(); err != nil {
+
+ if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
+
return conf, nil
}
-type configData struct {
- Library *library.Config
-}
+// Validate returns errors if there is a breaking issue with the config.
+func (c *Config) Validate() error {
+ var configErrs error
-// return errors if there is a *breaking* issue with the config
-func (d *configData) validate() error {
- if d.Library == nil {
- return errors.New("nil library config")
- }
- if err := d.Library.Validate(); err != nil {
- return fmt.Errorf("invalid library config: %#v: %w", d.Library, err)
- }
- return nil
-}
-
-var defaultConfigData *configData = &configData{
- Library: library.DefaultConfig,
-}
-
-// GetCurrentConfig will load and return the config
-// reading the config file in the user's config directory
-func GetCurrentConfig() (*Config, error) {
- // get the config file location
- configDir, err := GetUserConfigDirPath()
- if err != nil {
- return nil, fmt.Errorf("could not get user config directory path: %w", err)
- }
- configFilePath := path.Join(configDir, "config.toml")
-
- // create the config obj with the filepath we got, initializing with default data
- config, err := newConfig(configFilePath, defaultConfigData)
- if err != nil {
- return nil, fmt.Errorf("could not create new config: %w", err)
- }
- config.filePath = configFilePath
-
- // does the config file alaeady exist?
- // if not, create it
- _, err = os.Stat(configFilePath)
- if os.IsNotExist(err) {
- if err := config.WriteConfig(); err != nil {
- return nil, fmt.Errorf("could not write config: %w", err)
+ if c.Library != nil {
+ if len(c.Library.DirectoryPath) != 0 {
+ if err := c.Library.Validate(); err != nil {
+ configErrs = errors.Join(configErrs, err)
+ }
}
}
- // now that we have our config file, load it in
- config, err = config.loadConfig()
- if err != nil {
- return nil, fmt.Errorf("could not load config file %s: %w", configFilePath, err)
+ if configErrs != nil {
+ return fmt.Errorf("one or more config parts are invalid: %w", configErrs)
}
- // before we return the config, lets make sure sub configs can invoke saving when they need
- err = config.updateSubConfigSaveFuncReferences()
- if err != nil {
- return nil, fmt.Errorf("could not update sub config save func references: %w", err)
- }
- return config, nil
+ return nil
}
-func (c *Config) loadConfig() (*Config, error) {
+// Load reads and parses the config file from disk.
+func (c *Config) Load() error {
+ if _, err := os.Stat(c.filePath); err != nil {
+ if os.IsNotExist(err) {
+ c.logger.Debug("no config file exists, creating empty config")
+
+ if err := c.Save(); err != nil {
+ return fmt.Errorf(
+ "could not save empty config to file (%s): %w",
+ c.filePath,
+ err,
+ )
+ }
+ } else {
+ return fmt.Errorf("could not get file info (%s): %w", c.filePath, err)
+ }
+ }
+
// read in the file
confFileData, err := os.ReadFile(c.filePath)
if err != nil {
- return nil, fmt.Errorf("problem reading config file %s: %w", c.filePath, err)
+ return fmt.Errorf("problem reading config file %s: %w", c.filePath, err)
}
// parse it into the config struct
- var confData configData
- _, err = toml.Decode(string(confFileData), &confData)
+ _, err = toml.Decode(string(confFileData), c)
if err != nil {
- return nil, fmt.Errorf("problem parsing config file %s: %w", c.filePath, err)
+ return fmt.Errorf("problem parsing config file %s: %w", c.filePath, err)
}
// validate the config
- if err = confData.validate(); err != nil {
- return nil, fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
+ if err = c.Validate(); err != nil {
+ return fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
}
- config, err := newConfig(c.filePath, &confData)
- if err != nil {
- return nil, fmt.Errorf("could not create config from config file data at %s: %w", c.filePath, err)
- }
+ c.logger.Debug("loaded config file", "file", c.filePath)
- // before we return the config, lets make sure sub configs can invoke saving when they need
- err = config.updateSubConfigSaveFuncReferences()
- if err != nil {
- return nil, fmt.Errorf("could not update sub config save func references: %w", err)
- }
-
- return config, nil
+ return nil
}
-func (c *Config) WriteConfig() error {
- if err := c.validate(); err != nil {
+// Save writes the config to disk.
+func (c *Config) Save() error {
+ if err := c.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
+
confFileData, err := toml.Marshal(c)
if err != nil {
return fmt.Errorf("could not marshal config struct: %w", err)
}
- err = os.WriteFile(c.filePath, confFileData, os.FileMode(int(0666)))
+ err = os.WriteFile(c.filePath, confFileData, os.FileMode(int(0o666)))
if err != nil {
- return fmt.Errorf("could not write config file: %w", err)
+ return fmt.Errorf("could not write config file (%s): %w", c.filePath, err)
}
+
+ c.logger.Debug("saved config to file", "file", c.filePath)
+
return nil
}
-func (c *Config) updateSubConfigSaveFuncReferences() error {
- c.Library.SaveFunc = c.WriteConfig
- return nil
+// SetContext sets the Wails runtime context for event emission.
+func (c *Config) SetContext(ctx context.Context) {
+ c.ctx = ctx
}
diff --git a/backend/config/httphandler.go b/backend/config/httphandler.go
new file mode 100644
index 0000000..4ac6cce
--- /dev/null
+++ b/backend/config/httphandler.go
@@ -0,0 +1,71 @@
+package config
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gorilla/schema"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+ "yellowjacket/backend/events"
+)
+
+var formDecoder = schema.NewDecoder()
+
+func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.serveMux.ServeHTTP(w, r)
+}
+
+func (c *Config) handle(w http.ResponseWriter, r *http.Request) {
+ c.logger.Debug("handling request from config http handler")
+
+ switch r.Method {
+ case http.MethodGet:
+ if err := c.form().Render(r.Context(), w); err != nil {
+ c.logger.Error("problem getting config html", "err", err.Error())
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ case http.MethodPost:
+ if err := c.handleConfigPost(r); err != nil {
+ c.logger.Error("problem handling config post request", "err", err.Error())
+ c.formSubmitError(err.Error()).Render(r.Context(), w)
+ w.WriteHeader(http.StatusInternalServerError)
+
+ return
+ }
+
+ c.formSubmitSuccess().Render(r.Context(), w)
+ w.WriteHeader(http.StatusOK)
+ }
+}
+
+func (c *Config) handleConfigPost(r *http.Request) error {
+ if err := r.ParseForm(); err != nil {
+ return fmt.Errorf("could not parse form data: %w", err)
+ }
+
+ var postedConfig Config
+
+ err := formDecoder.Decode(&postedConfig, r.PostForm)
+ if err != nil {
+ return fmt.Errorf("could not decode form data: %w", err)
+ }
+
+ c.logger.Debug("decoded config post form data", "postedConfig", postedConfig)
+
+ // Update local config and emit event for listeners
+ if postedConfig.Library != nil {
+ c.Library = postedConfig.Library
+
+ if c.ctx != nil {
+ runtime.EventsEmit(c.ctx, events.LibraryConfigChanged, map[string]any{
+ "DirectoryPath": string(c.Library.DirectoryPath),
+ })
+ }
+ }
+
+ if err := c.Save(); err != nil {
+ return fmt.Errorf("could not save posted config: %w", err)
+ }
+
+ return nil
+}
diff --git a/backend/config/userdata.go b/backend/config/userdata.go
deleted file mode 100644
index 58e8918..0000000
--- a/backend/config/userdata.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
- "os/user"
- "runtime"
-)
-
-// user config directories vary by operating system and purpose
-// Linux/Mac: ~/.config
-// Windows: C:\Users\\AppData
-func GetUserConfigDirPath() (string, error) {
- path := ""
- currentUser, err := user.Current()
- if err != nil {
- return "", fmt.Errorf("could not get current user: %w", err)
- }
- switch currentOS := runtime.GOOS; currentOS {
- case "darwin":
- path = fmt.Sprintf("/Users/%s/.config/yellowjacket", currentUser.Username)
- case "linux":
- path = fmt.Sprintf("/home/%s/.config/yellowjacket", currentUser.Username)
- case "windows":
- path = fmt.Sprintf(`C:\Users\%s\AppData\local\yellowjacket\config`, currentUser.Username)
- default:
- return "", fmt.Errorf("unsupported OS: %s", currentOS)
- }
- err = os.MkdirAll(path, os.ModePerm)
- if err != nil {
- return "", fmt.Errorf("could not make user config directory: %w", err)
- }
-
- // final check
- dirInfo, err := os.Stat(path)
- if err != nil {
- return "", fmt.Errorf("could not stat the user config directory %s: %w", path, err)
- }
-
- if !dirInfo.IsDir() {
- return "", fmt.Errorf("not a directory: %s", path)
- }
-
- return path, nil
-}
-
-// user data directories vary by operating system and purpose
-// Linux/Mac: ~/.local/share
-// Windows: C:\Users\\AppData\Local
-func getUserDataDirPath() (string, error) {
- path := ""
- currentUser, err := user.Current()
- if err != nil {
- return "", fmt.Errorf("could not get current user: %w", err)
- }
- switch currentOS := runtime.GOOS; currentOS {
- case "darwin":
- path = fmt.Sprintf("/Users/%s/.local/share/yellowjacket", currentUser.Username)
- case "linux":
- path = fmt.Sprintf("/home/%s/.local/share/yellowjacket", currentUser.Username)
- case "windows":
- path = fmt.Sprintf(`C:\Users\%s\AppData\local\yellowjacket\config`, currentUser.Username)
- default:
- return "", fmt.Errorf("unsupported OS: %s", currentOS)
- }
- err = os.MkdirAll(path, os.ModePerm)
- if err != nil {
- return "", fmt.Errorf("could not make user data directory: %w", err)
- }
-
- // final check
- dirInfo, err := os.Stat(path)
- if err != nil {
- return "", fmt.Errorf("could not stat the user data directory %s: %w", path, err)
- }
-
- if !dirInfo.IsDir() {
- return "", fmt.Errorf("not a directory: %s", path)
- }
-
- return path, nil
-}
diff --git a/backend/database/database.go b/backend/database/database.go
index fd8e968..3eac98b 100644
--- a/backend/database/database.go
+++ b/backend/database/database.go
@@ -1,25 +1,94 @@
+// Package database provides SQLite database access.
package database
import (
+ "context"
"database/sql"
+ "embed"
"fmt"
+ "io/fs"
+ "log/slog"
+ "path"
+ "path/filepath"
- _ "modernc.org/sqlite"
+ _ "modernc.org/sqlite" // Register sqlite driver.
+ "yellowjacket/backend/database/sql/sqlcgen"
+ "yellowjacket/backend/system"
)
-//go:generate sqlc vet
-//go:generate sqlc generate
+//go:generate go tool sqlc generate
-type DB struct{
- db *sql.DB
+//go:embed sql/schemas/*.sql
+var schemas embed.FS
+
+// DB wraps the SQLite database connection and queries.
+type DB struct {
+ db *sql.DB
+ Ctx context.Context
+ Queries *sqlcgen.Queries
+ logger *slog.Logger
}
-func NewDB(sqliteDBFilePath string) (*DB, error) {
- db, err := sql.Open("sqlite", ":memory:")
+// NewDB opens the database and applies schema migrations.
+func NewDB(logger *slog.Logger) (*DB, error) {
+ dbCtx := context.Background()
+
+ userDataDir, err := system.GetUserDataDirPath()
+ if err != nil {
+ return nil, fmt.Errorf("could not get user data directory: %w", err)
+ }
+
+ sqliteDBFilePath := path.Join(userDataDir, "yj.db")
+
+ logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
+
+ db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
}
+
+ db.SetMaxOpenConns(1) // SQLite only supports one writer at a time
+
+ // Execute SQL files from the embedded schemas directory
+ logger.Debug("reading sql schema files from embedded directory")
+
+ dirEntries, err := schemas.ReadDir("sql/schemas")
+ if err != nil {
+ return nil, fmt.Errorf("could not read schemas directory: %w", err)
+ }
+
+ logger.Debug("executing all sql schema files")
+
+ for _, dirEntry := range dirEntries {
+ if !dirEntry.IsDir() {
+ filePath := filepath.Join("sql/schemas", dirEntry.Name())
+ sqlContent, err := fs.ReadFile(schemas, filePath)
+ if err != nil {
+ return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
+ }
+
+ logger.Debug(
+ "executing sql schema file",
+ "filepath",
+ filePath,
+ "sql",
+ string(sqlContent),
+ )
+
+ _, err = db.ExecContext(dbCtx, string(sqlContent)) // Execute the SQL
+ if err != nil {
+ return nil, fmt.Errorf("error executing sql from file %s: %w", filePath, err)
+ }
+ }
+ }
+
+ // Get generated queries
+ queries := sqlcgen.New(db)
+
return &DB{
- db: db,
- }, nil
+ db: db,
+ Ctx: dbCtx,
+ Queries: queries,
+ logger: logger,
+ }, err
}
diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql
index d7049d8..73e6219 100644
--- a/backend/database/sql/queries/artist_credit.sql
+++ b/backend/database/sql/queries/artist_credit.sql
@@ -6,12 +6,20 @@ RETURNING *;
SELECT * FROM artist_credit
WHERE id = ? LIMIT 1;
+-- name: GetArtistCreditByText :one
+SELECT * FROM artist_credit
+WHERE text = ? LIMIT 1;
+
+-- name: UpsertArtistCredit :one
+INSERT INTO artist_credit (text) VALUES (?)
+ON CONFLICT(text) DO UPDATE SET text = excluded.text
+RETURNING *;
+
-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
-WHERE id =?;
+WHERE id = ?;
-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
-WHERE id =?;
-
+WHERE id = ?;
diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql
index 35d9003..36b2933 100644
--- a/backend/database/sql/queries/artists.sql
+++ b/backend/database/sql/queries/artists.sql
@@ -6,12 +6,24 @@ RETURNING *;
SELECT * FROM artists
WHERE id = ? LIMIT 1;
+-- name: GetArtistByName :one
+SELECT * FROM artists
+WHERE name = ? LIMIT 1;
+
+-- name: UpsertArtist :one
+INSERT INTO artists (name) VALUES (?)
+ON CONFLICT(name) DO UPDATE SET name = excluded.name
+RETURNING *;
+
-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
-WHERE id =?;
+WHERE id = ?;
-- name: DeleteArtist :exec
DELETE FROM artists
-WHERE id =?;
+WHERE id = ?;
+-- name: GetAllArtists :many
+SELECT * FROM artists
+ORDER BY name;
diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql
index 4cf66c0..eea0aef 100644
--- a/backend/database/sql/queries/audio_files.sql
+++ b/backend/database/sql/queries/audio_files.sql
@@ -6,12 +6,82 @@ RETURNING *;
SELECT * FROM audio_files
WHERE id = ? LIMIT 1;
+-- name: GetAudioFileByPath :one
+SELECT * FROM audio_files
+WHERE file_path = ? LIMIT 1;
+
-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
-WHERE id =?;
+WHERE id = ?;
+
+-- name: UpdateAudioFileRecording :exec
+UPDATE audio_files
+SET recording_id = ?
+WHERE id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files
-WHERE id =?;
+WHERE id = ?;
+-- name: CountAudioFiles :one
+SELECT count(*) FROM audio_files;
+
+-- name: GetRandomAudioFilePath :one
+SELECT file_path FROM audio_files
+ORDER BY RANDOM()
+LIMIT 1;
+
+-- name: GetAllAudioFiles :many
+SELECT * FROM audio_files;
+
+-- name: GetAllAudioFilePaths :many
+SELECT id, file_path FROM audio_files;
+
+-- name: GetAudioFilesNeedingMetadata :many
+SELECT * FROM audio_files
+WHERE recording_id = 0;
+
+-- name: GetAllAudioFilesWithArtist :many
+SELECT
+ af.id,
+ af.file_path,
+ af.length_milliseconds,
+ af.file_type_id,
+ af.recording_id,
+ COALESCE(ac.text, '') AS artist_name,
+ COALESCE(r.name, '') AS title
+FROM audio_files af
+JOIN recordings r ON af.recording_id = r.id
+JOIN artist_credit ac ON r.artist_credit_id = ac.id;
+
+-- name: GetTrackMetadataByPath :one
+SELECT
+ af.file_path,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist,
+ COALESCE(rg.name, '') AS album,
+ COALESCE(ca.file_path, '') AS cover_art_path
+FROM audio_files af
+LEFT JOIN recordings r ON af.recording_id = r.id
+LEFT 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 cover_art ca ON rg.cover_art_id = ca.id
+WHERE af.file_path = ?
+LIMIT 1;
+
+-- name: GetAudioFilesByReleaseGroup :many
+SELECT
+ af.file_path,
+ af.length_milliseconds,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist_name,
+ rgr.track_number,
+ rgr.disc_number
+FROM release_group_recordings rgr
+JOIN recordings r ON rgr.recording_id = r.id
+JOIN audio_files af ON af.recording_id = r.id
+LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
+WHERE rgr.release_group_id = ?
+ORDER BY rgr.disc_number, rgr.track_number;
diff --git a/backend/database/sql/queries/cover_art.sql b/backend/database/sql/queries/cover_art.sql
index c69a445..4006232 100644
--- a/backend/database/sql/queries/cover_art.sql
+++ b/backend/database/sql/queries/cover_art.sql
@@ -1,17 +1,28 @@
-- name: CreateCoverArt :one
-INSERT INTO cover_art (is_embedded, file_path, file_type_id) VALUES (?, ?, ?)
+INSERT INTO cover_art (is_embedded, file_path, mime_type) VALUES (?, ?, ?)
RETURNING *;
-- name: GetCoverArt :one
SELECT * FROM cover_art
WHERE id = ? LIMIT 1;
+-- name: GetCoverArtByPath :one
+SELECT * FROM cover_art
+WHERE file_path = ? LIMIT 1;
+
+-- name: UpsertCoverArt :one
+INSERT INTO cover_art (is_embedded, file_path, mime_type)
+VALUES (?, ?, ?)
+ON CONFLICT(file_path) DO UPDATE SET
+ is_embedded = excluded.is_embedded,
+ mime_type = excluded.mime_type
+RETURNING *;
+
-- name: UpdateCoverArt :exec
UPDATE cover_art
-SET is_embedded = ?, file_path = ?, file_type_id = ?
-WHERE id =?;
+SET is_embedded = ?, file_path = ?, mime_type = ?
+WHERE id = ?;
-- name: DeleteCoverArt :exec
DELETE FROM cover_art
-WHERE id =?;
-
+WHERE id = ?;
diff --git a/backend/database/sql/queries/player_state.sql b/backend/database/sql/queries/player_state.sql
new file mode 100644
index 0000000..8cd4437
--- /dev/null
+++ b/backend/database/sql/queries/player_state.sql
@@ -0,0 +1,8 @@
+-- name: GetPlayerState :one
+SELECT volume, muted, last_track_path, last_position_seconds
+FROM player_state WHERE id = 1;
+
+-- name: UpdatePlayerState :exec
+UPDATE player_state
+SET volume = ?, muted = ?, last_track_path = ?, last_position_seconds = ?
+WHERE id = 1;
diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql
new file mode 100644
index 0000000..36156cc
--- /dev/null
+++ b/backend/database/sql/queries/playlists.sql
@@ -0,0 +1,32 @@
+-- name: CreatePlaylist :one
+INSERT INTO playlists (name) VALUES (?)
+RETURNING *;
+
+-- name: GetPlaylist :one
+SELECT * FROM playlists WHERE id = ? LIMIT 1;
+
+-- name: GetAllPlaylists :many
+SELECT * FROM playlists ORDER BY updated_at DESC;
+
+-- name: UpdatePlaylistName :exec
+UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
+
+-- name: DeletePlaylist :exec
+DELETE FROM playlists WHERE id = ?;
+
+-- name: AddPlaylistTrack :one
+INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?)
+RETURNING *;
+
+-- name: GetPlaylistTracks :many
+SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, af.file_path
+FROM playlist_tracks pt
+JOIN audio_files af ON pt.audio_file_id = af.id
+WHERE pt.playlist_id = ?
+ORDER BY pt.position;
+
+-- name: RemovePlaylistTrack :exec
+DELETE FROM playlist_tracks WHERE id = ?;
+
+-- name: ClearPlaylistTracks :exec
+DELETE FROM playlist_tracks WHERE playlist_id = ?;
diff --git a/backend/database/sql/queries/queue.sql b/backend/database/sql/queries/queue.sql
new file mode 100644
index 0000000..fee35d2
--- /dev/null
+++ b/backend/database/sql/queries/queue.sql
@@ -0,0 +1,49 @@
+-- name: GetQueueState :one
+SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
+FROM queue WHERE id = 1;
+
+-- name: UpdateQueueState :exec
+UPDATE queue
+SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
+WHERE id = 1;
+
+-- name: UpdateQueuePosition :exec
+UPDATE queue
+SET current_position = ?
+WHERE id = 1;
+
+-- name: GetQueueTracks :many
+SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist
+FROM queue_tracks qt
+JOIN audio_files af ON qt.audio_file_id = af.id
+LEFT JOIN recordings r ON af.recording_id = r.id
+LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
+ORDER BY qt.position;
+
+-- name: GetQueueTrackCount :one
+SELECT count(*) FROM queue_tracks;
+
+-- name: InsertQueueTrack :one
+INSERT INTO queue_tracks (audio_file_id, position) VALUES (?, ?)
+RETURNING *;
+
+-- name: ClearQueueTracks :exec
+DELETE FROM queue_tracks;
+
+-- name: RemoveQueueTrack :exec
+DELETE FROM queue_tracks WHERE id = ?;
+
+-- name: RemoveQueueTrackByPosition :exec
+DELETE FROM queue_tracks WHERE position = ?;
+
+-- name: ShiftQueuePositionsDown :exec
+UPDATE queue_tracks
+SET position = position - 1
+WHERE position > ?;
+
+-- name: ShiftQueuePositionsUp :exec
+UPDATE queue_tracks
+SET position = position + 1
+WHERE position >= ?;
diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql
index be44d12..4b21e67 100644
--- a/backend/database/sql/queries/recordings.sql
+++ b/backend/database/sql/queries/recordings.sql
@@ -1,5 +1,12 @@
-- name: CreateRecording :one
-INSERT INTO recordings (name) VALUES (?)
+INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
+RETURNING *;
+
+-- name: CreateRecordingFull :one
+INSERT INTO recordings (
+ name, artist_credit_id, track_number, disc_number,
+ year, genre, composer, lyrics, comment
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetRecording :one
@@ -8,10 +15,19 @@ WHERE id = ? LIMIT 1;
-- name: UpdateRecording :exec
UPDATE recordings
-SET name = ?
-WHERE id =?;
+SET name = ?, artist_credit_id = ?
+WHERE id = ?;
+
+-- name: UpdateRecordingFull :exec
+UPDATE recordings
+SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
+ year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
+WHERE id = ?;
-- name: DeleteRecording :exec
DELETE FROM recordings
-WHERE id =?;
+WHERE id = ?;
+-- name: GetAllRecordings :many
+SELECT * FROM recordings
+ORDER BY name;
diff --git a/backend/database/sql/queries/release_group_recordings.sql b/backend/database/sql/queries/release_group_recordings.sql
index de502d3..500caf9 100644
--- a/backend/database/sql/queries/release_group_recordings.sql
+++ b/backend/database/sql/queries/release_group_recordings.sql
@@ -1,17 +1,25 @@
-- name: CreateReleaseGroupRecording :one
-INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?,?)
+INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
+VALUES (?, ?, ?, ?)
RETURNING *;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
--- name: UpdateReleaseGroupRecording :exec
-UPDATE release_group_recordings
-SET release_group_id = ?, recording_id = ?
-WHERE id =?;
+-- name: GetReleaseGroupRecordings :many
+SELECT * FROM release_group_recordings
+WHERE release_group_id = ?
+ORDER BY disc_number, track_number;
+
+-- name: GetRecordingReleaseGroups :many
+SELECT * FROM release_group_recordings
+WHERE recording_id = ?;
-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
-WHERE id =?;
+WHERE id = ?;
+-- name: DeleteReleaseGroupRecordingByFK :exec
+DELETE FROM release_group_recordings
+WHERE release_group_id = ? AND recording_id = ?;
diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql
index 9b0c1d8..7e59491 100644
--- a/backend/database/sql/queries/release_groups.sql
+++ b/backend/database/sql/queries/release_groups.sql
@@ -2,16 +2,54 @@
INSERT INTO release_groups (name) VALUES (?)
RETURNING *;
+-- name: CreateReleaseGroupFull :one
+INSERT INTO release_groups (
+ name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
+) VALUES (?, ?, ?, ?, ?, ?)
+RETURNING *;
+
-- name: GetReleaseGroup :one
SELECT * FROM release_groups
WHERE id = ? LIMIT 1;
+-- name: GetReleaseGroupByName :one
+SELECT * FROM release_groups
+WHERE name = ? LIMIT 1;
+
+-- name: UpsertReleaseGroup :one
+INSERT INTO release_groups (name, album_artist_credit_id, year)
+VALUES (?, ?, ?)
+ON CONFLICT(name) DO UPDATE SET
+ album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
+ year = COALESCE(excluded.year, release_groups.year)
+RETURNING *;
+
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
-WHERE id =?;
+WHERE id = ?;
+
+-- name: UpdateReleaseGroupCoverArt :exec
+UPDATE release_groups
+SET cover_art_id = ?
+WHERE id = ?;
-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
-WHERE id =?;
+WHERE id = ?;
+-- name: GetAllReleaseGroups :many
+SELECT * FROM release_groups
+ORDER BY name;
+
+-- name: GetAllAlbumsWithDetails :many
+SELECT
+ rg.id,
+ rg.name,
+ rg.year,
+ COALESCE(ac.text, '') as artist_name,
+ COALESCE(ca.file_path, '') as cover_art_path
+FROM release_groups rg
+LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
+LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
+ORDER BY rg.name;
diff --git a/backend/database/sql/schemas/artist_credit.sql b/backend/database/sql/schemas/artist_credit.sql
index 70428e5..1656de0 100644
--- a/backend/database/sql/schemas/artist_credit.sql
+++ b/backend/database/sql/schemas/artist_credit.sql
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS artist_credit (
- id int PRIMARY KEY,
- text string NOT NULL
+ id INTEGER PRIMARY KEY,
+ text TEXT NOT NULL UNIQUE
);
diff --git a/backend/database/sql/schemas/artist_credit_artist.sql b/backend/database/sql/schemas/artist_credit_artist.sql
index e2f125a..11a8cc5 100644
--- a/backend/database/sql/schemas/artist_credit_artist.sql
+++ b/backend/database/sql/schemas/artist_credit_artist.sql
@@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS artist_credit_artist (
- id int PRIMARY KEY,
+ id integer PRIMARY KEY,
artist_id int NOT NULL,
credit_id int NOT NULL,
FOREIGN KEY(artist_id) REFERENCES artists(id),
diff --git a/backend/database/sql/schemas/artists.sql b/backend/database/sql/schemas/artists.sql
index fd9b224..93bcb09 100644
--- a/backend/database/sql/schemas/artists.sql
+++ b/backend/database/sql/schemas/artists.sql
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS artists (
- id int PRIMARY KEY,
- name text NOT NULL
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE
);
diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql
index 7e64388..fc4d7ef 100644
--- a/backend/database/sql/schemas/audio_files.sql
+++ b/backend/database/sql/schemas/audio_files.sql
@@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS audio_files (
- id int PRIMARY KEY,
+ id integer PRIMARY KEY,
file_path text NOT NULL UNIQUE,
length_milliseconds int NOT NULL,
file_type_id int NOT NULL,
diff --git a/backend/database/sql/schemas/cover_art.sql b/backend/database/sql/schemas/cover_art.sql
index 0e3d611..70dd4fc 100644
--- a/backend/database/sql/schemas/cover_art.sql
+++ b/backend/database/sql/schemas/cover_art.sql
@@ -1,7 +1,6 @@
CREATE TABLE IF NOT EXISTS cover_art (
- id int PRIMARY KEY,
- is_embedded bool NOT NULL DEFAULT(false),
- file_path text NOT NULL,
- file_type_id int NOT NULL,
- FOREIGN KEY(file_type_id) REFERENCES file_types(id)
+ id INTEGER PRIMARY KEY,
+ is_embedded BOOL NOT NULL DEFAULT(false),
+ file_path TEXT NOT NULL UNIQUE,
+ mime_type TEXT NOT NULL
);
diff --git a/backend/database/sql/schemas/file_types.sql b/backend/database/sql/schemas/file_types.sql
index bc2088b..073d613 100644
--- a/backend/database/sql/schemas/file_types.sql
+++ b/backend/database/sql/schemas/file_types.sql
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS file_types (
- id INTEGER PRIMARY KEY,
+ id integer PRIMARY KEY,
extension text NOT NULL UNIQUE
);
diff --git a/backend/database/sql/schemas/player_state.sql b/backend/database/sql/schemas/player_state.sql
new file mode 100644
index 0000000..ea4c2aa
--- /dev/null
+++ b/backend/database/sql/schemas/player_state.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS player_state (
+ id INTEGER PRIMARY KEY CHECK(id = 1),
+ volume INTEGER NOT NULL DEFAULT 100,
+ muted BOOLEAN NOT NULL DEFAULT false,
+ last_track_path TEXT NOT NULL DEFAULT '',
+ last_position_seconds INTEGER NOT NULL DEFAULT 0
+);
+
+INSERT OR IGNORE INTO player_state (id) VALUES (1);
diff --git a/backend/database/sql/schemas/playlist_tracks.sql b/backend/database/sql/schemas/playlist_tracks.sql
new file mode 100644
index 0000000..ad431c3
--- /dev/null
+++ b/backend/database/sql/schemas/playlist_tracks.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS playlist_tracks (
+ id INTEGER PRIMARY KEY,
+ playlist_id INTEGER NOT NULL,
+ audio_file_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
+ FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
+);
diff --git a/backend/database/sql/schemas/playlists.sql b/backend/database/sql/schemas/playlists.sql
new file mode 100644
index 0000000..8d7947b
--- /dev/null
+++ b/backend/database/sql/schemas/playlists.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS playlists (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/backend/database/sql/schemas/queue.sql b/backend/database/sql/schemas/queue.sql
new file mode 100644
index 0000000..08c5658
--- /dev/null
+++ b/backend/database/sql/schemas/queue.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS queue (
+ id INTEGER PRIMARY KEY CHECK(id = 1),
+ source_playlist_id INTEGER,
+ current_position INTEGER NOT NULL DEFAULT 0,
+ shuffle_mode BOOLEAN NOT NULL DEFAULT false,
+ repeat_mode TEXT NOT NULL DEFAULT 'off',
+ shuffle_order TEXT,
+ FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
+);
+
+INSERT OR IGNORE INTO queue (id) VALUES (1);
diff --git a/backend/database/sql/schemas/queue_tracks.sql b/backend/database/sql/schemas/queue_tracks.sql
new file mode 100644
index 0000000..9d8f7bd
--- /dev/null
+++ b/backend/database/sql/schemas/queue_tracks.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS queue_tracks (
+ id INTEGER PRIMARY KEY,
+ audio_file_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
+);
diff --git a/backend/database/sql/schemas/recordings.sql b/backend/database/sql/schemas/recordings.sql
index b129ca1..bcdd322 100644
--- a/backend/database/sql/schemas/recordings.sql
+++ b/backend/database/sql/schemas/recordings.sql
@@ -1,7 +1,13 @@
CREATE TABLE IF NOT EXISTS recordings (
- id int PRIMARY KEY,
- name text NOT NULL,
- artist_credit_id int NOT NULL,
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ artist_credit_id INTEGER NOT NULL,
+ track_number INTEGER,
+ disc_number INTEGER,
+ year INTEGER,
+ genre TEXT,
+ composer TEXT,
+ lyrics TEXT,
+ comment TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
-
diff --git a/backend/database/sql/schemas/release_group_recordings.sql b/backend/database/sql/schemas/release_group_recordings.sql
index b08aaf9..0c7102b 100644
--- a/backend/database/sql/schemas/release_group_recordings.sql
+++ b/backend/database/sql/schemas/release_group_recordings.sql
@@ -1,8 +1,9 @@
CREATE TABLE IF NOT EXISTS release_group_recordings (
- id int PRIMARY KEY,
- release_group_id int NOT NULL,
- recording_id int NOT NULL,
+ id INTEGER PRIMARY KEY,
+ release_group_id INTEGER NOT NULL,
+ recording_id INTEGER NOT NULL,
+ track_number INTEGER,
+ disc_number INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
-
diff --git a/backend/database/sql/schemas/release_groups.sql b/backend/database/sql/schemas/release_groups.sql
index 48d1d1b..7fc4b0b 100644
--- a/backend/database/sql/schemas/release_groups.sql
+++ b/backend/database/sql/schemas/release_groups.sql
@@ -1,6 +1,11 @@
CREATE TABLE IF NOT EXISTS release_groups (
- id int PRIMARY KEY,
- name text NOT NULL,
- cover_art_id int NOT NULL,
- FOREIGN KEY(cover_art_id) REFERENCES cover_art(id)
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ cover_art_id INTEGER,
+ album_artist_credit_id INTEGER,
+ year INTEGER,
+ total_tracks INTEGER,
+ total_discs INTEGER,
+ FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
+ FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id)
);
diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go
index c380851..27cdf7a 100644
--- a/backend/database/sql/sqlcgen/artist_credit.sql.go
+++ b/backend/database/sql/sqlcgen/artist_credit.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: artist_credit.sql
package sqlcgen
@@ -14,7 +14,7 @@ INSERT INTO artist_credit (text) VALUES (?)
RETURNING id, text
`
-func (q *Queries) CreateArtistCredit(ctx context.Context, text interface{}) (ArtistCredit, error) {
+func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
@@ -23,7 +23,7 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text interface{}) (Art
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
@@ -43,14 +43,26 @@ func (q *Queries) GetArtistCredit(ctx context.Context, id int64) (ArtistCredit,
return i, err
}
+const getArtistCreditByText = `-- name: GetArtistCreditByText :one
+SELECT id, text FROM artist_credit
+WHERE text = ? LIMIT 1
+`
+
+func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (ArtistCredit, error) {
+ row := q.db.QueryRowContext(ctx, getArtistCreditByText, text)
+ var i ArtistCredit
+ err := row.Scan(&i.ID, &i.Text)
+ return i, err
+}
+
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
-WHERE id =?
+WHERE id = ?
`
type UpdateArtistCreditParams struct {
- Text interface{}
+ Text string
ID int64
}
@@ -58,3 +70,16 @@ func (q *Queries) UpdateArtistCredit(ctx context.Context, arg UpdateArtistCredit
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
return err
}
+
+const upsertArtistCredit = `-- name: UpsertArtistCredit :one
+INSERT INTO artist_credit (text) VALUES (?)
+ON CONFLICT(text) DO UPDATE SET text = excluded.text
+RETURNING id, text
+`
+
+func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
+ row := q.db.QueryRowContext(ctx, upsertArtistCredit, text)
+ var i ArtistCredit
+ err := row.Scan(&i.ID, &i.Text)
+ return i, err
+}
diff --git a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
index 21a77ca..3cd2f8b 100644
--- a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
+++ b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: artist_credit_artists.sql
package sqlcgen
diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go
index 37e042a..871a196 100644
--- a/backend/database/sql/sqlcgen/artists.sql.go
+++ b/backend/database/sql/sqlcgen/artists.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: artists.sql
package sqlcgen
@@ -23,7 +23,7 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error)
const deleteArtist = `-- name: DeleteArtist :exec
DELETE FROM artists
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
@@ -31,6 +31,34 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
return err
}
+const getAllArtists = `-- name: GetAllArtists :many
+SELECT id, name FROM artists
+ORDER BY name
+`
+
+func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
+ rows, err := q.db.QueryContext(ctx, getAllArtists)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Artist
+ for rows.Next() {
+ var i Artist
+ if err := rows.Scan(&i.ID, &i.Name); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getArtist = `-- name: GetArtist :one
SELECT id, name FROM artists
WHERE id = ? LIMIT 1
@@ -43,10 +71,22 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
return i, err
}
+const getArtistByName = `-- name: GetArtistByName :one
+SELECT id, name FROM artists
+WHERE name = ? LIMIT 1
+`
+
+func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
+ row := q.db.QueryRowContext(ctx, getArtistByName, name)
+ var i Artist
+ err := row.Scan(&i.ID, &i.Name)
+ return i, err
+}
+
const updateArtist = `-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
-WHERE id =?
+WHERE id = ?
`
type UpdateArtistParams struct {
@@ -58,3 +98,16 @@ func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) erro
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
return err
}
+
+const upsertArtist = `-- name: UpsertArtist :one
+INSERT INTO artists (name) VALUES (?)
+ON CONFLICT(name) DO UPDATE SET name = excluded.name
+RETURNING id, name
+`
+
+func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
+ row := q.db.QueryRowContext(ctx, upsertArtist, name)
+ var i Artist
+ err := row.Scan(&i.ID, &i.Name)
+ return i, err
+}
diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go
index 7cf082f..6783a7b 100644
--- a/backend/database/sql/sqlcgen/audio_files.sql.go
+++ b/backend/database/sql/sqlcgen/audio_files.sql.go
@@ -1,14 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: audio_files.sql
package sqlcgen
import (
"context"
+ "database/sql"
)
+const countAudioFiles = `-- name: CountAudioFiles :one
+SELECT count(*) FROM audio_files
+`
+
+func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countAudioFiles)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
const createAudioFile = `-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id
@@ -41,7 +53,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
const deleteAudioFile = `-- name: DeleteAudioFile :exec
DELETE FROM audio_files
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error {
@@ -49,6 +61,126 @@ func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error {
return err
}
+const getAllAudioFilePaths = `-- name: GetAllAudioFilePaths :many
+SELECT id, file_path FROM audio_files
+`
+
+type GetAllAudioFilePathsRow struct {
+ ID int64
+ FilePath string
+}
+
+func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePathsRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllAudioFilePaths)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllAudioFilePathsRow
+ for rows.Next() {
+ var i GetAllAudioFilePathsRow
+ if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAllAudioFiles = `-- name: GetAllAudioFiles :many
+SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
+`
+
+func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
+ rows, err := q.db.QueryContext(ctx, getAllAudioFiles)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []AudioFile
+ for rows.Next() {
+ var i AudioFile
+ if err := rows.Scan(
+ &i.ID,
+ &i.FilePath,
+ &i.LengthMilliseconds,
+ &i.FileTypeID,
+ &i.RecordingID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAllAudioFilesWithArtist = `-- name: GetAllAudioFilesWithArtist :many
+SELECT
+ af.id,
+ af.file_path,
+ af.length_milliseconds,
+ af.file_type_id,
+ af.recording_id,
+ COALESCE(ac.text, '') AS artist_name,
+ COALESCE(r.name, '') AS title
+FROM audio_files af
+JOIN recordings r ON af.recording_id = r.id
+JOIN artist_credit ac ON r.artist_credit_id = ac.id
+`
+
+type GetAllAudioFilesWithArtistRow struct {
+ ID int64
+ FilePath string
+ LengthMilliseconds int64
+ FileTypeID int64
+ RecordingID int64
+ ArtistName string
+ Title string
+}
+
+func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudioFilesWithArtistRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllAudioFilesWithArtist)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllAudioFilesWithArtistRow
+ for rows.Next() {
+ var i GetAllAudioFilesWithArtistRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.FilePath,
+ &i.LengthMilliseconds,
+ &i.FileTypeID,
+ &i.RecordingID,
+ &i.ArtistName,
+ &i.Title,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE id = ? LIMIT 1
@@ -67,10 +199,168 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
return i, err
}
+const getAudioFileByPath = `-- name: GetAudioFileByPath :one
+SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
+WHERE file_path = ? LIMIT 1
+`
+
+func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) {
+ row := q.db.QueryRowContext(ctx, getAudioFileByPath, filePath)
+ var i AudioFile
+ err := row.Scan(
+ &i.ID,
+ &i.FilePath,
+ &i.LengthMilliseconds,
+ &i.FileTypeID,
+ &i.RecordingID,
+ )
+ return i, err
+}
+
+const getAudioFilesByReleaseGroup = `-- name: GetAudioFilesByReleaseGroup :many
+SELECT
+ af.file_path,
+ af.length_milliseconds,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist_name,
+ rgr.track_number,
+ rgr.disc_number
+FROM release_group_recordings rgr
+JOIN recordings r ON rgr.recording_id = r.id
+JOIN audio_files af ON af.recording_id = r.id
+LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
+WHERE rgr.release_group_id = ?
+ORDER BY rgr.disc_number, rgr.track_number
+`
+
+type GetAudioFilesByReleaseGroupRow struct {
+ FilePath string
+ LengthMilliseconds int64
+ Title string
+ ArtistName string
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
+}
+
+func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupID int64) ([]GetAudioFilesByReleaseGroupRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAudioFilesByReleaseGroup, releaseGroupID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAudioFilesByReleaseGroupRow
+ for rows.Next() {
+ var i GetAudioFilesByReleaseGroupRow
+ if err := rows.Scan(
+ &i.FilePath,
+ &i.LengthMilliseconds,
+ &i.Title,
+ &i.ArtistName,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
+SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
+WHERE recording_id = 0
+`
+
+func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile, error) {
+ rows, err := q.db.QueryContext(ctx, getAudioFilesNeedingMetadata)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []AudioFile
+ for rows.Next() {
+ var i AudioFile
+ if err := rows.Scan(
+ &i.ID,
+ &i.FilePath,
+ &i.LengthMilliseconds,
+ &i.FileTypeID,
+ &i.RecordingID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getRandomAudioFilePath = `-- name: GetRandomAudioFilePath :one
+SELECT file_path FROM audio_files
+ORDER BY RANDOM()
+LIMIT 1
+`
+
+func (q *Queries) GetRandomAudioFilePath(ctx context.Context) (string, error) {
+ row := q.db.QueryRowContext(ctx, getRandomAudioFilePath)
+ var file_path string
+ err := row.Scan(&file_path)
+ return file_path, err
+}
+
+const getTrackMetadataByPath = `-- name: GetTrackMetadataByPath :one
+SELECT
+ af.file_path,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist,
+ COALESCE(rg.name, '') AS album,
+ COALESCE(ca.file_path, '') AS cover_art_path
+FROM audio_files af
+LEFT JOIN recordings r ON af.recording_id = r.id
+LEFT 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 cover_art ca ON rg.cover_art_id = ca.id
+WHERE af.file_path = ?
+LIMIT 1
+`
+
+type GetTrackMetadataByPathRow struct {
+ FilePath string
+ Title string
+ Artist string
+ Album string
+ CoverArtPath string
+}
+
+func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (GetTrackMetadataByPathRow, error) {
+ row := q.db.QueryRowContext(ctx, getTrackMetadataByPath, filePath)
+ var i GetTrackMetadataByPathRow
+ err := row.Scan(
+ &i.FilePath,
+ &i.Title,
+ &i.Artist,
+ &i.Album,
+ &i.CoverArtPath,
+ )
+ return i, err
+}
+
const updateAudioFile = `-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
-WHERE id =?
+WHERE id = ?
`
type UpdateAudioFileParams struct {
@@ -91,3 +381,19 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
)
return err
}
+
+const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec
+UPDATE audio_files
+SET recording_id = ?
+WHERE id = ?
+`
+
+type UpdateAudioFileRecordingParams struct {
+ RecordingID int64
+ ID int64
+}
+
+func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error {
+ _, err := q.db.ExecContext(ctx, updateAudioFileRecording, arg.RecordingID, arg.ID)
+ return err
+}
diff --git a/backend/database/sql/sqlcgen/cover_art.sql.go b/backend/database/sql/sqlcgen/cover_art.sql.go
index 9ac3f8c..b285cad 100644
--- a/backend/database/sql/sqlcgen/cover_art.sql.go
+++ b/backend/database/sql/sqlcgen/cover_art.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: cover_art.sql
package sqlcgen
@@ -10,31 +10,31 @@ import (
)
const createCoverArt = `-- name: CreateCoverArt :one
-INSERT INTO cover_art (is_embedded, file_path, file_type_id) VALUES (?, ?, ?)
-RETURNING id, is_embedded, file_path, file_type_id
+INSERT INTO cover_art (is_embedded, file_path, mime_type) VALUES (?, ?, ?)
+RETURNING id, is_embedded, file_path, mime_type
`
type CreateCoverArtParams struct {
IsEmbedded bool
FilePath string
- FileTypeID int64
+ MimeType string
}
func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams) (CoverArt, error) {
- row := q.db.QueryRowContext(ctx, createCoverArt, arg.IsEmbedded, arg.FilePath, arg.FileTypeID)
+ row := q.db.QueryRowContext(ctx, createCoverArt, arg.IsEmbedded, arg.FilePath, arg.MimeType)
var i CoverArt
err := row.Scan(
&i.ID,
&i.IsEmbedded,
&i.FilePath,
- &i.FileTypeID,
+ &i.MimeType,
)
return i, err
}
const deleteCoverArt = `-- name: DeleteCoverArt :exec
DELETE FROM cover_art
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteCoverArt(ctx context.Context, id int64) error {
@@ -43,7 +43,7 @@ func (q *Queries) DeleteCoverArt(ctx context.Context, id int64) error {
}
const getCoverArt = `-- name: GetCoverArt :one
-SELECT id, is_embedded, file_path, file_type_id FROM cover_art
+SELECT id, is_embedded, file_path, mime_type FROM cover_art
WHERE id = ? LIMIT 1
`
@@ -54,21 +54,38 @@ func (q *Queries) GetCoverArt(ctx context.Context, id int64) (CoverArt, error) {
&i.ID,
&i.IsEmbedded,
&i.FilePath,
- &i.FileTypeID,
+ &i.MimeType,
+ )
+ return i, err
+}
+
+const getCoverArtByPath = `-- name: GetCoverArtByPath :one
+SELECT id, is_embedded, file_path, mime_type FROM cover_art
+WHERE file_path = ? LIMIT 1
+`
+
+func (q *Queries) GetCoverArtByPath(ctx context.Context, filePath string) (CoverArt, error) {
+ row := q.db.QueryRowContext(ctx, getCoverArtByPath, filePath)
+ var i CoverArt
+ err := row.Scan(
+ &i.ID,
+ &i.IsEmbedded,
+ &i.FilePath,
+ &i.MimeType,
)
return i, err
}
const updateCoverArt = `-- name: UpdateCoverArt :exec
UPDATE cover_art
-SET is_embedded = ?, file_path = ?, file_type_id = ?
-WHERE id =?
+SET is_embedded = ?, file_path = ?, mime_type = ?
+WHERE id = ?
`
type UpdateCoverArtParams struct {
IsEmbedded bool
FilePath string
- FileTypeID int64
+ MimeType string
ID int64
}
@@ -76,8 +93,35 @@ func (q *Queries) UpdateCoverArt(ctx context.Context, arg UpdateCoverArtParams)
_, err := q.db.ExecContext(ctx, updateCoverArt,
arg.IsEmbedded,
arg.FilePath,
- arg.FileTypeID,
+ arg.MimeType,
arg.ID,
)
return err
}
+
+const upsertCoverArt = `-- name: UpsertCoverArt :one
+INSERT INTO cover_art (is_embedded, file_path, mime_type)
+VALUES (?, ?, ?)
+ON CONFLICT(file_path) DO UPDATE SET
+ is_embedded = excluded.is_embedded,
+ mime_type = excluded.mime_type
+RETURNING id, is_embedded, file_path, mime_type
+`
+
+type UpsertCoverArtParams struct {
+ IsEmbedded bool
+ FilePath string
+ MimeType string
+}
+
+func (q *Queries) UpsertCoverArt(ctx context.Context, arg UpsertCoverArtParams) (CoverArt, error) {
+ row := q.db.QueryRowContext(ctx, upsertCoverArt, arg.IsEmbedded, arg.FilePath, arg.MimeType)
+ var i CoverArt
+ err := row.Scan(
+ &i.ID,
+ &i.IsEmbedded,
+ &i.FilePath,
+ &i.MimeType,
+ )
+ return i, err
+}
diff --git a/backend/database/sql/sqlcgen/db.go b/backend/database/sql/sqlcgen/db.go
index b72fec4..3bd707e 100644
--- a/backend/database/sql/sqlcgen/db.go
+++ b/backend/database/sql/sqlcgen/db.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
package sqlcgen
diff --git a/backend/database/sql/sqlcgen/file_types.sql.go b/backend/database/sql/sqlcgen/file_types.sql.go
index 6a3c286..03f7175 100644
--- a/backend/database/sql/sqlcgen/file_types.sql.go
+++ b/backend/database/sql/sqlcgen/file_types.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: file_types.sql
package sqlcgen
diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go
index b35b1db..b9b86ba 100644
--- a/backend/database/sql/sqlcgen/models.go
+++ b/backend/database/sql/sqlcgen/models.go
@@ -1,9 +1,14 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
package sqlcgen
+import (
+ "database/sql"
+ "time"
+)
+
type Artist struct {
ID int64
Name string
@@ -11,7 +16,7 @@ type Artist struct {
type ArtistCredit struct {
ID int64
- Text interface{}
+ Text string
}
type ArtistCreditArtist struct {
@@ -32,7 +37,7 @@ type CoverArt struct {
ID int64
IsEmbedded bool
FilePath string
- FileTypeID int64
+ MimeType string
}
type FileType struct {
@@ -40,20 +45,70 @@ type FileType struct {
Extension string
}
+type PlayerState struct {
+ ID int64
+ Volume int64
+ Muted bool
+ LastTrackPath string
+ LastPositionSeconds int64
+}
+
+type Playlist struct {
+ ID int64
+ Name string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type PlaylistTrack struct {
+ ID int64
+ PlaylistID int64
+ AudioFileID int64
+ Position int64
+}
+
+type Queue struct {
+ ID int64
+ SourcePlaylistID sql.NullInt64
+ CurrentPosition int64
+ ShuffleMode bool
+ RepeatMode string
+ ShuffleOrder sql.NullString
+}
+
+type QueueTrack struct {
+ ID int64
+ AudioFileID int64
+ Position int64
+}
+
type Recording struct {
ID int64
Name string
ArtistCreditID int64
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
+ Year sql.NullInt64
+ Genre sql.NullString
+ Composer sql.NullString
+ Lyrics sql.NullString
+ Comment sql.NullString
}
type ReleaseGroup struct {
- ID int64
- Name string
- CoverArtID int64
+ ID int64
+ Name string
+ CoverArtID sql.NullInt64
+ AlbumArtistCreditID sql.NullInt64
+ Year sql.NullInt64
+ TotalTracks sql.NullInt64
+ TotalDiscs sql.NullInt64
}
type ReleaseGroupRecording struct {
ID int64
ReleaseGroupID int64
RecordingID int64
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
}
diff --git a/backend/database/sql/sqlcgen/player_state.sql.go b/backend/database/sql/sqlcgen/player_state.sql.go
new file mode 100644
index 0000000..413b5ea
--- /dev/null
+++ b/backend/database/sql/sqlcgen/player_state.sql.go
@@ -0,0 +1,57 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: player_state.sql
+
+package sqlcgen
+
+import (
+ "context"
+)
+
+const getPlayerState = `-- name: GetPlayerState :one
+SELECT volume, muted, last_track_path, last_position_seconds
+FROM player_state WHERE id = 1
+`
+
+type GetPlayerStateRow struct {
+ Volume int64
+ Muted bool
+ LastTrackPath string
+ LastPositionSeconds int64
+}
+
+func (q *Queries) GetPlayerState(ctx context.Context) (GetPlayerStateRow, error) {
+ row := q.db.QueryRowContext(ctx, getPlayerState)
+ var i GetPlayerStateRow
+ err := row.Scan(
+ &i.Volume,
+ &i.Muted,
+ &i.LastTrackPath,
+ &i.LastPositionSeconds,
+ )
+ return i, err
+}
+
+const updatePlayerState = `-- name: UpdatePlayerState :exec
+UPDATE player_state
+SET volume = ?, muted = ?, last_track_path = ?, last_position_seconds = ?
+WHERE id = 1
+`
+
+type UpdatePlayerStateParams struct {
+ Volume int64
+ Muted bool
+ LastTrackPath string
+ LastPositionSeconds int64
+}
+
+func (q *Queries) UpdatePlayerState(ctx context.Context, arg UpdatePlayerStateParams) error {
+ _, err := q.db.ExecContext(ctx, updatePlayerState,
+ arg.Volume,
+ arg.Muted,
+ arg.LastTrackPath,
+ arg.LastPositionSeconds,
+ )
+ return err
+}
diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go
new file mode 100644
index 0000000..0e1380c
--- /dev/null
+++ b/backend/database/sql/sqlcgen/playlists.sql.go
@@ -0,0 +1,184 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: playlists.sql
+
+package sqlcgen
+
+import (
+ "context"
+)
+
+const addPlaylistTrack = `-- name: AddPlaylistTrack :one
+INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?)
+RETURNING id, playlist_id, audio_file_id, position
+`
+
+type AddPlaylistTrackParams struct {
+ PlaylistID int64
+ AudioFileID int64
+ Position int64
+}
+
+func (q *Queries) AddPlaylistTrack(ctx context.Context, arg AddPlaylistTrackParams) (PlaylistTrack, error) {
+ row := q.db.QueryRowContext(ctx, addPlaylistTrack, arg.PlaylistID, arg.AudioFileID, arg.Position)
+ var i PlaylistTrack
+ err := row.Scan(
+ &i.ID,
+ &i.PlaylistID,
+ &i.AudioFileID,
+ &i.Position,
+ )
+ return i, err
+}
+
+const clearPlaylistTracks = `-- name: ClearPlaylistTracks :exec
+DELETE FROM playlist_tracks WHERE playlist_id = ?
+`
+
+func (q *Queries) ClearPlaylistTracks(ctx context.Context, playlistID int64) error {
+ _, err := q.db.ExecContext(ctx, clearPlaylistTracks, playlistID)
+ return err
+}
+
+const createPlaylist = `-- name: CreatePlaylist :one
+INSERT INTO playlists (name) VALUES (?)
+RETURNING id, name, created_at, updated_at
+`
+
+func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
+ row := q.db.QueryRowContext(ctx, createPlaylist, name)
+ var i Playlist
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const deletePlaylist = `-- name: DeletePlaylist :exec
+DELETE FROM playlists WHERE id = ?
+`
+
+func (q *Queries) DeletePlaylist(ctx context.Context, id int64) error {
+ _, err := q.db.ExecContext(ctx, deletePlaylist, id)
+ return err
+}
+
+const getAllPlaylists = `-- name: GetAllPlaylists :many
+SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC
+`
+
+func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
+ rows, err := q.db.QueryContext(ctx, getAllPlaylists)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Playlist
+ for rows.Next() {
+ var i Playlist
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getPlaylist = `-- name: GetPlaylist :one
+SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
+ row := q.db.QueryRowContext(ctx, getPlaylist, id)
+ var i Playlist
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getPlaylistTracks = `-- name: GetPlaylistTracks :many
+SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, af.file_path
+FROM playlist_tracks pt
+JOIN audio_files af ON pt.audio_file_id = af.id
+WHERE pt.playlist_id = ?
+ORDER BY pt.position
+`
+
+type GetPlaylistTracksRow struct {
+ ID int64
+ PlaylistID int64
+ AudioFileID int64
+ Position int64
+ FilePath string
+}
+
+func (q *Queries) GetPlaylistTracks(ctx context.Context, playlistID int64) ([]GetPlaylistTracksRow, error) {
+ rows, err := q.db.QueryContext(ctx, getPlaylistTracks, playlistID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetPlaylistTracksRow
+ for rows.Next() {
+ var i GetPlaylistTracksRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.PlaylistID,
+ &i.AudioFileID,
+ &i.Position,
+ &i.FilePath,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const removePlaylistTrack = `-- name: RemovePlaylistTrack :exec
+DELETE FROM playlist_tracks WHERE id = ?
+`
+
+func (q *Queries) RemovePlaylistTrack(ctx context.Context, id int64) error {
+ _, err := q.db.ExecContext(ctx, removePlaylistTrack, id)
+ return err
+}
+
+const updatePlaylistName = `-- name: UpdatePlaylistName :exec
+UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
+`
+
+type UpdatePlaylistNameParams struct {
+ Name string
+ ID int64
+}
+
+func (q *Queries) UpdatePlaylistName(ctx context.Context, arg UpdatePlaylistNameParams) error {
+ _, err := q.db.ExecContext(ctx, updatePlaylistName, arg.Name, arg.ID)
+ return err
+}
diff --git a/backend/database/sql/sqlcgen/queue.sql.go b/backend/database/sql/sqlcgen/queue.sql.go
new file mode 100644
index 0000000..e718b34
--- /dev/null
+++ b/backend/database/sql/sqlcgen/queue.sql.go
@@ -0,0 +1,200 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: queue.sql
+
+package sqlcgen
+
+import (
+ "context"
+ "database/sql"
+)
+
+const clearQueueTracks = `-- name: ClearQueueTracks :exec
+DELETE FROM queue_tracks
+`
+
+func (q *Queries) ClearQueueTracks(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, clearQueueTracks)
+ return err
+}
+
+const getQueueState = `-- name: GetQueueState :one
+SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
+FROM queue WHERE id = 1
+`
+
+type GetQueueStateRow struct {
+ SourcePlaylistID sql.NullInt64
+ CurrentPosition int64
+ ShuffleMode bool
+ RepeatMode string
+ ShuffleOrder sql.NullString
+}
+
+func (q *Queries) GetQueueState(ctx context.Context) (GetQueueStateRow, error) {
+ row := q.db.QueryRowContext(ctx, getQueueState)
+ var i GetQueueStateRow
+ err := row.Scan(
+ &i.SourcePlaylistID,
+ &i.CurrentPosition,
+ &i.ShuffleMode,
+ &i.RepeatMode,
+ &i.ShuffleOrder,
+ )
+ return i, err
+}
+
+const getQueueTrackCount = `-- name: GetQueueTrackCount :one
+SELECT count(*) FROM queue_tracks
+`
+
+func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) {
+ row := q.db.QueryRowContext(ctx, getQueueTrackCount)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const getQueueTracks = `-- name: GetQueueTracks :many
+SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
+ COALESCE(r.name, '') AS title,
+ COALESCE(ac.text, '') AS artist
+FROM queue_tracks qt
+JOIN audio_files af ON qt.audio_file_id = af.id
+LEFT JOIN recordings r ON af.recording_id = r.id
+LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
+ORDER BY qt.position
+`
+
+type GetQueueTracksRow struct {
+ ID int64
+ AudioFileID int64
+ Position int64
+ FilePath string
+ Title string
+ Artist string
+}
+
+func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
+ rows, err := q.db.QueryContext(ctx, getQueueTracks)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetQueueTracksRow
+ for rows.Next() {
+ var i GetQueueTracksRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.AudioFileID,
+ &i.Position,
+ &i.FilePath,
+ &i.Title,
+ &i.Artist,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const insertQueueTrack = `-- name: InsertQueueTrack :one
+INSERT INTO queue_tracks (audio_file_id, position) VALUES (?, ?)
+RETURNING id, audio_file_id, position
+`
+
+type InsertQueueTrackParams struct {
+ AudioFileID int64
+ Position int64
+}
+
+func (q *Queries) InsertQueueTrack(ctx context.Context, arg InsertQueueTrackParams) (QueueTrack, error) {
+ row := q.db.QueryRowContext(ctx, insertQueueTrack, arg.AudioFileID, arg.Position)
+ var i QueueTrack
+ err := row.Scan(&i.ID, &i.AudioFileID, &i.Position)
+ return i, err
+}
+
+const removeQueueTrack = `-- name: RemoveQueueTrack :exec
+DELETE FROM queue_tracks WHERE id = ?
+`
+
+func (q *Queries) RemoveQueueTrack(ctx context.Context, id int64) error {
+ _, err := q.db.ExecContext(ctx, removeQueueTrack, id)
+ return err
+}
+
+const removeQueueTrackByPosition = `-- name: RemoveQueueTrackByPosition :exec
+DELETE FROM queue_tracks WHERE position = ?
+`
+
+func (q *Queries) RemoveQueueTrackByPosition(ctx context.Context, position int64) error {
+ _, err := q.db.ExecContext(ctx, removeQueueTrackByPosition, position)
+ return err
+}
+
+const shiftQueuePositionsDown = `-- name: ShiftQueuePositionsDown :exec
+UPDATE queue_tracks
+SET position = position - 1
+WHERE position > ?
+`
+
+func (q *Queries) ShiftQueuePositionsDown(ctx context.Context, position int64) error {
+ _, err := q.db.ExecContext(ctx, shiftQueuePositionsDown, position)
+ return err
+}
+
+const shiftQueuePositionsUp = `-- name: ShiftQueuePositionsUp :exec
+UPDATE queue_tracks
+SET position = position + 1
+WHERE position >= ?
+`
+
+func (q *Queries) ShiftQueuePositionsUp(ctx context.Context, position int64) error {
+ _, err := q.db.ExecContext(ctx, shiftQueuePositionsUp, position)
+ return err
+}
+
+const updateQueuePosition = `-- name: UpdateQueuePosition :exec
+UPDATE queue
+SET current_position = ?
+WHERE id = 1
+`
+
+func (q *Queries) UpdateQueuePosition(ctx context.Context, currentPosition int64) error {
+ _, err := q.db.ExecContext(ctx, updateQueuePosition, currentPosition)
+ return err
+}
+
+const updateQueueState = `-- name: UpdateQueueState :exec
+UPDATE queue
+SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
+WHERE id = 1
+`
+
+type UpdateQueueStateParams struct {
+ SourcePlaylistID sql.NullInt64
+ CurrentPosition int64
+ ShuffleMode bool
+ RepeatMode string
+ ShuffleOrder sql.NullString
+}
+
+func (q *Queries) UpdateQueueState(ctx context.Context, arg UpdateQueueStateParams) error {
+ _, err := q.db.ExecContext(ctx, updateQueueState,
+ arg.SourcePlaylistID,
+ arg.CurrentPosition,
+ arg.ShuffleMode,
+ arg.RepeatMode,
+ arg.ShuffleOrder,
+ )
+ return err
+}
diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go
index 21cdfbb..8cc7f40 100644
--- a/backend/database/sql/sqlcgen/recordings.sql.go
+++ b/backend/database/sql/sqlcgen/recordings.sql.go
@@ -1,29 +1,94 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: recordings.sql
package sqlcgen
import (
"context"
+ "database/sql"
)
const createRecording = `-- name: CreateRecording :one
-INSERT INTO recordings (name) VALUES (?)
-RETURNING id, name, artist_credit_id
+INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
+RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment
`
-func (q *Queries) CreateRecording(ctx context.Context, name string) (Recording, error) {
- row := q.db.QueryRowContext(ctx, createRecording, name)
+type CreateRecordingParams struct {
+ Name string
+ ArtistCreditID int64
+}
+
+func (q *Queries) CreateRecording(ctx context.Context, arg CreateRecordingParams) (Recording, error) {
+ row := q.db.QueryRowContext(ctx, createRecording, arg.Name, arg.ArtistCreditID)
var i Recording
- err := row.Scan(&i.ID, &i.Name, &i.ArtistCreditID)
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ArtistCreditID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ &i.Year,
+ &i.Genre,
+ &i.Composer,
+ &i.Lyrics,
+ &i.Comment,
+ )
+ return i, err
+}
+
+const createRecordingFull = `-- name: CreateRecordingFull :one
+INSERT INTO recordings (
+ name, artist_credit_id, track_number, disc_number,
+ year, genre, composer, lyrics, comment
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment
+`
+
+type CreateRecordingFullParams struct {
+ Name string
+ ArtistCreditID int64
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
+ Year sql.NullInt64
+ Genre sql.NullString
+ Composer sql.NullString
+ Lyrics sql.NullString
+ Comment sql.NullString
+}
+
+func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFullParams) (Recording, error) {
+ row := q.db.QueryRowContext(ctx, createRecordingFull,
+ arg.Name,
+ arg.ArtistCreditID,
+ arg.TrackNumber,
+ arg.DiscNumber,
+ arg.Year,
+ arg.Genre,
+ arg.Composer,
+ arg.Lyrics,
+ arg.Comment,
+ )
+ var i Recording
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ArtistCreditID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ &i.Year,
+ &i.Genre,
+ &i.Composer,
+ &i.Lyrics,
+ &i.Comment,
+ )
return i, err
}
const deleteRecording = `-- name: DeleteRecording :exec
DELETE FROM recordings
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
@@ -31,30 +96,117 @@ func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
return err
}
+const getAllRecordings = `-- name: GetAllRecordings :many
+SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings
+ORDER BY name
+`
+
+func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) {
+ rows, err := q.db.QueryContext(ctx, getAllRecordings)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Recording
+ for rows.Next() {
+ var i Recording
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ArtistCreditID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ &i.Year,
+ &i.Genre,
+ &i.Composer,
+ &i.Lyrics,
+ &i.Comment,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getRecording = `-- name: GetRecording :one
-SELECT id, name, artist_credit_id FROM recordings
+SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings
WHERE id = ? LIMIT 1
`
func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) {
row := q.db.QueryRowContext(ctx, getRecording, id)
var i Recording
- err := row.Scan(&i.ID, &i.Name, &i.ArtistCreditID)
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ArtistCreditID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ &i.Year,
+ &i.Genre,
+ &i.Composer,
+ &i.Lyrics,
+ &i.Comment,
+ )
return i, err
}
const updateRecording = `-- name: UpdateRecording :exec
UPDATE recordings
-SET name = ?
-WHERE id =?
+SET name = ?, artist_credit_id = ?
+WHERE id = ?
`
type UpdateRecordingParams struct {
- Name string
- ID int64
+ Name string
+ ArtistCreditID int64
+ ID int64
}
func (q *Queries) UpdateRecording(ctx context.Context, arg UpdateRecordingParams) error {
- _, err := q.db.ExecContext(ctx, updateRecording, arg.Name, arg.ID)
+ _, err := q.db.ExecContext(ctx, updateRecording, arg.Name, arg.ArtistCreditID, arg.ID)
+ return err
+}
+
+const updateRecordingFull = `-- name: UpdateRecordingFull :exec
+UPDATE recordings
+SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
+ year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
+WHERE id = ?
+`
+
+type UpdateRecordingFullParams struct {
+ Name string
+ ArtistCreditID int64
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
+ Year sql.NullInt64
+ Genre sql.NullString
+ Composer sql.NullString
+ Lyrics sql.NullString
+ Comment sql.NullString
+ ID int64
+}
+
+func (q *Queries) UpdateRecordingFull(ctx context.Context, arg UpdateRecordingFullParams) error {
+ _, err := q.db.ExecContext(ctx, updateRecordingFull,
+ arg.Name,
+ arg.ArtistCreditID,
+ arg.TrackNumber,
+ arg.DiscNumber,
+ arg.Year,
+ arg.Genre,
+ arg.Composer,
+ arg.Lyrics,
+ arg.Comment,
+ arg.ID,
+ )
return err
}
diff --git a/backend/database/sql/sqlcgen/release_group_recordings.sql.go b/backend/database/sql/sqlcgen/release_group_recordings.sql.go
index 2c99cf5..5ab1dda 100644
--- a/backend/database/sql/sqlcgen/release_group_recordings.sql.go
+++ b/backend/database/sql/sqlcgen/release_group_recordings.sql.go
@@ -1,34 +1,49 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: release_group_recordings.sql
package sqlcgen
import (
"context"
+ "database/sql"
)
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
-INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?,?)
-RETURNING id, release_group_id, recording_id
+INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
+VALUES (?, ?, ?, ?)
+RETURNING id, release_group_id, recording_id, track_number, disc_number
`
type CreateReleaseGroupRecordingParams struct {
ReleaseGroupID int64
RecordingID int64
+ TrackNumber sql.NullInt64
+ DiscNumber sql.NullInt64
}
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
- row := q.db.QueryRowContext(ctx, createReleaseGroupRecording, arg.ReleaseGroupID, arg.RecordingID)
+ row := q.db.QueryRowContext(ctx, createReleaseGroupRecording,
+ arg.ReleaseGroupID,
+ arg.RecordingID,
+ arg.TrackNumber,
+ arg.DiscNumber,
+ )
var i ReleaseGroupRecording
- err := row.Scan(&i.ID, &i.ReleaseGroupID, &i.RecordingID)
+ err := row.Scan(
+ &i.ID,
+ &i.ReleaseGroupID,
+ &i.RecordingID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ )
return i, err
}
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) error {
@@ -36,31 +51,104 @@ func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) err
return err
}
+const deleteReleaseGroupRecordingByFK = `-- name: DeleteReleaseGroupRecordingByFK :exec
+DELETE FROM release_group_recordings
+WHERE release_group_id = ? AND recording_id = ?
+`
+
+type DeleteReleaseGroupRecordingByFKParams struct {
+ ReleaseGroupID int64
+ RecordingID int64
+}
+
+func (q *Queries) DeleteReleaseGroupRecordingByFK(ctx context.Context, arg DeleteReleaseGroupRecordingByFKParams) error {
+ _, err := q.db.ExecContext(ctx, deleteReleaseGroupRecordingByFK, arg.ReleaseGroupID, arg.RecordingID)
+ return err
+}
+
+const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
+SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
+WHERE recording_id = ?
+`
+
+func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int64) ([]ReleaseGroupRecording, error) {
+ rows, err := q.db.QueryContext(ctx, getRecordingReleaseGroups, recordingID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ReleaseGroupRecording
+ for rows.Next() {
+ var i ReleaseGroupRecording
+ if err := rows.Scan(
+ &i.ID,
+ &i.ReleaseGroupID,
+ &i.RecordingID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getReleaseGroupRecording = `-- name: GetReleaseGroupRecording :one
-SELECT id, release_group_id, recording_id FROM release_group_recordings
+SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
WHERE id = ? LIMIT 1
`
func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (ReleaseGroupRecording, error) {
row := q.db.QueryRowContext(ctx, getReleaseGroupRecording, id)
var i ReleaseGroupRecording
- err := row.Scan(&i.ID, &i.ReleaseGroupID, &i.RecordingID)
+ err := row.Scan(
+ &i.ID,
+ &i.ReleaseGroupID,
+ &i.RecordingID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ )
return i, err
}
-const updateReleaseGroupRecording = `-- name: UpdateReleaseGroupRecording :exec
-UPDATE release_group_recordings
-SET release_group_id = ?, recording_id = ?
-WHERE id =?
+const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
+SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
+WHERE release_group_id = ?
+ORDER BY disc_number, track_number
`
-type UpdateReleaseGroupRecordingParams struct {
- ReleaseGroupID int64
- RecordingID int64
- ID int64
-}
-
-func (q *Queries) UpdateReleaseGroupRecording(ctx context.Context, arg UpdateReleaseGroupRecordingParams) error {
- _, err := q.db.ExecContext(ctx, updateReleaseGroupRecording, arg.ReleaseGroupID, arg.RecordingID, arg.ID)
- return err
+func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) ([]ReleaseGroupRecording, error) {
+ rows, err := q.db.QueryContext(ctx, getReleaseGroupRecordings, releaseGroupID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ReleaseGroupRecording
+ for rows.Next() {
+ var i ReleaseGroupRecording
+ if err := rows.Scan(
+ &i.ID,
+ &i.ReleaseGroupID,
+ &i.RecordingID,
+ &i.TrackNumber,
+ &i.DiscNumber,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
}
diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go
index 91f1d65..1363e23 100644
--- a/backend/database/sql/sqlcgen/release_groups.sql.go
+++ b/backend/database/sql/sqlcgen/release_groups.sql.go
@@ -1,29 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.29.0
// source: release_groups.sql
package sqlcgen
import (
"context"
+ "database/sql"
)
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
-RETURNING id, name, cover_art_id
+RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, createReleaseGroup, name)
var i ReleaseGroup
- err := row.Scan(&i.ID, &i.Name, &i.CoverArtID)
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ )
+ return i, err
+}
+
+const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
+INSERT INTO release_groups (
+ name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
+) VALUES (?, ?, ?, ?, ?, ?)
+RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
+`
+
+type CreateReleaseGroupFullParams struct {
+ Name string
+ CoverArtID sql.NullInt64
+ AlbumArtistCreditID sql.NullInt64
+ Year sql.NullInt64
+ TotalTracks sql.NullInt64
+ TotalDiscs sql.NullInt64
+}
+
+func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseGroupFullParams) (ReleaseGroup, error) {
+ row := q.db.QueryRowContext(ctx, createReleaseGroupFull,
+ arg.Name,
+ arg.CoverArtID,
+ arg.AlbumArtistCreditID,
+ arg.Year,
+ arg.TotalTracks,
+ arg.TotalDiscs,
+ )
+ var i ReleaseGroup
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ )
return i, err
}
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
-WHERE id =?
+WHERE id = ?
`
func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
@@ -31,22 +78,136 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
return err
}
+const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
+SELECT
+ rg.id,
+ rg.name,
+ rg.year,
+ COALESCE(ac.text, '') as artist_name,
+ COALESCE(ca.file_path, '') as cover_art_path
+FROM release_groups rg
+LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
+LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
+ORDER BY rg.name
+`
+
+type GetAllAlbumsWithDetailsRow struct {
+ ID int64
+ Name string
+ Year sql.NullInt64
+ ArtistName string
+ CoverArtPath string
+}
+
+func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWithDetailsRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllAlbumsWithDetails)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllAlbumsWithDetailsRow
+ for rows.Next() {
+ var i GetAllAlbumsWithDetailsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Year,
+ &i.ArtistName,
+ &i.CoverArtPath,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
+SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
+ORDER BY name
+`
+
+func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, error) {
+ rows, err := q.db.QueryContext(ctx, getAllReleaseGroups)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ReleaseGroup
+ for rows.Next() {
+ var i ReleaseGroup
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getReleaseGroup = `-- name: GetReleaseGroup :one
-SELECT id, name, cover_art_id FROM release_groups
+SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
WHERE id = ? LIMIT 1
`
func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, getReleaseGroup, id)
var i ReleaseGroup
- err := row.Scan(&i.ID, &i.Name, &i.CoverArtID)
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ )
+ return i, err
+}
+
+const getReleaseGroupByName = `-- name: GetReleaseGroupByName :one
+SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
+WHERE name = ? LIMIT 1
+`
+
+func (q *Queries) GetReleaseGroupByName(ctx context.Context, name string) (ReleaseGroup, error) {
+ row := q.db.QueryRowContext(ctx, getReleaseGroupByName, name)
+ var i ReleaseGroup
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ )
return i, err
}
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
-WHERE id =?
+WHERE id = ?
`
type UpdateReleaseGroupParams struct {
@@ -58,3 +219,49 @@ func (q *Queries) UpdateReleaseGroup(ctx context.Context, arg UpdateReleaseGroup
_, err := q.db.ExecContext(ctx, updateReleaseGroup, arg.Name, arg.ID)
return err
}
+
+const updateReleaseGroupCoverArt = `-- name: UpdateReleaseGroupCoverArt :exec
+UPDATE release_groups
+SET cover_art_id = ?
+WHERE id = ?
+`
+
+type UpdateReleaseGroupCoverArtParams struct {
+ CoverArtID sql.NullInt64
+ ID int64
+}
+
+func (q *Queries) UpdateReleaseGroupCoverArt(ctx context.Context, arg UpdateReleaseGroupCoverArtParams) error {
+ _, err := q.db.ExecContext(ctx, updateReleaseGroupCoverArt, arg.CoverArtID, arg.ID)
+ return err
+}
+
+const upsertReleaseGroup = `-- name: UpsertReleaseGroup :one
+INSERT INTO release_groups (name, album_artist_credit_id, year)
+VALUES (?, ?, ?)
+ON CONFLICT(name) DO UPDATE SET
+ album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
+ year = COALESCE(excluded.year, release_groups.year)
+RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
+`
+
+type UpsertReleaseGroupParams struct {
+ Name string
+ AlbumArtistCreditID sql.NullInt64
+ Year sql.NullInt64
+}
+
+func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroupParams) (ReleaseGroup, error) {
+ row := q.db.QueryRowContext(ctx, upsertReleaseGroup, arg.Name, arg.AlbumArtistCreditID, arg.Year)
+ var i ReleaseGroup
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.CoverArtID,
+ &i.AlbumArtistCreditID,
+ &i.Year,
+ &i.TotalTracks,
+ &i.TotalDiscs,
+ )
+ return i, err
+}
diff --git a/backend/events/events.go b/backend/events/events.go
new file mode 100644
index 0000000..3c6d7b5
--- /dev/null
+++ b/backend/events/events.go
@@ -0,0 +1,50 @@
+// Package events contains centralized event name constants for
+// Wails frontend/backend communication. These names must match
+// the corresponding event names in the TypeScript frontend.
+package events
+
+// Playback control events.
+const (
+ PlaybackStateChanged = "PlaybackStateChanged"
+ PlaybackFinished = "PlaybackFinished"
+ RequestPlay = "RequestPlay"
+ RequestPause = "RequestPause"
+ RequestLoadFile = "RequestLoadFile"
+)
+
+// Track events.
+const (
+ TrackChanged = "TrackChanged"
+)
+
+// Seek events.
+const (
+ Seek = "Seek"
+ SeekFailed = "SeekFailed"
+)
+
+// Volume events.
+const (
+ RequestSetVolume = "RequestSetVolume"
+ VolumeChanged = "VolumeChanged"
+)
+
+// Queue events.
+const (
+ QueueChanged = "QueueChanged"
+ RequestNext = "RequestNext"
+ RequestPrevious = "RequestPrevious"
+ RequestSetQueue = "RequestSetQueue"
+ RequestAddToQueue = "RequestAddToQueue"
+ RequestPlayNext = "RequestPlayNext"
+ RequestRemoveFromQueue = "RequestRemoveFromQueue"
+ RequestToggleShuffle = "RequestToggleShuffle"
+ RequestCycleRepeat = "RequestCycleRepeat"
+ RequestAddTracksToQueue = "RequestAddTracksToQueue"
+ RequestPlayTracksNext = "RequestPlayTracksNext"
+)
+
+// Config events.
+const (
+ LibraryConfigChanged = "LibraryConfigChanged"
+)
diff --git a/backend/frontendbindings/frontendbindings.go b/backend/frontendbindings/frontendbindings.go
deleted file mode 100644
index b152358..0000000
--- a/backend/frontendbindings/frontendbindings.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package frontendbindings
-
-import (
- "context"
- "fmt"
-
- "github.com/wailsapp/wails/v2/pkg/runtime"
-)
-
-// FrontendBindings contain any Go functions that are specific to the frontend only and need to be bound
-type FrontendBindings struct {
- ctx context.Context
-}
-
-func NewFrontendBindings() (*FrontendBindings, error) {
- return &FrontendBindings{}, nil
-}
-
-func (fe *FrontendBindings) Init(ctx context.Context) error {
- fe.ctx = ctx
- return nil
-}
-
-// Open a directory picker
-func (fe *FrontendBindings) DirectoryPicker() (string, error) {
- runtime.LogInfo(fe.ctx, "selecting a directory")
- dir, err := runtime.OpenDirectoryDialog(
- fe.ctx,
- runtime.OpenDialogOptions{})
- if err != nil {
- return "", fmt.Errorf("could not open directory dialog\n%w", err)
- }
- if dir == "" {
- return "No Library Directory Selected", nil
- }
-
- return dir, nil
-}
diff --git a/backend/frontendutil/frontendutil.go b/backend/frontendutil/frontendutil.go
new file mode 100644
index 0000000..e06fce1
--- /dev/null
+++ b/backend/frontendutil/frontendutil.go
@@ -0,0 +1,38 @@
+// Package frontendutil provides Go functions bound to the frontend.
+package frontendutil
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// FrontendUtil provides frontend-bound Go functions.
+type FrontendUtil struct {
+ ctx context.Context
+}
+
+// NewFrontendUtil creates a new FrontendUtil instance.
+func NewFrontendUtil() (*FrontendUtil, error) {
+ return &FrontendUtil{}, nil
+}
+
+// SetContext sets the Wails runtime context.
+func (fe *FrontendUtil) SetContext(ctx context.Context) {
+ fe.ctx = ctx
+}
+
+// DirectoryPicker opens a directory selection dialog.
+func (fe *FrontendUtil) DirectoryPicker() (string, error) {
+ runtime.LogInfo(fe.ctx, "selecting a directory")
+
+ dir, err := runtime.OpenDirectoryDialog(
+ fe.ctx,
+ runtime.OpenDialogOptions{})
+ if err != nil {
+ return "", fmt.Errorf("could not open directory dialog\n%w", err)
+ }
+
+ return dir, nil
+}
diff --git a/backend/library/config.go b/backend/library/config.go
new file mode 100644
index 0000000..54aaa71
--- /dev/null
+++ b/backend/library/config.go
@@ -0,0 +1,43 @@
+// Package library manages the music library and its configuration.
+package library
+
+import (
+ "fmt"
+ "os"
+)
+
+// Config holds Library config data.
+type Config struct {
+ DirectoryPath Directory `form:"Directory" schema:"directory,required"`
+}
+
+// Directory represents a filesystem path to a music directory.
+type Directory string
+
+// NewConfig creates a validated library configuration.
+func NewConfig(dir string) (*Config, error) {
+ config := &Config{
+ DirectoryPath: Directory(dir),
+ }
+ if err := config.Validate(); err != nil {
+ return nil, fmt.Errorf("validation error for new library config: %w", err)
+ }
+
+ return config, nil
+}
+
+// Validate checks that the configured directory exists.
+func (c *Config) Validate() error {
+ if len(c.DirectoryPath) != 0 {
+ dirInfo, err := os.Stat(string(c.DirectoryPath))
+ if err != nil {
+ return fmt.Errorf("problem getting info on library dir (%s): %w", c.DirectoryPath, err)
+ }
+
+ if !dirInfo.IsDir() {
+ return fmt.Errorf("%s is not a directory", c.DirectoryPath)
+ }
+ }
+
+ return nil
+}
diff --git a/backend/library/config.templ b/backend/library/config.templ
new file mode 100644
index 0000000..f772558
--- /dev/null
+++ b/backend/library/config.templ
@@ -0,0 +1,39 @@
+package library
+
+templ (d Directory) ToFormElement() {
+
+
+
+
+}
diff --git a/backend/library/config_templ.go b/backend/library/config_templ.go
new file mode 100644
index 0000000..a98ef2c
--- /dev/null
+++ b/backend/library/config_templ.go
@@ -0,0 +1,53 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.865
+package library
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func (d Directory) ToFormElement() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/backend/library/coverart.go b/backend/library/coverart.go
new file mode 100644
index 0000000..c2b62dd
--- /dev/null
+++ b/backend/library/coverart.go
@@ -0,0 +1,80 @@
+package library
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "yellowjacket/backend/metadata"
+ "yellowjacket/backend/system"
+)
+
+// saveCoverArt saves embedded cover art to the cache directory.
+// Returns the file path where the art was saved, or empty string if no picture data.
+func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
+ if pic == nil || len(pic.Data) == 0 {
+ return "", nil
+ }
+
+ // Get the data directory for storing cover art
+ dataDir, err := system.GetUserDataDirPath()
+ if err != nil {
+ return "", fmt.Errorf("could not get user data directory: %w", err)
+ }
+
+ coverDir := filepath.Join(dataDir, "covers")
+
+ // Ensure directory exists
+ if err := os.MkdirAll(coverDir, 0o755); err != nil {
+ return "", fmt.Errorf("could not create covers directory: %w", err)
+ }
+
+ // Generate filename from content hash (deduplication)
+ hash := sha256.Sum256(pic.Data)
+ hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars
+
+ ext := pic.Ext
+ if ext == "" {
+ // Determine extension from MIME type
+ ext = extensionFromMIME(pic.MIMEType)
+ }
+
+ filename := fmt.Sprintf("%s.%s", hashStr, ext)
+ filePath := filepath.Join(coverDir, filename)
+
+ // Skip if already exists (same content hash)
+ if _, err := os.Stat(filePath); err == nil {
+ l.logger.Debug("cover art already exists", "path", filePath)
+
+ return filePath, nil
+ }
+
+ // Write file
+ if err := os.WriteFile(filePath, pic.Data, 0o644); err != nil {
+ return "", fmt.Errorf("could not write cover art: %w", err)
+ }
+
+ l.logger.Debug("saved cover art", "path", filePath, "size", len(pic.Data))
+
+ return filePath, nil
+}
+
+// extensionFromMIME returns a file extension for common image MIME types.
+func extensionFromMIME(mimeType string) string {
+ switch mimeType {
+ case "image/jpeg":
+ return "jpg"
+ case "image/png":
+ return "png"
+ case "image/gif":
+ return "gif"
+ case "image/webp":
+ return "webp"
+ case "image/bmp":
+ return "bmp"
+ default:
+ return "jpg" // Default to jpg
+ }
+}
diff --git a/backend/library/coverart_handler.go b/backend/library/coverart_handler.go
new file mode 100644
index 0000000..2ab3e89
--- /dev/null
+++ b/backend/library/coverart_handler.go
@@ -0,0 +1,42 @@
+package library
+
+import (
+ "fmt"
+ "net/http"
+ "path/filepath"
+
+ "yellowjacket/backend/system"
+)
+
+// CoverArtHandler serves cover art images via HTTP.
+type CoverArtHandler struct {
+ coversDir string
+}
+
+// NewCoverArtHandler creates a handler that serves cover art from the user data directory.
+func NewCoverArtHandler() (*CoverArtHandler, error) {
+ dataDir, err := system.GetUserDataDirPath()
+ if err != nil {
+ return nil, fmt.Errorf("could not get user data directory: %w", err)
+ }
+
+ return &CoverArtHandler{
+ coversDir: filepath.Join(dataDir, "covers"),
+ }, nil
+}
+
+// ServeHTTP handles requests for cover art images.
+func (h *CoverArtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Extract filename from path like "/covers/abc123.jpg"
+ filename := filepath.Base(r.URL.Path)
+
+ // Prevent directory traversal
+ if filename == "." || filename == ".." {
+ http.NotFound(w, r)
+
+ return
+ }
+
+ filePath := filepath.Join(h.coversDir, filename)
+ http.ServeFile(w, r, filePath)
+}
diff --git a/backend/library/library.go b/backend/library/library.go
index e147906..b464c00 100644
--- a/backend/library/library.go
+++ b/backend/library/library.go
@@ -2,62 +2,640 @@ package library
import (
"context"
+ "database/sql"
+ "errors"
"fmt"
+ "io/fs"
+ "log/slog"
"os"
+ "path/filepath"
+ goruntime "runtime"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+ "golang.org/x/sync/errgroup"
+ "yellowjacket/backend/database"
+ "yellowjacket/backend/database/sql/sqlcgen"
+ "yellowjacket/backend/events"
+ "yellowjacket/backend/metadata"
)
-type Config struct {
- DirectoryPath string
- SaveFunc func() error `toml:"-"`
-}
-
-func (c *Config) Validate() error {
- return nil
-}
-
-var DefaultConfig *Config = &Config{
- DirectoryPath: "",
-}
-
+// Library manages scanning and querying the music collection.
type Library struct {
- ctx context.Context
- conf *Config
+ ctx context.Context
+ logger *slog.Logger
+ conf *Config
+ db *database.DB
}
-func NewLibrary(conf *Config) (*Library, error) {
+// NewLibrary creates a new library with the given configuration.
+func NewLibrary(
+ ctx context.Context,
+ logger *slog.Logger,
+ conf *Config,
+ db *database.DB,
+) (*Library, error) {
if conf == nil {
- return nil, fmt.Errorf("nil config for library")
+ return nil, errors.New("nil config for library")
}
+
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
}
- return &Library{
- conf: conf,
- }, nil
+
+ library := &Library{
+ ctx: ctx,
+ logger: logger,
+ conf: conf,
+ db: db,
+ }
+
+ return library, nil
}
-func (l *Library) Init(ctx context.Context) error {
+// SetContext sets the Wails runtime context and registers event handlers.
+func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
+ l.registerEventHandlers()
+}
+
+func (l *Library) registerEventHandlers() {
+ if l.ctx == nil {
+ l.logger.Error("Context is nil, cannot register event handlers")
+
+ return
+ }
+
+ runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
+ l.logger.Info("Received LibraryConfigChanged event")
+
+ if len(data) == 0 {
+ l.logger.Error("LibraryConfigChanged event received with no data")
+
+ return
+ }
+
+ configMap, ok := data[0].(map[string]any)
+ if !ok {
+ l.logger.Error("LibraryConfigChanged event data is not a map", "data", data[0])
+
+ return
+ }
+
+ dir, ok := configMap["DirectoryPath"].(string)
+ if !ok {
+ l.logger.Error("DirectoryPath not found or not a string in config event")
+
+ return
+ }
+
+ updatedConfig := Config{DirectoryPath: Directory(dir)}
+ if err := l.handleConfigUpdate(updatedConfig); err != nil {
+ l.logger.Error("Failed to handle config update", "err", err)
+ }
+ })
+}
+
+// Scan syncs the library by adding new files and removing deleted ones.
+// Files that exist but have incomplete metadata (recording_id = 0) will be updated.
+func (l *Library) Scan() error {
+ l.logger.Info("beginning library scan", "workers", scanWorkerCount)
+
+ if len(l.conf.DirectoryPath) == 0 {
+ return errors.New("library directory not configured")
+ }
+
+ // Load existing file paths from the database into a sync.Map for concurrent access.
+ // The map tracks path → audioFile; entries are removed as files are "seen" during the walk.
+ // Any entries remaining after the walk are orphans (files deleted from disk).
+ existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx)
+ if err != nil {
+ return fmt.Errorf("could not load existing audio files: %w", err)
+ }
+
+ existingPaths := &sync.Map{}
+ for _, f := range existingFiles {
+ existingPaths.Store(f.FilePath, f)
+ }
+
+ l.logger.Debug(
+ "loaded existing files from database",
+ "count", len(existingFiles),
+ "library-directory", l.conf.DirectoryPath,
+ )
+
+ basePath := string(l.conf.DirectoryPath)
+ workChan := make(chan scanWork, 100)
+ resultChan := make(chan importResult, 100)
+
+ var added, skipped, updated atomic.Int64
+
+ var scanErr error
+
+ var errMu sync.Mutex
+
+ // Walker goroutine: traverse directory and send work items to workers
+ go func() {
+ defer close(workChan)
+
+ walkErr := fs.WalkDir(
+ os.DirFS(basePath),
+ ".",
+ func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ l.logger.Error("problem walking directory", "path", path, "err", err)
+
+ return nil // continue walking
+ }
+
+ if d.IsDir() {
+ return nil
+ }
+
+ absoluteFilePath := filepath.Join(basePath, path)
+ fileExt := filepath.Ext(d.Name())
+
+ fileType, isSupportedAudioFile := metadata.GetSupportedFileType(fileExt)
+ if !isSupportedAudioFile {
+ return nil
+ }
+
+ // Check if file already exists in database
+ if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
+ audioFile := existing.(sqlcgen.AudioFile)
+
+ // Check if this file needs metadata update (recording_id = 0)
+ if audioFile.RecordingID == 0 {
+ l.logger.Debug("file needs metadata update", "path", absoluteFilePath)
+
+ select {
+ case workChan <- scanWork{
+ absolutePath: absoluteFilePath,
+ fileType: fileType,
+ existingFileID: audioFile.ID,
+ needsUpdate: true,
+ existingLength: audioFile.LengthMilliseconds,
+ }:
+ case <-l.ctx.Done():
+ return l.ctx.Err()
+ }
+
+ return nil
+ }
+
+ l.logger.Debug(
+ "file already in library with metadata, skipping",
+ "path",
+ absoluteFilePath,
+ )
+ skipped.Add(1)
+
+ return nil
+ }
+
+ l.logger.Debug("queueing file for import", "path", absoluteFilePath)
+
+ // Send to workers for processing
+ select {
+ case workChan <- scanWork{absolutePath: absoluteFilePath, fileType: fileType}:
+ case <-l.ctx.Done():
+ return l.ctx.Err()
+ }
+
+ return nil
+ },
+ )
+
+ if walkErr != nil {
+ errMu.Lock()
+ scanErr = errors.Join(
+ scanErr,
+ fmt.Errorf("problem walking library directory: %w", walkErr),
+ )
+ errMu.Unlock()
+ }
+ }()
+
+ // DB writer goroutine: serialize all database writes to avoid SQLite contention
+ var dbWg sync.WaitGroup
+
+ dbWg.Add(1)
+
+ go func() {
+ defer dbWg.Done()
+
+ for result := range resultChan {
+ var saveErr error
+
+ if result.needsUpdate {
+ saveErr = l.updateAudioFileMetadata(result)
+ if saveErr == nil {
+ updated.Add(1)
+ }
+ } else {
+ saveErr = l.saveAudioFile(result)
+ if saveErr == nil {
+ added.Add(1)
+ }
+ }
+
+ if saveErr != nil {
+ l.logger.Warn(
+ "failed to save audio file",
+ "path",
+ result.absolutePath,
+ "err",
+ saveErr,
+ )
+
+ errMu.Lock()
+ scanErr = errors.Join(scanErr, saveErr)
+ errMu.Unlock()
+ }
+ }
+ }()
+
+ // Worker pool: extract metadata concurrently, send results to DB writer
+ g := new(errgroup.Group)
+ g.SetLimit(scanWorkerCount)
+
+ for work := range workChan {
+ g.Go(func() error {
+ result, err := l.extractAudioMetadata(work)
+ if err != nil {
+ l.logger.Warn("failed to extract metadata", "path", work.absolutePath, "err", err)
+
+ errMu.Lock()
+ scanErr = errors.Join(scanErr, err)
+ errMu.Unlock()
+
+ return nil // continue processing other files
+ }
+
+ // Send to DB writer
+ select {
+ case resultChan <- result:
+ case <-l.ctx.Done():
+ return l.ctx.Err()
+ }
+
+ return nil
+ })
+ }
+
+ _ = g.Wait() // Wait for all metadata extraction to complete
+
+ close(resultChan) // Signal DB writer to finish
+ dbWg.Wait() // Wait for all DB writes to complete
+
+ // Orphan cleanup: any entries remaining in existingPaths are files deleted from disk
+ var removed atomic.Int64
+
+ existingPaths.Range(func(key, value any) bool {
+ path := key.(string)
+ audioFile := value.(sqlcgen.AudioFile)
+
+ l.logger.Debug("removing orphaned database entry", "path", path, "id", audioFile.ID)
+
+ if err := l.db.Queries.DeleteAudioFile(l.ctx, audioFile.ID); err != nil {
+ l.logger.Warn(
+ "failed to delete orphaned audio file",
+ "path", path,
+ "id", audioFile.ID,
+ "err", err,
+ )
+
+ return true
+ }
+
+ removed.Add(1)
+
+ return true
+ })
+
+ l.logger.Info(
+ "library scan complete",
+ "added", added.Load(),
+ "updated", updated.Load(),
+ "removed", removed.Load(),
+ "skipped", skipped.Load(),
+ "library", l.conf.DirectoryPath,
+ )
+
+ return scanErr
+}
+
+// scanWorkerCount controls the number of concurrent file processors.
+// TODO: make configurable via Config.
+var scanWorkerCount = goruntime.NumCPU()
+
+// scanWork represents a file to be processed by a worker.
+type scanWork struct {
+ absolutePath string
+ fileType metadata.AudioFileExtension
+ existingFileID int64 // non-zero if this is an update
+ needsUpdate bool
+ existingLength int64 // existing length if updating
+}
+
+// importResult holds metadata extracted by workers, ready for DB insertion.
+type importResult struct {
+ absolutePath string
+ fileType metadata.AudioFileExtension
+ lengthMillis int64
+ tags *metadata.TrackMetadata
+ existingFileID int64 // non-zero if this is an update
+ needsUpdate bool
+}
+
+// extractAudioMetadata reads and extracts metadata from an audio file.
+func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
+ result := importResult{
+ absolutePath: work.absolutePath,
+ fileType: work.fileType,
+ existingFileID: work.existingFileID,
+ needsUpdate: work.needsUpdate,
+ }
+
+ // Get duration (skip if updating and we already have it)
+ if work.needsUpdate && work.existingLength > 0 {
+ result.lengthMillis = work.existingLength
+ } else {
+ trackLengthMillis, err := metadata.GetTrackLengthMillis(work.absolutePath)
+ if err != nil {
+ return result, fmt.Errorf(
+ "could not get track length for %s: %w",
+ work.absolutePath,
+ err,
+ )
+ }
+
+ result.lengthMillis = trackLengthMillis
+ }
+
+ // Extract tags
+ tags, err := metadata.ExtractTags(work.absolutePath)
+ if err != nil {
+ l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err)
+ // Continue with empty tags - not a fatal error
+ tags = &metadata.TrackMetadata{}
+ }
+
+ result.tags = tags
+
+ return result, nil
+}
+
+// saveAudioFile writes audio file metadata to the database (new files).
+func (l *Library) saveAudioFile(result importResult) error {
+ l.logger.Debug(
+ "saving audio file to db",
+ "absolute-path", result.absolutePath,
+ "track-length-millis", result.lengthMillis,
+ "file-type", int64(slices.Index(metadata.SupportedFileExtensions, result.fileType)),
+ )
+
+ // Process metadata and create related records
+ recordingID, err := l.processMetadata(result)
+ if err != nil {
+ return fmt.Errorf("could not process metadata: %w", err)
+ }
+
+ if _, err := l.db.Queries.CreateAudioFile(
+ l.ctx, sqlcgen.CreateAudioFileParams{
+ FilePath: result.absolutePath,
+ LengthMilliseconds: result.lengthMillis,
+ FileTypeID: int64(
+ slices.Index(metadata.SupportedFileExtensions, result.fileType),
+ ),
+ RecordingID: recordingID,
+ }); err != nil {
+ return fmt.Errorf("could not save audio file to db: %w", err)
+ }
+
+ l.logger.Debug("added audio file to library", "path", result.absolutePath)
+
return nil
}
-func (l *Library) GetDir() (string, error) {
- return l.conf.DirectoryPath, nil
-}
+// updateAudioFileMetadata updates an existing audio file with extracted metadata.
+func (l *Library) updateAudioFileMetadata(result importResult) error {
+ l.logger.Debug(
+ "updating audio file metadata",
+ "absolute-path", result.absolutePath,
+ "file-id", result.existingFileID,
+ )
-func (l *Library) SetDir(dirPath string) error {
- fileInfo, err := os.Stat(dirPath)
+ // Process metadata and create related records
+ recordingID, err := l.processMetadata(result)
if err != nil {
- return fmt.Errorf("could not stat %s: %w", dirPath, err)
- }
- if !fileInfo.IsDir() {
- return fmt.Errorf("dirPath is not a directory: %s", dirPath)
+ return fmt.Errorf("could not process metadata: %w", err)
}
- l.conf.DirectoryPath = dirPath
- err = l.conf.SaveFunc()
- if err != nil {
- return fmt.Errorf("could not save library dir config: %w", err)
+ if err := l.db.Queries.UpdateAudioFileRecording(
+ l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
+ RecordingID: recordingID,
+ ID: result.existingFileID,
+ }); err != nil {
+ return fmt.Errorf("could not update audio file recording: %w", err)
}
+
+ l.logger.Debug("updated audio file metadata", "path", result.absolutePath)
+
return nil
}
+
+// processMetadata creates all related database records for metadata and returns the recording ID.
+func (l *Library) processMetadata(result importResult) (int64, error) {
+ tags := result.tags
+ if tags == nil {
+ tags = &metadata.TrackMetadata{}
+ }
+
+ // 1. Handle cover art (if present)
+ var coverArtID sql.NullInt64
+
+ if tags.Picture != nil {
+ coverPath, err := l.saveCoverArt(tags.Picture)
+ if err != nil {
+ l.logger.Warn("could not save cover art", "err", err)
+ } else if coverPath != "" {
+ // Use upsert to avoid duplicates
+ ca, err := l.db.Queries.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{
+ IsEmbedded: true,
+ FilePath: coverPath,
+ MimeType: tags.Picture.MIMEType,
+ })
+ if err != nil {
+ l.logger.Warn("could not create cover art record", "err", err)
+ } else {
+ coverArtID = sql.NullInt64{Int64: ca.ID, Valid: true}
+ }
+ }
+ }
+
+ // 2. Get or create artist credit for track artist
+ artistName := tags.Artist
+ if artistName == "" {
+ artistName = "Unknown Artist"
+ }
+
+ artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
+ if err != nil {
+ return 0, fmt.Errorf("could not upsert artist credit: %w", err)
+ }
+
+ // Also create the artist record and link (best effort)
+ artist, err := l.db.Queries.UpsertArtist(l.ctx, artistName)
+ if err != nil {
+ l.logger.Warn("could not upsert artist", "err", err)
+ } else {
+ // Link artist to credit (ignore error if already linked)
+ _, _ = l.db.Queries.CreateArtistCreditArtist(l.ctx, sqlcgen.CreateArtistCreditArtistParams{
+ ArtistID: artist.ID,
+ CreditID: artistCredit.ID,
+ })
+ }
+
+ // 3. Get or create artist credit for album artist (if different)
+ var albumArtistCreditID sql.NullInt64
+
+ if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist {
+ albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, tags.AlbumArtist)
+ if err != nil {
+ l.logger.Warn("could not upsert album artist credit", "err", err)
+ } else {
+ albumArtistCreditID = sql.NullInt64{Int64: albumArtistCredit.ID, Valid: true}
+
+ // Also create the artist record and link
+ albumArtist, err := l.db.Queries.UpsertArtist(l.ctx, tags.AlbumArtist)
+ if err != nil {
+ l.logger.Warn("could not upsert album artist", "err", err)
+ } else {
+ _, _ = l.db.Queries.CreateArtistCreditArtist(
+ l.ctx,
+ sqlcgen.CreateArtistCreditArtistParams{
+ ArtistID: albumArtist.ID,
+ CreditID: albumArtistCredit.ID,
+ },
+ )
+ }
+ }
+ }
+
+ // 4. Get or create release group (album)
+ var releaseGroupID sql.NullInt64
+
+ if tags.Album != "" {
+ rg, err := l.db.Queries.UpsertReleaseGroup(l.ctx, sqlcgen.UpsertReleaseGroupParams{
+ Name: tags.Album,
+ AlbumArtistCreditID: albumArtistCreditID,
+ Year: toNullInt64(tags.Year),
+ })
+ if err != nil {
+ l.logger.Warn("could not upsert release group", "err", err)
+ } else {
+ releaseGroupID = sql.NullInt64{Int64: rg.ID, Valid: true}
+
+ // Update cover art if this album doesn't have one yet
+ if coverArtID.Valid && !rg.CoverArtID.Valid {
+ err := l.db.Queries.UpdateReleaseGroupCoverArt(
+ l.ctx,
+ sqlcgen.UpdateReleaseGroupCoverArtParams{
+ CoverArtID: coverArtID,
+ ID: rg.ID,
+ },
+ )
+ if err != nil {
+ l.logger.Warn("could not update release group cover art", "err", err)
+ }
+ }
+ }
+ }
+
+ // 5. Create recording
+ recording, err := l.db.Queries.CreateRecordingFull(l.ctx, sqlcgen.CreateRecordingFullParams{
+ Name: l.getRecordingName(tags, result.absolutePath),
+ ArtistCreditID: artistCredit.ID,
+ TrackNumber: toNullInt64(tags.TrackNumber),
+ DiscNumber: toNullInt64(tags.DiscNumber),
+ Year: toNullInt64(tags.Year),
+ Genre: toNullString(tags.Genre),
+ Composer: toNullString(tags.Composer),
+ Lyrics: toNullString(tags.Lyrics),
+ Comment: toNullString(tags.Comment),
+ })
+ if err != nil {
+ return 0, fmt.Errorf("could not create recording: %w", err)
+ }
+
+ // 6. Link recording to release group
+ if releaseGroupID.Valid {
+ _, err = l.db.Queries.CreateReleaseGroupRecording(
+ l.ctx,
+ sqlcgen.CreateReleaseGroupRecordingParams{
+ ReleaseGroupID: releaseGroupID.Int64,
+ RecordingID: recording.ID,
+ TrackNumber: toNullInt64(tags.TrackNumber),
+ DiscNumber: toNullInt64(tags.DiscNumber),
+ },
+ )
+ if err != nil {
+ l.logger.Warn("could not link recording to release group", "err", err)
+ }
+ }
+
+ return recording.ID, nil
+}
+
+// getRecordingName returns the track title, or falls back to the filename.
+func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
+ if tags.Title != "" {
+ return tags.Title
+ }
+ // Fallback to filename without extension
+ base := filepath.Base(filePath)
+
+ return strings.TrimSuffix(base, filepath.Ext(base))
+}
+
+// toNullInt64 converts an int to sql.NullInt64, treating 0 as null.
+func toNullInt64(v int) sql.NullInt64 {
+ if v == 0 {
+ return sql.NullInt64{}
+ }
+
+ return sql.NullInt64{Int64: int64(v), Valid: true}
+}
+
+// toNullString converts a string to sql.NullString, treating empty as null.
+func toNullString(v string) sql.NullString {
+ if v == "" {
+ return sql.NullString{}
+ }
+
+ return sql.NullString{String: v, Valid: true}
+}
+
+func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
+ l.logger.Info("handling config update", "updated", updatedConfigValues)
+
+ var updateErr error
+
+ if l.conf.DirectoryPath != updatedConfigValues.DirectoryPath {
+ l.logger.Info("new library, scanning")
+
+ l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
+ if err := l.Scan(); err != nil {
+ updateErr = errors.Join(
+ updateErr,
+ fmt.Errorf("problem scanning library on config update: %w", err),
+ )
+ }
+ }
+
+ return updateErr
+}
diff --git a/backend/library/query.go b/backend/library/query.go
new file mode 100644
index 0000000..c9069da
--- /dev/null
+++ b/backend/library/query.go
@@ -0,0 +1,121 @@
+package library
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strconv"
+)
+
+// Track represents a playable audio file in the library.
+type Track struct {
+ TrackName string
+ ArtistName string
+ TrackLength string
+ FilePath string
+}
+
+// Album represents an album for the cover grid display.
+type Album struct {
+ ID int64
+ Name string
+ ArtistName string
+ CoverArtPath string
+ Year int64
+}
+
+// GetAllTracks returns an array of track structs of every file in the library.
+func (l *Library) GetAllTracks() ([]Track, error) {
+ audioFiles, err := l.db.Queries.GetAllAudioFilesWithArtist(l.ctx)
+ if err != nil {
+ l.logger.Error("could not retrieve audio files", "error", err)
+
+ return nil, err
+ }
+
+ l.logger.Info("audio file list", "count", len(audioFiles))
+
+ if len(audioFiles) == 0 {
+ l.logger.Error("no tracks in library")
+
+ return nil, errors.New("no tracks in library")
+ }
+
+ var formattedTracks []Track
+
+ for _, file := range audioFiles {
+ track := Track{
+ TrackName: file.Title,
+ ArtistName: file.ArtistName,
+ TrackLength: strconv.FormatInt(file.LengthMilliseconds, 10),
+ FilePath: file.FilePath,
+ }
+ formattedTracks = append(formattedTracks, track)
+ }
+
+ l.logger.Info("formatted tracks", "count", len(formattedTracks))
+
+ return formattedTracks, nil
+}
+
+// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
+func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
+ rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
+ if err != nil {
+ l.logger.Error("could not retrieve album tracks", "albumID", albumID, "error", err)
+
+ return nil, fmt.Errorf("could not get album tracks: %w", err)
+ }
+
+ if len(rows) == 0 {
+ return nil, fmt.Errorf("no tracks found for album %d", albumID)
+ }
+
+ tracks := make([]Track, 0, len(rows))
+
+ for _, row := range rows {
+ tracks = append(tracks, Track{
+ TrackName: row.Title,
+ ArtistName: row.ArtistName,
+ TrackLength: strconv.FormatInt(row.LengthMilliseconds, 10),
+ FilePath: row.FilePath,
+ })
+ }
+
+ return tracks, nil
+}
+
+// GetAllAlbums returns all albums with cover art and artist info for the cover grid.
+func (l *Library) GetAllAlbums() ([]Album, error) {
+ rows, err := l.db.Queries.GetAllAlbumsWithDetails(l.ctx)
+ if err != nil {
+ l.logger.Error("could not retrieve albums", "error", err)
+
+ return nil, fmt.Errorf("could not get albums: %w", err)
+ }
+
+ l.logger.Info("album list", "count", len(rows))
+
+ albums := make([]Album, 0, len(rows))
+
+ for _, row := range rows {
+ album := Album{
+ ID: row.ID,
+ Name: row.Name,
+ ArtistName: row.ArtistName,
+ }
+
+ if row.Year.Valid {
+ album.Year = row.Year.Int64
+ }
+
+ // Convert filesystem path to URL path for the asset handler
+ if row.CoverArtPath != "" {
+ album.CoverArtPath = "/covers/" + filepath.Base(row.CoverArtPath)
+ }
+
+ albums = append(albums, album)
+ }
+
+ return albums, nil
+}
diff --git a/backend/logging/logging.go b/backend/logging/logging.go
index 23cb975..46bedaa 100644
--- a/backend/logging/logging.go
+++ b/backend/logging/logging.go
@@ -1,11 +1,94 @@
+// Package logging provides a slog-based logger adapter for Wails.
package logging
import (
- "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
)
-// TODO check that error
-func PrettyJSON(obj interface{}) string {
- bytes, _ := json.MarshalIndent(obj, "\t", "\t")
- return string(bytes)
+// Logger wraps slog to implement the Wails logger interface.
+type Logger struct {
+ slogger *slog.Logger
+ moduleFilters []string
+}
+
+// NewLogger creates a logger with optional message filters.
+func NewLogger(slogger *slog.Logger, filters []string) *Logger {
+ return &Logger{
+ slogger: slogger,
+ moduleFilters: filters,
+ }
+}
+
+// Print outputs a message if not filtered.
+func (l *Logger) Print(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ fmt.Printf("[Print] %s\n", message)
+}
+
+// Trace logs a trace-level message if not filtered.
+func (l *Logger) Trace(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Debug("[Trace] " + message)
+}
+
+// Debug logs a debug-level message if not filtered.
+func (l *Logger) Debug(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Debug(message)
+}
+
+// Info logs an info-level message if not filtered.
+func (l *Logger) Info(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Info(message)
+}
+
+// Warning logs a warning-level message if not filtered.
+func (l *Logger) Warning(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Warn(message)
+}
+
+func (l *Logger) Error(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Error(message)
+}
+
+// Fatal logs a fatal-level message if not filtered.
+func (l *Logger) Fatal(message string) {
+ if l.isFilteredOut(message) {
+ return
+ }
+
+ l.slogger.Error("[Trace] " + message)
+}
+
+func (l *Logger) isFilteredOut(message string) bool {
+ for _, f := range l.moduleFilters {
+ if strings.HasPrefix(message, fmt.Sprintf("[%s]", f)) {
+ return true
+ }
+ }
+
+ return false
}
diff --git a/backend/metadata/decoder.go b/backend/metadata/decoder.go
new file mode 100644
index 0000000..da7edb6
--- /dev/null
+++ b/backend/metadata/decoder.go
@@ -0,0 +1,36 @@
+// Package metadata handles audio file decoding and metadata extraction.
+package metadata
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/TheCodeOfCaleb/beep/v2"
+ "github.com/TheCodeOfCaleb/beep/v2/flac"
+ "github.com/TheCodeOfCaleb/beep/v2/mp3"
+ "github.com/TheCodeOfCaleb/beep/v2/vorbis"
+ "github.com/TheCodeOfCaleb/beep/v2/wav"
+)
+
+// ErrUnsupportedFileType is returned when the audio file type is not supported.
+var ErrUnsupportedFileType = errors.New("unsupported file type")
+
+// DecodeFile decodes an audio file into a stream seeker and format.
+func DecodeFile(f *os.File) (beep.StreamSeekCloser, beep.Format, error) {
+ ext := filepath.Ext(f.Name())
+
+ switch ext {
+ case ".mp3":
+ return mp3.Decode(f)
+ case ".flac":
+ return flac.Decode(f)
+ case ".ogg":
+ return vorbis.Decode(f)
+ case ".wav":
+ return wav.Decode(f)
+ default:
+ return nil, beep.Format{}, fmt.Errorf("%w: %s", ErrUnsupportedFileType, ext)
+ }
+}
diff --git a/backend/metadata/metadata.go b/backend/metadata/metadata.go
new file mode 100644
index 0000000..93088f2
--- /dev/null
+++ b/backend/metadata/metadata.go
@@ -0,0 +1,52 @@
+package metadata
+
+import (
+ "fmt"
+ "os"
+)
+
+// AudioFileExtension represents a supported audio file extension.
+type AudioFileExtension string
+
+// Supported audio file extensions.
+const (
+ MP3 AudioFileExtension = ".mp3"
+ FLAC AudioFileExtension = ".flac"
+ OGG AudioFileExtension = ".ogg"
+ WAV AudioFileExtension = ".wav"
+)
+
+// SupportedFileExtensions lists all supported audio formats.
+var SupportedFileExtensions = []AudioFileExtension{MP3, FLAC, OGG, WAV}
+
+// GetSupportedFileType checks if a file extension is supported.
+func GetSupportedFileType(ext string) (AudioFileExtension, bool) {
+ for _, supported := range SupportedFileExtensions {
+ if string(supported) == ext {
+ return supported, true
+ }
+ }
+
+ return "", false
+}
+
+// GetTrackLengthMillis returns the duration of an audio file in milliseconds.
+func GetTrackLengthMillis(path string) (int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return 0, fmt.Errorf("could not open file: %w", err)
+ }
+
+ streamer, format, err := DecodeFile(f)
+ if err != nil {
+ f.Close()
+
+ return 0, fmt.Errorf("error decoding file: %w", err)
+ }
+
+ lengthMillis := int64(float64(streamer.Len()*1000) / float64(format.SampleRate))
+ streamer.Close()
+ f.Close()
+
+ return lengthMillis, nil
+}
diff --git a/backend/metadata/tags.go b/backend/metadata/tags.go
new file mode 100644
index 0000000..c9cf895
--- /dev/null
+++ b/backend/metadata/tags.go
@@ -0,0 +1,102 @@
+package metadata
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/dhowden/tag"
+)
+
+// TrackMetadata holds all extracted tag data for an audio file.
+type TrackMetadata struct {
+ // Basic info
+ Title string
+ Artist string
+ Album string
+ AlbumArtist string
+ Composer string
+ Genre string
+ Year int
+
+ // Track position
+ TrackNumber int
+ TotalTracks int
+ DiscNumber int
+ TotalDiscs int
+
+ // Extended
+ Lyrics string
+ Comment string
+
+ // Cover art (if present)
+ Picture *PictureData
+
+ // Format info
+ TagFormat string // "ID3v2.3", "VORBIS", etc.
+ FileFormat string // "MP3", "FLAC", etc.
+}
+
+// PictureData holds embedded artwork.
+type PictureData struct {
+ Data []byte
+ MIMEType string
+ Ext string // "jpg", "png", etc.
+}
+
+// ExtractTags reads metadata tags from an audio file.
+func ExtractTags(path string) (*TrackMetadata, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("could not open file for tag extraction: %w", err)
+ }
+ defer f.Close()
+
+ return ExtractTagsFromReader(f)
+}
+
+// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
+func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
+ m, err := tag.ReadFrom(r)
+ if err != nil {
+ // No tags found is not necessarily an error - return empty metadata
+ if errors.Is(err, tag.ErrNoTagsFound) {
+ return &TrackMetadata{}, nil
+ }
+
+ return nil, fmt.Errorf("could not read tags: %w", err)
+ }
+
+ trackNum, totalTracks := m.Track()
+ discNum, totalDiscs := m.Disc()
+
+ meta := &TrackMetadata{
+ Title: m.Title(),
+ Artist: m.Artist(),
+ Album: m.Album(),
+ AlbumArtist: m.AlbumArtist(),
+ Composer: m.Composer(),
+ Genre: m.Genre(),
+ Year: m.Year(),
+ TrackNumber: trackNum,
+ TotalTracks: totalTracks,
+ DiscNumber: discNum,
+ TotalDiscs: totalDiscs,
+ Lyrics: m.Lyrics(),
+ Comment: m.Comment(),
+ TagFormat: string(m.Format()),
+ FileFormat: string(m.FileType()),
+ }
+
+ // Extract picture if present
+ if pic := m.Picture(); pic != nil {
+ meta.Picture = &PictureData{
+ Data: pic.Data,
+ MIMEType: pic.MIMEType,
+ Ext: pic.Ext,
+ }
+ }
+
+ return meta, nil
+}
diff --git a/backend/models/art.go b/backend/models/art.go
new file mode 100644
index 0000000..ae2e05d
--- /dev/null
+++ b/backend/models/art.go
@@ -0,0 +1,5 @@
+// Package models defines domain types for music data.
+package models
+
+// Art holds album artwork data.
+type Art struct{}
diff --git a/backend/models/files.go b/backend/models/files.go
new file mode 100644
index 0000000..b3bc866
--- /dev/null
+++ b/backend/models/files.go
@@ -0,0 +1,21 @@
+package models
+
+import "time"
+
+// AudioFileType identifies the format of an audio file.
+type AudioFileType int
+
+const (
+ mp3 AudioFileType = iota
+ flac
+ wav
+ ogg
+ midi
+)
+
+// AudioFile represents a music file with its metadata.
+type AudioFile struct {
+ Path string
+ Type AudioFileType
+ Length time.Duration
+}
diff --git a/backend/models/music.go b/backend/models/music.go
new file mode 100644
index 0000000..0e97dff
--- /dev/null
+++ b/backend/models/music.go
@@ -0,0 +1,21 @@
+package models
+
+// Album represents a music album with its tracks and metadata.
+type Album struct {
+ Name string
+ Tracks []Track
+ MusicBrainzReleaseID string
+ CoverArt Art
+}
+
+// Track represents a single music track.
+type Track struct {
+ Name string
+ MusicBrainzRecordingID string
+}
+
+// Artist represents a music artist.
+type Artist struct {
+ Name string
+ MusicBrainzArtistID string
+}
diff --git a/backend/player/player.go b/backend/player/player.go
index 1be6729..413387d 100644
--- a/backend/player/player.go
+++ b/backend/player/player.go
@@ -1,63 +1,231 @@
+// Package player provides audio playback functionality.
package player
import (
"context"
+ "errors"
"fmt"
+ "log/slog"
"math"
"os"
+ "path/filepath"
"time"
- "github.com/gopxl/beep"
- "github.com/gopxl/beep/effects"
- "github.com/gopxl/beep/generators"
- "github.com/gopxl/beep/mp3"
- "github.com/gopxl/beep/speaker"
+ "github.com/TheCodeOfCaleb/beep/v2"
+ "github.com/TheCodeOfCaleb/beep/v2/effects"
+ "github.com/TheCodeOfCaleb/beep/v2/generators"
+ "github.com/TheCodeOfCaleb/beep/v2/speaker"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "yellowjacket/backend/database"
+ "yellowjacket/backend/database/sql/sqlcgen"
+ "yellowjacket/backend/events"
+ "yellowjacket/backend/metadata"
)
+// Player handles audio playback and state management.
type Player struct {
- ctx context.Context
- state PlayerState
- currentFile *os.File
- format beep.Format
- baseStreamer beep.Streamer
- seeker beep.StreamSeeker
- resampled beep.Streamer
- control *beep.Ctrl
- volume *effects.Volume
- speakerStreamer beep.Streamer
+ ctx context.Context
+ logger *slog.Logger
+ db *database.DB
+ state PlayerState
+ currentFile *os.File
+ format beep.Format
+ baseStreamer beep.Streamer
+ seeker beep.StreamSeeker
+ resampled beep.Streamer
+ control *beep.Ctrl
+ volume *effects.Volume
+ speakerStreamer beep.Streamer
+ playbackFinishedHandler func()
}
-type PlayerState int
+// PlayerState represents the current playback state.
+type PlayerState string
+// Playback state values.
const (
- Playing PlayerState = iota
- Paused
- Stopped
+ Playing PlayerState = "playing"
+ Paused PlayerState = "paused"
+ Stopped PlayerState = "stopped"
)
var speakerSampleRate = beep.SampleRate(44100)
-func NewPlayer() (*Player, error) {
- return &Player{
+// NewPlayer creates a player and initializes the audio speaker.
+func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) {
+ player := &Player{
+ ctx: ctx,
+ logger: logger,
+ db: db,
state: Stopped,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
},
- }, nil
-}
-
-func (p *Player) Init(ctx context.Context) error {
- p.ctx = ctx
-
- // Initialize speaker
- // TODO: allow user to change buffer size and speaker sample rate
- err := speaker.Init(p.format.SampleRate, p.format.SampleRate.N(time.Second/10))
- if err != nil {
- return fmt.Errorf("failed to initialize speaker %w", err)
}
- return nil
+ // TODO: allow user to change buffer size and speaker sample rate
+ err := speaker.Init(player.format.SampleRate, player.format.SampleRate.N(time.Second/10))
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize speaker %w", err)
+ }
+
+ return player, nil
+}
+
+// SetPlaybackFinishedHandler sets a callback that is invoked when a track finishes naturally.
+// This allows the queue to drive auto-advance without circular imports.
+func (p *Player) SetPlaybackFinishedHandler(handler func()) {
+ p.playbackFinishedHandler = handler
+}
+
+// SetContext sets the Wails context, registers event handlers, and restores persisted state.
+func (p *Player) SetContext(ctx context.Context) {
+ p.ctx = ctx
+ p.registerEventHandlers()
+ p.RestoreState()
+}
+
+func (p *Player) registerEventHandlers() {
+ if p.ctx == nil {
+ p.logger.Error("Context is nil, cannot register event handlers")
+
+ return
+ }
+
+ runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) {
+ p.logger.Info("Received RequestPlayEvent")
+ p.Play()
+ })
+ runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
+ p.logger.Info("Received RequestPauseEvent")
+ p.Pause()
+ })
+ runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
+ p.logger.Info("Received RequestLoadFileEvent")
+
+ filePath := data[0].(string)
+ p.logger.Info(filePath)
+
+ err := p.LoadFile(filePath)
+ if err != nil {
+ p.logger.Error(err.Error())
+ } else {
+ p.logger.Info(p.currentFile.Name())
+ }
+ })
+ runtime.EventsOn(p.ctx, events.Seek, func(data ...any) {
+ p.logger.Info("Received SeekEvent", "Data", data[0])
+ seekValue := int(data[0].(float64))
+
+ err := p.Seek(seekValue)
+ if err != nil {
+ p.logger.Error("cannot seek", "error", err)
+ }
+ })
+ runtime.EventsOn(p.ctx, events.RequestSetVolume, func(data ...any) {
+ desiredVolume := UserVolume(data[0].(float64))
+ p.logger.Info("Received RequestSetVolumeEvent", "volume", desiredVolume)
+
+ err := p.SetVolume(desiredVolume)
+ if err != nil {
+ p.logger.Error("cannot set volume", "error", err)
+
+ return
+ }
+
+ p.emitVolumeChanged()
+ })
+}
+
+// emitPlaybackStateChanged emits a playback state change event.
+func (p *Player) emitPlaybackStateChanged(state PlayerState) {
+ if p.ctx == nil {
+ p.logger.Error("Context is nil, cannot emit event")
+
+ return
+ }
+
+ p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state)
+ runtime.EventsEmit(
+ p.ctx,
+ events.PlaybackStateChanged,
+ map[string]string{"state": string(state)},
+ )
+}
+
+func (p *Player) emitPlaybackFinished() {
+ if p.ctx == nil {
+ p.logger.Error("Context is nil, cannot emit event")
+
+ return
+ }
+
+ p.logger.Info("Emitting PlaybackFinishedEvent")
+ runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil)
+}
+
+func (p *Player) emitVolumeChanged() {
+ if p.ctx == nil {
+ p.logger.Error("Context is nil, cannot emit event")
+
+ return
+ }
+
+ volume := int(p.getUserVolume())
+ p.logger.Info("Emitting VolumeChangedEvent", "volume", volume)
+ runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
+}
+
+func (p *Player) emitTrackChanged() {
+ if p.ctx == nil {
+ p.logger.Error("Context is nil, cannot emit event")
+
+ return
+ }
+
+ trackLengthSecs, err := p.TrackLengthInSeconds()
+ if err != nil {
+ p.logger.Error("Cannot get track length")
+ }
+
+ trackInfo, err := p.GetCurrentTrackInfo()
+ if err != nil {
+ p.logger.Error("Cannot get track info")
+ trackInfo = map[string]interface{}{
+ "fileName": "",
+ "filePath": "",
+ "state": string(p.state),
+ }
+ }
+
+ // Compute current seek position in seconds.
+ seekPosition := 0
+ if p.seeker != nil {
+ speaker.Lock()
+ seekPosition = p.seeker.Position() / int(p.format.SampleRate)
+ speaker.Unlock()
+ }
+
+ // Emit comprehensive track info
+ trackInfo["trackLength"] = trackLengthSecs
+ trackInfo["seekPosition"] = seekPosition
+ runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
+
+ p.logger.Info("Emitting TrackChangedEvent with track info", "trackInfo", trackInfo)
+}
+
+// EmitCurrentState pushes the current player state to the frontend.
+// This is intended to be called after the frontend is ready to receive events,
+// separately from RestoreState which does the heavy lifting during OnStartup.
+func (p *Player) EmitCurrentState() {
+ p.emitVolumeChanged()
+
+ if p.currentFile != nil {
+ p.emitPlaybackStateChanged(p.state)
+ p.emitTrackChanged()
+ }
}
func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.SampleRate) error {
@@ -72,12 +240,21 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.resampled}
+ // Preserve existing volume settings across track changes.
+ prevVolume := 0.0
+ prevSilent := false
+
+ if p.volume != nil {
+ prevVolume = p.volume.Volume
+ prevSilent = p.volume.Silent
+ }
+
// wrap in volume streamer
p.volume = &effects.Volume{
Streamer: p.control,
Base: 2,
- Volume: 0,
- Silent: false,
+ Volume: prevVolume,
+ Silent: prevSilent,
}
// set "final" streamer
@@ -86,68 +263,153 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
return nil
}
-// TODO: proper state management extracted to function
-func (p *Player) changeState(desiredState PlayerState) error {
- return nil
+// startPaused registers the current streamer chain with the speaker in a
+// paused state. This keeps the speaker always active when a file is loaded,
+// so Play() only ever needs to unpause the control gate.
+func (p *Player) startPaused() {
+ speaker.Lock()
+ p.control.Paused = true
+ speaker.Unlock()
+
+ speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
+ p.state = Stopped
+ p.emitPlaybackStateChanged(p.state)
+ p.emitPlaybackFinished()
+ p.logger.Info("Playback finished naturally")
+
+ // Notify queue for auto-advance.
+ if p.playbackFinishedHandler != nil {
+ p.playbackFinishedHandler()
+ }
+ })))
+
+ p.state = Paused
}
-// reads a file and creates a streamer, also wraps necessary streamers
+// LoadFile opens and decodes an audio file for playback.
func (p *Player) LoadFile(filePath string) error {
// opening file
f, err := os.Open(filePath)
if err != nil {
+ p.logger.Error("Failed to open file")
+
return fmt.Errorf("failed to open file %w", err)
}
- // attempt to decode mp3 file and create streamer and format data
- streamer, format, err := mp3.Decode(f)
+ streamer, format, err := metadata.DecodeFile(f)
if err != nil {
- return fmt.Errorf("failed to decode mp3 %w", err)
+ p.logger.Error("failed to decode audio file", "path", filePath, "err", err)
+
+ return fmt.Errorf("failed to decode audio file: %w", err)
+ }
+ // Stop existing playback before loading new file.
+ speaker.Lock()
+ if p.control != nil {
+ p.control.Paused = true
+ }
+
+ p.state = Stopped
+ speaker.Unlock()
+
+ if p.currentFile != nil {
+ p.currentFile.Close()
}
p.currentFile = f
p.updateStreamers(streamer, format.SampleRate)
+ p.startPaused()
+ p.emitPlaybackStateChanged(p.state)
+ p.emitTrackChanged()
+ p.logger.Info("File loaded, state set to paused", "file", filePath)
return nil
}
+func (p *Player) validateReadyToPlay() error {
+ if p.control == nil {
+ return errors.New("no control streamer")
+ }
+
+ if p.currentFile == nil {
+ return errors.New("no audio file loaded")
+ }
+
+ if p.speakerStreamer == nil {
+ return errors.New("no streamer to play")
+ }
+
+ return nil
+}
+
+// Play starts or resumes audio playback.
func (p *Player) Play() error {
- p.state = Playing
- // hangs until song finishes playing
- done := make(chan bool)
- speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
- p.state = Paused //called when streamer finishes
- done <- true
- })))
+ if err := p.validateReadyToPlay(); err != nil {
+ return err
+ }
- <-done
- return nil
-}
+ if p.state == Playing {
+ p.logger.Info("Already playing")
-// TODO: reduce dupilcation in pause/resume functions
-func (p *Player) Pause() error {
- speaker.Lock()
- p.control.Paused = true
- speaker.Unlock()
- return nil
-}
+ return nil
+ }
-// TODO: reduce dupilcation in pause/resume functions
-func (p *Player) Resume() error {
+ // Track finished naturally — seek to the beginning and re-register
+ // a paused stream with the speaker so the unpause below starts it.
+ if p.state == Stopped && p.seeker != nil {
+ speaker.Lock()
+ err := p.seeker.Seek(0)
+ speaker.Unlock()
+
+ if err != nil {
+ return fmt.Errorf("failed to seek to beginning: %w", err)
+ }
+
+ p.updateStreamers(p.seeker, p.format.SampleRate)
+ p.startPaused()
+ p.logger.Info("Rebuilt streamers for replay")
+ }
+
+ // Unpause — works for both resume-from-pause and replay-from-stopped.
speaker.Lock()
p.control.Paused = false
speaker.Unlock()
+
+ p.state = Playing
+ p.emitPlaybackStateChanged(p.state)
+ p.logger.Info("Started playback")
+
return nil
}
-//Paraprasing info from the beep docs here:
-/*
-To INCREASE volume by 1 means to multiply the signal by Base.
-Volume = 0 means unchanged volume.
-Positive Volume value means increasing volume
-Negative Volume value means decreasing volume
-*/
+// Pause pauses the current playback.
+func (p *Player) Pause() error {
+ if p.control == nil {
+ return errors.New("no audio stream to pause")
+ }
+
+ if p.state == Paused {
+ p.logger.Info("Already paused")
+
+ return nil
+ }
+
+ if p.state == Playing {
+ speaker.Lock()
+ p.control.Paused = true
+ speaker.Unlock()
+
+ p.state = Paused
+ p.logger.Info("Paused playback")
+ p.emitPlaybackStateChanged(p.state)
+ } else {
+ p.logger.Info("Already paused or not playing")
+ }
+
+ return nil
+}
+
+// SetVolume sets the playback volume (0-100).
func (p *Player) SetVolume(desiredVolume UserVolume) error {
speaker.Lock()
// clamp value between 1 and 100
@@ -160,6 +422,8 @@ func (p *Player) SetVolume(desiredVolume UserVolume) error {
return nil
}
+
+// ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error {
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume))
}
@@ -168,27 +432,258 @@ func (p *Player) getUserVolume() UserVolume {
return PlayerVolume(p.volume.Volume).ToUserVolume()
}
+// MuteToggle toggles the mute state.
func (p *Player) MuteToggle() error {
p.volume.Silent = !p.volume.Silent
+
return nil
}
-// return the current position as an int between 0 and 100 to work with progress bar easily.
+// CurrentPositionSeconds returns the current playback position in seconds.
+func (p *Player) CurrentPositionSeconds() (int, error) {
+ if p.seeker == nil {
+ return 0, errors.New("no audio file loaded")
+ }
+
+ speaker.Lock()
+ pos := p.seeker.Position() / int(p.format.SampleRate)
+ speaker.Unlock()
+
+ return pos, nil
+}
+
+// CurrentPosition returns the playback position as a percentage (0-100).
func (p *Player) CurrentPosition() (int, error) {
+ if p.seeker == nil {
+ return 0, errors.New("no audio file loaded")
+ }
+
+ speaker.Lock()
pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len()))
+ speaker.Unlock()
+
return int(pos), nil
}
-// TODO: double check best type for percentage parameter
-// percentage comes from the progress bar as a value between 0 and 100
-func (p *Player) Seek(percentage int) error {
- //take percentage value (0-100), make 0-1, multiply by total number of samples in stream
- samples := int(math.Round((float64(percentage) / 100.0) * float64(p.seeker.Len())))
+// Seek jumps to a specific position in seconds.
+func (p *Player) Seek(targetSeconds int) error {
+ if p.seeker == nil {
+ runtime.EventsEmit(p.ctx, events.SeekFailed)
+
+ return errors.New("no audio file loaded")
+ }
+
+ lengthSecs, err := p.TrackLengthInSeconds()
+ if err != nil {
+ return fmt.Errorf("cannot get track length: %w", err)
+ }
+
+ speaker.Lock()
+ samples := int(
+ math.Round((float64(targetSeconds) / float64(lengthSecs)) * float64(p.seeker.Len())),
+ )
+ p.logger.Debug(
+ "attempting to seek",
+ "target-seconds",
+ targetSeconds,
+ "song-length",
+ lengthSecs,
+ "samples",
+ samples,
+ )
p.seeker.Seek(samples)
+ speaker.Unlock()
+
return nil
}
+// GetCurrentTrackInfo returns information about the currently loaded track.
+func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
+ if p.currentFile == nil {
+ return map[string]interface{}{
+ "fileName": "",
+ "filePath": "",
+ "state": string(p.state),
+ "title": "",
+ "artist": "",
+ "album": "",
+ "coverArt": "",
+ }, nil
+ }
+
+ fileName := filepath.Base(p.currentFile.Name())
+ filePath := p.currentFile.Name()
+
+ // Default values
+ title := fileName
+ artist := ""
+ album := ""
+ coverArt := ""
+
+ // Try to get metadata from database
+ if p.db != nil {
+ meta, err := p.db.Queries.GetTrackMetadataByPath(p.ctx, filePath)
+ if err == nil {
+ if meta.Title != "" {
+ title = meta.Title
+ }
+
+ artist = meta.Artist
+ album = meta.Album
+
+ if meta.CoverArtPath != "" {
+ coverArt = "/covers/" + filepath.Base(meta.CoverArtPath)
+ }
+ } else {
+ p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err)
+ }
+ }
+
+ return map[string]interface{}{
+ "fileName": fileName,
+ "filePath": filePath,
+ "state": string(p.state),
+ "title": title,
+ "artist": artist,
+ "album": album,
+ "coverArt": coverArt,
+ }, nil
+}
+
+// TrackLengthInSeconds returns the duration of the current track.
func (p *Player) TrackLengthInSeconds() (int, error) {
- len := p.seeker.Len() / int(p.format.SampleRate)
- return len, nil
+ if p.seeker == nil {
+ return 0, errors.New("no audio file loaded")
+ }
+
+ speaker.Lock()
+ length := p.seeker.Len() / int(p.format.SampleRate)
+ speaker.Unlock()
+
+ return length, nil
+}
+
+// SaveState persists the current player state to the database.
+func (p *Player) SaveState() {
+ if p.db == nil {
+ p.logger.Warn("No database available, cannot save player state")
+
+ return
+ }
+
+ volume := int64(MaxUserVol)
+ muted := false
+
+ if p.volume != nil {
+ volume = int64(p.getUserVolume())
+ muted = p.volume.Silent
+ }
+
+ trackPath := ""
+ if p.currentFile != nil {
+ trackPath = p.currentFile.Name()
+ }
+
+ positionSeconds := int64(0)
+
+ if p.seeker != nil {
+ speaker.Lock()
+ positionSeconds = int64(p.seeker.Position()) / int64(p.format.SampleRate)
+ speaker.Unlock()
+ }
+
+ err := p.db.Queries.UpdatePlayerState(p.db.Ctx, sqlcgen.UpdatePlayerStateParams{
+ Volume: volume,
+ Muted: muted,
+ LastTrackPath: trackPath,
+ LastPositionSeconds: positionSeconds,
+ })
+ if err != nil {
+ p.logger.Error("Failed to save player state", "err", err)
+
+ return
+ }
+
+ p.logger.Info("Player state saved",
+ "volume", volume,
+ "muted", muted,
+ "trackPath", trackPath,
+ "positionSeconds", positionSeconds,
+ )
+}
+
+// RestoreState loads the persisted player state from the database.
+func (p *Player) RestoreState() {
+ if p.db == nil {
+ p.logger.Warn("No database available, cannot restore player state")
+
+ return
+ }
+
+ state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
+ if err != nil {
+ p.logger.Error("Failed to load player state", "err", err)
+
+ return
+ }
+
+ // Restore volume.
+ // Ensure volume is initialized before restoring settings. The volume
+ // effect is normally created by updateStreamers during LoadFile, but
+ // RestoreState runs before any file is loaded.
+ if p.volume == nil {
+ p.volume = &effects.Volume{
+ Streamer: p.control,
+ Base: 2,
+ }
+ }
+
+ vol := clampVolume(UserVolume(state.Volume))
+
+ err = p.SetVolume(vol)
+ if err != nil {
+ p.logger.Error("Failed to restore volume", "err", err)
+ }
+
+ if state.Muted {
+ p.volume.Silent = true
+ }
+
+ // Restore last track if the file still exists.
+ if state.LastTrackPath != "" {
+ if _, statErr := os.Stat(state.LastTrackPath); statErr != nil {
+ p.logger.Warn("Last track file no longer exists, skipping restore",
+ "path", state.LastTrackPath,
+ "err", statErr,
+ )
+
+ return
+ }
+
+ err = p.LoadFile(state.LastTrackPath)
+ if err != nil {
+ p.logger.Error("Failed to restore last track", "path", state.LastTrackPath, "err", err)
+
+ return
+ }
+
+ // Restore playback position.
+ if state.LastPositionSeconds > 0 {
+ err = p.Seek(int(state.LastPositionSeconds))
+ if err != nil {
+ p.logger.Error("Failed to restore playback position",
+ "seconds", state.LastPositionSeconds,
+ "err", err,
+ )
+ }
+ }
+
+ }
+
+ p.logger.Info("Player state restored",
+ "volume", vol,
+ "muted", state.Muted,
+ "trackPath", state.LastTrackPath,
+ "positionSeconds", state.LastPositionSeconds,
+ )
}
diff --git a/backend/player/player_test.go b/backend/player/player_test.go
index 488929a..41de230 100644
--- a/backend/player/player_test.go
+++ b/backend/player/player_test.go
@@ -2,6 +2,7 @@ package player
import (
"context"
+ "log/slog"
"testing"
)
@@ -13,30 +14,29 @@ var testQueue = []string{
func TestPlayer(t *testing.T) {
t.Logf("Starting test")
- p, err := NewPlayer()
+
+ p, err := NewPlayer(context.Background(), slog.Default(), nil)
if err != nil {
t.Errorf("could not create player\n%s", err.Error())
t.Failed()
}
+
+ p.SetContext(t.Context())
t.Logf("initializing player")
- err = p.Init(context.Background())
- if err != nil {
- t.Errorf("could not initialize player\n%s", err.Error())
- t.Failed()
- }
for _, track := range testQueue {
t.Logf("loading file: %s", track)
+
err = p.LoadFile(track)
if err != nil {
t.Errorf("could not load file\n%s\n%s", track, err.Error())
t.Failed()
}
+
err = p.Play()
if err != nil {
t.Errorf("could not play file\n%s\n%s", track, err.Error())
t.Failed()
}
}
-
}
diff --git a/backend/player/volume.go b/backend/player/volume.go
index 1320e15..05b19d7 100644
--- a/backend/player/volume.go
+++ b/backend/player/volume.go
@@ -1,14 +1,24 @@
package player
+// UserVolume represents volume on a user-facing scale (0-100).
type UserVolume int
+
+// PlayerVolume represents volume on an internal scale (-10 to 10).
type PlayerVolume float64
-const MinUserVol UserVolume = 0
-const MaxUserVol UserVolume = 100
+// User volume range bounds.
+const (
+ MinUserVol UserVolume = 0
+ MaxUserVol UserVolume = 100
+)
-const MinPlayerVol PlayerVolume = -10
-const MaxPlayerVol PlayerVolume = 10
+// Player volume range bounds.
+const (
+ MinPlayerVol PlayerVolume = -4
+ MaxPlayerVol PlayerVolume = 0
+)
+// ToPlayerVolume converts user volume to internal player volume.
func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
var newVol PlayerVolume
@@ -16,9 +26,11 @@ func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
ratio := PlayerVolume(oldVol-MinUserVol) / PlayerVolume(MaxUserVol-MinUserVol)
newVol = ratio*(MaxPlayerVol-MinPlayerVol) + MinPlayerVol
}
+
return newVol
}
+// ToUserVolume converts internal player volume to user volume.
func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
var newVol UserVolume
@@ -26,6 +38,7 @@ func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
ratio := (oldVolFloat - MinPlayerVol) / (MaxPlayerVol - MinPlayerVol)
newVol = UserVolume(ratio*PlayerVolume(MaxUserVol-MinUserVol)) + MinUserVol
}
+
return newVol
}
@@ -33,8 +46,10 @@ func clampVolume(v UserVolume) UserVolume {
if v > MaxUserVol {
return MaxUserVol
}
+
if v < MinUserVol {
return MinUserVol
}
+
return v
}
diff --git a/backend/queue/queue.go b/backend/queue/queue.go
new file mode 100644
index 0000000..58e996a
--- /dev/null
+++ b/backend/queue/queue.go
@@ -0,0 +1,1070 @@
+// Package queue manages the playback queue and auto-advance logic.
+package queue
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "math/rand/v2"
+ "sync"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "yellowjacket/backend/database"
+ "yellowjacket/backend/database/sql/sqlcgen"
+ "yellowjacket/backend/events"
+)
+
+// RepeatMode represents the queue repeat behavior.
+type RepeatMode string
+
+// Repeat mode values.
+const (
+ RepeatOff RepeatMode = "off"
+ RepeatAll RepeatMode = "all"
+ RepeatOne RepeatMode = "one"
+)
+
+// PreviousRestartThreshold is the number of seconds into a track before
+// "Previous" restarts the current track instead of going to the prior one.
+const PreviousRestartThreshold = 3
+
+// TrackLoader is the interface the queue uses to tell the player to load a file.
+type TrackLoader interface {
+ LoadFile(filePath string) error
+ Play() error
+ CurrentPositionSeconds() (int, error)
+}
+
+// QueueTrack represents a track in the queue with its metadata.
+type QueueTrack struct {
+ ID int64 `json:"id"`
+ AudioFileID int64 `json:"audioFileId"`
+ FilePath string `json:"filePath"`
+ Position int64 `json:"position"`
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+}
+
+// QueueState is the full state emitted to the frontend.
+type QueueState struct {
+ Tracks []QueueTrack `json:"tracks"`
+ CurrentIndex int `json:"currentIndex"`
+ ShuffleMode bool `json:"shuffleMode"`
+ RepeatMode RepeatMode `json:"repeatMode"`
+ SourcePlaylistID int64 `json:"sourcePlaylistId"`
+}
+
+// Queue manages an ordered list of tracks for playback.
+type Queue struct {
+ ctx context.Context
+ logger *slog.Logger
+ db *database.DB
+ player TrackLoader
+
+ mu sync.Mutex
+ tracks []QueueTrack
+ currentIndex int
+ shuffleMode bool
+ repeatMode RepeatMode
+ shuffleOrder []int
+ sourcePlaylistID int64
+}
+
+// NewQueue creates a new queue manager.
+func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
+ return &Queue{
+ logger: logger.WithGroup("queue"),
+ db: db,
+ repeatMode: RepeatOff,
+ }
+}
+
+// SetContext sets the Wails runtime context and registers event handlers.
+func (q *Queue) SetContext(ctx context.Context) {
+ q.ctx = ctx
+ q.registerEventHandlers()
+}
+
+// SetPlayer provides the queue with a reference to the player for auto-advance.
+func (q *Queue) SetPlayer(player TrackLoader) {
+ q.player = player
+}
+
+// OnPlaybackFinished is called when a track finishes playing naturally.
+// This drives the auto-advance behavior.
+func (q *Queue) OnPlaybackFinished() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if len(q.tracks) == 0 {
+ return
+ }
+
+ // Repeat One: replay the current track.
+ if q.repeatMode == RepeatOne {
+ q.playCurrentTrack()
+
+ return
+ }
+
+ nextIdx := q.nextIndex()
+ if nextIdx == -1 {
+ // Queue exhausted — this is the extension point for a future fallback playlist.
+ q.onQueueExhausted()
+
+ return
+ }
+
+ q.currentIndex = nextIdx
+ q.playCurrentTrack()
+}
+
+// registerEventHandlers sets up Wails event listeners for queue commands.
+func (q *Queue) registerEventHandlers() {
+ if q.ctx == nil {
+ q.logger.Error("Context is nil, cannot register event handlers")
+
+ return
+ }
+
+ runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) {
+ q.logger.Info("Received RequestNext")
+ q.Next()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) {
+ q.logger.Info("Received RequestPrevious")
+ q.Previous()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) {
+ q.logger.Info("Received RequestSetQueue")
+ q.handleSetQueue(data...)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) {
+ q.logger.Info("Received RequestAddToQueue")
+ q.handleAddToQueue(data...)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) {
+ q.logger.Info("Received RequestPlayNext")
+ q.handlePlayNext(data...)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestRemoveFromQueue, func(data ...any) {
+ q.logger.Info("Received RequestRemoveFromQueue")
+ q.handleRemoveFromQueue(data...)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestToggleShuffle, func(_ ...any) {
+ q.logger.Info("Received RequestToggleShuffle")
+ q.ToggleShuffle()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestCycleRepeat, func(_ ...any) {
+ q.logger.Info("Received RequestCycleRepeat")
+ q.CycleRepeat()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestAddTracksToQueue, func(data ...any) {
+ q.logger.Info("Received RequestAddTracksToQueue")
+ q.handleAddTracksToQueue(data...)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestPlayTracksNext, func(data ...any) {
+ q.logger.Info("Received RequestPlayTracksNext")
+ q.handlePlayTracksNext(data...)
+ })
+}
+
+// handleSetQueue processes the RequestSetQueue event payload.
+// Expects data[0] = []interface{} of file path strings, data[1] = float64 start index.
+func (q *Queue) handleSetQueue(data ...any) {
+ if len(data) < 2 {
+ q.logger.Error("RequestSetQueue: missing data")
+
+ return
+ }
+
+ filePathsRaw, ok := data[0].([]interface{})
+ if !ok {
+ q.logger.Error("RequestSetQueue: invalid filePaths type")
+
+ return
+ }
+
+ filePaths := make([]string, 0, len(filePathsRaw))
+
+ for _, fp := range filePathsRaw {
+ if s, ok := fp.(string); ok {
+ filePaths = append(filePaths, s)
+ }
+ }
+
+ startIndex := 0
+
+ if si, ok := data[1].(float64); ok {
+ startIndex = int(si)
+ }
+
+ q.SetQueue(filePaths, startIndex)
+}
+
+// handleAddToQueue processes the RequestAddToQueue event payload.
+// Expects data[0] = string file path.
+func (q *Queue) handleAddToQueue(data ...any) {
+ if len(data) < 1 {
+ q.logger.Error("RequestAddToQueue: missing data")
+
+ return
+ }
+
+ filePath, ok := data[0].(string)
+ if !ok {
+ q.logger.Error("RequestAddToQueue: invalid filePath type", "got", data[0])
+
+ return
+ }
+
+ q.AddTrack(filePath)
+}
+
+// handlePlayNext processes the RequestPlayNext event payload.
+// Expects data[0] = string file path.
+func (q *Queue) handlePlayNext(data ...any) {
+ if len(data) < 1 {
+ q.logger.Error("RequestPlayNext: missing data")
+
+ return
+ }
+
+ filePath, ok := data[0].(string)
+ if !ok {
+ q.logger.Error("RequestPlayNext: invalid filePath type", "got", data[0])
+
+ return
+ }
+
+ q.InsertNext(filePath)
+}
+
+// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload.
+// Expects data[0] = float64 position.
+func (q *Queue) handleRemoveFromQueue(data ...any) {
+ if len(data) < 1 {
+ q.logger.Error("RequestRemoveFromQueue: missing data")
+
+ return
+ }
+
+ position, ok := data[0].(float64)
+ if !ok {
+ q.logger.Error("RequestRemoveFromQueue: invalid position type", "got", data[0])
+
+ return
+ }
+
+ q.RemoveTrack(int(position))
+}
+
+// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload.
+// Expects data[0] = []interface{} of file path strings.
+func (q *Queue) handleAddTracksToQueue(data ...any) {
+ if len(data) < 1 {
+ q.logger.Error("RequestAddTracksToQueue: missing data")
+
+ return
+ }
+
+ filePathsRaw, ok := data[0].([]interface{})
+ if !ok {
+ q.logger.Error("RequestAddTracksToQueue: invalid filePaths type", "got", data[0])
+
+ return
+ }
+
+ filePaths := make([]string, 0, len(filePathsRaw))
+
+ for _, fp := range filePathsRaw {
+ if s, ok := fp.(string); ok {
+ filePaths = append(filePaths, s)
+ }
+ }
+
+ q.AddTracks(filePaths)
+}
+
+// handlePlayTracksNext processes the RequestPlayTracksNext event payload.
+// Expects data[0] = []interface{} of file path strings.
+func (q *Queue) handlePlayTracksNext(data ...any) {
+ if len(data) < 1 {
+ q.logger.Error("RequestPlayTracksNext: missing data")
+
+ return
+ }
+
+ filePathsRaw, ok := data[0].([]interface{})
+ if !ok {
+ q.logger.Error("RequestPlayTracksNext: invalid filePaths type", "got", data[0])
+
+ return
+ }
+
+ filePaths := make([]string, 0, len(filePathsRaw))
+
+ for _, fp := range filePathsRaw {
+ if s, ok := fp.(string); ok {
+ filePaths = append(filePaths, s)
+ }
+ }
+
+ q.InsertNextTracks(filePaths)
+}
+
+// SetQueue replaces the entire queue with new tracks and starts playing.
+func (q *Queue) SetQueue(filePaths []string, startIndex int) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ // Look up audio file IDs and metadata for all paths.
+ tracks := make([]QueueTrack, 0, len(filePaths))
+
+ for i, fp := range filePaths {
+ af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
+ if err != nil {
+ q.logger.Warn("Could not find audio file in database", "path", fp, "err", err)
+
+ continue
+ }
+
+ track := QueueTrack{
+ AudioFileID: af.ID,
+ FilePath: fp,
+ Position: int64(i),
+ }
+
+ // Try to get metadata.
+ meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
+ if metaErr == nil {
+ track.Title = meta.Title
+ track.Artist = meta.Artist
+ }
+
+ tracks = append(tracks, track)
+ }
+
+ q.tracks = tracks
+ q.sourcePlaylistID = 0
+
+ if startIndex >= 0 && startIndex < len(q.tracks) {
+ q.currentIndex = startIndex
+ } else {
+ q.currentIndex = 0
+ }
+
+ // Regenerate shuffle order if shuffle is on.
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ }
+
+ // Persist to DB.
+ q.persistTracks()
+ q.persistState()
+
+ // Start playing the selected track.
+ q.playCurrentTrack()
+ q.emitQueueChanged()
+}
+
+// AddTrack appends a track to the end of the queue.
+// If the queue was empty, it starts playing the added track immediately.
+func (q *Queue) AddTrack(filePath string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath)
+ if err != nil {
+ q.logger.Error("Could not find audio file", "path", filePath, "err", err)
+
+ return
+ }
+
+ wasEmpty := len(q.tracks) == 0
+
+ track := QueueTrack{
+ AudioFileID: af.ID,
+ FilePath: filePath,
+ Position: int64(len(q.tracks)),
+ }
+
+ // Try to get metadata.
+ meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath)
+ if metaErr == nil {
+ track.Title = meta.Title
+ track.Artist = meta.Artist
+ }
+
+ q.tracks = append(q.tracks, track)
+
+ // Persist.
+ _, insertErr := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
+ AudioFileID: af.ID,
+ Position: track.Position,
+ })
+ if insertErr != nil {
+ q.logger.Error("Failed to persist queue track", "err", insertErr)
+ }
+
+ // Update shuffle order if shuffle is on.
+ if q.shuffleMode {
+ q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1)
+ }
+
+ // Auto-play if this is the first track added to an empty queue.
+ if wasEmpty {
+ q.currentIndex = 0
+ q.playCurrentTrack()
+ }
+
+ q.emitQueueChanged()
+}
+
+// AddTracks appends multiple tracks to the end of the queue.
+// If the queue was empty, it starts playing the first added track immediately.
+func (q *Queue) AddTracks(filePaths []string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ wasEmpty := len(q.tracks) == 0
+
+ for _, fp := range filePaths {
+ af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
+ if err != nil {
+ q.logger.Warn("Could not find audio file", "path", fp, "err", err)
+
+ continue
+ }
+
+ track := QueueTrack{
+ AudioFileID: af.ID,
+ FilePath: fp,
+ Position: int64(len(q.tracks)),
+ }
+
+ meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
+ if metaErr == nil {
+ track.Title = meta.Title
+ track.Artist = meta.Artist
+ }
+
+ q.tracks = append(q.tracks, track)
+ }
+
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ }
+
+ q.persistTracks()
+ q.persistState()
+
+ if wasEmpty && len(q.tracks) > 0 {
+ q.currentIndex = 0
+ q.playCurrentTrack()
+ }
+
+ q.emitQueueChanged()
+}
+
+// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
+func (q *Queue) InsertNextTracks(filePaths []string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ insertPos := q.currentIndex + 1
+ if insertPos > len(q.tracks) {
+ insertPos = len(q.tracks)
+ }
+
+ wasEmpty := len(q.tracks) == 0
+ var newTracks []QueueTrack
+
+ for _, fp := range filePaths {
+ af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
+ if err != nil {
+ q.logger.Warn("Could not find audio file", "path", fp, "err", err)
+
+ continue
+ }
+
+ track := QueueTrack{
+ AudioFileID: af.ID,
+ FilePath: fp,
+ }
+
+ meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
+ if metaErr == nil {
+ track.Title = meta.Title
+ track.Artist = meta.Artist
+ }
+
+ newTracks = append(newTracks, track)
+ }
+
+ if len(newTracks) == 0 {
+ return
+ }
+
+ // Insert the block into the slice at insertPos.
+ tail := make([]QueueTrack, len(q.tracks[insertPos:]))
+ copy(tail, q.tracks[insertPos:])
+ q.tracks = append(q.tracks[:insertPos], newTracks...)
+ q.tracks = append(q.tracks, tail...)
+
+ q.reindexPositions()
+
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ }
+
+ q.persistTracks()
+ q.persistState()
+
+ if wasEmpty {
+ q.currentIndex = 0
+ q.playCurrentTrack()
+ }
+
+ q.emitQueueChanged()
+}
+
+// InsertNext inserts a track right after the currently playing track.
+func (q *Queue) InsertNext(filePath string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath)
+ if err != nil {
+ q.logger.Error("Could not find audio file", "path", filePath, "err", err)
+
+ return
+ }
+
+ insertPos := q.currentIndex + 1
+ if insertPos > len(q.tracks) {
+ insertPos = len(q.tracks)
+ }
+
+ track := QueueTrack{
+ AudioFileID: af.ID,
+ FilePath: filePath,
+ Position: int64(insertPos),
+ }
+
+ // Try to get metadata.
+ meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath)
+ if metaErr == nil {
+ track.Title = meta.Title
+ track.Artist = meta.Artist
+ }
+
+ // Insert into slice.
+ q.tracks = append(q.tracks, QueueTrack{})
+ copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
+ q.tracks[insertPos] = track
+
+ // Reindex positions.
+ q.reindexPositions()
+
+ // Regenerate shuffle order if needed.
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ }
+
+ q.persistTracks()
+ q.emitQueueChanged()
+}
+
+// RemoveTrack removes a track at the given position from the queue.
+func (q *Queue) RemoveTrack(position int) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if position < 0 || position >= len(q.tracks) {
+ q.logger.Warn("RemoveTrack: position out of range", "position", position)
+
+ return
+ }
+
+ q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
+
+ // Adjust current index if needed.
+ if position < q.currentIndex {
+ q.currentIndex--
+ } else if position == q.currentIndex && q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
+ q.currentIndex = len(q.tracks) - 1
+ }
+
+ q.reindexPositions()
+
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ }
+
+ q.persistTracks()
+ q.persistState()
+ q.emitQueueChanged()
+}
+
+// Next advances to the next track.
+func (q *Queue) Next() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if len(q.tracks) == 0 {
+ return
+ }
+
+ nextIdx := q.nextIndex()
+ if nextIdx == -1 {
+ q.onQueueExhausted()
+
+ return
+ }
+
+ q.currentIndex = nextIdx
+ q.playCurrentTrack()
+ q.emitQueueChanged()
+}
+
+// Previous goes to the previous track (or restarts current if >3s in).
+func (q *Queue) Previous() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if len(q.tracks) == 0 {
+ return
+ }
+
+ // If more than 3 seconds into the track, restart it.
+ if q.player != nil {
+ posSecs, err := q.player.CurrentPositionSeconds()
+ if err == nil && posSecs > PreviousRestartThreshold {
+ q.playCurrentTrack()
+ q.emitQueueChanged()
+
+ return
+ }
+ }
+
+ prevIdx := q.previousIndex()
+ if prevIdx == -1 {
+ // At the beginning — just restart the current track.
+ q.playCurrentTrack()
+ q.emitQueueChanged()
+
+ return
+ }
+
+ q.currentIndex = prevIdx
+ q.playCurrentTrack()
+ q.emitQueueChanged()
+}
+
+// ToggleShuffle toggles shuffle mode on/off.
+func (q *Queue) ToggleShuffle() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.shuffleMode = !q.shuffleMode
+
+ if q.shuffleMode {
+ q.generateShuffleOrder()
+ } else {
+ q.shuffleOrder = nil
+ }
+
+ q.persistState()
+ q.emitQueueChanged()
+}
+
+// CycleRepeat cycles through repeat modes: off → all → one → off.
+func (q *Queue) CycleRepeat() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ switch q.repeatMode {
+ case RepeatOff:
+ q.repeatMode = RepeatAll
+ case RepeatAll:
+ q.repeatMode = RepeatOne
+ case RepeatOne:
+ q.repeatMode = RepeatOff
+ }
+
+ q.persistState()
+ q.emitQueueChanged()
+}
+
+// GetState returns the current queue state for the frontend.
+func (q *Queue) GetState() QueueState {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ tracks := make([]QueueTrack, len(q.tracks))
+ copy(tracks, q.tracks)
+
+ return QueueState{
+ Tracks: tracks,
+ CurrentIndex: q.currentIndex,
+ ShuffleMode: q.shuffleMode,
+ RepeatMode: q.repeatMode,
+ SourcePlaylistID: q.sourcePlaylistID,
+ }
+}
+
+// EmitCurrentState emits the current queue state to the frontend.
+// This is called after the frontend DOM is ready.
+func (q *Queue) EmitCurrentState() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.emitQueueChanged()
+}
+
+// SaveState persists the queue state to the database.
+func (q *Queue) SaveState() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.persistTracks()
+ q.persistState()
+ q.logger.Info("Queue state saved",
+ "trackCount", len(q.tracks),
+ "currentIndex", q.currentIndex,
+ "shuffleMode", q.shuffleMode,
+ "repeatMode", q.repeatMode,
+ )
+}
+
+// RestoreState loads the queue state from the database.
+func (q *Queue) RestoreState() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ // Restore queue metadata.
+ state, err := q.db.Queries.GetQueueState(q.db.Ctx)
+ if err != nil {
+ q.logger.Error("Failed to load queue state", "err", err)
+
+ return
+ }
+
+ q.currentIndex = int(state.CurrentPosition)
+ q.shuffleMode = state.ShuffleMode
+ q.repeatMode = RepeatMode(state.RepeatMode)
+
+ if state.SourcePlaylistID.Valid {
+ q.sourcePlaylistID = state.SourcePlaylistID.Int64
+ }
+
+ // Restore shuffle order.
+ if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" {
+ var order []int
+
+ if err := json.Unmarshal([]byte(state.ShuffleOrder.String), &order); err != nil {
+ q.logger.Warn("Failed to parse shuffle order", "err", err)
+ } else {
+ q.shuffleOrder = order
+ }
+ }
+
+ // Restore queue tracks.
+ rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx)
+ if err != nil {
+ q.logger.Error("Failed to load queue tracks", "err", err)
+
+ return
+ }
+
+ q.tracks = make([]QueueTrack, 0, len(rows))
+
+ for _, row := range rows {
+ q.tracks = append(q.tracks, QueueTrack{
+ ID: row.ID,
+ AudioFileID: row.AudioFileID,
+ FilePath: row.FilePath,
+ Position: row.Position,
+ Title: row.Title,
+ Artist: row.Artist,
+ })
+ }
+
+ // Clamp current index.
+ if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
+ q.currentIndex = len(q.tracks) - 1
+ }
+
+ q.logger.Info("Queue state restored",
+ "trackCount", len(q.tracks),
+ "currentIndex", q.currentIndex,
+ "shuffleMode", q.shuffleMode,
+ "repeatMode", q.repeatMode,
+ )
+}
+
+// nextIndex returns the next track index respecting shuffle and repeat modes.
+// Returns -1 if there is no next track (queue exhausted).
+func (q *Queue) nextIndex() int {
+ if len(q.tracks) == 0 {
+ return -1
+ }
+
+ if q.shuffleMode && len(q.shuffleOrder) > 0 {
+ return q.nextShuffledIndex()
+ }
+
+ next := q.currentIndex + 1
+ if next >= len(q.tracks) {
+ if q.repeatMode == RepeatAll {
+ return 0
+ }
+
+ return -1
+ }
+
+ return next
+}
+
+// previousIndex returns the previous track index respecting shuffle and repeat.
+// Returns -1 if there is no previous track.
+func (q *Queue) previousIndex() int {
+ if len(q.tracks) == 0 {
+ return -1
+ }
+
+ if q.shuffleMode && len(q.shuffleOrder) > 0 {
+ return q.previousShuffledIndex()
+ }
+
+ prev := q.currentIndex - 1
+ if prev < 0 {
+ if q.repeatMode == RepeatAll {
+ return len(q.tracks) - 1
+ }
+
+ return -1
+ }
+
+ return prev
+}
+
+// nextShuffledIndex finds the next index in the shuffle order.
+func (q *Queue) nextShuffledIndex() int {
+ shufflePos := q.currentShufflePosition()
+ if shufflePos == -1 {
+ // Current track not found in shuffle order — shouldn't happen.
+ return -1
+ }
+
+ nextShufflePos := shufflePos + 1
+ if nextShufflePos >= len(q.shuffleOrder) {
+ if q.repeatMode == RepeatAll {
+ return q.shuffleOrder[0]
+ }
+
+ return -1
+ }
+
+ return q.shuffleOrder[nextShufflePos]
+}
+
+// previousShuffledIndex finds the previous index in the shuffle order.
+func (q *Queue) previousShuffledIndex() int {
+ shufflePos := q.currentShufflePosition()
+ if shufflePos == -1 {
+ return -1
+ }
+
+ prevShufflePos := shufflePos - 1
+ if prevShufflePos < 0 {
+ if q.repeatMode == RepeatAll {
+ return q.shuffleOrder[len(q.shuffleOrder)-1]
+ }
+
+ return -1
+ }
+
+ return q.shuffleOrder[prevShufflePos]
+}
+
+// currentShufflePosition finds where the current track index is in the shuffle order.
+func (q *Queue) currentShufflePosition() int {
+ for i, idx := range q.shuffleOrder {
+ if idx == q.currentIndex {
+ return i
+ }
+ }
+
+ return -1
+}
+
+// generateShuffleOrder creates a Fisher-Yates shuffled index order,
+// placing the current track at position 0 so it doesn't replay immediately.
+func (q *Queue) generateShuffleOrder() {
+ n := len(q.tracks)
+ if n == 0 {
+ q.shuffleOrder = nil
+
+ return
+ }
+
+ order := make([]int, n)
+ for i := range order {
+ order[i] = i
+ }
+
+ // Fisher-Yates shuffle.
+ for i := n - 1; i > 0; i-- {
+ j := rand.IntN(i + 1)
+ order[i], order[j] = order[j], order[i]
+ }
+
+ // Move the current track to position 0 so it doesn't replay immediately.
+ for i, idx := range order {
+ if idx == q.currentIndex {
+ order[0], order[i] = order[i], order[0]
+
+ break
+ }
+ }
+
+ q.shuffleOrder = order
+}
+
+// playCurrentTrack tells the player to load and play the current track.
+func (q *Queue) playCurrentTrack() {
+ if q.player == nil {
+ q.logger.Error("No player set, cannot play track")
+
+ return
+ }
+
+ if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
+ q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
+
+ return
+ }
+
+ track := q.tracks[q.currentIndex]
+ q.logger.Info("Playing track from queue", "filePath", track.FilePath, "position", q.currentIndex)
+
+ err := q.player.LoadFile(track.FilePath)
+ if err != nil {
+ q.logger.Error("Failed to load file from queue", "filePath", track.FilePath, "err", err)
+
+ return
+ }
+
+ err = q.player.Play()
+ if err != nil {
+ q.logger.Error("Failed to play file from queue", "filePath", track.FilePath, "err", err)
+ }
+
+ q.persistState()
+}
+
+// onQueueExhausted is called when there are no more tracks to play.
+// This is the extension point for a future fallback playlist feature.
+func (q *Queue) onQueueExhausted() {
+ q.logger.Info("Queue exhausted, stopping playback")
+ // Future: load fallback playlist here.
+}
+
+// reindexPositions updates the Position field of all tracks to match slice index.
+func (q *Queue) reindexPositions() {
+ for i := range q.tracks {
+ q.tracks[i].Position = int64(i)
+ }
+}
+
+// persistTracks writes the current queue tracks to the database.
+func (q *Queue) persistTracks() {
+ err := q.db.Queries.ClearQueueTracks(q.db.Ctx)
+ if err != nil {
+ q.logger.Error("Failed to clear queue tracks", "err", err)
+
+ return
+ }
+
+ for _, track := range q.tracks {
+ _, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
+ AudioFileID: track.AudioFileID,
+ Position: track.Position,
+ })
+ if err != nil {
+ q.logger.Error("Failed to insert queue track", "err", err)
+ }
+ }
+}
+
+// persistState writes the queue metadata to the database.
+func (q *Queue) persistState() {
+ var shuffleOrderJSON sql.NullString
+
+ if len(q.shuffleOrder) > 0 {
+ data, err := json.Marshal(q.shuffleOrder)
+ if err != nil {
+ q.logger.Error("Failed to marshal shuffle order", "err", err)
+ } else {
+ shuffleOrderJSON = sql.NullString{String: string(data), Valid: true}
+ }
+ }
+
+ sourcePlaylistID := sql.NullInt64{}
+ if q.sourcePlaylistID > 0 {
+ sourcePlaylistID = sql.NullInt64{Int64: q.sourcePlaylistID, Valid: true}
+ }
+
+ err := q.db.Queries.UpdateQueueState(q.db.Ctx, sqlcgen.UpdateQueueStateParams{
+ SourcePlaylistID: sourcePlaylistID,
+ CurrentPosition: int64(q.currentIndex),
+ ShuffleMode: q.shuffleMode,
+ RepeatMode: string(q.repeatMode),
+ ShuffleOrder: shuffleOrderJSON,
+ })
+ if err != nil {
+ q.logger.Error("Failed to persist queue state", "err", err)
+ }
+}
+
+// emitQueueChanged emits the full queue state to the frontend.
+func (q *Queue) emitQueueChanged() {
+ if q.ctx == nil {
+ return
+ }
+
+ state := QueueState{
+ Tracks: q.tracks,
+ CurrentIndex: q.currentIndex,
+ ShuffleMode: q.shuffleMode,
+ RepeatMode: q.repeatMode,
+ SourcePlaylistID: q.sourcePlaylistID,
+ }
+
+ // Ensure tracks is never nil in JSON.
+ if state.Tracks == nil {
+ state.Tracks = []QueueTrack{}
+ }
+
+ runtime.EventsEmit(q.ctx, events.QueueChanged, state)
+}
+
+// Sentinel errors.
+var (
+ ErrEmptyQueue = errors.New("queue is empty")
+ ErrNoPlayer = errors.New("no player set")
+)
diff --git a/backend/system/userdata.go b/backend/system/userdata.go
new file mode 100644
index 0000000..c7ffad2
--- /dev/null
+++ b/backend/system/userdata.go
@@ -0,0 +1,81 @@
+// Package system provides OS-specific system utilities.
+package system
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/user"
+ "runtime"
+)
+
+var (
+ errNotDirectory = errors.New("path is not a directory")
+ errUnsupportedOS = errors.New("unsupported operating system")
+)
+
+// dirType represents the type of user directory.
+type dirType string
+
+const (
+ dirTypeConfig dirType = "config"
+ dirTypeData dirType = "data"
+)
+
+// getUserDirPath returns and creates the path for a user directory.
+func getUserDirPath(dt dirType) (string, error) {
+ currentUser, err := user.Current()
+ if err != nil {
+ return "", fmt.Errorf("could not get current user: %w", err)
+ }
+
+ path, err := buildUserDirPath(currentUser.Username, dt)
+ if err != nil {
+ return "", err
+ }
+
+ if err := os.MkdirAll(path, os.ModePerm); err != nil {
+ return "", fmt.Errorf("could not make user %s directory: %w", dt, err)
+ }
+
+ dirInfo, err := os.Stat(path)
+ if err != nil {
+ return "", fmt.Errorf("could not stat the user %s directory %s: %w", dt, path, err)
+ }
+
+ if !dirInfo.IsDir() {
+ return "", fmt.Errorf("%w: %s", errNotDirectory, path)
+ }
+
+ return path, nil
+}
+
+// buildUserDirPath constructs the OS-specific path for a user directory.
+func buildUserDirPath(username string, dt dirType) (string, error) {
+ // Map directory types to their Unix subdirectory paths
+ unixSubdirs := map[dirType]string{
+ dirTypeConfig: ".config",
+ dirTypeData: ".local/share",
+ }
+
+ switch currentOS := runtime.GOOS; currentOS {
+ case "darwin":
+ return fmt.Sprintf("/Users/%s/%s/yellowjacket", username, unixSubdirs[dt]), nil
+ case "linux":
+ return fmt.Sprintf("/home/%s/%s/yellowjacket", username, unixSubdirs[dt]), nil
+ case "windows":
+ return fmt.Sprintf(`C:\Users\%s\AppData\local\yellowjacket\%s`, username, dt), nil
+ default:
+ return "", fmt.Errorf("%w: %s", errUnsupportedOS, currentOS)
+ }
+}
+
+// GetUserConfigDirPath returns the user config directory path.
+func GetUserConfigDirPath() (string, error) {
+ return getUserDirPath(dirTypeConfig)
+}
+
+// GetUserDataDirPath returns the user data directory path.
+func GetUserDataDirPath() (string, error) {
+ return getUserDirPath(dirTypeData)
+}
diff --git a/docs/dev/config-suggestions.md b/docs/dev/config-suggestions.md
new file mode 100644
index 0000000..f60bc66
--- /dev/null
+++ b/docs/dev/config-suggestions.md
@@ -0,0 +1,194 @@
+# Config Improvement Suggestions
+
+Remaining suggestions for improving the configuration system in YellowJacket.
+
+## 2. Thread Safety Concerns
+
+The current `Config` struct lacks synchronization:
+- `Load()` and `Save()` can race with concurrent reads
+- `handleConfigUpdate()` in library mutates `l.conf.DirectoryPath` without locks
+
+**Suggestion:** Add a `sync.RWMutex` to protect config access, especially if config is read during scans.
+
+```go
+type Config struct {
+ mu sync.RWMutex
+ ctx context.Context
+ logger *slog.Logger
+ // ...
+}
+
+func (c *Config) Load() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ // ...
+}
+```
+
+## 3. Nil Safety in Validation
+
+In `config.go`, validation only runs if `c.Library != nil`, but `handleConfigPost` dereferences `postedConfig.Library` without checking for nil:
+
+```go
+if postedConfig.Library != nil {
+ c.Library = postedConfig.Library
+ // ...
+}
+```
+
+**Status:** Partially addressed in the event refactor, but consider adding explicit nil checks in `Validate()` as well.
+
+## 4. Inconsistent Error Handling on HTTP Responses
+
+In `httphandler.go:28-31`, `WriteHeader` is called *after* rendering the error template, which won't work as expected (headers must be set before writing body):
+
+```go
+c.formSubmitError(err.Error()).Render(r.Context(), w)
+w.WriteHeader(http.StatusInternalServerError) // Too late!
+```
+
+**Fix:** Set the status code before rendering:
+
+```go
+w.WriteHeader(http.StatusInternalServerError)
+c.formSubmitError(err.Error()).Render(r.Context(), w)
+```
+
+## 5. Make `scanWorkerCount` Configurable
+
+There's a TODO at `library.go:289`:
+```go
+// TODO: make configurable via Config.
+var scanWorkerCount = goruntime.NumCPU()
+```
+
+**Suggestion:** Add this to `library.Config`:
+
+```go
+type Config struct {
+ DirectoryPath Directory `form:"Directory" schema:"directory,required"`
+ ScanWorkers int `form:"ScanWorkers" schema:"scan_workers"`
+}
+```
+
+Then in `NewLibrary()` or `Scan()`:
+
+```go
+workers := l.conf.ScanWorkers
+if workers <= 0 {
+ workers = goruntime.NumCPU()
+}
+```
+
+## 6. Consider Config Defaults
+
+Currently if no config exists, an empty one is saved. Consider providing sensible defaults (e.g., common music directories like `~/Music`).
+
+```go
+func (c *Config) setDefaults() {
+ if c.Library == nil {
+ c.Library = &library.Config{}
+ }
+ if c.Library.DirectoryPath == "" {
+ // Try common music directories
+ home, _ := os.UserHomeDir()
+ musicDir := filepath.Join(home, "Music")
+ if info, err := os.Stat(musicDir); err == nil && info.IsDir() {
+ c.Library.DirectoryPath = library.Directory(musicDir)
+ }
+ }
+}
+```
+
+## 7. Config Reload/Watch Capability
+
+The config is only loaded at startup. Consider adding:
+- File watcher for external config changes (using `fsnotify`)
+- Explicit reload method callable from UI
+
+```go
+func (c *Config) Watch() error {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return err
+ }
+
+ go func() {
+ for event := range watcher.Events {
+ if event.Op&fsnotify.Write == fsnotify.Write {
+ c.Load()
+ // Emit event for listeners
+ }
+ }
+ }()
+
+ return watcher.Add(c.filePath)
+}
+```
+
+## 8. Validation Should Return Structured Errors
+
+Currently validation returns combined errors. Consider returning a structured validation result that the UI can map to specific fields for better user feedback.
+
+```go
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+type ValidationResult struct {
+ Valid bool
+ Errors []ValidationError
+}
+
+func (c *Config) ValidateStructured() ValidationResult {
+ var result ValidationResult
+ result.Valid = true
+
+ if c.Library != nil {
+ if err := c.Library.Validate(); err != nil {
+ result.Valid = false
+ result.Errors = append(result.Errors, ValidationError{
+ Field: "Library.DirectoryPath",
+ Message: err.Error(),
+ })
+ }
+ }
+
+ return result
+}
+```
+
+## 9. Use Standard Library for Config Paths
+
+The path construction in `system/userdata.go` doesn't respect `$XDG_CONFIG_HOME` on Linux or use the standard Go `os.UserConfigDir()`.
+
+**Current implementation:**
+```go
+case "linux":
+ return fmt.Sprintf("/home/%s/%s/yellowjacket", username, unixSubdirs[dt]), nil
+```
+
+**Suggested improvement:**
+```go
+func GetUserConfigDirPath() (string, error) {
+ baseDir, err := os.UserConfigDir() // Respects XDG_CONFIG_HOME
+ if err != nil {
+ return "", fmt.Errorf("could not get user config directory: %w", err)
+ }
+
+ path := filepath.Join(baseDir, "yellowjacket")
+
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ return "", fmt.Errorf("could not create config directory: %w", err)
+ }
+
+ return path, nil
+}
+```
+
+This approach:
+- Respects `$XDG_CONFIG_HOME` on Linux
+- Uses proper macOS paths (`~/Library/Application Support`)
+- Uses `%AppData%` on Windows
+- Is more portable and follows platform conventions
diff --git a/docs/dev/overview.md b/docs/dev/overview.md
new file mode 100644
index 0000000..c5e91a7
--- /dev/null
+++ b/docs/dev/overview.md
@@ -0,0 +1,53 @@
+# Development Overview
+
+YellowJacket is a moderately complex application. This document gives an overview of how development of it works.
+
+## Logical Breakdown
+
+YellowJacket can be thought about in a heirarchy of logical modules and components. The borders of these logical sections are mostly represented in the code and directory structure as well.
+
+- Frontend
+ - UI Components (see [Lit](###lit-web-components))
+- Backend
+ - App
+ - Asset Handler
+ - Logging
+ - System
+ - Player
+ - Library
+ - Config
+ - Database
+ - Queries (see [sqlc](###sqlc))
+
+## Dependencies
+
+YellowJacket uses many tools and libraries to provide its functionality.
+This section lists each of these dependencies and explains how they are used.
+
+### [Wails](https://wails.io)
+
+Used to create desktop apps with Go and web technologies.
+
+### [SQLite](https://github.com/mattn/go-sqlite3?tab=readme-ov-file#go-sqlite3)
+
+Used for local database.
+
+### [sqlc](https://sqlc.dev/)
+
+Used to generate Go code from SQL.
+
+### [Templ](https://templ.guide/)
+
+Used to generate HTML templates with Go code.
+
+### [Beep](https://github.com/TheCodeOfCaleb/beep/v2?tab=readme-ov-file#beep)
+
+Used for audio playback.
+
+### [Lit Web Components](https://lit.dev/)
+
+Used for dynamic/reactive frontend components.
+
+### [HTMX](https://htmx.org/)
+
+Used for requesting HTML fragments from the backend and rendering them on the frontend.
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
new file mode 100644
index 0000000..cd29bf2
--- /dev/null
+++ b/docs/dev/roadmap.md
@@ -0,0 +1,1648 @@
+# Yellowjacket Development Roadmap
+
+This document outlines the phased development plan for Yellowjacket, from current state to a fully-featured desktop music player.
+
+## Project Vision
+
+Yellowjacket aims to be a modern, cross-platform desktop music player inspired by MusicBee, featuring:
+- High performance with large libraries (50,000+ tracks)
+- MusicBrainz-powered metadata autotagging
+- Device syncing with re-encoding
+- Highly customizable UI with arrangeable components
+
+## Current State
+
+As of the start of this roadmap:
+- Basic playback working (MP3, FLAC, OGG, WAV)
+- Library scanning with metadata extraction
+- Track list and album grid views
+- Play/pause, seek, volume (backend)
+- Configuration page
+
+---
+
+## Cross-Cutting Concern: Configuration/Settings Infrastructure
+
+**IMPORTANT:** Settings and configuration should be considered at the forefront of every feature. Each new feature should have its configurable options designed alongside the feature itself, not bolted on afterwards.
+
+### Settings Architecture Overview
+
+The application needs a unified settings system that:
+1. Stores preferences persistently (TOML config file on backend)
+2. Exposes settings to both backend and frontend
+3. Allows components to register their own settings sections
+4. Provides a consistent UI for editing settings
+
+### Backend Settings Infrastructure
+
+#### Config Package Structure
+
+The existing `backend/config/` package should be extended to support:
+
+```go
+// backend/config/config.go
+
+type Config struct {
+ Library LibraryConfig `toml:"library"`
+ Player PlayerConfig `toml:"player"`
+ Queue QueueConfig `toml:"queue"`
+ UI UIConfig `toml:"ui"`
+ // New sections added as features are built
+}
+
+type PlayerConfig struct {
+ DefaultVolume int `toml:"default_volume"` // 0-100
+ ResumePlayback bool `toml:"resume_playback"` // Resume on startup
+ CrossfadeSeconds int `toml:"crossfade_seconds"` // 0 = disabled
+ ReplayGain string `toml:"replay_gain"` // "off", "track", "album"
+}
+
+type QueueConfig struct {
+ RememberQueue bool `toml:"remember_queue"` // Persist queue across sessions
+ DefaultRepeat string `toml:"default_repeat"` // "none", "all", "one"
+ DefaultShuffle bool `toml:"default_shuffle"`
+}
+
+type UIConfig struct {
+ Theme string `toml:"theme"`
+ SidebarWidth int `toml:"sidebar_width"`
+ Layout string `toml:"layout_preset"`
+ ColumnConfig map[string][]string `toml:"column_config"` // Per-view column selection
+}
+```
+
+#### Config Change Notification
+
+When config values change, components need to be notified:
+
+```go
+// Config change callback pattern (already exists for Library)
+type ConfigSection interface {
+ OnConfigChanged(newConfig any) error
+}
+
+// Or use events
+runtime.EventsEmit(ctx, events.ConfigChanged, map[string]any{
+ "section": "player",
+ "key": "default_volume",
+ "value": 75,
+})
+```
+
+### Frontend Settings Infrastructure
+
+#### SettingsStore
+
+```typescript
+// frontend/src/store/settings-store.ts
+
+interface SettingsState {
+ player: PlayerSettings;
+ queue: QueueSettings;
+ ui: UISettings;
+ library: LibrarySettings;
+ // Extensible for new features
+}
+
+class SettingsStore {
+ private state: SettingsState;
+
+ // Load all settings from backend on startup
+ async initialize(): Promise;
+
+ // Get a specific setting
+ get(section: string, key: string): T;
+
+ // Update a setting (persists to backend)
+ async set(section: string, key: string, value: any): Promise;
+
+ // Subscribe to changes
+ subscribe(callback: () => void): () => void;
+}
+```
+
+#### Settings UI Component Architecture
+
+Each feature's settings should be encapsulated in a dedicated component:
+
+```typescript
+// Pattern for settings sub-panels
+interface SettingsPanel {
+ id: string; // e.g., "player-settings"
+ title: string; // e.g., "Playback"
+ icon: string; // Icon for settings nav
+ component: typeof LitElement; // The settings panel component
+ order: number; // Display order in settings nav
+}
+
+// Registry for settings panels
+class SettingsRegistry {
+ register(panel: SettingsPanel): void;
+ getAll(): SettingsPanel[];
+}
+```
+
+#### Unified Settings Window
+
+```typescript
+// frontend/src/components/settings/settings-window.ts
+
+@customElement('settings-window')
+class SettingsWindow extends LitElement {
+ // Left sidebar: list of settings sections
+ // Right panel: active settings section component
+ // Each section component handles its own settings
+}
+```
+
+### Settings Design Checklist for New Features
+
+When implementing any new feature, consider:
+
+1. **What user preferences exist?**
+ - Default values
+ - Behavior toggles
+ - Display options
+
+2. **Where should settings be stored?**
+ - Backend config (persistent, affects backend behavior)
+ - Frontend localStorage (UI-only preferences)
+ - Both (synced)
+
+3. **How are settings exposed?**
+ - Add to appropriate Config struct section
+ - Create settings panel component
+ - Register with SettingsRegistry
+
+4. **How do components react to changes?**
+ - Subscribe to SettingsStore
+ - Handle ConfigChanged events
+ - Apply changes immediately vs. on restart
+
+### Settings Infrastructure Tasks (Integrated with Features)
+
+These tasks should be completed early and extended as features are added:
+
+#### Task: Extend backend Config structure
+
+As each feature is built, add its configuration section to `backend/config/config.go`. Follow the existing pattern used for `LibraryConfig`.
+
+#### Task: Create SettingsStore on frontend
+
+Create `frontend/src/store/settings-store.ts` following the same pattern as PlayerStore. Load settings from backend on app startup.
+
+#### Task: Create settings panel registry
+
+Create `frontend/src/registry/settings-registry.ts` to allow features to register their settings panels.
+
+#### Task: Create unified settings window component
+
+Create `frontend/src/components/settings/settings-window.ts` that:
+- Shows navigation sidebar with all registered settings panels
+- Renders the active panel
+- Handles save/cancel/apply actions
+
+#### Task: Migrate existing config page
+
+The current HTMX-based config page (`/config`) should be migrated to use the new settings infrastructure, becoming the "Library" settings panel.
+
+---
+
+## Roadmap Phases
+
+---
+
+## Phase 1: Core Playback Experience
+
+**Goal:** Complete the fundamental playback features that users expect from a music player.
+
+**Settings to consider for this phase:**
+- Default volume level
+- Resume playback on startup (remember last track/position)
+- Remember queue across sessions
+- Default shuffle/repeat modes
+- "Previous" button behavior (restart threshold in seconds)
+
+### 1.1 Playlist System (Go Backend)
+
+Implement a playlist system in the Go backend. The "Now Playing" queue is a special transient playlist that coordinates with the Player. All playlists (including the queue) share the same underlying data structures and operations.
+
+**Key insight:** The queue is simply a playlist with special behavior:
+- It's transient (not persisted by default, but optionally can be)
+- It's always "active" (connected to the Player)
+- It has shuffle/repeat modes that affect playback order
+
+#### Task 1.1.1: Create playlist package structure
+
+Create `backend/playlist/` package with the following files:
+- `playlist.go` - Playlist struct and methods
+- `queue.go` - Queue (active playlist) with Player integration
+- `storage.go` - Persistence for saved playlists
+
+#### Task 1.1.2: Design playlist database schema
+
+Add to `backend/database/sql/schemas/`:
+
+```sql
+-- playlists.sql
+CREATE TABLE IF NOT EXISTS playlists (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ is_smart BOOLEAN NOT NULL DEFAULT false,
+ smart_rules TEXT, -- JSON for smart playlist rules
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- playlist_tracks.sql
+CREATE TABLE IF NOT EXISTS playlist_tracks (
+ id INTEGER PRIMARY KEY,
+ playlist_id INTEGER NOT NULL,
+ audio_file_id INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
+ FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
+CREATE INDEX idx_playlist_tracks_position ON playlist_tracks(playlist_id, position);
+```
+
+#### Task 1.1.3: Define Playlist data structures
+
+```go
+// backend/playlist/playlist.go
+
+type PlaylistTrack struct {
+ ID int64
+ FilePath string
+ FileName string
+ Title string
+ Artist string
+ Album string
+ TrackLength int64 // milliseconds
+ Position int // Position in playlist
+}
+
+type Playlist struct {
+ ID int64
+ Name string
+ Description string
+ IsSmart bool
+ SmartRules string // JSON
+ Tracks []PlaylistTrack
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+```
+
+#### Task 1.1.4: Define Queue (Active Playlist) data structures
+
+```go
+// backend/playlist/queue.go
+
+type RepeatMode string
+const (
+ RepeatNone RepeatMode = "none"
+ RepeatAll RepeatMode = "all"
+ RepeatOne RepeatMode = "one"
+)
+
+// Queue is the "Now Playing" playlist - always exactly one exists
+type Queue struct {
+ ctx context.Context
+ logger *slog.Logger
+ db *database.DB
+ player *player.Player
+
+ tracks []PlaylistTrack
+ currentIndex int
+ originalOrder []PlaylistTrack // For unshuffle restoration
+ shuffleOrder []int // Shuffled indices
+
+ shuffleEnabled bool
+ repeatMode RepeatMode
+
+ // Config-driven settings
+ rememberQueue bool // Persist queue across sessions
+}
+```
+
+#### Task 1.1.5: Implement Playlist CRUD operations
+
+```go
+// backend/playlist/storage.go
+
+func (s *Storage) CreatePlaylist(name, description string) (*Playlist, error)
+func (s *Storage) GetPlaylist(id int64) (*Playlist, error)
+func (s *Storage) GetAllPlaylists() ([]Playlist, error)
+func (s *Storage) UpdatePlaylist(playlist *Playlist) error
+func (s *Storage) DeletePlaylist(id int64) error
+
+func (s *Storage) AddTracksToPlaylist(playlistID int64, trackIDs []int64) error
+func (s *Storage) RemoveTrackFromPlaylist(playlistID int64, position int) error
+func (s *Storage) ReorderPlaylistTrack(playlistID int64, fromPos, toPos int) error
+func (s *Storage) GetPlaylistTracks(playlistID int64) ([]PlaylistTrack, error)
+```
+
+#### Task 1.1.6: Implement playlist CRUD SQL queries
+
+Add to `backend/database/sql/queries/playlists.sql`:
+
+```sql
+-- name: CreatePlaylist :one
+INSERT INTO playlists (name, description, is_smart, smart_rules)
+VALUES (?, ?, ?, ?) RETURNING *;
+
+-- name: GetPlaylist :one
+SELECT * FROM playlists WHERE id = ?;
+
+-- name: GetAllPlaylists :many
+SELECT * FROM playlists WHERE is_smart = false ORDER BY name;
+
+-- name: UpdatePlaylist :exec
+UPDATE playlists SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
+
+-- name: DeletePlaylist :exec
+DELETE FROM playlists WHERE id = ?;
+
+-- name: GetPlaylistTracks :many
+SELECT
+ af.id, af.file_path, af.length_milliseconds,
+ r.name as title,
+ COALESCE(ac.text, '') as artist,
+ COALESCE(rg.name, '') as album,
+ pt.position
+FROM playlist_tracks pt
+JOIN audio_files af ON pt.audio_file_id = af.id
+JOIN recordings r ON af.recording_id = r.id
+LEFT 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
+WHERE pt.playlist_id = ?
+ORDER BY pt.position;
+
+-- name: AddTrackToPlaylist :exec
+INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
+VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_tracks WHERE playlist_id = ?));
+
+-- name: RemoveTrackFromPlaylist :exec
+DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?;
+
+-- name: ReorderPlaylistTracks :exec
+UPDATE playlist_tracks SET position = ? WHERE playlist_id = ? AND audio_file_id = ?;
+```
+
+#### Task 1.1.7: Implement Queue constructor
+
+```go
+func NewQueue(ctx context.Context, logger *slog.Logger, db *database.DB, player *player.Player, config *config.QueueConfig) *Queue
+```
+
+- Initialize with empty tracks slice
+- Set currentIndex to -1 (nothing playing)
+- Load shuffle/repeat defaults from config
+- Store reference to Player for playback control
+- If `config.RememberQueue` is true, load persisted queue from DB
+
+#### Task 1.1.8: Implement queue manipulation methods
+
+```go
+// Set the entire queue (e.g., when user clicks "Play All" or clicks a track)
+func (q *Queue) Set(tracks []PlaylistTrack, startIndex int)
+
+// Add tracks to end of queue
+func (q *Queue) Add(tracks ...PlaylistTrack)
+
+// Insert tracks at specific position
+func (q *Queue) InsertAt(index int, tracks ...PlaylistTrack)
+
+// Remove track at index
+func (q *Queue) Remove(index int)
+
+// Clear entire queue
+func (q *Queue) Clear()
+
+// Move track from one position to another (for drag-and-drop reordering)
+func (q *Queue) Move(fromIndex, toIndex int)
+
+// Load from a saved playlist
+func (q *Queue) LoadPlaylist(playlistID int64) error
+
+// Save current queue as a new playlist
+func (q *Queue) SaveAsPlaylist(name string) (*Playlist, error)
+```
+
+#### Task 1.1.9: Implement playback control methods
+
+```go
+// Play track at current index
+func (q *Queue) PlayCurrent() error
+
+// Skip to next track (respects shuffle and repeat modes)
+func (q *Queue) Next() error
+
+// Skip to previous track
+func (q *Queue) Previous() error
+
+// Jump to specific index in queue
+func (q *Queue) PlayAt(index int) error
+```
+
+**Logic for Next():**
+1. If repeat mode is "one", restart current track
+2. If shuffle enabled, pick next from shuffle order
+3. Otherwise, increment currentIndex
+4. If at end of queue:
+ - If repeat mode is "all", go to index 0
+ - Otherwise, stop playback
+5. Call `q.player.LoadFile()` and `q.player.Play()`
+
+**Logic for Previous():**
+1. If current position > N seconds (configurable, default 3), restart current track
+2. Otherwise, go to previous track (respecting shuffle order)
+3. If at beginning, stay at index 0
+
+#### Task 1.1.10: Implement shuffle functionality
+
+```go
+func (q *Queue) SetShuffle(enabled bool)
+```
+
+**When enabling shuffle:**
+1. Store current order in `originalOrder`
+2. Create shuffled index order (Fisher-Yates shuffle)
+3. Keep current track at current position in shuffle order
+
+**When disabling shuffle:**
+1. Restore `originalOrder`
+2. Find current track's position in original order
+3. Set currentIndex to that position
+
+#### Task 1.1.11: Implement repeat functionality
+
+```go
+func (q *Queue) SetRepeat(mode RepeatMode)
+```
+
+This just sets the mode; the logic is in `Next()`.
+
+#### Task 1.1.12: Implement queue state getters
+
+```go
+func (q *Queue) GetTracks() []PlaylistTrack
+func (q *Queue) GetCurrentIndex() int
+func (q *Queue) GetCurrentTrack() *PlaylistTrack
+func (q *Queue) IsShuffleEnabled() bool
+func (q *Queue) GetRepeatMode() RepeatMode
+func (q *Queue) GetDuration() int64 // Total queue duration in ms
+```
+
+#### Task 1.1.13: Register event handlers for queue
+
+```go
+func (q *Queue) registerEventHandlers() {
+ // Listen for PlaybackFinished to auto-advance
+ runtime.EventsOn(q.ctx, events.PlaybackFinished, func(_ ...any) {
+ q.Next()
+ })
+
+ // Listen for frontend requests
+ runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) {
+ q.Next()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) {
+ q.Previous()
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestSetShuffle, func(data ...any) {
+ enabled := data[0].(bool)
+ q.SetShuffle(enabled)
+ })
+
+ runtime.EventsOn(q.ctx, events.RequestSetRepeat, func(data ...any) {
+ mode := RepeatMode(data[0].(string))
+ q.SetRepeat(mode)
+ })
+}
+```
+
+#### Task 1.1.14: Implement queue change event emission
+
+```go
+func (q *Queue) emitQueueChanged() {
+ runtime.EventsEmit(q.ctx, events.QueueChanged, map[string]any{
+ "tracks": q.tracks,
+ "currentIndex": q.currentIndex,
+ "shuffle": q.shuffleEnabled,
+ "repeat": string(q.repeatMode),
+ })
+}
+```
+
+Call this after any queue modification.
+
+#### Task 1.1.15: Add queue and playlist events to events package
+
+Update `backend/events/events.go`:
+
+```go
+// Queue events
+const (
+ QueueChanged = "QueueChanged"
+ RequestNext = "RequestNext"
+ RequestPrevious = "RequestPrevious"
+ RequestSetShuffle = "RequestSetShuffle"
+ RequestSetRepeat = "RequestSetRepeat"
+ RequestAddToQueue = "RequestAddToQueue"
+ RequestClearQueue = "RequestClearQueue"
+)
+
+// Playlist events
+const (
+ PlaylistsChanged = "PlaylistsChanged" // When playlists are created/deleted/renamed
+ PlaylistUpdated = "PlaylistUpdated" // When a playlist's tracks change
+)
+```
+
+#### Task 1.1.16: Add events to frontend events.ts
+
+Update `frontend/src/events.ts` to mirror backend events for queue and playlists.
+
+#### Task 1.1.17: Integrate Queue and Playlist Storage into app.go
+
+- Create playlist Storage in `NewYellowJacketApp()`
+- Create Queue in `OnStartup()` after Player is created
+- Pass Player reference and config to Queue constructor
+- Add Queue and playlist Storage to FEBindings
+- Ensure Queue's event handlers are registered
+
+#### Task 1.1.18: Update PlayerStore to cache queue state
+
+Update `frontend/src/store/player-store.ts`:
+
+```typescript
+interface PlayerState {
+ // Existing...
+ isPlaying: boolean;
+ currentTrack: TrackInfo | null;
+ volume: number;
+
+ // New queue state
+ queue: PlaylistTrack[];
+ queueIndex: number;
+ shuffleEnabled: boolean;
+ repeatMode: 'none' | 'all' | 'one';
+}
+```
+
+Add event listener for `QueueChanged`.
+
+#### Task 1.1.19: Update PlayerController with queue actions
+
+Add methods to PlayerController:
+- `next()`, `previous()`
+- `setShuffle(enabled: boolean)`
+- `setRepeat(mode: string)`
+- `addToQueue(tracks: Track[])`
+- `clearQueue()`
+- `loadPlaylist(playlistId: number)`
+- `saveQueueAsPlaylist(name: string)`
+
+#### Task 1.1.20: Create PlaylistStore for saved playlists
+
+Create `frontend/src/store/playlist-store.ts`:
+
+```typescript
+interface PlaylistState {
+ playlists: Playlist[];
+ loading: boolean;
+}
+
+class PlaylistStore {
+ // Load all playlists from backend
+ async loadPlaylists(): Promise;
+
+ // CRUD operations (delegate to backend)
+ async createPlaylist(name: string): Promise;
+ async deletePlaylist(id: number): Promise;
+ async renamePlaylist(id: number, name: string): Promise;
+
+ // Track operations
+ async addTracksToPlaylist(playlistId: number, trackIds: number[]): Promise;
+ async removeTrackFromPlaylist(playlistId: number, position: number): Promise;
+}
+```
+
+---
+
+### 1.2 Playlist UI Components
+
+#### Task 1.2.1: Create playlist sidebar section
+
+Update `frontend/src/components/sidebar/app-sidebar.ts` or create new component:
+- Show list of playlists below navigation
+- "New Playlist" button
+- Click playlist to view contents
+- Right-click for context menu (rename, delete)
+
+#### Task 1.2.2: Create playlist view component
+
+Create `frontend/src/components/playlist/playlist-view.ts`:
+- Display tracks in a playlist
+- Drag to reorder tracks
+- Remove track button
+- Play all / shuffle play buttons
+- Edit playlist name/description
+
+#### Task 1.2.3: Add "Add to Playlist" context menu
+
+Create reusable context menu component:
+- Right-click track -> Add to Playlist -> [list of playlists]
+- Option to create new playlist
+
+#### Task 1.2.4: Create "Now Playing" queue panel
+
+Create `frontend/src/components/queue/queue-panel.ts`:
+- Shows current queue
+- Highlights currently playing track
+- Drag to reorder
+- Remove tracks
+- Clear queue button
+- Save as playlist button
+
+---
+
+### 1.3 Skip Next/Previous
+
+#### Task 1.3.1: Add skip buttons to player-controls component
+
+Update `frontend/src/components/audio-player/controls/player-controls.ts`:
+
+- Add "previous" button (calls `controller.previous()`)
+- Add "next" button (calls `controller.next()`)
+- Use appropriate icons from webawesome
+
+#### Task 1.3.2: Style skip buttons
+
+Ensure buttons match existing play/pause styling.
+
+---
+
+### 1.4 Shuffle & Repeat Modes
+
+#### Task 1.4.1: Add shuffle toggle to player-controls
+
+- Add shuffle button that toggles `controller.setShuffle(!current)`
+- Visual indicator when shuffle is enabled (icon color change or background)
+
+#### Task 1.4.2: Add repeat toggle to player-controls
+
+- Add repeat button that cycles through modes: none -> all -> one -> none
+- Different icon or indicator for each mode:
+ - none: repeat icon, dimmed
+ - all: repeat icon, highlighted
+ - one: repeat-one icon, highlighted
+
+---
+
+### 1.5 Keyboard Shortcuts
+
+#### Task 1.5.1: Create keyboard shortcut handler
+
+Create `frontend/src/utils/keyboard-shortcuts.ts`:
+
+```typescript
+import { playerStore } from '@store/player-store';
+
+export function initKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Don't trigger if user is typing in an input
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
+ return;
+ }
+
+ switch (e.code) {
+ case 'Space':
+ e.preventDefault();
+ playerStore.getState().isPlaying ? playerStore.pause() : playerStore.play();
+ break;
+ case 'ArrowRight':
+ if (e.ctrlKey || e.metaKey) {
+ playerStore.next();
+ }
+ break;
+ case 'ArrowLeft':
+ if (e.ctrlKey || e.metaKey) {
+ playerStore.previous();
+ }
+ break;
+ // Add more shortcuts as needed
+ }
+ });
+}
+```
+
+#### Task 1.5.2: Initialize shortcuts in index.ts
+
+Call `initKeyboardShortcuts()` in `frontend/index.ts`.
+
+#### Task 1.5.3: Document keyboard shortcuts
+
+Consider adding a help modal or tooltip showing available shortcuts.
+
+---
+
+### 1.6 Volume UI
+
+#### Task 1.6.1: Verify backend volume support
+
+Check that `Player.SetVolume()` is working and exposed via FEBindings or events.
+
+If not exposed via events, add:
+- `RequestSetVolume` event in `backend/events/events.go`
+- Event handler in Player that calls `SetVolume()`
+- Emit `VolumeChanged` event after volume changes
+
+#### Task 1.6.2: Implement volume-control component
+
+Update `frontend/src/components/audio-player/volume-control/volume-control.ts`:
+
+- Add PlayerController
+- Render slider from 0-100
+- Display current volume from `controller.volume`
+- On change, call `controller.setVolume(value)`
+
+#### Task 1.6.3: Add mute toggle
+
+- Add mute button that sets volume to 0 (store previous volume)
+- Click again to restore previous volume
+- Icon changes based on volume level (muted, low, medium, high)
+
+---
+
+## Phase 2: Performance & Scale
+
+**Goal:** Optimize the application for large music libraries (50,000+ tracks).
+
+**Settings to consider for this phase:**
+- Page size for virtualized lists
+- Prefetch buffer size (how many pages to load ahead)
+- Library scan behavior (auto-scan on startup, watch for changes)
+- Cache settings (cover art cache size, etc.)
+
+### 2.1 Database Optimization
+
+#### Task 2.1.1: Add indexes to frequently queried columns
+
+Create new migration file `backend/database/sql/schemas/indexes.sql`:
+
+```sql
+-- Index for audio_files queries
+CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id ON audio_files(recording_id);
+CREATE INDEX IF NOT EXISTS idx_audio_files_file_type_id ON audio_files(file_type_id);
+
+-- Index for recordings queries
+CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id ON recordings(artist_credit_id);
+CREATE INDEX IF NOT EXISTS idx_recordings_name ON recordings(name);
+CREATE INDEX IF NOT EXISTS idx_recordings_year ON recordings(year);
+CREATE INDEX IF NOT EXISTS idx_recordings_genre ON recordings(genre);
+
+-- Index for release_groups queries
+CREATE INDEX IF NOT EXISTS idx_release_groups_name ON release_groups(name);
+CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id ON release_groups(album_artist_credit_id);
+CREATE INDEX IF NOT EXISTS idx_release_groups_year ON release_groups(year);
+
+-- Index for artist_credit
+CREATE INDEX IF NOT EXISTS idx_artist_credit_text ON artist_credit(text);
+
+-- Index for release_group_recordings
+CREATE INDEX IF NOT EXISTS idx_rgr_release_group_id ON release_group_recordings(release_group_id);
+CREATE INDEX IF NOT EXISTS idx_rgr_recording_id ON release_group_recordings(recording_id);
+```
+
+#### Task 2.1.2: Fix release_groups UNIQUE constraint issue
+
+Current schema has `name TEXT NOT NULL UNIQUE` which breaks if two albums have the same name by different artists.
+
+Options:
+1. Remove UNIQUE constraint (allow duplicates, rely on other fields)
+2. Create composite unique on (name, album_artist_credit_id, year)
+3. Add a generated hash column for uniqueness
+
+**Recommended:** Option 2 - composite unique constraint.
+
+Create migration to alter table or recreate with proper constraints.
+
+#### Task 2.1.3: Analyze query performance
+
+Use SQLite `EXPLAIN QUERY PLAN` on common queries to verify indexes are being used:
+
+```sql
+EXPLAIN QUERY PLAN SELECT * FROM recordings WHERE artist_credit_id = ?;
+```
+
+---
+
+### 2.2 Paginated Backend Queries
+
+#### Task 2.2.1: Add paginated track query
+
+Add to `backend/database/sql/queries/audio_files.sql`:
+
+```sql
+-- name: GetTracksPaginated :many
+SELECT
+ af.id,
+ af.file_path,
+ af.length_milliseconds,
+ r.name as title,
+ COALESCE(ac.text, '') as artist,
+ COALESCE(rg.name, '') as album
+FROM audio_files af
+JOIN recordings r ON af.recording_id = r.id
+LEFT 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
+ORDER BY r.name
+LIMIT ? OFFSET ?;
+
+-- name: GetTracksCount :one
+SELECT COUNT(*) FROM audio_files;
+```
+
+#### Task 2.2.2: Add filtered/sorted track query
+
+```sql
+-- name: GetTracksFiltered :many
+SELECT ...
+WHERE
+ (r.name LIKE ? OR ? = '') AND
+ (ac.text LIKE ? OR ? = '') AND
+ (rg.name LIKE ? OR ? = '')
+ORDER BY
+ CASE WHEN ? = 'title' THEN r.name END,
+ CASE WHEN ? = 'artist' THEN ac.text END,
+ CASE WHEN ? = 'album' THEN rg.name END
+LIMIT ? OFFSET ?;
+```
+
+#### Task 2.2.3: Update Library package with paginated methods
+
+Add to `backend/library/query.go`:
+
+```go
+type TrackQuery struct {
+ Offset int
+ Limit int
+ SortBy string // "title", "artist", "album", "year"
+ SortDir string // "asc", "desc"
+ Search string // Search across title, artist, album
+}
+
+type TracksResult struct {
+ Tracks []Track
+ TotalCount int
+}
+
+func (l *Library) GetTracks(query TrackQuery) (TracksResult, error)
+```
+
+#### Task 2.2.4: Expose paginated query via Wails binding
+
+Add `GetTracks(query TrackQuery)` to FEBindings.
+
+---
+
+### 2.3 Virtualized Lists
+
+#### Task 2.3.1: Install @lit-labs/virtualizer
+
+```bash
+cd frontend && npm install @lit-labs/virtualizer
+```
+
+#### Task 2.3.2: Create virtualized track list component
+
+Create `frontend/src/components/track-list/virtualized-track-list.ts`:
+
+```typescript
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import '@lit-labs/virtualizer';
+import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
+import { GetTracks } from '@go/library/Library';
+
+@customElement('virtualized-track-list')
+export class VirtualizedTrackList extends LitElement {
+ @state() private tracks: Track[] = [];
+ @state() private totalCount = 0;
+
+ private pageSize = 100;
+ private loadedPages = new Set();
+
+ override async connectedCallback() {
+ super.connectedCallback();
+ await this.loadPage(0);
+ }
+
+ private async loadPage(page: number) {
+ if (this.loadedPages.has(page)) return;
+
+ const result = await GetTracks({
+ offset: page * this.pageSize,
+ limit: this.pageSize,
+ sortBy: 'title',
+ sortDir: 'asc',
+ search: '',
+ });
+
+ this.totalCount = result.TotalCount;
+ this.loadedPages.add(page);
+
+ // Merge into sparse array
+ const newTracks = [...this.tracks];
+ result.Tracks.forEach((track, i) => {
+ newTracks[page * this.pageSize + i] = track;
+ });
+ this.tracks = newTracks;
+ }
+
+ private onVisibilityChanged(e: CustomEvent) {
+ const { first, last } = e;
+ const firstPage = Math.floor(first / this.pageSize);
+ const lastPage = Math.floor(last / this.pageSize);
+
+ for (let p = firstPage; p <= lastPage + 1; p++) {
+ this.loadPage(p);
+ }
+ }
+
+ override render() {
+ return html`
+ this.tracks[i])}
+ .renderItem=${(track: Track | undefined, index: number) =>
+ track
+ ? html` this.onTrackClick(track)}>`
+ : html``
+ }
+ .layout=${flow()}
+ @visibilityChanged=${this.onVisibilityChanged}
+ >
+ `;
+ }
+}
+```
+
+#### Task 2.3.3: Create track-row component
+
+Create `frontend/src/components/track-list/track-row.ts` for individual track rendering.
+
+#### Task 2.3.4: Create track-row-skeleton component
+
+Loading placeholder while data is being fetched.
+
+#### Task 2.3.5: Create virtualized album grid
+
+Similar to track list but using `grid` layout:
+
+```typescript
+import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
+
+.layout=${grid({ itemSize: { width: '180px', height: '220px' } })}
+```
+
+#### Task 2.3.6: Replace existing track-list and cover-grid
+
+Swap out the old components for virtualized versions.
+
+---
+
+## Phase 3: Library Organization
+
+**Goal:** Provide powerful tools for organizing and finding music.
+
+**Settings to consider for this phase:**
+- Default sort order for track lists
+- Default columns displayed
+- Search behavior (instant vs. press enter, search scope)
+- Smart playlist default settings
+
+### 3.1 Search & Filtering
+
+#### Task 3.1.1: Add search input component
+
+Create `frontend/src/components/search/search-input.ts`:
+- Text input with debounced onChange
+- Dispatches search event or updates LibraryStore
+
+#### Task 3.1.2: Create LibraryStore
+
+Create `frontend/src/store/library-store.ts`:
+
+```typescript
+interface LibraryState {
+ searchQuery: string;
+ sortBy: string;
+ sortDir: 'asc' | 'desc';
+ filters: {
+ genre?: string;
+ year?: number;
+ artist?: string;
+ };
+}
+```
+
+#### Task 3.1.3: Create LibraryController
+
+Similar to PlayerController, connects components to LibraryStore.
+
+#### Task 3.1.4: Integrate search with virtualized list
+
+When search query changes:
+1. Reset loaded pages
+2. Update query parameters
+3. Reload from page 0
+
+#### Task 3.1.5: Add filter dropdowns
+
+Genre, year, artist filters that update LibraryStore.
+
+---
+
+### 3.2 Custom Columns
+
+#### Task 3.2.1: Define available columns
+
+```typescript
+interface ColumnDefinition {
+ id: string;
+ label: string;
+ field: string; // Path into track object
+ width: number;
+ sortable: boolean;
+}
+
+const availableColumns: ColumnDefinition[] = [
+ { id: 'title', label: 'Title', field: 'name', width: 200, sortable: true },
+ { id: 'artist', label: 'Artist', field: 'artistName', width: 150, sortable: true },
+ { id: 'album', label: 'Album', field: 'albumName', width: 150, sortable: true },
+ { id: 'duration', label: 'Duration', field: 'lengthMilliseconds', width: 80, sortable: true },
+ { id: 'year', label: 'Year', field: 'year', width: 60, sortable: true },
+ { id: 'genre', label: 'Genre', field: 'genre', width: 100, sortable: true },
+ { id: 'trackNum', label: '#', field: 'trackNumber', width: 40, sortable: true },
+ // ... more columns
+];
+```
+
+#### Task 3.2.2: Create column selector UI
+
+Modal or dropdown where user can:
+- Check/uncheck columns to show
+- Drag to reorder columns
+
+#### Task 3.2.3: Persist column preferences
+
+Save selected columns and order to config.
+
+#### Task 3.2.4: Update track list to use dynamic columns
+
+Read column configuration and render accordingly.
+
+---
+
+### 3.3 Smart Playlists
+
+**Note:** Basic playlist functionality (database schema, CRUD, UI) is implemented in Phase 1. This section extends playlists with smart/dynamic features.
+
+#### Task 3.3.1: Define smart playlist rule structure
+
+```typescript
+interface SmartPlaylistRule {
+ field: string; // 'genre', 'year', 'artist', 'playCount', etc.
+ operator: string; // 'equals', 'contains', 'greaterThan', 'lessThan'
+ value: string | number;
+}
+
+interface SmartPlaylistRules {
+ matchType: 'all' | 'any'; // AND vs OR
+ rules: SmartPlaylistRule[];
+ limit?: number;
+ sortBy?: string;
+}
+```
+
+#### Task 3.3.2: Implement smart playlist query builder
+
+Convert rules to SQL WHERE clause dynamically.
+
+#### Task 3.3.3: Create smart playlist editor UI
+
+Form to add/remove rules, preview results.
+
+---
+
+### 3.4 Auto-Playlists
+
+#### Task 3.4.1: Implement "Recently Added" auto-playlist
+
+Query tracks sorted by date added, limit 100.
+
+#### Task 3.4.2: Implement "Recently Played" auto-playlist
+
+Requires tracking play history (new table).
+
+#### Task 3.4.3: Add play history tracking
+
+```sql
+CREATE TABLE IF NOT EXISTS play_history (
+ id INTEGER PRIMARY KEY,
+ audio_file_id INTEGER NOT NULL,
+ played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(audio_file_id) REFERENCES audio_files(id)
+);
+```
+
+Update Player to log plays.
+
+---
+
+## Phase 4: UI Customization
+
+**Goal:** Allow users to arrange and customize the UI layout.
+
+**Settings to consider for this phase:**
+- Active layout preset
+- Per-component configurations (each component can define its own settings)
+- Theme/appearance settings
+- Sidebar default widths
+- Visibility toggles for UI elements
+
+**Important:** This phase heavily integrates with the settings infrastructure. Each registered component should be able to define its own settings schema that appears in the settings window.
+
+### 4.1 Component Registry System
+
+#### Task 4.1.1: Define component registry interface
+
+```typescript
+interface RegisteredComponent {
+ id: string;
+ name: string;
+ description: string;
+ component: typeof LitElement;
+ defaultSlot: 'main' | 'left-sidebar' | 'right-sidebar' | 'top-bar' | 'bottom-bar';
+ allowedSlots: string[];
+ defaultConfig: Record;
+}
+```
+
+#### Task 4.1.2: Create component registry
+
+```typescript
+// frontend/src/registry/component-registry.ts
+class ComponentRegistry {
+ private components = new Map();
+
+ register(component: RegisteredComponent): void;
+ get(id: string): RegisteredComponent | undefined;
+ getAll(): RegisteredComponent[];
+ getForSlot(slot: string): RegisteredComponent[];
+}
+
+export const componentRegistry = new ComponentRegistry();
+```
+
+#### Task 4.1.3: Register existing components
+
+```typescript
+componentRegistry.register({
+ id: 'track-list',
+ name: 'Track List',
+ description: 'Display all tracks in a table',
+ component: TrackList,
+ defaultSlot: 'main',
+ allowedSlots: ['main'],
+ defaultConfig: {},
+});
+
+componentRegistry.register({
+ id: 'now-playing',
+ name: 'Now Playing',
+ description: 'Show current track info',
+ component: NowPlaying,
+ defaultSlot: 'bottom-bar',
+ allowedSlots: ['bottom-bar', 'left-sidebar', 'right-sidebar'],
+ defaultConfig: {},
+});
+```
+
+---
+
+### 4.2 Layout Configuration System
+
+#### Task 4.2.1: Define layout configuration structure
+
+```typescript
+interface LayoutConfig {
+ 'top-bar': ComponentPlacement[];
+ 'bottom-bar': ComponentPlacement[];
+ 'left-sidebar': ComponentPlacement[];
+ 'right-sidebar': ComponentPlacement[];
+ 'main': ComponentPlacement[];
+}
+
+interface ComponentPlacement {
+ componentId: string;
+ config: Record;
+ order: number;
+}
+```
+
+#### Task 4.2.2: Create LayoutStore
+
+Store current layout configuration, provide methods to modify.
+
+#### Task 4.2.3: Create layout persistence
+
+Save/load layout from config file or localStorage.
+
+#### Task 4.2.4: Create dynamic slot renderer
+
+Component that reads layout config and renders appropriate components in each slot.
+
+```typescript
+@customElement('layout-slot')
+class LayoutSlot extends LitElement {
+ @property() slotName: string;
+
+ render() {
+ const placements = layoutStore.getSlot(this.slotName);
+ return html`
+ ${placements.map(p => {
+ const reg = componentRegistry.get(p.componentId);
+ const tag = reg.component.tagName;
+ return html`<${tag} .config=${p.config}>${tag}>`;
+ })}
+ `;
+ }
+}
+```
+
+---
+
+### 4.3 Layout Editor UI
+
+#### Task 4.3.1: Create layout editor modal
+
+- Visual representation of slots
+- Drag components between slots
+- Add/remove components from slots
+
+#### Task 4.3.2: Create component configurator
+
+Per-component settings panel for components that support configuration.
+
+#### Task 4.3.3: Add layout presets
+
+Default layouts users can choose from:
+- "Classic" (sidebar + main + bottom bar)
+- "Minimal" (just player controls)
+- "Full" (all panels visible)
+
+---
+
+## Phase 5: MusicBrainz Integration
+
+**Goal:** Enable automatic metadata tagging via MusicBrainz.
+
+**Settings to consider for this phase:**
+- AcoustID API key
+- Auto-tag behavior (prompt always, auto-accept high confidence, etc.)
+- Minimum confidence threshold for auto-accept
+- Which metadata fields to overwrite
+- Backup original tags before overwriting
+
+### 5.1 MusicBrainz API Client
+
+#### Task 5.1.1: Create MusicBrainz package
+
+`backend/musicbrainz/client.go`:
+- HTTP client with rate limiting (1 req/sec per MB guidelines)
+- User-Agent header with app name and contact
+
+#### Task 5.1.2: Implement recording search
+
+```go
+func (c *Client) SearchRecordings(query string) ([]Recording, error)
+func (c *Client) GetRecording(mbid string) (*Recording, error)
+```
+
+#### Task 5.1.3: Implement release search
+
+```go
+func (c *Client) SearchReleases(query string) ([]Release, error)
+func (c *Client) GetRelease(mbid string) (*Release, error)
+```
+
+#### Task 5.1.4: Implement artist search
+
+```go
+func (c *Client) SearchArtists(query string) ([]Artist, error)
+```
+
+---
+
+### 5.2 AcoustID Integration
+
+#### Task 5.2.1: Integrate chromaprint for fingerprinting
+
+Use chromaprint library to generate audio fingerprints.
+
+#### Task 5.2.2: Create AcoustID client
+
+```go
+func (c *AcoustIDClient) Lookup(fingerprint string, duration int) ([]AcoustIDResult, error)
+```
+
+#### Task 5.2.3: Map AcoustID results to MusicBrainz
+
+AcoustID returns MusicBrainz recording IDs; use those to fetch full metadata.
+
+---
+
+### 5.3 Autotag Workflow
+
+#### Task 5.3.1: Create autotag service
+
+`backend/autotag/autotag.go`:
+
+```go
+type AutotagResult struct {
+ FilePath string
+ MatchConfidence float64
+ CurrentMetadata TrackMetadata
+ SuggestedMetadata TrackMetadata
+ MBRecordingID string
+}
+
+func (s *Service) AnalyzeTrack(filePath string) (*AutotagResult, error)
+func (s *Service) AnalyzeAlbum(tracks []string) ([]AutotagResult, error)
+func (s *Service) ApplyTags(result *AutotagResult) error
+```
+
+#### Task 5.3.2: Create autotag UI component
+
+- Show current vs suggested metadata side-by-side
+- Confidence indicator
+- Accept/reject buttons
+- Batch operations for albums
+
+#### Task 5.3.3: Implement tag writing
+
+Write accepted metadata back to audio files using tag library.
+
+---
+
+### 5.4 MusicBrainz Visual Browser
+
+#### Task 5.4.1: Create artist browser view
+
+- Search artists
+- View artist discography
+- Click release to see tracklist
+
+#### Task 5.4.2: Create release browser view
+
+- Album art (from Cover Art Archive)
+- Track listing
+- Credits and relationships
+
+#### Task 5.4.3: Link local tracks to MB entities
+
+Show which local tracks match MB recordings; allow manual linking.
+
+---
+
+## Phase 6: Device Sync
+
+**Goal:** Sync music to Android devices with optional re-encoding.
+
+**Settings to consider for this phase:**
+- Per-device sync profiles (encoding quality, playlists to sync)
+- Encoding cache location and size limit
+- Sync behavior (delete removed tracks from device, etc.)
+- Custom encoding profiles (advanced users)
+- FFmpeg binary path (if not bundled)
+
+### 6.1 Android Sync (MTP)
+
+#### Task 6.1.1: Research MTP libraries for Go
+
+Options:
+- libmtp bindings
+- gousb for raw USB
+- Call external tools (jmtpfs, go-mtpfs)
+
+#### Task 6.1.2: Create device detection
+
+Detect connected MTP devices, list storage volumes.
+
+#### Task 6.1.3: Create file transfer service
+
+```go
+type SyncService struct {
+ // ...
+}
+
+func (s *SyncService) GetDevices() ([]Device, error)
+func (s *SyncService) SyncPlaylist(device Device, playlist Playlist, profile EncodingProfile) error
+func (s *SyncService) SyncTracks(device Device, tracks []Track, profile EncodingProfile) error
+```
+
+---
+
+### 6.2 Re-encoding Pipeline
+
+#### Task 6.2.1: Integrate FFmpeg
+
+Use FFmpeg for transcoding. Options:
+- Call ffmpeg binary
+- Use go-ffmpeg bindings
+
+#### Task 6.2.2: Define encoding profiles
+
+```go
+type EncodingProfile struct {
+ Name string
+ Format string // "mp3", "aac", "opus"
+ Bitrate int // kbps
+ SampleRate int // Hz
+}
+
+var presets = []EncodingProfile{
+ {Name: "High Quality MP3", Format: "mp3", Bitrate: 320, SampleRate: 44100},
+ {Name: "Balanced MP3", Format: "mp3", Bitrate: 192, SampleRate: 44100},
+ {Name: "Space Saver", Format: "mp3", Bitrate: 128, SampleRate: 44100},
+}
+```
+
+#### Task 6.2.3: Create encoding cache
+
+Cache encoded files to avoid re-encoding on every sync:
+- Hash source file + profile = cache key
+- Store encoded files in cache directory
+
+#### Task 6.2.4: Create sync progress UI
+
+- Device selection
+- Playlist/track selection
+- Encoding profile selection
+- Progress bar with current file
+- Cancel button
+
+---
+
+## Phase 7: Cross-Platform Polish
+
+**Goal:** Ensure excellent experience on Windows and macOS.
+
+**Settings to consider for this phase:**
+- System tray behavior (minimize to tray, close to tray)
+- Startup behavior (start minimized, start with system)
+- Media key handling (enable/disable)
+- Notification preferences (track change, etc.)
+- File association preferences
+
+### 7.1 Platform Testing
+
+#### Task 7.1.1: Set up Windows build environment
+
+- Windows VM or machine
+- Go + Node.js toolchain
+- Wails CLI
+
+#### Task 7.1.2: Set up macOS build environment
+
+- macOS machine (required for signing)
+- Xcode command line tools
+- Go + Node.js toolchain
+
+#### Task 7.1.3: Fix platform-specific issues
+
+Test and fix:
+- File paths (forward vs backslash)
+- System directories
+- Audio device handling
+- Window chrome differences
+
+---
+
+### 7.2 Platform Integration
+
+#### Task 7.2.1: System media key support
+
+Respond to keyboard media keys (play/pause, next, previous).
+
+Research:
+- Windows: RegisterHotKey or low-level keyboard hook
+- macOS: SPMediaKeyTap or MediaKeySession
+- Linux: D-Bus MPRIS
+
+#### Task 7.2.2: MPRIS integration (Linux)
+
+Implement MPRIS D-Bus interface for integration with desktop environments.
+
+#### Task 7.2.3: System tray icon
+
+Minimize to tray, show playback controls in tray menu.
+
+#### Task 7.2.4: Native notifications
+
+Show track change notifications using system notification APIs.
+
+---
+
+### 7.3 Distribution
+
+#### Task 7.3.1: Create installer for Windows
+
+- NSIS or WiX installer
+- Start menu shortcut
+- File associations (.mp3, .flac, etc.)
+
+#### Task 7.3.2: Create DMG for macOS
+
+- Signed and notarized app bundle
+- Drag-to-Applications installer
+
+#### Task 7.3.3: Create packages for Linux
+
+- AppImage (universal)
+- .deb (Debian/Ubuntu)
+- .rpm (Fedora)
+- Flatpak (sandboxed)
+
+#### Task 7.3.4: Set up CI/CD for releases
+
+GitHub Actions workflow to:
+- Build for all platforms
+- Run tests
+- Create release artifacts
+- Publish to GitHub Releases
+
+---
+
+## Technical Debt Items
+
+These items should be addressed as time permits, integrated with feature work:
+
+### Testing
+
+- [ ] Unit tests for PlayerStore
+- [ ] Unit tests for Go Queue package
+- [ ] Unit tests for Go Library scanning
+- [ ] Integration tests for Wails event flow
+- [ ] E2E tests for critical user flows
+
+### Documentation
+
+- [ ] User documentation / help pages
+- [ ] Developer setup guide
+- [ ] Architecture documentation
+- [ ] API documentation for plugin authors (future)
+
+### Code Quality
+
+- [ ] Consistent error handling patterns in Go
+- [ ] Consistent logging throughout
+- [ ] Performance profiling and optimization
+- [ ] Accessibility audit (keyboard navigation, screen readers)
+
+### Security
+
+- [ ] Input validation for all user inputs
+- [ ] Safe file path handling
+- [ ] Sanitize metadata before display (XSS prevention)
+
+---
+
+## Decision Log
+
+Key architectural decisions made during planning:
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Queue model | Queue as special playlist | Unified data structures, queue can be saved as playlist |
+| Queue/Playlist location | Go backend | Tighter integration with Player, single source of truth |
+| Frontend state | Custom store + Controllers | Production-ready, integrates with Wails events |
+| Virtualization | @lit-labs/virtualizer | Native Lit integration, supports both list and grid |
+| State management lib | None (custom) | Signals not production-ready, custom gives full control |
+| Track per file | Yes (no deduplication) | Practical for real-world music libraries |
+| Testing | Deferred | Focus on architecture first, add tests incrementally |
+| Settings architecture | Unified, extensible | Each feature adds its own config section; single settings UI |
+| Playlists in Phase 1 | Yes | Core feature, needed for queue; smart playlists in Phase 3 |
diff --git a/frontend/index.css b/frontend/index.css
index 1bee9c0..5105bbb 100644
--- a/frontend/index.css
+++ b/frontend/index.css
@@ -1,4 +1,116 @@
+html {
+ height: 100%;
+}
+
body {
- background-color: black;
- color: white;
+ background-color: black;
+ color: white;
+ margin: 0;
+ height: 100vh;
+ display: grid;
+ grid-template: "top-bar top-bar" 4em "sidebar main-panel" 1fr "bottom-bar bottom-bar" 4em / auto 1fr;
+ overflow: hidden;
+}
+
+p {
+ margin: 0;
+ /* I want to set paragraph margins myself */
+}
+
+.top-bar {
+ grid-area: top-bar;
+ height: 100%;
+ padding-left: 2em;
+ padding-right: 2em;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: #343a40;
+}
+
+ul {
+ list-style-type: none;
+}
+
+.title {
+ font-size: 1.5em;
+ margin-bottom: 0;
+}
+
+ .subtitle {
+ font-size: 0.8em;
+ margin-top: 0;
+ }
+
+body div.sidebar {
+ grid-area: sidebar;
+ background-color: #212529;
+ overflow: hidden;
+}
+
+.bottom-bar {
+ grid-area: bottom-bar;
+ padding: 0.25em;
+ background-color: #343a40;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+
+ #now-playing-info {
+ justify-self: start;
+ display: flex;
+ min-width: 0;
+ overflow: hidden;
+
+ #album-art {
+ width: 3.5em;
+ min-width: 3.5em;
+ height: 3.5em;
+ min-height: 3.5em;
+ border-radius: 0.25em;
+ background-color: #ffd43b;
+ }
+
+ #track-info {
+ display: flex;
+ margin-left: 1em;
+ flex-direction: column;
+ font-size: 0.75em;
+ justify-content: center;
+ text-wrap-mode: nowrap;
+ overflow: hidden;
+
+ p {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ audio-player {
+ justify-self: center;
+ margin: 0.5em 1em;
+ }
+
+ #queue-button {
+ justify-self: end;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 8px;
+ display: flex;
+ align-items: center;
+ }
+
+ #queue-button:hover {
+ color: #ffd43b;
+ }
+}
+
+.main-panel {
+ grid-area: main-panel;
+ padding: 0.25em;
+ background-color: #212529;
+ overflow: auto;
}
diff --git a/frontend/index.html b/frontend/index.html
index 9fffb7b..fed5768 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -10,40 +10,29 @@
-
-
+
+
+ YellowJacket
+ Music how it was meant to bee.
+
+
+
+
-
-
+
+
+
-