Squash merge audio-player-component into main
This commit is contained in:
@@ -3,3 +3,4 @@ node_modules
|
|||||||
build
|
build
|
||||||
test_data
|
test_data
|
||||||
test.db
|
test.db
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -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
|
||||||
Vendored
+3
-2
@@ -17,7 +17,8 @@
|
|||||||
"mode": "exec",
|
"mode": "exec",
|
||||||
"program": "${workspaceFolder}/build/bin/yellowjacket",
|
"program": "${workspaceFolder}/build/bin/yellowjacket",
|
||||||
"preLaunchTask": "build debug",
|
"preLaunchTask": "build debug",
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}",
|
||||||
|
"outputMode": "remote",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Wails: Dev yellowjacket",
|
"name": "Wails: Dev yellowjacket",
|
||||||
@@ -29,4 +30,4 @@
|
|||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
dev:
|
dev: generate clean
|
||||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 wails dev -tags webkit2_41
|
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
|
wails build -tags webkit2_41 -debug -clean
|
||||||
|
|
||||||
build-prod:
|
build-prod: generate
|
||||||
wails build -tags webkit2_41 -clean -obfuscated -upx
|
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:
|
generate:
|
||||||
go generate ./...
|
go generate ./...
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# YellowJacket
|
# YellowJacket
|
||||||
|
|
||||||
A MusicBee inspired music player and library manager.
|
How music was meant to bee.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
TODO: put release links here
|
You can grab the latest release [here](https://github.com/LJ-Software/yellowjacket/releases/latest)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -12,8 +12,7 @@ TODO: add feature list and screenshots here
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
YellowJacket relies on [Wails](https://wails.io/docs/introduction) for development.
|
Development documentation can be found [here](./docs/dev/overview.md).
|
||||||
Wails allows us to build a frontend with HTML/CSS/JS and backend with Golang.
|
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
|||||||
+172
@@ -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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
<strong>Error: { msg }</strong>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ (c *Config) formSubmitSuccess() {
|
||||||
|
<p>Config saved</p>
|
||||||
|
}
|
||||||
@@ -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, "<strong>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, "</strong>")
|
||||||
|
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, "<p>Config saved</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
+77
-87
@@ -1,145 +1,135 @@
|
|||||||
|
// Package config manages application configuration persistence.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"yellowjacket/backend/library"
|
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"yellowjacket/backend/library"
|
||||||
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config represents the application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
filePath string // required
|
ctx context.Context
|
||||||
*configData
|
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{
|
conf := &Config{
|
||||||
filePath: filePath,
|
filePath: path.Join(confDir, "config.toml"),
|
||||||
configData: data,
|
serveMux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
// TODO make sure required fields have SOMETHING in them
|
conf.logger = logger.WithGroup("config").With("config", conf)
|
||||||
// TODO Merge existing config with read in config
|
conf.serveMux.HandleFunc("/", conf.handle)
|
||||||
if data == nil {
|
|
||||||
return nil, errors.New("nil config")
|
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 nil, fmt.Errorf("invalid config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return conf, nil
|
return conf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type configData struct {
|
// Validate returns errors if there is a breaking issue with the config.
|
||||||
Library *library.Config
|
func (c *Config) Validate() error {
|
||||||
}
|
var configErrs error
|
||||||
|
|
||||||
// return errors if there is a *breaking* issue with the config
|
if c.Library != nil {
|
||||||
func (d *configData) validate() error {
|
if len(c.Library.DirectoryPath) != 0 {
|
||||||
if d.Library == nil {
|
if err := c.Library.Validate(); err != nil {
|
||||||
return errors.New("nil library config")
|
configErrs = errors.Join(configErrs, err)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// now that we have our config file, load it in
|
if configErrs != nil {
|
||||||
config, err = config.loadConfig()
|
return fmt.Errorf("one or more config parts are invalid: %w", configErrs)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not load config file %s: %w", configFilePath, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// before we return the config, lets make sure sub configs can invoke saving when they need
|
return nil
|
||||||
err = config.updateSubConfigSaveFuncReferences()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not update sub config save func references: %w", err)
|
|
||||||
}
|
|
||||||
return config, 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
|
// read in the file
|
||||||
confFileData, err := os.ReadFile(c.filePath)
|
confFileData, err := os.ReadFile(c.filePath)
|
||||||
if err != nil {
|
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
|
// parse it into the config struct
|
||||||
var confData configData
|
_, err = toml.Decode(string(confFileData), c)
|
||||||
_, err = toml.Decode(string(confFileData), &confData)
|
|
||||||
if err != nil {
|
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
|
// validate the config
|
||||||
if err = confData.validate(); err != nil {
|
if err = c.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
|
return fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := newConfig(c.filePath, &confData)
|
c.logger.Debug("loaded config file", "file", c.filePath)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not create config from config file data at %s: %w", c.filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// before we return the config, lets make sure sub configs can invoke saving when they need
|
return nil
|
||||||
err = config.updateSubConfigSaveFuncReferences()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not update sub config save func references: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) WriteConfig() error {
|
// Save writes the config to disk.
|
||||||
if err := c.validate(); err != nil {
|
func (c *Config) Save() error {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid config: %w", err)
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
confFileData, err := toml.Marshal(c)
|
confFileData, err := toml.Marshal(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not marshal config struct: %w", err)
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) updateSubConfigSaveFuncReferences() error {
|
// SetContext sets the Wails runtime context for event emission.
|
||||||
c.Library.SaveFunc = c.WriteConfig
|
func (c *Config) SetContext(ctx context.Context) {
|
||||||
return nil
|
c.ctx = ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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\<username>\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\<username>\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
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,94 @@
|
|||||||
|
// Package database provides SQLite database access.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
"fmt"
|
"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 go tool sqlc generate
|
||||||
//go:generate sqlc generate
|
|
||||||
|
|
||||||
type DB struct{
|
//go:embed sql/schemas/*.sql
|
||||||
db *sql.DB
|
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) {
|
// NewDB opens the database and applies schema migrations.
|
||||||
db, err := sql.Open("sqlite", ":memory:")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
|
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{
|
return &DB{
|
||||||
db: db,
|
db: db,
|
||||||
}, nil
|
Ctx: dbCtx,
|
||||||
|
Queries: queries,
|
||||||
|
logger: logger,
|
||||||
|
}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ RETURNING *;
|
|||||||
SELECT * FROM artist_credit
|
SELECT * FROM artist_credit
|
||||||
WHERE id = ? LIMIT 1;
|
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
|
-- name: UpdateArtistCredit :exec
|
||||||
UPDATE artist_credit
|
UPDATE artist_credit
|
||||||
SET text = ?
|
SET text = ?
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteArtistCredit :exec
|
-- name: DeleteArtistCredit :exec
|
||||||
DELETE FROM artist_credit
|
DELETE FROM artist_credit
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,24 @@ RETURNING *;
|
|||||||
SELECT * FROM artists
|
SELECT * FROM artists
|
||||||
WHERE id = ? LIMIT 1;
|
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
|
-- name: UpdateArtist :exec
|
||||||
UPDATE artists
|
UPDATE artists
|
||||||
SET name = ?
|
SET name = ?
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteArtist :exec
|
-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: GetAllArtists :many
|
||||||
|
SELECT * FROM artists
|
||||||
|
ORDER BY name;
|
||||||
|
|||||||
@@ -6,12 +6,82 @@ RETURNING *;
|
|||||||
SELECT * FROM audio_files
|
SELECT * FROM audio_files
|
||||||
WHERE id = ? LIMIT 1;
|
WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetAudioFileByPath :one
|
||||||
|
SELECT * FROM audio_files
|
||||||
|
WHERE file_path = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: UpdateAudioFile :exec
|
-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
|
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
|
-- name: DeleteAudioFile :exec
|
||||||
DELETE FROM audio_files
|
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;
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
-- name: CreateCoverArt :one
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetCoverArt :one
|
-- name: GetCoverArt :one
|
||||||
SELECT * FROM cover_art
|
SELECT * FROM cover_art
|
||||||
WHERE id = ? LIMIT 1;
|
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
|
-- name: UpdateCoverArt :exec
|
||||||
UPDATE cover_art
|
UPDATE cover_art
|
||||||
SET is_embedded = ?, file_path = ?, file_type_id = ?
|
SET is_embedded = ?, file_path = ?, mime_type = ?
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteCoverArt :exec
|
-- name: DeleteCoverArt :exec
|
||||||
DELETE FROM cover_art
|
DELETE FROM cover_art
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 = ?;
|
||||||
@@ -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 >= ?;
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
-- name: CreateRecording :one
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetRecording :one
|
-- name: GetRecording :one
|
||||||
@@ -8,10 +15,19 @@ WHERE id = ? LIMIT 1;
|
|||||||
|
|
||||||
-- name: UpdateRecording :exec
|
-- name: UpdateRecording :exec
|
||||||
UPDATE recordings
|
UPDATE recordings
|
||||||
SET name = ?
|
SET name = ?, artist_credit_id = ?
|
||||||
WHERE 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
|
-- name: DeleteRecording :exec
|
||||||
DELETE FROM recordings
|
DELETE FROM recordings
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: GetAllRecordings :many
|
||||||
|
SELECT * FROM recordings
|
||||||
|
ORDER BY name;
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
-- name: CreateReleaseGroupRecording :one
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetReleaseGroupRecording :one
|
-- name: GetReleaseGroupRecording :one
|
||||||
SELECT * FROM release_group_recordings
|
SELECT * FROM release_group_recordings
|
||||||
WHERE id = ? LIMIT 1;
|
WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: UpdateReleaseGroupRecording :exec
|
-- name: GetReleaseGroupRecordings :many
|
||||||
UPDATE release_group_recordings
|
SELECT * FROM release_group_recordings
|
||||||
SET release_group_id = ?, recording_id = ?
|
WHERE release_group_id = ?
|
||||||
WHERE id =?;
|
ORDER BY disc_number, track_number;
|
||||||
|
|
||||||
|
-- name: GetRecordingReleaseGroups :many
|
||||||
|
SELECT * FROM release_group_recordings
|
||||||
|
WHERE recording_id = ?;
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecording :exec
|
-- name: DeleteReleaseGroupRecording :exec
|
||||||
DELETE FROM release_group_recordings
|
DELETE FROM release_group_recordings
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteReleaseGroupRecordingByFK :exec
|
||||||
|
DELETE FROM release_group_recordings
|
||||||
|
WHERE release_group_id = ? AND recording_id = ?;
|
||||||
|
|||||||
@@ -2,16 +2,54 @@
|
|||||||
INSERT INTO release_groups (name) VALUES (?)
|
INSERT INTO release_groups (name) VALUES (?)
|
||||||
RETURNING *;
|
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
|
-- name: GetReleaseGroup :one
|
||||||
SELECT * FROM release_groups
|
SELECT * FROM release_groups
|
||||||
WHERE id = ? LIMIT 1;
|
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
|
-- name: UpdateReleaseGroup :exec
|
||||||
UPDATE release_groups
|
UPDATE release_groups
|
||||||
SET name = ?
|
SET name = ?
|
||||||
WHERE id =?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: UpdateReleaseGroupCoverArt :exec
|
||||||
|
UPDATE release_groups
|
||||||
|
SET cover_art_id = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteReleaseGroup :exec
|
-- name: DeleteReleaseGroup :exec
|
||||||
DELETE FROM release_groups
|
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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE IF NOT EXISTS artist_credit (
|
CREATE TABLE IF NOT EXISTS artist_credit (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
text string NOT NULL
|
text TEXT NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
CREATE TABLE IF NOT EXISTS artist_credit_artist (
|
CREATE TABLE IF NOT EXISTS artist_credit_artist (
|
||||||
id int PRIMARY KEY,
|
id integer PRIMARY KEY,
|
||||||
artist_id int NOT NULL,
|
artist_id int NOT NULL,
|
||||||
credit_id int NOT NULL,
|
credit_id int NOT NULL,
|
||||||
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE IF NOT EXISTS artists (
|
CREATE TABLE IF NOT EXISTS artists (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name text NOT NULL
|
name TEXT NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
CREATE TABLE IF NOT EXISTS audio_files (
|
CREATE TABLE IF NOT EXISTS audio_files (
|
||||||
id int PRIMARY KEY,
|
id integer PRIMARY KEY,
|
||||||
file_path text NOT NULL UNIQUE,
|
file_path text NOT NULL UNIQUE,
|
||||||
length_milliseconds int NOT NULL,
|
length_milliseconds int NOT NULL,
|
||||||
file_type_id int NOT NULL,
|
file_type_id int NOT NULL,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
CREATE TABLE IF NOT EXISTS cover_art (
|
CREATE TABLE IF NOT EXISTS cover_art (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
is_embedded bool NOT NULL DEFAULT(false),
|
is_embedded BOOL NOT NULL DEFAULT(false),
|
||||||
file_path text NOT NULL,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
file_type_id int NOT NULL,
|
mime_type TEXT NOT NULL
|
||||||
FOREIGN KEY(file_type_id) REFERENCES file_types(id)
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE IF NOT EXISTS file_types (
|
CREATE TABLE IF NOT EXISTS file_types (
|
||||||
id INTEGER PRIMARY KEY,
|
id integer PRIMARY KEY,
|
||||||
extension text NOT NULL UNIQUE
|
extension text NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
CREATE TABLE IF NOT EXISTS recordings (
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name text NOT NULL,
|
name TEXT NOT NULL,
|
||||||
artist_credit_id int 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)
|
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
CREATE TABLE IF NOT EXISTS release_group_recordings (
|
CREATE TABLE IF NOT EXISTS release_group_recordings (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
release_group_id int NOT NULL,
|
release_group_id INTEGER NOT NULL,
|
||||||
recording_id int NOT NULL,
|
recording_id INTEGER NOT NULL,
|
||||||
|
track_number INTEGER,
|
||||||
|
disc_number INTEGER,
|
||||||
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
|
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
|
||||||
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
CREATE TABLE IF NOT EXISTS release_groups (
|
CREATE TABLE IF NOT EXISTS release_groups (
|
||||||
id int PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name text NOT NULL,
|
name TEXT NOT NULL UNIQUE,
|
||||||
cover_art_id int NOT NULL,
|
cover_art_id INTEGER,
|
||||||
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id)
|
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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: artist_credit.sql
|
// source: artist_credit.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
@@ -14,7 +14,7 @@ INSERT INTO artist_credit (text) VALUES (?)
|
|||||||
RETURNING id, text
|
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)
|
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
|
||||||
var i ArtistCredit
|
var i ArtistCredit
|
||||||
err := row.Scan(&i.ID, &i.Text)
|
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
|
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
|
||||||
DELETE FROM artist_credit
|
DELETE FROM artist_credit
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
|
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
|
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
|
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
|
||||||
UPDATE artist_credit
|
UPDATE artist_credit
|
||||||
SET text = ?
|
SET text = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateArtistCreditParams struct {
|
type UpdateArtistCreditParams struct {
|
||||||
Text interface{}
|
Text string
|
||||||
ID int64
|
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)
|
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: artist_credit_artists.sql
|
// source: artist_credit_artists.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: artists.sql
|
// source: artists.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
@@ -23,7 +23,7 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error)
|
|||||||
|
|
||||||
const deleteArtist = `-- name: DeleteArtist :exec
|
const deleteArtist = `-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
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
|
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
|
const getArtist = `-- name: GetArtist :one
|
||||||
SELECT id, name FROM artists
|
SELECT id, name FROM artists
|
||||||
WHERE id = ? LIMIT 1
|
WHERE id = ? LIMIT 1
|
||||||
@@ -43,10 +71,22 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
|||||||
return i, err
|
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
|
const updateArtist = `-- name: UpdateArtist :exec
|
||||||
UPDATE artists
|
UPDATE artists
|
||||||
SET name = ?
|
SET name = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateArtistParams struct {
|
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)
|
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: audio_files.sql
|
// source: audio_files.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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
|
const createAudioFile = `-- name: CreateAudioFile :one
|
||||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?)
|
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
|
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
|
const deleteAudioFile = `-- name: DeleteAudioFile :exec
|
||||||
DELETE FROM audio_files
|
DELETE FROM audio_files
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error {
|
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
|
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
|
const getAudioFile = `-- name: GetAudioFile :one
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
||||||
WHERE id = ? LIMIT 1
|
WHERE id = ? LIMIT 1
|
||||||
@@ -67,10 +199,168 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
|||||||
return i, err
|
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
|
const updateAudioFile = `-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
|
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAudioFileParams struct {
|
type UpdateAudioFileParams struct {
|
||||||
@@ -91,3 +381,19 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
|
|||||||
)
|
)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: cover_art.sql
|
// source: cover_art.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
@@ -10,31 +10,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createCoverArt = `-- name: CreateCoverArt :one
|
const createCoverArt = `-- 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 id, is_embedded, file_path, file_type_id
|
RETURNING id, is_embedded, file_path, mime_type
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateCoverArtParams struct {
|
type CreateCoverArtParams struct {
|
||||||
IsEmbedded bool
|
IsEmbedded bool
|
||||||
FilePath string
|
FilePath string
|
||||||
FileTypeID int64
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams) (CoverArt, error) {
|
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
|
var i CoverArt
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.IsEmbedded,
|
&i.IsEmbedded,
|
||||||
&i.FilePath,
|
&i.FilePath,
|
||||||
&i.FileTypeID,
|
&i.MimeType,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCoverArt = `-- name: DeleteCoverArt :exec
|
const deleteCoverArt = `-- name: DeleteCoverArt :exec
|
||||||
DELETE FROM cover_art
|
DELETE FROM cover_art
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteCoverArt(ctx context.Context, id int64) error {
|
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
|
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
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -54,21 +54,38 @@ func (q *Queries) GetCoverArt(ctx context.Context, id int64) (CoverArt, error) {
|
|||||||
&i.ID,
|
&i.ID,
|
||||||
&i.IsEmbedded,
|
&i.IsEmbedded,
|
||||||
&i.FilePath,
|
&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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCoverArt = `-- name: UpdateCoverArt :exec
|
const updateCoverArt = `-- name: UpdateCoverArt :exec
|
||||||
UPDATE cover_art
|
UPDATE cover_art
|
||||||
SET is_embedded = ?, file_path = ?, file_type_id = ?
|
SET is_embedded = ?, file_path = ?, mime_type = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateCoverArtParams struct {
|
type UpdateCoverArtParams struct {
|
||||||
IsEmbedded bool
|
IsEmbedded bool
|
||||||
FilePath string
|
FilePath string
|
||||||
FileTypeID int64
|
MimeType string
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +93,35 @@ func (q *Queries) UpdateCoverArt(ctx context.Context, arg UpdateCoverArtParams)
|
|||||||
_, err := q.db.ExecContext(ctx, updateCoverArt,
|
_, err := q.db.ExecContext(ctx, updateCoverArt,
|
||||||
arg.IsEmbedded,
|
arg.IsEmbedded,
|
||||||
arg.FilePath,
|
arg.FilePath,
|
||||||
arg.FileTypeID,
|
arg.MimeType,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: file_types.sql
|
// source: file_types.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
@@ -11,7 +16,7 @@ type Artist struct {
|
|||||||
|
|
||||||
type ArtistCredit struct {
|
type ArtistCredit struct {
|
||||||
ID int64
|
ID int64
|
||||||
Text interface{}
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistCreditArtist struct {
|
type ArtistCreditArtist struct {
|
||||||
@@ -32,7 +37,7 @@ type CoverArt struct {
|
|||||||
ID int64
|
ID int64
|
||||||
IsEmbedded bool
|
IsEmbedded bool
|
||||||
FilePath string
|
FilePath string
|
||||||
FileTypeID int64
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileType struct {
|
type FileType struct {
|
||||||
@@ -40,20 +45,70 @@ type FileType struct {
|
|||||||
Extension string
|
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 {
|
type Recording struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
ArtistCreditID int64
|
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 {
|
type ReleaseGroup struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
CoverArtID int64
|
CoverArtID sql.NullInt64
|
||||||
|
AlbumArtistCreditID sql.NullInt64
|
||||||
|
Year sql.NullInt64
|
||||||
|
TotalTracks sql.NullInt64
|
||||||
|
TotalDiscs sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReleaseGroupRecording struct {
|
type ReleaseGroupRecording struct {
|
||||||
ID int64
|
ID int64
|
||||||
ReleaseGroupID int64
|
ReleaseGroupID int64
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
TrackNumber sql.NullInt64
|
||||||
|
DiscNumber sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,29 +1,94 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: recordings.sql
|
// source: recordings.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createRecording = `-- name: CreateRecording :one
|
const createRecording = `-- name: CreateRecording :one
|
||||||
INSERT INTO recordings (name) VALUES (?)
|
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
|
||||||
RETURNING id, name, artist_credit_id
|
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) {
|
type CreateRecordingParams struct {
|
||||||
row := q.db.QueryRowContext(ctx, createRecording, name)
|
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
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRecording = `-- name: DeleteRecording :exec
|
const deleteRecording = `-- name: DeleteRecording :exec
|
||||||
DELETE FROM recordings
|
DELETE FROM recordings
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
|
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
|
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
|
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
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) {
|
func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getRecording, id)
|
row := q.db.QueryRowContext(ctx, getRecording, id)
|
||||||
var i Recording
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateRecording = `-- name: UpdateRecording :exec
|
const updateRecording = `-- name: UpdateRecording :exec
|
||||||
UPDATE recordings
|
UPDATE recordings
|
||||||
SET name = ?
|
SET name = ?, artist_credit_id = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateRecordingParams struct {
|
type UpdateRecordingParams struct {
|
||||||
Name string
|
Name string
|
||||||
ID int64
|
ArtistCreditID int64
|
||||||
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateRecording(ctx context.Context, arg UpdateRecordingParams) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,49 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: release_group_recordings.sql
|
// source: release_group_recordings.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
|
const createReleaseGroupRecording = `-- 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)
|
||||||
RETURNING id, release_group_id, recording_id
|
VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING id, release_group_id, recording_id, track_number, disc_number
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateReleaseGroupRecordingParams struct {
|
type CreateReleaseGroupRecordingParams struct {
|
||||||
ReleaseGroupID int64
|
ReleaseGroupID int64
|
||||||
RecordingID int64
|
RecordingID int64
|
||||||
|
TrackNumber sql.NullInt64
|
||||||
|
DiscNumber sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
|
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
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
|
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
|
||||||
DELETE FROM release_group_recordings
|
DELETE FROM release_group_recordings
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) error {
|
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
|
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
|
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
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (ReleaseGroupRecording, error) {
|
func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (ReleaseGroupRecording, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getReleaseGroupRecording, id)
|
row := q.db.QueryRowContext(ctx, getReleaseGroupRecording, id)
|
||||||
var i ReleaseGroupRecording
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateReleaseGroupRecording = `-- name: UpdateReleaseGroupRecording :exec
|
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
|
||||||
UPDATE release_group_recordings
|
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
|
||||||
SET release_group_id = ?, recording_id = ?
|
WHERE release_group_id = ?
|
||||||
WHERE id =?
|
ORDER BY disc_number, track_number
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateReleaseGroupRecordingParams struct {
|
func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) ([]ReleaseGroupRecording, error) {
|
||||||
ReleaseGroupID int64
|
rows, err := q.db.QueryContext(ctx, getReleaseGroupRecordings, releaseGroupID)
|
||||||
RecordingID int64
|
if err != nil {
|
||||||
ID int64
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
func (q *Queries) UpdateReleaseGroupRecording(ctx context.Context, arg UpdateReleaseGroupRecordingParams) error {
|
var items []ReleaseGroupRecording
|
||||||
_, err := q.db.ExecContext(ctx, updateReleaseGroupRecording, arg.ReleaseGroupID, arg.RecordingID, arg.ID)
|
for rows.Next() {
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,76 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.29.0
|
||||||
// source: release_groups.sql
|
// source: release_groups.sql
|
||||||
|
|
||||||
package sqlcgen
|
package sqlcgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createReleaseGroup = `-- name: CreateReleaseGroup :one
|
const createReleaseGroup = `-- name: CreateReleaseGroup :one
|
||||||
INSERT INTO release_groups (name) VALUES (?)
|
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) {
|
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
|
||||||
row := q.db.QueryRowContext(ctx, createReleaseGroup, name)
|
row := q.db.QueryRowContext(ctx, createReleaseGroup, name)
|
||||||
var i ReleaseGroup
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
|
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
|
||||||
DELETE FROM release_groups
|
DELETE FROM release_groups
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
|
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
|
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
|
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
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, error) {
|
func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getReleaseGroup, id)
|
row := q.db.QueryRowContext(ctx, getReleaseGroup, id)
|
||||||
var i ReleaseGroup
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
|
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
|
||||||
UPDATE release_groups
|
UPDATE release_groups
|
||||||
SET name = ?
|
SET name = ?
|
||||||
WHERE id =?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateReleaseGroupParams struct {
|
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)
|
_, err := q.db.ExecContext(ctx, updateReleaseGroup, arg.Name, arg.ID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
templ (d Directory) ToFormElement() {
|
||||||
|
<script>
|
||||||
|
function selectLibraryDirectory(pElement) {
|
||||||
|
try {
|
||||||
|
window.DirectoryPicker()
|
||||||
|
.then((result) => {
|
||||||
|
if (result.length != 0) {
|
||||||
|
pElement.value = result;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("error with directory picker: " + err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scanLibrary(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Scanning...";
|
||||||
|
window.Scan()
|
||||||
|
.then(() => {
|
||||||
|
button.textContent = "Scan Library";
|
||||||
|
button.disabled = false;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("error scanning library: " + err);
|
||||||
|
button.textContent = "Scan Library";
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<button type="button" onclick="selectLibraryDirectory(this.nextElementSibling)">Select</button>
|
||||||
|
<input type="text" name="library.directory" value={ d } readonly/>
|
||||||
|
<button type="button" onclick="scanLibrary(this)">Scan Library</button>
|
||||||
|
}
|
||||||
@@ -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, "<script>\n function selectLibraryDirectory(pElement) {\n try {\n window.DirectoryPicker()\n .then((result) => {\n if (result.length != 0) {\n pElement.value = result;\n }\n })\n .catch((err) => {\n console.error(\"error with directory picker: \" + err);\n });\n }\n catch (err) {\n console.error(err);\n }\n }\n function scanLibrary(button) {\n button.disabled = true;\n button.textContent = \"Scanning...\";\n window.Scan()\n .then(() => {\n button.textContent = \"Scan Library\";\n button.disabled = false;\n })\n .catch((err) => {\n console.error(\"error scanning library: \" + err);\n button.textContent = \"Scan Library\";\n button.disabled = false;\n });\n }\n </script><button type=\"button\" onclick=\"selectLibraryDirectory(this.nextElementSibling)\">Select</button> <input type=\"text\" name=\"library.directory\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library/config.templ`, Line: 37, Col: 54}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" readonly> <button type=\"button\" onclick=\"scanLibrary(this)\">Scan Library</button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+612
-34
@@ -2,62 +2,640 @@ package library
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"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 {
|
// Library manages scanning and querying the music collection.
|
||||||
DirectoryPath string
|
|
||||||
SaveFunc func() error `toml:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) Validate() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var DefaultConfig *Config = &Config{
|
|
||||||
DirectoryPath: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Library struct {
|
type Library struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
conf *Config
|
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 {
|
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 {
|
if err := conf.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
|
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
|
||||||
}
|
}
|
||||||
return &Library{
|
|
||||||
conf: conf,
|
library := &Library{
|
||||||
}, nil
|
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.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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Library) GetDir() (string, error) {
|
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
|
||||||
return l.conf.DirectoryPath, nil
|
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 {
|
// Process metadata and create related records
|
||||||
fileInfo, err := os.Stat(dirPath)
|
recordingID, err := l.processMetadata(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not stat %s: %w", dirPath, err)
|
return fmt.Errorf("could not process metadata: %w", err)
|
||||||
}
|
|
||||||
if !fileInfo.IsDir() {
|
|
||||||
return fmt.Errorf("dirPath is not a directory: %s", dirPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.conf.DirectoryPath = dirPath
|
if err := l.db.Queries.UpdateAudioFileRecording(
|
||||||
err = l.conf.SaveFunc()
|
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
||||||
if err != nil {
|
RecordingID: recordingID,
|
||||||
return fmt.Errorf("could not save library dir config: %w", err)
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,94 @@
|
|||||||
|
// Package logging provides a slog-based logger adapter for Wails.
|
||||||
package logging
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO check that error
|
// Logger wraps slog to implement the Wails logger interface.
|
||||||
func PrettyJSON(obj interface{}) string {
|
type Logger struct {
|
||||||
bytes, _ := json.MarshalIndent(obj, "\t", "\t")
|
slogger *slog.Logger
|
||||||
return string(bytes)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package models defines domain types for music data.
|
||||||
|
package models
|
||||||
|
|
||||||
|
// Art holds album artwork data.
|
||||||
|
type Art struct{}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+571
-76
@@ -1,63 +1,231 @@
|
|||||||
|
// Package player provides audio playback functionality.
|
||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gopxl/beep"
|
"github.com/TheCodeOfCaleb/beep/v2"
|
||||||
"github.com/gopxl/beep/effects"
|
"github.com/TheCodeOfCaleb/beep/v2/effects"
|
||||||
"github.com/gopxl/beep/generators"
|
"github.com/TheCodeOfCaleb/beep/v2/generators"
|
||||||
"github.com/gopxl/beep/mp3"
|
"github.com/TheCodeOfCaleb/beep/v2/speaker"
|
||||||
"github.com/gopxl/beep/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 {
|
type Player struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
state PlayerState
|
logger *slog.Logger
|
||||||
currentFile *os.File
|
db *database.DB
|
||||||
format beep.Format
|
state PlayerState
|
||||||
baseStreamer beep.Streamer
|
currentFile *os.File
|
||||||
seeker beep.StreamSeeker
|
format beep.Format
|
||||||
resampled beep.Streamer
|
baseStreamer beep.Streamer
|
||||||
control *beep.Ctrl
|
seeker beep.StreamSeeker
|
||||||
volume *effects.Volume
|
resampled beep.Streamer
|
||||||
speakerStreamer 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 (
|
const (
|
||||||
Playing PlayerState = iota
|
Playing PlayerState = "playing"
|
||||||
Paused
|
Paused PlayerState = "paused"
|
||||||
Stopped
|
Stopped PlayerState = "stopped"
|
||||||
)
|
)
|
||||||
|
|
||||||
var speakerSampleRate = beep.SampleRate(44100)
|
var speakerSampleRate = beep.SampleRate(44100)
|
||||||
|
|
||||||
func NewPlayer() (*Player, error) {
|
// NewPlayer creates a player and initializes the audio speaker.
|
||||||
return &Player{
|
func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) {
|
||||||
|
player := &Player{
|
||||||
|
ctx: ctx,
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
state: Stopped,
|
state: Stopped,
|
||||||
baseStreamer: generators.Silence(-1),
|
baseStreamer: generators.Silence(-1),
|
||||||
format: beep.Format{
|
format: beep.Format{
|
||||||
SampleRate: speakerSampleRate,
|
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 {
|
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
|
// wrap in ctrl streamer to allow play/pause
|
||||||
p.control = &beep.Ctrl{Streamer: p.resampled}
|
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
|
// wrap in volume streamer
|
||||||
p.volume = &effects.Volume{
|
p.volume = &effects.Volume{
|
||||||
Streamer: p.control,
|
Streamer: p.control,
|
||||||
Base: 2,
|
Base: 2,
|
||||||
Volume: 0,
|
Volume: prevVolume,
|
||||||
Silent: false,
|
Silent: prevSilent,
|
||||||
}
|
}
|
||||||
|
|
||||||
// set "final" streamer
|
// set "final" streamer
|
||||||
@@ -86,68 +263,153 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: proper state management extracted to function
|
// startPaused registers the current streamer chain with the speaker in a
|
||||||
func (p *Player) changeState(desiredState PlayerState) error {
|
// paused state. This keeps the speaker always active when a file is loaded,
|
||||||
return nil
|
// 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 {
|
func (p *Player) LoadFile(filePath string) error {
|
||||||
// opening file
|
// opening file
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
p.logger.Error("Failed to open file")
|
||||||
|
|
||||||
return fmt.Errorf("failed to open file %w", err)
|
return fmt.Errorf("failed to open file %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt to decode mp3 file and create streamer and format data
|
streamer, format, err := metadata.DecodeFile(f)
|
||||||
streamer, format, err := mp3.Decode(f)
|
|
||||||
if err != nil {
|
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.currentFile = f
|
||||||
|
|
||||||
p.updateStreamers(streamer, format.SampleRate)
|
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
|
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 {
|
func (p *Player) Play() error {
|
||||||
p.state = Playing
|
if err := p.validateReadyToPlay(); err != nil {
|
||||||
// hangs until song finishes playing
|
return err
|
||||||
done := make(chan bool)
|
}
|
||||||
speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
|
|
||||||
p.state = Paused //called when streamer finishes
|
|
||||||
done <- true
|
|
||||||
})))
|
|
||||||
|
|
||||||
<-done
|
if p.state == Playing {
|
||||||
return nil
|
p.logger.Info("Already playing")
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: reduce dupilcation in pause/resume functions
|
return nil
|
||||||
func (p *Player) Pause() error {
|
}
|
||||||
speaker.Lock()
|
|
||||||
p.control.Paused = true
|
|
||||||
speaker.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: reduce dupilcation in pause/resume functions
|
// Track finished naturally — seek to the beginning and re-register
|
||||||
func (p *Player) Resume() error {
|
// 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()
|
speaker.Lock()
|
||||||
p.control.Paused = false
|
p.control.Paused = false
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
|
p.state = Playing
|
||||||
|
p.emitPlaybackStateChanged(p.state)
|
||||||
|
p.logger.Info("Started playback")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//Paraprasing info from the beep docs here:
|
// Pause pauses the current playback.
|
||||||
/*
|
func (p *Player) Pause() error {
|
||||||
To INCREASE volume by 1 means to multiply the signal by Base.
|
if p.control == nil {
|
||||||
Volume = 0 means unchanged volume.
|
return errors.New("no audio stream to pause")
|
||||||
Positive Volume value means increasing volume
|
}
|
||||||
Negative Volume value means decreasing volume
|
|
||||||
*/
|
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 {
|
func (p *Player) SetVolume(desiredVolume UserVolume) error {
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
// clamp value between 1 and 100
|
// clamp value between 1 and 100
|
||||||
@@ -160,6 +422,8 @@ func (p *Player) SetVolume(desiredVolume UserVolume) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangeVolume adjusts the volume by a relative amount.
|
||||||
func (p *Player) ChangeVolume(deltaVolume int) error {
|
func (p *Player) ChangeVolume(deltaVolume int) error {
|
||||||
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume))
|
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume))
|
||||||
}
|
}
|
||||||
@@ -168,27 +432,258 @@ func (p *Player) getUserVolume() UserVolume {
|
|||||||
return PlayerVolume(p.volume.Volume).ToUserVolume()
|
return PlayerVolume(p.volume.Volume).ToUserVolume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MuteToggle toggles the mute state.
|
||||||
func (p *Player) MuteToggle() error {
|
func (p *Player) MuteToggle() error {
|
||||||
p.volume.Silent = !p.volume.Silent
|
p.volume.Silent = !p.volume.Silent
|
||||||
|
|
||||||
return nil
|
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) {
|
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()))
|
pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len()))
|
||||||
|
speaker.Unlock()
|
||||||
|
|
||||||
return int(pos), nil
|
return int(pos), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: double check best type for percentage parameter
|
// Seek jumps to a specific position in seconds.
|
||||||
// percentage comes from the progress bar as a value between 0 and 100
|
func (p *Player) Seek(targetSeconds int) error {
|
||||||
func (p *Player) Seek(percentage int) error {
|
if p.seeker == nil {
|
||||||
//take percentage value (0-100), make 0-1, multiply by total number of samples in stream
|
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
||||||
samples := int(math.Round((float64(percentage) / 100.0) * float64(p.seeker.Len())))
|
|
||||||
|
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)
|
p.seeker.Seek(samples)
|
||||||
|
speaker.Unlock()
|
||||||
|
|
||||||
return nil
|
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) {
|
func (p *Player) TrackLengthInSeconds() (int, error) {
|
||||||
len := p.seeker.Len() / int(p.format.SampleRate)
|
if p.seeker == nil {
|
||||||
return len, 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package player
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,30 +14,29 @@ var testQueue = []string{
|
|||||||
|
|
||||||
func TestPlayer(t *testing.T) {
|
func TestPlayer(t *testing.T) {
|
||||||
t.Logf("Starting test")
|
t.Logf("Starting test")
|
||||||
p, err := NewPlayer()
|
|
||||||
|
p, err := NewPlayer(context.Background(), slog.Default(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not create player\n%s", err.Error())
|
t.Errorf("could not create player\n%s", err.Error())
|
||||||
t.Failed()
|
t.Failed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.SetContext(t.Context())
|
||||||
t.Logf("initializing player")
|
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 {
|
for _, track := range testQueue {
|
||||||
t.Logf("loading file: %s", track)
|
t.Logf("loading file: %s", track)
|
||||||
|
|
||||||
err = p.LoadFile(track)
|
err = p.LoadFile(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not load file\n%s\n%s", track, err.Error())
|
t.Errorf("could not load file\n%s\n%s", track, err.Error())
|
||||||
t.Failed()
|
t.Failed()
|
||||||
}
|
}
|
||||||
|
|
||||||
err = p.Play()
|
err = p.Play()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not play file\n%s\n%s", track, err.Error())
|
t.Errorf("could not play file\n%s\n%s", track, err.Error())
|
||||||
t.Failed()
|
t.Failed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
|
// UserVolume represents volume on a user-facing scale (0-100).
|
||||||
type UserVolume int
|
type UserVolume int
|
||||||
|
|
||||||
|
// PlayerVolume represents volume on an internal scale (-10 to 10).
|
||||||
type PlayerVolume float64
|
type PlayerVolume float64
|
||||||
|
|
||||||
const MinUserVol UserVolume = 0
|
// User volume range bounds.
|
||||||
const MaxUserVol UserVolume = 100
|
const (
|
||||||
|
MinUserVol UserVolume = 0
|
||||||
|
MaxUserVol UserVolume = 100
|
||||||
|
)
|
||||||
|
|
||||||
const MinPlayerVol PlayerVolume = -10
|
// Player volume range bounds.
|
||||||
const MaxPlayerVol PlayerVolume = 10
|
const (
|
||||||
|
MinPlayerVol PlayerVolume = -4
|
||||||
|
MaxPlayerVol PlayerVolume = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToPlayerVolume converts user volume to internal player volume.
|
||||||
func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
|
func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
|
||||||
var newVol PlayerVolume
|
var newVol PlayerVolume
|
||||||
|
|
||||||
@@ -16,9 +26,11 @@ func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
|
|||||||
ratio := PlayerVolume(oldVol-MinUserVol) / PlayerVolume(MaxUserVol-MinUserVol)
|
ratio := PlayerVolume(oldVol-MinUserVol) / PlayerVolume(MaxUserVol-MinUserVol)
|
||||||
newVol = ratio*(MaxPlayerVol-MinPlayerVol) + MinPlayerVol
|
newVol = ratio*(MaxPlayerVol-MinPlayerVol) + MinPlayerVol
|
||||||
}
|
}
|
||||||
|
|
||||||
return newVol
|
return newVol
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToUserVolume converts internal player volume to user volume.
|
||||||
func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
|
func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
|
||||||
var newVol UserVolume
|
var newVol UserVolume
|
||||||
|
|
||||||
@@ -26,6 +38,7 @@ func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
|
|||||||
ratio := (oldVolFloat - MinPlayerVol) / (MaxPlayerVol - MinPlayerVol)
|
ratio := (oldVolFloat - MinPlayerVol) / (MaxPlayerVol - MinPlayerVol)
|
||||||
newVol = UserVolume(ratio*PlayerVolume(MaxUserVol-MinUserVol)) + MinUserVol
|
newVol = UserVolume(ratio*PlayerVolume(MaxUserVol-MinUserVol)) + MinUserVol
|
||||||
}
|
}
|
||||||
|
|
||||||
return newVol
|
return newVol
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +46,10 @@ func clampVolume(v UserVolume) UserVolume {
|
|||||||
if v > MaxUserVol {
|
if v > MaxUserVol {
|
||||||
return MaxUserVol
|
return MaxUserVol
|
||||||
}
|
}
|
||||||
|
|
||||||
if v < MinUserVol {
|
if v < MinUserVol {
|
||||||
return MinUserVol
|
return MinUserVol
|
||||||
}
|
}
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
+1648
File diff suppressed because it is too large
Load Diff
+114
-2
@@ -1,4 +1,116 @@
|
|||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: white;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-30
@@ -10,40 +10,29 @@
|
|||||||
<script src="/index.ts" type="module"></script>
|
<script src="/index.ts" type="module"></script>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header class="container">
|
<header class="top-bar">
|
||||||
<nav>
|
<hgroup>
|
||||||
<ul>
|
<h1 class="title">YellowJacket</h1>
|
||||||
<li>
|
<h3 class="subtitle">Music how it was meant to bee.</h3>
|
||||||
<hgroup>
|
</hgroup>
|
||||||
<h1>YellowJacket</h1>
|
<a href="/src/pages/config/config.html">
|
||||||
<p>Music how it was meant to bee.</p>
|
<img src="/src/assets/images/icons/ui/settings.svg" />
|
||||||
</hgroup>
|
</a>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<details class="dropdown">
|
|
||||||
<summary role="button" class="outline">
|
|
||||||
YJ
|
|
||||||
</summary>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="/src/pages/config/config.html">
|
|
||||||
<img src="/src/assets/images/icons/ui/settings.svg" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
<hr />
|
<div class="sidebar">
|
||||||
<main class="container">
|
<app-sidebar></app-sidebar>
|
||||||
|
</div>
|
||||||
|
<main class="main-panel" id="main-content">
|
||||||
|
<track-list></track-list>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer class="bottom-bar">
|
||||||
|
<now-playing></now-playing>
|
||||||
<audio-player></audio-player>
|
<audio-player></audio-player>
|
||||||
|
<button id="queue-button">
|
||||||
|
<wa-icon name="list"></wa-icon>
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
<queue-panel id="queue-panel"></queue-panel>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+64
-3
@@ -1,5 +1,66 @@
|
|||||||
import '@components/audio-player/audio-player.ts';
|
import '@components/audio-player/audio-player.ts';
|
||||||
import '@node_modules/@shoelace-style/shoelace/dist/themes/light.css';
|
import '@components/track-list/track-list.ts';
|
||||||
import { setBasePath } from '@node_modules/@shoelace-style/shoelace/dist/utilities/base-path';
|
import '@components/cover-grid/cover-grid.ts';
|
||||||
|
import '@components/now-playing/now-playing.ts';
|
||||||
|
import '@components/sidebar/app-sidebar.ts';
|
||||||
|
import '@components/queue-panel/queue-panel.ts';
|
||||||
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
|
|
||||||
setBasePath("dist/shoelace")
|
setBasePath('/dist/webawesome');
|
||||||
|
|
||||||
|
// Scroll state management per view
|
||||||
|
const scrollPositions = new Map<string, number>();
|
||||||
|
let currentView = 'tracks';
|
||||||
|
|
||||||
|
// Navigation event listener for view switching
|
||||||
|
document.addEventListener('navigate', (e: Event) => {
|
||||||
|
const { view } = (e as CustomEvent).detail;
|
||||||
|
const mainContent = document.getElementById('main-content');
|
||||||
|
|
||||||
|
if (!mainContent) return;
|
||||||
|
|
||||||
|
// Save scroll position for current view before switching
|
||||||
|
scrollPositions.set(currentView, mainContent.scrollTop);
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'albums':
|
||||||
|
mainContent.innerHTML = '<cover-grid></cover-grid>';
|
||||||
|
break;
|
||||||
|
case 'tracks':
|
||||||
|
mainContent.innerHTML = '<track-list></track-list>';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mainContent.innerHTML = `<div style="padding: 1em; color: #b3b3b3;">
|
||||||
|
<p>Coming soon: ${view}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll position for new view
|
||||||
|
mainContent.scrollTop = scrollPositions.get(view) ?? 0;
|
||||||
|
|
||||||
|
// Update current view tracker
|
||||||
|
currentView = view;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue panel toggle
|
||||||
|
const queueButton = document.getElementById('queue-button');
|
||||||
|
const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (queueButton && queuePanel) {
|
||||||
|
queueButton.addEventListener('click', () => {
|
||||||
|
const isOpen = queuePanel.hasAttribute('open');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
queuePanel.removeAttribute('open');
|
||||||
|
} else {
|
||||||
|
queuePanel.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close panel when the component dispatches a close event
|
||||||
|
queuePanel.addEventListener('queue-panel-close', () => {
|
||||||
|
queuePanel.removeAttribute('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+24
-17
@@ -1,19 +1,26 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lit-labs/signals": "^0.1.2",
|
"@awesome.me/webawesome": "^3.2.1",
|
||||||
"@shoelace-style/shoelace": "^2.20.1",
|
"@lit-labs/signals": "^0.1.2",
|
||||||
"lit": "^3.2.1"
|
"htmx.org": "2.0.4",
|
||||||
},
|
"lit": "^3.2.1"
|
||||||
"devDependencies": {
|
},
|
||||||
"ts-lit-plugin": "^2.0.2",
|
"devDependencies": {
|
||||||
"typescript-lit-html-plugin": "^0.9.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
"vite": "^6.2.3",
|
"ts-lit-plugin": "^2.0.2",
|
||||||
"vite-plugin-static-copy": "^2.3.1",
|
"typescript-lit-html-plugin": "^0.9.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite": "^6.3.5",
|
||||||
}
|
"vite-plugin-static-copy": "^2.3.1",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
},
|
||||||
|
"stylelint": {
|
||||||
|
"extends": [
|
||||||
|
"stylelint-config-standard"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1f0c68f2bbba06f2a24fb43e1238cb42
|
0abc2bb78bacb130d41b3ed39483ae9f
|
||||||
Generated
+907
-237
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,34 @@
|
|||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement } from 'lit/decorators.js';
|
import { customElement } from 'lit/decorators.js';
|
||||||
import './controls/player-controls';
|
import './controls/player-controls';
|
||||||
import './seekbar/seek-bar';
|
import './seekbar/seek-bar';
|
||||||
import '@go/player/Player';
|
import './volume-control/volume-control';
|
||||||
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
|
|
||||||
|
|
||||||
|
|
||||||
const audioPlayer = () => html`
|
|
||||||
<div>
|
|
||||||
<player-controls></player-controls>
|
|
||||||
<div style="width: 50%">
|
|
||||||
<seek-bar></seek-bar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
@customElement('audio-player')
|
@customElement('audio-player')
|
||||||
export class AudioPlayer extends LitElement {
|
export class AudioPlayer extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.audio-player-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return audioPlayer();
|
return html`
|
||||||
|
<div class="audio-player-container">
|
||||||
|
<div class="player-main">
|
||||||
|
<player-controls></player-controls>
|
||||||
|
<div>
|
||||||
|
<seek-bar></seek-bar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<volume-control></volume-control>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,110 @@
|
|||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement } from 'lit/decorators.js';
|
||||||
import { Play, Pause } from '@go/player/Player';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
// assets
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
import pauseSVG from "@assets/images/icons/music/pause-solid.svg"
|
|
||||||
import playSVG from "@assets/images/icons/music/play-solid.svg"
|
|
||||||
import shuffleSVG from "@assets/images/icons/music/shuffle.svg"
|
|
||||||
import skipPrevSVG from "@assets/images/icons/music/skip-prev-solid.svg"
|
|
||||||
import skipNextSVG from "@assets/images/icons/music/skip-next-solid.svg"
|
|
||||||
import repeatSVG from "@assets/images/icons/music/repeat.svg"
|
|
||||||
|
|
||||||
@customElement('player-controls')
|
@customElement('player-controls')
|
||||||
export class PlayerControls extends LitElement {
|
export class PlayerControls extends LitElement {
|
||||||
|
private player = new PlayerController(this);
|
||||||
|
private queue = new QueueController(this);
|
||||||
|
|
||||||
@property({ type: Boolean })
|
static override styles = css`
|
||||||
isPlaying = false
|
#player-control-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repeat-one {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repeat-one::after {
|
||||||
|
content: '1';
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private handlePlayClick = () => {
|
||||||
|
this.player.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePauseClick = () => {
|
||||||
|
this.player.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleNextClick = () => {
|
||||||
|
this.queue.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePreviousClick = () => {
|
||||||
|
this.queue.previous();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleShuffleClick = () => {
|
||||||
|
this.queue.toggleShuffle();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleRepeatClick = () => {
|
||||||
|
this.queue.cycleRepeat();
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
var imagePath = this.isPlaying ? pauseSVG : playSVG
|
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
|
||||||
|
const playOrPauseHandler = this.player.isPlaying
|
||||||
|
? this.handlePauseClick
|
||||||
|
: this.handlePlayClick;
|
||||||
|
|
||||||
|
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
|
||||||
|
const repeatMode = this.queue.repeatMode;
|
||||||
|
const repeatClasses = [
|
||||||
|
repeatMode !== 'off' ? 'active' : '',
|
||||||
|
repeatMode === 'one' ? 'repeat-one' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div>
|
<div id="player-control-buttons">
|
||||||
<button>
|
<button class=${shuffleClass} @click=${this.handleShuffleClick}>
|
||||||
<img src="${shuffleSVG}"></img>
|
<wa-icon name="shuffle"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<button @click=${this.handlePreviousClick}>
|
||||||
<img src="${skipPrevSVG}"></img>
|
<wa-icon name="backward-step"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
<button @click="${this.onPlayPauseClick}">
|
<button @click="${playOrPauseHandler}">
|
||||||
<img src="${imagePath}"></img>
|
<wa-icon name=${playOrPauseIcon}></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<button @click=${this.handleNextClick}>
|
||||||
<img src="${skipNextSVG}"></img>
|
<wa-icon name="forward-step"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<button class=${repeatClasses} @click=${this.handleRepeatClick}>
|
||||||
<img src="${repeatSVG}"></img>
|
<wa-icon name="repeat"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlayPauseClick() {
|
|
||||||
if (this.isPlaying) {
|
|
||||||
Play().then(() => {
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("there is an error with playing " + err);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Pause().then(() => {
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("there is an error with pausing " + err);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
this.isPlaying = !this.isPlaying
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,169 @@
|
|||||||
import { LitElement, html, css, type PropertyValues } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, property} from 'lit/decorators.js';
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
|
|
||||||
import { ref, createRef } from 'lit/directives/ref.js';
|
import { ref, createRef } from 'lit/directives/ref.js';
|
||||||
import { SlRange } from '@node_modules/@shoelace-style/shoelace/dist/shoelace';
|
import WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||||
|
import { formatSeconds } from '@utils/time';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
|
|
||||||
const progress = signal(20);
|
const ProgressIntervalMillis = 1000;
|
||||||
|
|
||||||
@customElement('seek-bar')
|
@customElement('seek-bar')
|
||||||
export class SeekBar extends SignalWatcher(LitElement) {
|
export class SeekBar extends LitElement {
|
||||||
@property()
|
private player = new PlayerController(this);
|
||||||
progressIntervalMillis: number = 1000;
|
private rangeRef = createRef<WaSlider>();
|
||||||
|
private timerID: number = -1;
|
||||||
|
private previousTrackPath: string | null = null;
|
||||||
|
|
||||||
@property()
|
@state()
|
||||||
isProgressing: boolean = false;
|
private seekValue: number = 0;
|
||||||
|
|
||||||
timerID: number = -1;
|
static override styles = css`
|
||||||
private rangeRef = createRef<SlRange>();
|
wa-slider {
|
||||||
|
--track-size: 6px;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 1em;
|
||||||
|
--wa-tooltip-background-color: #343a40;
|
||||||
|
--wa-tooltip-content-color: white;
|
||||||
|
--wa-tooltip-border-color: #343a40;
|
||||||
|
--wa-tooltip-border-radius: 4px;
|
||||||
|
--wa-tooltip-font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(){
|
wa-slider::part(track) {
|
||||||
super();
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(indicator) {
|
||||||
|
background: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(thumb) {
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seek-bar-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// DERIVED STATE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private get hasTrack(): boolean {
|
||||||
|
return this.player.currentTrack !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get trackLength(): number {
|
||||||
|
return this.player.currentTrack?.trackLength ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isPlaying(): boolean {
|
||||||
|
return this.player.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// LIFECYCLE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
this.stopProgress();
|
this.stopProgress();
|
||||||
}
|
}
|
||||||
static override styles = css`
|
|
||||||
sl-range::part(base) {
|
override updated() {
|
||||||
--track-color-active: red;
|
// Detect track change and reset seek position
|
||||||
--track-color-inactive: white;
|
const currentPath = this.player.currentTrack?.filePath ?? null;
|
||||||
--track-height: 6px;
|
|
||||||
}
|
if (currentPath !== this.previousTrackPath) {
|
||||||
sl-range::part(form-control-input) {
|
this.previousTrackPath = currentPath;
|
||||||
--sl-color-primary-600: yellow;
|
this.seekValue = this.player.currentTrack?.seekPosition ?? 0;
|
||||||
}
|
|
||||||
`;
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<sl-range
|
|
||||||
value="${progress.get()}"
|
|
||||||
${ref(this.rangeRef)}
|
|
||||||
@sl-change="${(event: CustomEvent) => {
|
|
||||||
this.setProgressValue((event.target as SlRange).value);
|
|
||||||
if(this.isProgressing) this.startProgress();
|
|
||||||
else this.stopProgress();
|
|
||||||
}}"
|
|
||||||
@sl-input="${() => {
|
|
||||||
var progressing = this.isProgressing;
|
|
||||||
this.stopProgress();
|
this.stopProgress();
|
||||||
this.isProgressing = progressing;
|
}
|
||||||
}}"></sl-range>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
init(interval: number, playing: boolean){
|
// Start/stop progress interval based on playback state
|
||||||
this.progressIntervalMillis = interval;
|
if (this.isPlaying && this.hasTrack) {
|
||||||
if(playing)this.startProgress
|
this.startProgress();
|
||||||
}
|
} else {
|
||||||
|
this.stopProgress();
|
||||||
stopProgress(){
|
|
||||||
this.isProgressing = false;
|
|
||||||
clearInterval(this.timerID);
|
|
||||||
}
|
|
||||||
|
|
||||||
startProgress(){
|
|
||||||
this.isProgressing = true;
|
|
||||||
this.timerID = setInterval(this.incrementProgressValue, this.progressIntervalMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgressValue(val: number){
|
|
||||||
if(val < 0) val = 0;
|
|
||||||
if(val > 100) val = 100;
|
|
||||||
progress.set(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgressInterval(intervalMillis: number){
|
|
||||||
if(intervalMillis < 10) intervalMillis = 10;
|
|
||||||
|
|
||||||
}
|
|
||||||
async incrementProgressValue(){
|
|
||||||
if(progress.get() <100)
|
|
||||||
{
|
|
||||||
progress.set(progress.get() + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// PROGRESS INTERVAL
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private stopProgress() {
|
||||||
|
if (this.timerID !== -1) {
|
||||||
|
clearInterval(this.timerID);
|
||||||
|
this.timerID = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startProgress() {
|
||||||
|
// Don't start multiple intervals
|
||||||
|
if (this.timerID !== -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timerID = window.setInterval(() => {
|
||||||
|
if (this.seekValue < this.trackLength) {
|
||||||
|
this.seekValue += 1;
|
||||||
|
}
|
||||||
|
}, ProgressIntervalMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// EVENT HANDLERS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private handleChange(e: Event) {
|
||||||
|
const newSeekVal = (e.target as WaSlider).value;
|
||||||
|
this.setSeekValue(newSeekVal);
|
||||||
|
this.player.seek(newSeekVal);
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.startProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops progress while user is dragging the thumb
|
||||||
|
private handleInput() {
|
||||||
|
this.stopProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSeekValue(val: number) {
|
||||||
|
if (val < 0) val = 0;
|
||||||
|
if (val > this.trackLength) val = this.trackLength;
|
||||||
|
this.seekValue = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// RENDER
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const elapsedTime = this.hasTrack ? formatSeconds(this.seekValue) : '--:--';
|
||||||
|
const remainingTime = this.hasTrack
|
||||||
|
? formatSeconds(this.trackLength - this.seekValue)
|
||||||
|
: '--:--';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div id="seek-bar-container">
|
||||||
|
<small>${elapsedTime}</small>
|
||||||
|
<wa-slider
|
||||||
|
.value="${this.seekValue}"
|
||||||
|
max="${this.trackLength}"
|
||||||
|
?with-tooltip="${this.hasTrack}"
|
||||||
|
.valueFormatter="${this.hasTrack ? formatSeconds : null}"
|
||||||
|
${ref(this.rangeRef)}
|
||||||
|
@change="${this.handleChange}"
|
||||||
|
@input="${this.handleInput}"
|
||||||
|
></wa-slider>
|
||||||
|
<small>${remainingTime}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||||
|
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
|
|
||||||
|
@customElement('volume-control')
|
||||||
|
export class VolumeControl extends LitElement {
|
||||||
|
private player = new PlayerController(this);
|
||||||
|
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private showSlider = false;
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.25em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1em 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider {
|
||||||
|
--track-size: 6px;
|
||||||
|
--thumb-width: 1em;
|
||||||
|
--thumb-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(track) {
|
||||||
|
background: white;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(indicator) {
|
||||||
|
background: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-slider::part(thumb) {
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// DERIVED STATE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private get volumeIcon(): string {
|
||||||
|
const vol = this.player.volume;
|
||||||
|
|
||||||
|
if (vol === 0) return 'volume-xmark';
|
||||||
|
if (vol <= 50) return 'volume-low';
|
||||||
|
|
||||||
|
return 'volume-high';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// LIFECYCLE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// EVENT HANDLERS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private toggleSlider(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.showSlider = !this.showSlider;
|
||||||
|
|
||||||
|
if (this.showSlider) {
|
||||||
|
document.addEventListener('click', this.boundHandleOutsideClick);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOutsideClick(e: Event) {
|
||||||
|
const path = e.composedPath();
|
||||||
|
|
||||||
|
if (!path.includes(this)) {
|
||||||
|
this.showSlider = false;
|
||||||
|
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInput(e: Event) {
|
||||||
|
const value = (e.target as WaSlider).value;
|
||||||
|
this.player.setVolume(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePopupClick(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// RENDER
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<button @click="${this.toggleSlider}">
|
||||||
|
<wa-icon name=${this.volumeIcon}></wa-icon>
|
||||||
|
</button>
|
||||||
|
${this.showSlider
|
||||||
|
? html`
|
||||||
|
<div class="volume-popup" @click="${this.handlePopupClick}">
|
||||||
|
<wa-slider
|
||||||
|
orientation="vertical"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
.value="${this.player.volume}"
|
||||||
|
@change="${this.handleInput}"
|
||||||
|
></wa-slider>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { GetDir, SetDir } from '@go/library/Library.js';
|
|
||||||
import { DirectoryPicker } from '@go/frontendbindings/FrontendBindings.js';
|
import { DirectoryPicker } from '@go/frontendbindings/FrontendBindings.js';
|
||||||
|
|
||||||
@customElement('library-picker')
|
@customElement('library-picker')
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
|
import { EventsEmit } from '@runtime/runtime';
|
||||||
|
import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library';
|
||||||
|
import { library } from '@go/models';
|
||||||
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
|
||||||
|
@customElement('cover-grid')
|
||||||
|
export class CoverGrid extends LitElement {
|
||||||
|
private queue = new QueueController(this);
|
||||||
|
|
||||||
|
private closeHandler = () => this.closeContextMenu();
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:focus {
|
||||||
|
outline: 2px solid #1db954;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #404040 0%, #282828 100%);
|
||||||
|
color: #b3b3b3;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
margin-top: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel {
|
||||||
|
background-color: #2a2a3e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item::part(base) {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item::part(base):hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private albums: library.Album[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private loading = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private contextMenuOpen = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private contextMenuAlbum: library.Album | null = null;
|
||||||
|
|
||||||
|
@query('#context-menu')
|
||||||
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.loadAlbums();
|
||||||
|
document.addEventListener('click', this.closeHandler);
|
||||||
|
document.addEventListener('contextmenu', this.closeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('click', this.closeHandler);
|
||||||
|
document.removeEventListener('contextmenu', this.closeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAlbums() {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const albums = await GetAllAlbums();
|
||||||
|
this.albums = albums ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading albums:", error);
|
||||||
|
this.albums = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAlbumFilePaths(album: library.Album): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const tracks = await GetAlbumTracks(album.ID);
|
||||||
|
|
||||||
|
return tracks.map((t) => t.FilePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading album tracks:", error);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAlbumContextMenu(e: MouseEvent, album: library.Album) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.contextMenuAlbum = album;
|
||||||
|
this.contextMenuOpen = true;
|
||||||
|
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).anchor = {
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
top: e.clientY,
|
||||||
|
left: e.clientX,
|
||||||
|
right: e.clientX,
|
||||||
|
bottom: e.clientY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(popup as any).active = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onContextMenuAction(action: string) {
|
||||||
|
if (!this.contextMenuAlbum) return;
|
||||||
|
|
||||||
|
const filePaths = await this.getAlbumFilePaths(this.contextMenuAlbum);
|
||||||
|
|
||||||
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
this.queue.setQueue(filePaths, 0);
|
||||||
|
break;
|
||||||
|
case 'add-to-queue':
|
||||||
|
this.queue.addTracksToQueue(filePaths);
|
||||||
|
break;
|
||||||
|
case 'play-next':
|
||||||
|
this.queue.playTracksNext(filePaths);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeContextMenu() {
|
||||||
|
if (!this.contextMenuOpen) return;
|
||||||
|
|
||||||
|
this.contextMenuOpen = false;
|
||||||
|
this.contextMenuAlbum = null;
|
||||||
|
|
||||||
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.loading) {
|
||||||
|
return html`<div class="loading">Loading albums...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.albums.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No albums found</p>
|
||||||
|
<p>Add music to your library to see album covers here.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="grid">
|
||||||
|
${this.albums.map(album => this.renderAlbumCard(album))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<wa-popup
|
||||||
|
id="context-menu"
|
||||||
|
placement="bottom-start"
|
||||||
|
.active=${this.contextMenuOpen}
|
||||||
|
>
|
||||||
|
${this.contextMenuOpen
|
||||||
|
? html`
|
||||||
|
<div class="context-menu-panel">
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('play')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="play"></wa-icon>
|
||||||
|
Play
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||||
|
Add to Queue
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('play-next')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||||
|
Play Next
|
||||||
|
</wa-dropdown-item>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</wa-popup>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAlbumCard(album: library.Album) {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="album-card"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="${album.Name} by ${album.ArtistName}"
|
||||||
|
@click=${() => this.onAlbumClick(album)}
|
||||||
|
@keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)}
|
||||||
|
@contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)}
|
||||||
|
>
|
||||||
|
<div class="cover-container">
|
||||||
|
${album.CoverArtPath
|
||||||
|
? html`<img
|
||||||
|
class="cover-image"
|
||||||
|
src="${album.CoverArtPath}"
|
||||||
|
alt="${album.Name} cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>`
|
||||||
|
: html`<div class="placeholder-cover">
|
||||||
|
${this.getAlbumInitial(album.Name)}
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="album-info">
|
||||||
|
<div class="album-name" title="${album.Name}">${album.Name}</div>
|
||||||
|
<div class="artist-name" title="${album.ArtistName}">
|
||||||
|
${album.ArtistName}${album.Year ? ` - ${album.Year}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAlbumInitial(name: string): string {
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAlbumClick(album: library.Album) {
|
||||||
|
EventsEmit('AlbumSelected', album);
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('album-selected', {
|
||||||
|
detail: album,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAlbumKeydown(e: KeyboardEvent, album: library.Album) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onAlbumClick(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'cover-grid': CoverGrid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
|
|
||||||
|
@customElement('now-playing')
|
||||||
|
export class NowPlaying extends LitElement {
|
||||||
|
private player = new PlayerController(this);
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
.now-playing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-art {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-art img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder wa-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const track = this.player.currentTrack;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return html`
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="cover-art">
|
||||||
|
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="cover-art">
|
||||||
|
${track.coverArt
|
||||||
|
? html`<img src="${track.coverArt}" alt="Album cover" />`
|
||||||
|
: html`<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>`}
|
||||||
|
</div>
|
||||||
|
<div class="track-info">
|
||||||
|
<span class="track-title">${track.title}</span>
|
||||||
|
<span class="track-artist">${track.artist || 'Unknown Artist'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
|
|
||||||
|
@customElement('queue-panel')
|
||||||
|
export class QueuePanel extends LitElement {
|
||||||
|
private queue = new QueueController(this);
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
open = false;
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 4em; /* below header */
|
||||||
|
right: 0;
|
||||||
|
bottom: 4em; /* above footer */
|
||||||
|
width: 320px;
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease-in-out;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active {
|
||||||
|
background-color: rgba(255, 212, 59, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-position {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active .track-position {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active .track-title {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover .remove-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state wa-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private handleClose() {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('queue-panel-close', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRemoveTrack(position: number) {
|
||||||
|
this.queue.removeFromQueue(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDisplayTitle(track: { title: string; filePath: string }): string {
|
||||||
|
if (track.title) return track.title;
|
||||||
|
|
||||||
|
// Fall back to filename without extension.
|
||||||
|
const parts = track.filePath.split(/[\\/]/);
|
||||||
|
const filename = parts[parts.length - 1] ?? track.filePath;
|
||||||
|
|
||||||
|
return filename.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const tracks = this.queue.tracks;
|
||||||
|
const currentIndex = this.queue.currentIndex;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="header">
|
||||||
|
<h3>Queue</h3>
|
||||||
|
<button class="close-button" @click=${this.handleClose}>
|
||||||
|
<wa-icon name="xmark"></wa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${tracks.length === 0
|
||||||
|
? html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<wa-icon name="list"></wa-icon>
|
||||||
|
<p>Queue is empty</p>
|
||||||
|
<p style="font-size: 12px;">Click a track to start playing</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<ul class="track-list">
|
||||||
|
${tracks.map(
|
||||||
|
(track, index) => html`
|
||||||
|
<li class="track-item ${index === currentIndex ? 'active' : ''}">
|
||||||
|
<span class="track-position">${index + 1}</span>
|
||||||
|
<div class="track-details">
|
||||||
|
<span class="track-title">${this.getDisplayTitle(track)}</span>
|
||||||
|
<span class="track-artist">${track.artist || 'Unknown Artist'}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="remove-button"
|
||||||
|
@click=${() => this.handleRemoveTrack(index)}
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<wa-icon name="xmark"></wa-icon>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: View;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_WIDTH = 120;
|
||||||
|
const MAX_WIDTH = 400;
|
||||||
|
const DEFAULT_WIDTH = 200;
|
||||||
|
|
||||||
|
@customElement('app-sidebar')
|
||||||
|
export class AppSidebar extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #212529;
|
||||||
|
min-width: ${MIN_WIDTH}px;
|
||||||
|
max-width: ${MAX_WIDTH}px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover,
|
||||||
|
.resize-handle.dragging {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
background-color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.active {
|
||||||
|
background-color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
li p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private activeView: View = 'tracks';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private isDragging = false;
|
||||||
|
|
||||||
|
private navItems: NavItem[] = [
|
||||||
|
{ id: 'home', label: 'Home' },
|
||||||
|
{ id: 'libraries', label: 'Libraries' },
|
||||||
|
{ id: 'playlists', label: 'Playlists' },
|
||||||
|
{ id: 'artists', label: 'Artists' },
|
||||||
|
{ id: 'albums', label: 'Albums' },
|
||||||
|
{ id: 'tracks', label: 'Tracks' },
|
||||||
|
];
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.style.width = `${DEFAULT_WIDTH}px`;
|
||||||
|
document.addEventListener('mousemove', this.handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', this.handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
|
||||||
|
@mousedown=${this.handleMouseDown}
|
||||||
|
></div>
|
||||||
|
<ul>
|
||||||
|
${this.navItems.map(item => html`
|
||||||
|
<li
|
||||||
|
class="${this.activeView === item.id ? 'active' : ''}"
|
||||||
|
@click=${() => this.navigate(item.id)}
|
||||||
|
>
|
||||||
|
<p>${item.label}</p>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
const newWidth = e.clientX - rect.left;
|
||||||
|
const clampedWidth = Math.min(Math.max(newWidth, MIN_WIDTH), MAX_WIDTH);
|
||||||
|
|
||||||
|
this.style.width = `${clampedWidth}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleMouseUp = () => {
|
||||||
|
this.isDragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private navigate(view: View) {
|
||||||
|
this.activeView = view;
|
||||||
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'app-sidebar': AppSidebar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { GetAllTracks } from '@go/library/Library';
|
||||||
|
import { library } from '@go/models';
|
||||||
|
import { LogPrint } from '@runtime/runtime';
|
||||||
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
|
||||||
|
@customElement('track-list')
|
||||||
|
export class TrackList extends LitElement {
|
||||||
|
private player = new PlayerController(this);
|
||||||
|
private queue = new QueueController(this);
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private tracks: library.Track[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private contextMenuOpen = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private contextMenuTrack: library.Track | null = null;
|
||||||
|
|
||||||
|
@query('#context-menu')
|
||||||
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
|
private closeHandler = () => this.closeContextMenu();
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
border-bottom: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.active {
|
||||||
|
background-color: rgba(255, 212, 59, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.active .track-name-button {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel {
|
||||||
|
background-color: #2a2a3e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item::part(base) {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-panel wa-dropdown-item::part(base):hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.loadTracks();
|
||||||
|
document.addEventListener('click', this.closeHandler);
|
||||||
|
document.addEventListener('contextmenu', this.closeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('click', this.closeHandler);
|
||||||
|
document.removeEventListener('contextmenu', this.closeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTracks() {
|
||||||
|
try {
|
||||||
|
const tracks = await GetAllTracks();
|
||||||
|
this.tracks = tracks;
|
||||||
|
|
||||||
|
if (tracks[0]) {
|
||||||
|
LogPrint(tracks[0].TrackName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tracks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTrackClick(track: library.Track) {
|
||||||
|
this.queue.setQueue([track.FilePath], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.contextMenuTrack = track;
|
||||||
|
this.contextMenuOpen = true;
|
||||||
|
|
||||||
|
// Position the popup at the mouse cursor using a virtual anchor.
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).anchor = {
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
top: e.clientY,
|
||||||
|
left: e.clientX,
|
||||||
|
right: e.clientX,
|
||||||
|
bottom: e.clientY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(popup as any).active = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onContextMenuAction(action: string) {
|
||||||
|
if (!this.contextMenuTrack) return;
|
||||||
|
|
||||||
|
const filePath = this.contextMenuTrack.FilePath;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
this.queue.setQueue([filePath], 0);
|
||||||
|
break;
|
||||||
|
case 'add-to-queue':
|
||||||
|
this.queue.addToQueue(filePath);
|
||||||
|
break;
|
||||||
|
case 'play-next':
|
||||||
|
this.queue.playNext(filePath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeContextMenu() {
|
||||||
|
if (!this.contextMenuOpen) return;
|
||||||
|
|
||||||
|
this.contextMenuOpen = false;
|
||||||
|
this.contextMenuTrack = null;
|
||||||
|
|
||||||
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isActiveTrack(track: library.Track): boolean {
|
||||||
|
const currentTrack = this.player.currentTrack;
|
||||||
|
|
||||||
|
if (!currentTrack) return false;
|
||||||
|
|
||||||
|
return currentTrack.filePath === track.FilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
${this.tracks.length === 0
|
||||||
|
? html`<p>Loading tracks...</p>`
|
||||||
|
: html`
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track Name</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Track Length</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${this.tracks.map(
|
||||||
|
(track) => html`
|
||||||
|
<tr
|
||||||
|
class=${this.isActiveTrack(track) ? 'active' : ''}
|
||||||
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
|
this.onTrackContextMenu(e, track)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="track-name-button"
|
||||||
|
@click=${() => this.onTrackClick(track)}
|
||||||
|
>
|
||||||
|
${track.TrackName}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>${track.ArtistName}</td>
|
||||||
|
<td>${formatMilliseconds(track.TrackLength)}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<wa-popup
|
||||||
|
id="context-menu"
|
||||||
|
placement="bottom-start"
|
||||||
|
.active=${this.contextMenuOpen}
|
||||||
|
>
|
||||||
|
${this.contextMenuOpen
|
||||||
|
? html`
|
||||||
|
<div class="context-menu-panel">
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('play')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="play"></wa-icon>
|
||||||
|
Play
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||||
|
Add to Queue
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() => this.onContextMenuAction('play-next')}
|
||||||
|
>
|
||||||
|
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||||
|
Play Next
|
||||||
|
</wa-dropdown-item>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</wa-popup>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Centralized event name constants for Wails frontend/backend communication.
|
||||||
|
// These names must match the corresponding event names in the Go backend.
|
||||||
|
|
||||||
|
export const Events = {
|
||||||
|
// Playback control events
|
||||||
|
PlaybackStateChanged: "PlaybackStateChanged",
|
||||||
|
PlaybackFinished: "PlaybackFinished",
|
||||||
|
RequestPlay: "RequestPlay",
|
||||||
|
RequestPause: "RequestPause",
|
||||||
|
RequestLoadFile: "RequestLoadFile",
|
||||||
|
|
||||||
|
// Track events
|
||||||
|
TrackChanged: "TrackChanged",
|
||||||
|
|
||||||
|
// Seek events
|
||||||
|
Seek: "Seek",
|
||||||
|
SeekFailed: "SeekFailed",
|
||||||
|
|
||||||
|
// Volume events
|
||||||
|
RequestSetVolume: "RequestSetVolume",
|
||||||
|
VolumeChanged: "VolumeChanged",
|
||||||
|
|
||||||
|
// Queue events
|
||||||
|
QueueChanged: "QueueChanged",
|
||||||
|
RequestNext: "RequestNext",
|
||||||
|
RequestPrevious: "RequestPrevious",
|
||||||
|
RequestSetQueue: "RequestSetQueue",
|
||||||
|
RequestAddToQueue: "RequestAddToQueue",
|
||||||
|
RequestPlayNext: "RequestPlayNext",
|
||||||
|
RequestRemoveFromQueue: "RequestRemoveFromQueue",
|
||||||
|
RequestToggleShuffle: "RequestToggleShuffle",
|
||||||
|
RequestCycleRepeat: "RequestCycleRepeat",
|
||||||
|
RequestAddTracksToQueue: "RequestAddTracksToQueue",
|
||||||
|
RequestPlayTracksNext: "RequestPlayTracksNext",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventName = (typeof Events)[keyof typeof Events];
|
||||||
@@ -16,38 +16,15 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header class="container">
|
<header class="container">
|
||||||
<nav>
|
<hgroup>
|
||||||
<ul>
|
<a href="/index.html">
|
||||||
<li>
|
<h1>YellowJacket</h1>
|
||||||
<hgroup>
|
</a>
|
||||||
<a href="/index.html">
|
</hgroup>
|
||||||
<h1>YellowJacket</h1>
|
|
||||||
</a>
|
|
||||||
<p>Config</p>
|
|
||||||
</hgroup>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<details class="dropdown">
|
|
||||||
<summary role="button" class="outline">
|
|
||||||
YJ
|
|
||||||
</summary>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="/src/pages/config/config.html">
|
|
||||||
<img src="/src/assets/images/icons/ui/settings.svg" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
<hr />
|
<hr />
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<library-picker currentLibraryDirectoryText="hello world" ></library-picker>
|
<div hx-get="/config" hx-trigger="load"></div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
import '@components/config/options/library-picker.ts';
|
import 'htmx.org/dist/htmx.js'
|
||||||
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window { DirectoryPicker: any; }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DirectoryPicker = DirectoryPicker
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
import type { PlayerState, TrackInfo } from '../player-store';
|
||||||
|
import { playerStore } from '../player-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerController connects a Lit component to the PlayerStore.
|
||||||
|
*
|
||||||
|
* Usage in a component:
|
||||||
|
*
|
||||||
|
* private player = new PlayerController(this);
|
||||||
|
*
|
||||||
|
* render() {
|
||||||
|
* return html`
|
||||||
|
* <span>${this.player.currentTrack?.fileName}</span>
|
||||||
|
* <button @click=${() => this.player.play()}>Play</button>
|
||||||
|
* `;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export class PlayerController implements ReactiveController {
|
||||||
|
private host: ReactiveControllerHost;
|
||||||
|
private unsubscribe?: () => void;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this.host = host;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// LIFECYCLE HOOKS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
// Subscribe to store when component mounts
|
||||||
|
this.unsubscribe = playerStore.subscribe(() => {
|
||||||
|
this.host.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
// Unsubscribe when component unmounts (prevents memory leaks)
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// STATE ACCESSORS
|
||||||
|
// Convenience getters so components don't need to call getState()
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
get state(): Readonly<PlayerState> {
|
||||||
|
return playerStore.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPlaying(): boolean {
|
||||||
|
return this.state.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTrack(): TrackInfo | null {
|
||||||
|
return this.state.currentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
get volume(): number {
|
||||||
|
return this.state.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ACTIONS
|
||||||
|
// Delegate to store (which delegates to backend)
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
play(): void {
|
||||||
|
playerStore.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
playerStore.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTrack(filePath: string): void {
|
||||||
|
playerStore.loadTrack(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(seconds: number): void {
|
||||||
|
playerStore.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(level: number): void {
|
||||||
|
playerStore.setVolume(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
import type { QueueState, QueueTrack, RepeatMode } from '../queue-store';
|
||||||
|
import { queueStore } from '../queue-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QueueController connects a Lit component to the QueueStore.
|
||||||
|
*
|
||||||
|
* Usage in a component:
|
||||||
|
*
|
||||||
|
* private queue = new QueueController(this);
|
||||||
|
*
|
||||||
|
* render() {
|
||||||
|
* return html`
|
||||||
|
* <span>Tracks: ${this.queue.tracks.length}</span>
|
||||||
|
* <button @click=${() => this.queue.next()}>Next</button>
|
||||||
|
* `;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export class QueueController implements ReactiveController {
|
||||||
|
private host: ReactiveControllerHost;
|
||||||
|
private unsubscribe?: () => void;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this.host = host;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// LIFECYCLE HOOKS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
this.unsubscribe = queueStore.subscribe(() => {
|
||||||
|
this.host.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// STATE ACCESSORS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
get state(): Readonly<QueueState> {
|
||||||
|
return queueStore.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
get tracks(): QueueTrack[] {
|
||||||
|
return this.state.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentIndex(): number {
|
||||||
|
return this.state.currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTrack(): QueueTrack | undefined {
|
||||||
|
return this.state.tracks[this.state.currentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
get shuffleMode(): boolean {
|
||||||
|
return this.state.shuffleMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
get repeatMode(): RepeatMode {
|
||||||
|
return this.state.repeatMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ACTIONS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
next(): void {
|
||||||
|
queueStore.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): void {
|
||||||
|
queueStore.previous();
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueue(filePaths: string[], startIndex: number): void {
|
||||||
|
queueStore.setQueue(filePaths, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToQueue(filePath: string): void {
|
||||||
|
queueStore.addToQueue(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
playNext(filePath: string): void {
|
||||||
|
queueStore.playNext(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromQueue(position: number): void {
|
||||||
|
queueStore.removeFromQueue(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTracksToQueue(filePaths: string[]): void {
|
||||||
|
queueStore.addTracksToQueue(filePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
playTracksNext(filePaths: string[]): void {
|
||||||
|
queueStore.playTracksNext(filePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle(): void {
|
||||||
|
queueStore.toggleShuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRepeat(): void {
|
||||||
|
queueStore.cycleRepeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { playerStore } from './player-store';
|
||||||
|
export type { PlayerState, TrackInfo } from './player-store';
|
||||||
|
export { PlayerController } from './controllers/player-controller';
|
||||||
|
export { queueStore } from './queue-store';
|
||||||
|
export type { QueueState, QueueTrack, RepeatMode } from './queue-store';
|
||||||
|
export { QueueController } from './controllers/queue-controller';
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { EventsOn, EventsEmit } from '@runtime/runtime';
|
||||||
|
import { Events } from '../events';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface TrackInfo {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
trackLength: number; // in seconds
|
||||||
|
seekPosition: number; // current playback position in seconds
|
||||||
|
state: string; // playback state from backend
|
||||||
|
title: string; // track title (falls back to fileName)
|
||||||
|
artist: string; // artist name
|
||||||
|
album: string; // album name
|
||||||
|
coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
// Cached from backend
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTrack: TrackInfo | null;
|
||||||
|
volume: number; // 0-100
|
||||||
|
|
||||||
|
// Frontend-only state (for future use)
|
||||||
|
// selectedTrackIds: Set<number>;
|
||||||
|
// isQueuePanelOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
class PlayerStore {
|
||||||
|
private state: PlayerState = {
|
||||||
|
isPlaying: false,
|
||||||
|
currentTrack: null,
|
||||||
|
volume: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
private subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initializeEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// WAILS EVENT BRIDGE
|
||||||
|
// Subscribe to backend events and update cached state
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private initializeEventListeners(): void {
|
||||||
|
EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => {
|
||||||
|
this.update({ isPlaying: data.state === 'playing' });
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.TrackChanged, (trackInfo: TrackInfo) => {
|
||||||
|
this.update({ currentTrack: trackInfo });
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaybackFinished, () => {
|
||||||
|
this.update({ isPlaying: false });
|
||||||
|
// Queue auto-advance is handled by the backend queue package.
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.VolumeChanged, (volume: number) => {
|
||||||
|
this.update({ volume });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// STATE ACCESS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
getState(): Readonly<PlayerState> {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ACTIONS
|
||||||
|
// These delegate to the backend via Wails events
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
play(): void {
|
||||||
|
EventsEmit(Events.RequestPlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
EventsEmit(Events.RequestPause);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTrack(filePath: string): void {
|
||||||
|
EventsEmit(Events.RequestLoadFile, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(seconds: number): void {
|
||||||
|
EventsEmit(Events.Seek, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(level: number): void {
|
||||||
|
EventsEmit(Events.RequestSetVolume, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SUBSCRIPTION SYSTEM
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
subscribe(callback: Subscriber): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
|
||||||
|
return () => this.subscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(partial: Partial<PlayerState>): void {
|
||||||
|
this.state = { ...this.state, ...partial };
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.subscribers.forEach((callback) => callback());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const playerStore = new PlayerStore();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user