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}>`; + })} + `; + } +} +``` + +--- + +### 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.

+
+ + +
-
-
+ +
+
-