Squash merge audio-player-component into main

This commit is contained in:
2026-02-13 20:39:23 -06:00
parent 9b7cfd5bd1
commit d78c0584e2
122 changed files with 11750 additions and 1175 deletions
+172
View File
@@ -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()
}
}()
}
+68
View File
@@ -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)
}
+15
View File
@@ -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>
}
+113
View File
@@ -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
View File
@@ -1,145 +1,135 @@
// Package config manages application configuration persistence.
package config
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path"
"yellowjacket/backend/library"
"github.com/BurntSushi/toml"
"yellowjacket/backend/library"
"yellowjacket/backend/system"
)
// Config represents the application configuration.
type Config struct {
filePath string // required
*configData
ctx context.Context
logger *slog.Logger
serveMux *http.ServeMux
filePath string // required
Library *library.Config `form:"Library" schema:"library,required"`
}
func newConfig(filePath string, data *configData) (*Config, error) {
// NewConfig creates a new config by loading it from disk.
func NewConfig(logger *slog.Logger) (*Config, error) {
confDir, err := system.GetUserConfigDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user config directory: %w", err)
}
conf := &Config{
filePath: filePath,
configData: data,
filePath: path.Join(confDir, "config.toml"),
serveMux: http.NewServeMux(),
}
// TODO make sure required fields have SOMETHING in them
// TODO Merge existing config with read in config
if data == nil {
return nil, errors.New("nil config")
conf.logger = logger.WithGroup("config").With("config", conf)
conf.serveMux.HandleFunc("/", conf.handle)
if err := conf.Load(); err != nil {
return nil, fmt.Errorf("could not load config: %w", err)
}
if err := data.validate(); err != nil {
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return conf, nil
}
type configData struct {
Library *library.Config
}
// Validate returns errors if there is a breaking issue with the config.
func (c *Config) Validate() error {
var configErrs error
// return errors if there is a *breaking* issue with the config
func (d *configData) validate() error {
if d.Library == nil {
return errors.New("nil library config")
}
if err := d.Library.Validate(); err != nil {
return fmt.Errorf("invalid library config: %#v: %w", d.Library, err)
}
return nil
}
var defaultConfigData *configData = &configData{
Library: library.DefaultConfig,
}
// GetCurrentConfig will load and return the config
// reading the config file in the user's config directory
func GetCurrentConfig() (*Config, error) {
// get the config file location
configDir, err := GetUserConfigDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user config directory path: %w", err)
}
configFilePath := path.Join(configDir, "config.toml")
// create the config obj with the filepath we got, initializing with default data
config, err := newConfig(configFilePath, defaultConfigData)
if err != nil {
return nil, fmt.Errorf("could not create new config: %w", err)
}
config.filePath = configFilePath
// does the config file alaeady exist?
// if not, create it
_, err = os.Stat(configFilePath)
if os.IsNotExist(err) {
if err := config.WriteConfig(); err != nil {
return nil, fmt.Errorf("could not write config: %w", err)
if c.Library != nil {
if len(c.Library.DirectoryPath) != 0 {
if err := c.Library.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
}
// now that we have our config file, load it in
config, err = config.loadConfig()
if err != nil {
return nil, fmt.Errorf("could not load config file %s: %w", configFilePath, err)
if configErrs != nil {
return fmt.Errorf("one or more config parts are invalid: %w", configErrs)
}
// before we return the config, lets make sure sub configs can invoke saving when they need
err = config.updateSubConfigSaveFuncReferences()
if err != nil {
return nil, fmt.Errorf("could not update sub config save func references: %w", err)
}
return config, nil
return nil
}
func (c *Config) loadConfig() (*Config, error) {
// Load reads and parses the config file from disk.
func (c *Config) Load() error {
if _, err := os.Stat(c.filePath); err != nil {
if os.IsNotExist(err) {
c.logger.Debug("no config file exists, creating empty config")
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save empty config to file (%s): %w",
c.filePath,
err,
)
}
} else {
return fmt.Errorf("could not get file info (%s): %w", c.filePath, err)
}
}
// read in the file
confFileData, err := os.ReadFile(c.filePath)
if err != nil {
return nil, fmt.Errorf("problem reading config file %s: %w", c.filePath, err)
return fmt.Errorf("problem reading config file %s: %w", c.filePath, err)
}
// parse it into the config struct
var confData configData
_, err = toml.Decode(string(confFileData), &confData)
_, err = toml.Decode(string(confFileData), c)
if err != nil {
return nil, fmt.Errorf("problem parsing config file %s: %w", c.filePath, err)
return fmt.Errorf("problem parsing config file %s: %w", c.filePath, err)
}
// validate the config
if err = confData.validate(); err != nil {
return nil, fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
if err = c.Validate(); err != nil {
return fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
}
config, err := newConfig(c.filePath, &confData)
if err != nil {
return nil, fmt.Errorf("could not create config from config file data at %s: %w", c.filePath, err)
}
c.logger.Debug("loaded config file", "file", c.filePath)
// before we return the config, lets make sure sub configs can invoke saving when they need
err = config.updateSubConfigSaveFuncReferences()
if err != nil {
return nil, fmt.Errorf("could not update sub config save func references: %w", err)
}
return config, nil
return nil
}
func (c *Config) WriteConfig() error {
if err := c.validate(); err != nil {
// Save writes the config to disk.
func (c *Config) Save() error {
if err := c.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
confFileData, err := toml.Marshal(c)
if err != nil {
return fmt.Errorf("could not marshal config struct: %w", err)
}
err = os.WriteFile(c.filePath, confFileData, os.FileMode(int(0666)))
err = os.WriteFile(c.filePath, confFileData, os.FileMode(int(0o666)))
if err != nil {
return fmt.Errorf("could not write config file: %w", err)
return fmt.Errorf("could not write config file (%s): %w", c.filePath, err)
}
c.logger.Debug("saved config to file", "file", c.filePath)
return nil
}
func (c *Config) updateSubConfigSaveFuncReferences() error {
c.Library.SaveFunc = c.WriteConfig
return nil
// SetContext sets the Wails runtime context for event emission.
func (c *Config) SetContext(ctx context.Context) {
c.ctx = ctx
}
+71
View File
@@ -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
}
-82
View File
@@ -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
}
+78 -9
View File
@@ -1,25 +1,94 @@
// Package database provides SQLite database access.
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"path"
"path/filepath"
_ "modernc.org/sqlite"
_ "modernc.org/sqlite" // Register sqlite driver.
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/system"
)
//go:generate sqlc vet
//go:generate sqlc generate
//go:generate go tool sqlc generate
type DB struct{
db *sql.DB
//go:embed sql/schemas/*.sql
var schemas embed.FS
// DB wraps the SQLite database connection and queries.
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
func NewDB(sqliteDBFilePath string) (*DB, error) {
db, err := sql.Open("sqlite", ":memory:")
// NewDB opens the database and applies schema migrations.
func NewDB(logger *slog.Logger) (*DB, error) {
dbCtx := context.Background()
userDataDir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user data directory: %w", err)
}
sqliteDBFilePath := path.Join(userDataDir, "yj.db")
logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
}
db.SetMaxOpenConns(1) // SQLite only supports one writer at a time
// Execute SQL files from the embedded schemas directory
logger.Debug("reading sql schema files from embedded directory")
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
return nil, fmt.Errorf("could not read schemas directory: %w", err)
}
logger.Debug("executing all sql schema files")
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
filePath := filepath.Join("sql/schemas", dirEntry.Name())
sqlContent, err := fs.ReadFile(schemas, filePath)
if err != nil {
return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
}
logger.Debug(
"executing sql schema file",
"filepath",
filePath,
"sql",
string(sqlContent),
)
_, err = db.ExecContext(dbCtx, string(sqlContent)) // Execute the SQL
if err != nil {
return nil, fmt.Errorf("error executing sql from file %s: %w", filePath, err)
}
}
}
// Get generated queries
queries := sqlcgen.New(db)
return &DB{
db: db,
}, nil
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
}, err
}
+11 -3
View File
@@ -6,12 +6,20 @@ RETURNING *;
SELECT * FROM artist_credit
WHERE id = ? LIMIT 1;
-- name: GetArtistCreditByText :one
SELECT * FROM artist_credit
WHERE text = ? LIMIT 1;
-- name: UpsertArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT(text) DO UPDATE SET text = excluded.text
RETURNING *;
-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
WHERE id =?;
WHERE id = ?;
-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id =?;
WHERE id = ?;
+14 -2
View File
@@ -6,12 +6,24 @@ RETURNING *;
SELECT * FROM artists
WHERE id = ? LIMIT 1;
-- name: GetArtistByName :one
SELECT * FROM artists
WHERE name = ? LIMIT 1;
-- name: UpsertArtist :one
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING *;
-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
WHERE id =?;
WHERE id = ?;
-- name: DeleteArtist :exec
DELETE FROM artists
WHERE id =?;
WHERE id = ?;
-- name: GetAllArtists :many
SELECT * FROM artists
ORDER BY name;
+72 -2
View File
@@ -6,12 +6,82 @@ RETURNING *;
SELECT * FROM audio_files
WHERE id = ? LIMIT 1;
-- name: GetAudioFileByPath :one
SELECT * FROM audio_files
WHERE file_path = ? LIMIT 1;
-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
WHERE id =?;
WHERE id = ?;
-- name: UpdateAudioFileRecording :exec
UPDATE audio_files
SET recording_id = ?
WHERE id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files
WHERE id =?;
WHERE id = ?;
-- name: CountAudioFiles :one
SELECT count(*) FROM audio_files;
-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files
ORDER BY RANDOM()
LIMIT 1;
-- name: GetAllAudioFiles :many
SELECT * FROM audio_files;
-- name: GetAllAudioFilePaths :many
SELECT id, file_path FROM audio_files;
-- name: GetAudioFilesNeedingMetadata :many
SELECT * FROM audio_files
WHERE recording_id = 0;
-- name: GetAllAudioFilesWithArtist :many
SELECT
af.id,
af.file_path,
af.length_milliseconds,
af.file_type_id,
af.recording_id,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.name, '') AS title
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id;
-- name: GetTrackMetadataByPath :one
SELECT
af.file_path,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
WHERE rgr.release_group_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
+16 -5
View File
@@ -1,17 +1,28 @@
-- name: CreateCoverArt :one
INSERT INTO cover_art (is_embedded, file_path, file_type_id) VALUES (?, ?, ?)
INSERT INTO cover_art (is_embedded, file_path, mime_type) VALUES (?, ?, ?)
RETURNING *;
-- name: GetCoverArt :one
SELECT * FROM cover_art
WHERE id = ? LIMIT 1;
-- name: GetCoverArtByPath :one
SELECT * FROM cover_art
WHERE file_path = ? LIMIT 1;
-- name: UpsertCoverArt :one
INSERT INTO cover_art (is_embedded, file_path, mime_type)
VALUES (?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
is_embedded = excluded.is_embedded,
mime_type = excluded.mime_type
RETURNING *;
-- name: UpdateCoverArt :exec
UPDATE cover_art
SET is_embedded = ?, file_path = ?, file_type_id = ?
WHERE id =?;
SET is_embedded = ?, file_path = ?, mime_type = ?
WHERE id = ?;
-- name: DeleteCoverArt :exec
DELETE FROM cover_art
WHERE id =?;
WHERE id = ?;
@@ -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 = ?;
+49
View File
@@ -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 >= ?;
+20 -4
View File
@@ -1,5 +1,12 @@
-- name: CreateRecording :one
INSERT INTO recordings (name) VALUES (?)
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
RETURNING *;
-- name: CreateRecordingFull :one
INSERT INTO recordings (
name, artist_credit_id, track_number, disc_number,
year, genre, composer, lyrics, comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetRecording :one
@@ -8,10 +15,19 @@ WHERE id = ? LIMIT 1;
-- name: UpdateRecording :exec
UPDATE recordings
SET name = ?
WHERE id =?;
SET name = ?, artist_credit_id = ?
WHERE id = ?;
-- name: UpdateRecordingFull :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
WHERE id = ?;
-- name: DeleteRecording :exec
DELETE FROM recordings
WHERE id =?;
WHERE id = ?;
-- name: GetAllRecordings :many
SELECT * FROM recordings
ORDER BY name;
@@ -1,17 +1,25 @@
-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?,?)
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
RETURNING *;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
-- name: UpdateReleaseGroupRecording :exec
UPDATE release_group_recordings
SET release_group_id = ?, recording_id = ?
WHERE id =?;
-- name: GetReleaseGroupRecordings :many
SELECT * FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number;
-- name: GetRecordingReleaseGroups :many
SELECT * FROM release_group_recordings
WHERE recording_id = ?;
-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
WHERE id =?;
WHERE id = ?;
-- name: DeleteReleaseGroupRecordingByFK :exec
DELETE FROM release_group_recordings
WHERE release_group_id = ? AND recording_id = ?;
@@ -2,16 +2,54 @@
INSERT INTO release_groups (name) VALUES (?)
RETURNING *;
-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetReleaseGroup :one
SELECT * FROM release_groups
WHERE id = ? LIMIT 1;
-- name: GetReleaseGroupByName :one
SELECT * FROM release_groups
WHERE name = ? LIMIT 1;
-- name: UpsertReleaseGroup :one
INSERT INTO release_groups (name, album_artist_credit_id, year)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING *;
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
WHERE id =?;
WHERE id = ?;
-- name: UpdateReleaseGroupCoverArt :exec
UPDATE release_groups
SET cover_art_id = ?
WHERE id = ?;
-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
WHERE id =?;
WHERE id = ?;
-- name: GetAllReleaseGroups :many
SELECT * FROM release_groups
ORDER BY name;
-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY rg.name;
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS artist_credit (
id int PRIMARY KEY,
text string NOT NULL
id INTEGER PRIMARY KEY,
text TEXT NOT NULL UNIQUE
);
@@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS artist_credit_artist (
id int PRIMARY KEY,
id integer PRIMARY KEY,
artist_id int NOT NULL,
credit_id int NOT NULL,
FOREIGN KEY(artist_id) REFERENCES artists(id),
+2 -2
View File
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS artists (
id int PRIMARY KEY,
name text NOT NULL
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
+1 -1
View File
@@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS audio_files (
id int PRIMARY KEY,
id integer PRIMARY KEY,
file_path text NOT NULL UNIQUE,
length_milliseconds int NOT NULL,
file_type_id int NOT NULL,
+4 -5
View File
@@ -1,7 +1,6 @@
CREATE TABLE IF NOT EXISTS cover_art (
id int PRIMARY KEY,
is_embedded bool NOT NULL DEFAULT(false),
file_path text NOT NULL,
file_type_id int NOT NULL,
FOREIGN KEY(file_type_id) REFERENCES file_types(id)
id INTEGER PRIMARY KEY,
is_embedded BOOL NOT NULL DEFAULT(false),
file_path TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL
);
+1 -1
View File
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS file_types (
id INTEGER PRIMARY KEY,
id integer PRIMARY KEY,
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
);
+11
View File
@@ -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
);
+10 -4
View File
@@ -1,7 +1,13 @@
CREATE TABLE IF NOT EXISTS recordings (
id int PRIMARY KEY,
name text NOT NULL,
artist_credit_id int NOT NULL,
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
composer TEXT,
lyrics TEXT,
comment TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
@@ -1,8 +1,9 @@
CREATE TABLE IF NOT EXISTS release_group_recordings (
id int PRIMARY KEY,
release_group_id int NOT NULL,
recording_id int NOT NULL,
id INTEGER PRIMARY KEY,
release_group_id INTEGER NOT NULL,
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
@@ -1,6 +1,11 @@
CREATE TABLE IF NOT EXISTS release_groups (
id int PRIMARY KEY,
name text NOT NULL,
cover_art_id int NOT NULL,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id)
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id)
);
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: artist_credit.sql
package sqlcgen
@@ -14,7 +14,7 @@ INSERT INTO artist_credit (text) VALUES (?)
RETURNING id, text
`
func (q *Queries) CreateArtistCredit(ctx context.Context, text interface{}) (ArtistCredit, error) {
func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
@@ -23,7 +23,7 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text interface{}) (Art
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
@@ -43,14 +43,26 @@ func (q *Queries) GetArtistCredit(ctx context.Context, id int64) (ArtistCredit,
return i, err
}
const getArtistCreditByText = `-- name: GetArtistCreditByText :one
SELECT id, text FROM artist_credit
WHERE text = ? LIMIT 1
`
func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, getArtistCreditByText, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
WHERE id =?
WHERE id = ?
`
type UpdateArtistCreditParams struct {
Text interface{}
Text string
ID int64
}
@@ -58,3 +70,16 @@ func (q *Queries) UpdateArtistCredit(ctx context.Context, arg UpdateArtistCredit
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
return err
}
const upsertArtistCredit = `-- name: UpsertArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT(text) DO UPDATE SET text = excluded.text
RETURNING id, text
`
func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, upsertArtistCredit, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: artist_credit_artists.sql
package sqlcgen
+56 -3
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: artists.sql
package sqlcgen
@@ -23,7 +23,7 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error)
const deleteArtist = `-- name: DeleteArtist :exec
DELETE FROM artists
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
@@ -31,6 +31,34 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
return err
}
const getAllArtists = `-- name: GetAllArtists :many
SELECT id, name FROM artists
ORDER BY name
`
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAllArtists)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Artist
for rows.Next() {
var i Artist
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getArtist = `-- name: GetArtist :one
SELECT id, name FROM artists
WHERE id = ? LIMIT 1
@@ -43,10 +71,22 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
return i, err
}
const getArtistByName = `-- name: GetArtistByName :one
SELECT id, name FROM artists
WHERE name = ? LIMIT 1
`
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
row := q.db.QueryRowContext(ctx, getArtistByName, name)
var i Artist
err := row.Scan(&i.ID, &i.Name)
return i, err
}
const updateArtist = `-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
WHERE id =?
WHERE id = ?
`
type UpdateArtistParams struct {
@@ -58,3 +98,16 @@ func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) erro
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
return err
}
const upsertArtist = `-- name: UpsertArtist :one
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING id, name
`
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
row := q.db.QueryRowContext(ctx, upsertArtist, name)
var i Artist
err := row.Scan(&i.ID, &i.Name)
return i, err
}
+309 -3
View File
@@ -1,14 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: audio_files.sql
package sqlcgen
import (
"context"
"database/sql"
)
const countAudioFiles = `-- name: CountAudioFiles :one
SELECT count(*) FROM audio_files
`
func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAudioFiles)
var count int64
err := row.Scan(&count)
return count, err
}
const createAudioFile = `-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id
@@ -41,7 +53,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
const deleteAudioFile = `-- name: DeleteAudioFile :exec
DELETE FROM audio_files
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error {
@@ -49,6 +61,126 @@ func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error {
return err
}
const getAllAudioFilePaths = `-- name: GetAllAudioFilePaths :many
SELECT id, file_path FROM audio_files
`
type GetAllAudioFilePathsRow struct {
ID int64
FilePath string
}
func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePathsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllAudioFilePaths)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllAudioFilePathsRow
for rows.Next() {
var i GetAllAudioFilePathsRow
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
`
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
rows, err := q.db.QueryContext(ctx, getAllAudioFiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AudioFile
for rows.Next() {
var i AudioFile
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllAudioFilesWithArtist = `-- name: GetAllAudioFilesWithArtist :many
SELECT
af.id,
af.file_path,
af.length_milliseconds,
af.file_type_id,
af.recording_id,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.name, '') AS title
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
`
type GetAllAudioFilesWithArtistRow struct {
ID int64
FilePath string
LengthMilliseconds int64
FileTypeID int64
RecordingID int64
ArtistName string
Title string
}
func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudioFilesWithArtistRow, error) {
rows, err := q.db.QueryContext(ctx, getAllAudioFilesWithArtist)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllAudioFilesWithArtistRow
for rows.Next() {
var i GetAllAudioFilesWithArtistRow
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
&i.ArtistName,
&i.Title,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE id = ? LIMIT 1
@@ -67,10 +199,168 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
return i, err
}
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE file_path = ? LIMIT 1
`
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) {
row := q.db.QueryRowContext(ctx, getAudioFileByPath, filePath)
var i AudioFile
err := row.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
)
return i, err
}
const getAudioFilesByReleaseGroup = `-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
WHERE rgr.release_group_id = ?
ORDER BY rgr.disc_number, rgr.track_number
`
type GetAudioFilesByReleaseGroupRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
}
func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupID int64) ([]GetAudioFilesByReleaseGroupRow, error) {
rows, err := q.db.QueryContext(ctx, getAudioFilesByReleaseGroup, releaseGroupID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAudioFilesByReleaseGroupRow
for rows.Next() {
var i GetAudioFilesByReleaseGroupRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE recording_id = 0
`
func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile, error) {
rows, err := q.db.QueryContext(ctx, getAudioFilesNeedingMetadata)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AudioFile
for rows.Next() {
var i AudioFile
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRandomAudioFilePath = `-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files
ORDER BY RANDOM()
LIMIT 1
`
func (q *Queries) GetRandomAudioFilePath(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getRandomAudioFilePath)
var file_path string
err := row.Scan(&file_path)
return file_path, err
}
const getTrackMetadataByPath = `-- name: GetTrackMetadataByPath :one
SELECT
af.file_path,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1
`
type GetTrackMetadataByPathRow struct {
FilePath string
Title string
Artist string
Album string
CoverArtPath string
}
func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (GetTrackMetadataByPathRow, error) {
row := q.db.QueryRowContext(ctx, getTrackMetadataByPath, filePath)
var i GetTrackMetadataByPathRow
err := row.Scan(
&i.FilePath,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverArtPath,
)
return i, err
}
const updateAudioFile = `-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?
WHERE id =?
WHERE id = ?
`
type UpdateAudioFileParams struct {
@@ -91,3 +381,19 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
)
return err
}
const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec
UPDATE audio_files
SET recording_id = ?
WHERE id = ?
`
type UpdateAudioFileRecordingParams struct {
RecordingID int64
ID int64
}
func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error {
_, err := q.db.ExecContext(ctx, updateAudioFileRecording, arg.RecordingID, arg.ID)
return err
}
+57 -13
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: cover_art.sql
package sqlcgen
@@ -10,31 +10,31 @@ import (
)
const createCoverArt = `-- name: CreateCoverArt :one
INSERT INTO cover_art (is_embedded, file_path, file_type_id) VALUES (?, ?, ?)
RETURNING id, is_embedded, file_path, file_type_id
INSERT INTO cover_art (is_embedded, file_path, mime_type) VALUES (?, ?, ?)
RETURNING id, is_embedded, file_path, mime_type
`
type CreateCoverArtParams struct {
IsEmbedded bool
FilePath string
FileTypeID int64
MimeType string
}
func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams) (CoverArt, error) {
row := q.db.QueryRowContext(ctx, createCoverArt, arg.IsEmbedded, arg.FilePath, arg.FileTypeID)
row := q.db.QueryRowContext(ctx, createCoverArt, arg.IsEmbedded, arg.FilePath, arg.MimeType)
var i CoverArt
err := row.Scan(
&i.ID,
&i.IsEmbedded,
&i.FilePath,
&i.FileTypeID,
&i.MimeType,
)
return i, err
}
const deleteCoverArt = `-- name: DeleteCoverArt :exec
DELETE FROM cover_art
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteCoverArt(ctx context.Context, id int64) error {
@@ -43,7 +43,7 @@ func (q *Queries) DeleteCoverArt(ctx context.Context, id int64) error {
}
const getCoverArt = `-- name: GetCoverArt :one
SELECT id, is_embedded, file_path, file_type_id FROM cover_art
SELECT id, is_embedded, file_path, mime_type FROM cover_art
WHERE id = ? LIMIT 1
`
@@ -54,21 +54,38 @@ func (q *Queries) GetCoverArt(ctx context.Context, id int64) (CoverArt, error) {
&i.ID,
&i.IsEmbedded,
&i.FilePath,
&i.FileTypeID,
&i.MimeType,
)
return i, err
}
const getCoverArtByPath = `-- name: GetCoverArtByPath :one
SELECT id, is_embedded, file_path, mime_type FROM cover_art
WHERE file_path = ? LIMIT 1
`
func (q *Queries) GetCoverArtByPath(ctx context.Context, filePath string) (CoverArt, error) {
row := q.db.QueryRowContext(ctx, getCoverArtByPath, filePath)
var i CoverArt
err := row.Scan(
&i.ID,
&i.IsEmbedded,
&i.FilePath,
&i.MimeType,
)
return i, err
}
const updateCoverArt = `-- name: UpdateCoverArt :exec
UPDATE cover_art
SET is_embedded = ?, file_path = ?, file_type_id = ?
WHERE id =?
SET is_embedded = ?, file_path = ?, mime_type = ?
WHERE id = ?
`
type UpdateCoverArtParams struct {
IsEmbedded bool
FilePath string
FileTypeID int64
MimeType string
ID int64
}
@@ -76,8 +93,35 @@ func (q *Queries) UpdateCoverArt(ctx context.Context, arg UpdateCoverArtParams)
_, err := q.db.ExecContext(ctx, updateCoverArt,
arg.IsEmbedded,
arg.FilePath,
arg.FileTypeID,
arg.MimeType,
arg.ID,
)
return err
}
const upsertCoverArt = `-- name: UpsertCoverArt :one
INSERT INTO cover_art (is_embedded, file_path, mime_type)
VALUES (?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
is_embedded = excluded.is_embedded,
mime_type = excluded.mime_type
RETURNING id, is_embedded, file_path, mime_type
`
type UpsertCoverArtParams struct {
IsEmbedded bool
FilePath string
MimeType string
}
func (q *Queries) UpsertCoverArt(ctx context.Context, arg UpsertCoverArtParams) (CoverArt, error) {
row := q.db.QueryRowContext(ctx, upsertCoverArt, arg.IsEmbedded, arg.FilePath, arg.MimeType)
var i CoverArt
err := row.Scan(
&i.ID,
&i.IsEmbedded,
&i.FilePath,
&i.MimeType,
)
return i, err
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
package sqlcgen
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: file_types.sql
package sqlcgen
+61 -6
View File
@@ -1,9 +1,14 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
package sqlcgen
import (
"database/sql"
"time"
)
type Artist struct {
ID int64
Name string
@@ -11,7 +16,7 @@ type Artist struct {
type ArtistCredit struct {
ID int64
Text interface{}
Text string
}
type ArtistCreditArtist struct {
@@ -32,7 +37,7 @@ type CoverArt struct {
ID int64
IsEmbedded bool
FilePath string
FileTypeID int64
MimeType string
}
type FileType struct {
@@ -40,20 +45,70 @@ type FileType struct {
Extension string
}
type PlayerState struct {
ID int64
Volume int64
Muted bool
LastTrackPath string
LastPositionSeconds int64
}
type Playlist struct {
ID int64
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistTrack struct {
ID int64
PlaylistID int64
AudioFileID int64
Position int64
}
type Queue struct {
ID int64
SourcePlaylistID sql.NullInt64
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
}
type QueueTrack struct {
ID int64
AudioFileID int64
Position int64
}
type Recording struct {
ID int64
Name string
ArtistCreditID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Year sql.NullInt64
Genre sql.NullString
Composer sql.NullString
Lyrics sql.NullString
Comment sql.NullString
}
type ReleaseGroup struct {
ID int64
Name string
CoverArtID int64
ID int64
Name string
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
}
type ReleaseGroupRecording struct {
ID int64
ReleaseGroupID int64
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
}
@@ -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
}
+200
View File
@@ -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
}
+166 -14
View File
@@ -1,29 +1,94 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: recordings.sql
package sqlcgen
import (
"context"
"database/sql"
)
const createRecording = `-- name: CreateRecording :one
INSERT INTO recordings (name) VALUES (?)
RETURNING id, name, artist_credit_id
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment
`
func (q *Queries) CreateRecording(ctx context.Context, name string) (Recording, error) {
row := q.db.QueryRowContext(ctx, createRecording, name)
type CreateRecordingParams struct {
Name string
ArtistCreditID int64
}
func (q *Queries) CreateRecording(ctx context.Context, arg CreateRecordingParams) (Recording, error) {
row := q.db.QueryRowContext(ctx, createRecording, arg.Name, arg.ArtistCreditID)
var i Recording
err := row.Scan(&i.ID, &i.Name, &i.ArtistCreditID)
err := row.Scan(
&i.ID,
&i.Name,
&i.ArtistCreditID,
&i.TrackNumber,
&i.DiscNumber,
&i.Year,
&i.Genre,
&i.Composer,
&i.Lyrics,
&i.Comment,
)
return i, err
}
const createRecordingFull = `-- name: CreateRecordingFull :one
INSERT INTO recordings (
name, artist_credit_id, track_number, disc_number,
year, genre, composer, lyrics, comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment
`
type CreateRecordingFullParams struct {
Name string
ArtistCreditID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Year sql.NullInt64
Genre sql.NullString
Composer sql.NullString
Lyrics sql.NullString
Comment sql.NullString
}
func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFullParams) (Recording, error) {
row := q.db.QueryRowContext(ctx, createRecordingFull,
arg.Name,
arg.ArtistCreditID,
arg.TrackNumber,
arg.DiscNumber,
arg.Year,
arg.Genre,
arg.Composer,
arg.Lyrics,
arg.Comment,
)
var i Recording
err := row.Scan(
&i.ID,
&i.Name,
&i.ArtistCreditID,
&i.TrackNumber,
&i.DiscNumber,
&i.Year,
&i.Genre,
&i.Composer,
&i.Lyrics,
&i.Comment,
)
return i, err
}
const deleteRecording = `-- name: DeleteRecording :exec
DELETE FROM recordings
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
@@ -31,30 +96,117 @@ func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
return err
}
const getAllRecordings = `-- name: GetAllRecordings :many
SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings
ORDER BY name
`
func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) {
rows, err := q.db.QueryContext(ctx, getAllRecordings)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Recording
for rows.Next() {
var i Recording
if err := rows.Scan(
&i.ID,
&i.Name,
&i.ArtistCreditID,
&i.TrackNumber,
&i.DiscNumber,
&i.Year,
&i.Genre,
&i.Composer,
&i.Lyrics,
&i.Comment,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecording = `-- name: GetRecording :one
SELECT id, name, artist_credit_id FROM recordings
SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment FROM recordings
WHERE id = ? LIMIT 1
`
func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) {
row := q.db.QueryRowContext(ctx, getRecording, id)
var i Recording
err := row.Scan(&i.ID, &i.Name, &i.ArtistCreditID)
err := row.Scan(
&i.ID,
&i.Name,
&i.ArtistCreditID,
&i.TrackNumber,
&i.DiscNumber,
&i.Year,
&i.Genre,
&i.Composer,
&i.Lyrics,
&i.Comment,
)
return i, err
}
const updateRecording = `-- name: UpdateRecording :exec
UPDATE recordings
SET name = ?
WHERE id =?
SET name = ?, artist_credit_id = ?
WHERE id = ?
`
type UpdateRecordingParams struct {
Name string
ID int64
Name string
ArtistCreditID int64
ID int64
}
func (q *Queries) UpdateRecording(ctx context.Context, arg UpdateRecordingParams) error {
_, err := q.db.ExecContext(ctx, updateRecording, arg.Name, arg.ID)
_, err := q.db.ExecContext(ctx, updateRecording, arg.Name, arg.ArtistCreditID, arg.ID)
return err
}
const updateRecordingFull = `-- name: UpdateRecordingFull :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
WHERE id = ?
`
type UpdateRecordingFullParams struct {
Name string
ArtistCreditID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Year sql.NullInt64
Genre sql.NullString
Composer sql.NullString
Lyrics sql.NullString
Comment sql.NullString
ID int64
}
func (q *Queries) UpdateRecordingFull(ctx context.Context, arg UpdateRecordingFullParams) error {
_, err := q.db.ExecContext(ctx, updateRecordingFull,
arg.Name,
arg.ArtistCreditID,
arg.TrackNumber,
arg.DiscNumber,
arg.Year,
arg.Genre,
arg.Composer,
arg.Lyrics,
arg.Comment,
arg.ID,
)
return err
}
@@ -1,34 +1,49 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: release_group_recordings.sql
package sqlcgen
import (
"context"
"database/sql"
)
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?,?)
RETURNING id, release_group_id, recording_id
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number
`
type CreateReleaseGroupRecordingParams struct {
ReleaseGroupID int64
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
}
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
row := q.db.QueryRowContext(ctx, createReleaseGroupRecording, arg.ReleaseGroupID, arg.RecordingID)
row := q.db.QueryRowContext(ctx, createReleaseGroupRecording,
arg.ReleaseGroupID,
arg.RecordingID,
arg.TrackNumber,
arg.DiscNumber,
)
var i ReleaseGroupRecording
err := row.Scan(&i.ID, &i.ReleaseGroupID, &i.RecordingID)
err := row.Scan(
&i.ID,
&i.ReleaseGroupID,
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
)
return i, err
}
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) error {
@@ -36,31 +51,104 @@ func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) err
return err
}
const deleteReleaseGroupRecordingByFK = `-- name: DeleteReleaseGroupRecordingByFK :exec
DELETE FROM release_group_recordings
WHERE release_group_id = ? AND recording_id = ?
`
type DeleteReleaseGroupRecordingByFKParams struct {
ReleaseGroupID int64
RecordingID int64
}
func (q *Queries) DeleteReleaseGroupRecordingByFK(ctx context.Context, arg DeleteReleaseGroupRecordingByFKParams) error {
_, err := q.db.ExecContext(ctx, deleteReleaseGroupRecordingByFK, arg.ReleaseGroupID, arg.RecordingID)
return err
}
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
WHERE recording_id = ?
`
func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int64) ([]ReleaseGroupRecording, error) {
rows, err := q.db.QueryContext(ctx, getRecordingReleaseGroups, recordingID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ReleaseGroupRecording
for rows.Next() {
var i ReleaseGroupRecording
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupID,
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getReleaseGroupRecording = `-- name: GetReleaseGroupRecording :one
SELECT id, release_group_id, recording_id FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
WHERE id = ? LIMIT 1
`
func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (ReleaseGroupRecording, error) {
row := q.db.QueryRowContext(ctx, getReleaseGroupRecording, id)
var i ReleaseGroupRecording
err := row.Scan(&i.ID, &i.ReleaseGroupID, &i.RecordingID)
err := row.Scan(
&i.ID,
&i.ReleaseGroupID,
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
)
return i, err
}
const updateReleaseGroupRecording = `-- name: UpdateReleaseGroupRecording :exec
UPDATE release_group_recordings
SET release_group_id = ?, recording_id = ?
WHERE id =?
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number
`
type UpdateReleaseGroupRecordingParams struct {
ReleaseGroupID int64
RecordingID int64
ID int64
}
func (q *Queries) UpdateReleaseGroupRecording(ctx context.Context, arg UpdateReleaseGroupRecordingParams) error {
_, err := q.db.ExecContext(ctx, updateReleaseGroupRecording, arg.ReleaseGroupID, arg.RecordingID, arg.ID)
return err
func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) ([]ReleaseGroupRecording, error) {
rows, err := q.db.QueryContext(ctx, getReleaseGroupRecordings, releaseGroupID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ReleaseGroupRecording
for rows.Next() {
var i ReleaseGroupRecording
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupID,
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -1,29 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
// source: release_groups.sql
package sqlcgen
import (
"context"
"database/sql"
)
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, createReleaseGroup, name)
var i ReleaseGroup
err := row.Scan(&i.ID, &i.Name, &i.CoverArtID)
err := row.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
)
return i, err
}
const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
`
type CreateReleaseGroupFullParams struct {
Name string
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
}
func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseGroupFullParams) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, createReleaseGroupFull,
arg.Name,
arg.CoverArtID,
arg.AlbumArtistCreditID,
arg.Year,
arg.TotalTracks,
arg.TotalDiscs,
)
var i ReleaseGroup
err := row.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
)
return i, err
}
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
WHERE id =?
WHERE id = ?
`
func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
@@ -31,22 +78,136 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
return err
}
const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY rg.name
`
type GetAllAlbumsWithDetailsRow struct {
ID int64
Name string
Year sql.NullInt64
ArtistName string
CoverArtPath string
}
func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWithDetailsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllAlbumsWithDetails)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllAlbumsWithDetailsRow
for rows.Next() {
var i GetAllAlbumsWithDetailsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Year,
&i.ArtistName,
&i.CoverArtPath,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
ORDER BY name
`
func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, error) {
rows, err := q.db.QueryContext(ctx, getAllReleaseGroups)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ReleaseGroup
for rows.Next() {
var i ReleaseGroup
if err := rows.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
WHERE id = ? LIMIT 1
`
func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, getReleaseGroup, id)
var i ReleaseGroup
err := row.Scan(&i.ID, &i.Name, &i.CoverArtID)
err := row.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
)
return i, err
}
const getReleaseGroupByName = `-- name: GetReleaseGroupByName :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups
WHERE name = ? LIMIT 1
`
func (q *Queries) GetReleaseGroupByName(ctx context.Context, name string) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, getReleaseGroupByName, name)
var i ReleaseGroup
err := row.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
)
return i, err
}
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
WHERE id =?
WHERE id = ?
`
type UpdateReleaseGroupParams struct {
@@ -58,3 +219,49 @@ func (q *Queries) UpdateReleaseGroup(ctx context.Context, arg UpdateReleaseGroup
_, err := q.db.ExecContext(ctx, updateReleaseGroup, arg.Name, arg.ID)
return err
}
const updateReleaseGroupCoverArt = `-- name: UpdateReleaseGroupCoverArt :exec
UPDATE release_groups
SET cover_art_id = ?
WHERE id = ?
`
type UpdateReleaseGroupCoverArtParams struct {
CoverArtID sql.NullInt64
ID int64
}
func (q *Queries) UpdateReleaseGroupCoverArt(ctx context.Context, arg UpdateReleaseGroupCoverArtParams) error {
_, err := q.db.ExecContext(ctx, updateReleaseGroupCoverArt, arg.CoverArtID, arg.ID)
return err
}
const upsertReleaseGroup = `-- name: UpsertReleaseGroup :one
INSERT INTO release_groups (name, album_artist_credit_id, year)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
`
type UpsertReleaseGroupParams struct {
Name string
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
}
func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroupParams) (ReleaseGroup, error) {
row := q.db.QueryRowContext(ctx, upsertReleaseGroup, arg.Name, arg.AlbumArtistCreditID, arg.Year)
var i ReleaseGroup
err := row.Scan(
&i.ID,
&i.Name,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.TotalTracks,
&i.TotalDiscs,
)
return i, err
}
+50
View File
@@ -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
}
+38
View File
@@ -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
}
+43
View File
@@ -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
}
+39
View File
@@ -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>
}
+53
View File
@@ -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
+80
View File
@@ -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
}
}
+42
View File
@@ -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
View File
@@ -2,62 +2,640 @@ package library
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
goruntime "runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/metadata"
)
type Config struct {
DirectoryPath string
SaveFunc func() error `toml:"-"`
}
func (c *Config) Validate() error {
return nil
}
var DefaultConfig *Config = &Config{
DirectoryPath: "",
}
// Library manages scanning and querying the music collection.
type Library struct {
ctx context.Context
conf *Config
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
}
func NewLibrary(conf *Config) (*Library, error) {
// NewLibrary creates a new library with the given configuration.
func NewLibrary(
ctx context.Context,
logger *slog.Logger,
conf *Config,
db *database.DB,
) (*Library, error) {
if conf == nil {
return nil, fmt.Errorf("nil config for library")
return nil, errors.New("nil config for library")
}
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
}
return &Library{
conf: conf,
}, nil
library := &Library{
ctx: ctx,
logger: logger,
conf: conf,
db: db,
}
return library, nil
}
func (l *Library) Init(ctx context.Context) error {
// SetContext sets the Wails runtime context and registers event handlers.
func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
l.registerEventHandlers()
}
func (l *Library) registerEventHandlers() {
if l.ctx == nil {
l.logger.Error("Context is nil, cannot register event handlers")
return
}
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
l.logger.Info("Received LibraryConfigChanged event")
if len(data) == 0 {
l.logger.Error("LibraryConfigChanged event received with no data")
return
}
configMap, ok := data[0].(map[string]any)
if !ok {
l.logger.Error("LibraryConfigChanged event data is not a map", "data", data[0])
return
}
dir, ok := configMap["DirectoryPath"].(string)
if !ok {
l.logger.Error("DirectoryPath not found or not a string in config event")
return
}
updatedConfig := Config{DirectoryPath: Directory(dir)}
if err := l.handleConfigUpdate(updatedConfig); err != nil {
l.logger.Error("Failed to handle config update", "err", err)
}
})
}
// Scan syncs the library by adding new files and removing deleted ones.
// Files that exist but have incomplete metadata (recording_id = 0) will be updated.
func (l *Library) Scan() error {
l.logger.Info("beginning library scan", "workers", scanWorkerCount)
if len(l.conf.DirectoryPath) == 0 {
return errors.New("library directory not configured")
}
// Load existing file paths from the database into a sync.Map for concurrent access.
// The map tracks path → audioFile; entries are removed as files are "seen" during the walk.
// Any entries remaining after the walk are orphans (files deleted from disk).
existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx)
if err != nil {
return fmt.Errorf("could not load existing audio files: %w", err)
}
existingPaths := &sync.Map{}
for _, f := range existingFiles {
existingPaths.Store(f.FilePath, f)
}
l.logger.Debug(
"loaded existing files from database",
"count", len(existingFiles),
"library-directory", l.conf.DirectoryPath,
)
basePath := string(l.conf.DirectoryPath)
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
var added, skipped, updated atomic.Int64
var scanErr error
var errMu sync.Mutex
// Walker goroutine: traverse directory and send work items to workers
go func() {
defer close(workChan)
walkErr := fs.WalkDir(
os.DirFS(basePath),
".",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
l.logger.Error("problem walking directory", "path", path, "err", err)
return nil // continue walking
}
if d.IsDir() {
return nil
}
absoluteFilePath := filepath.Join(basePath, path)
fileExt := filepath.Ext(d.Name())
fileType, isSupportedAudioFile := metadata.GetSupportedFileType(fileExt)
if !isSupportedAudioFile {
return nil
}
// Check if file already exists in database
if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
audioFile := existing.(sqlcgen.AudioFile)
// Check if this file needs metadata update (recording_id = 0)
if audioFile.RecordingID == 0 {
l.logger.Debug("file needs metadata update", "path", absoluteFilePath)
select {
case workChan <- scanWork{
absolutePath: absoluteFilePath,
fileType: fileType,
existingFileID: audioFile.ID,
needsUpdate: true,
existingLength: audioFile.LengthMilliseconds,
}:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
}
l.logger.Debug(
"file already in library with metadata, skipping",
"path",
absoluteFilePath,
)
skipped.Add(1)
return nil
}
l.logger.Debug("queueing file for import", "path", absoluteFilePath)
// Send to workers for processing
select {
case workChan <- scanWork{absolutePath: absoluteFilePath, fileType: fileType}:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
},
)
if walkErr != nil {
errMu.Lock()
scanErr = errors.Join(
scanErr,
fmt.Errorf("problem walking library directory: %w", walkErr),
)
errMu.Unlock()
}
}()
// DB writer goroutine: serialize all database writes to avoid SQLite contention
var dbWg sync.WaitGroup
dbWg.Add(1)
go func() {
defer dbWg.Done()
for result := range resultChan {
var saveErr error
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(result)
if saveErr == nil {
updated.Add(1)
}
} else {
saveErr = l.saveAudioFile(result)
if saveErr == nil {
added.Add(1)
}
}
if saveErr != nil {
l.logger.Warn(
"failed to save audio file",
"path",
result.absolutePath,
"err",
saveErr,
)
errMu.Lock()
scanErr = errors.Join(scanErr, saveErr)
errMu.Unlock()
}
}
}()
// Worker pool: extract metadata concurrently, send results to DB writer
g := new(errgroup.Group)
g.SetLimit(scanWorkerCount)
for work := range workChan {
g.Go(func() error {
result, err := l.extractAudioMetadata(work)
if err != nil {
l.logger.Warn("failed to extract metadata", "path", work.absolutePath, "err", err)
errMu.Lock()
scanErr = errors.Join(scanErr, err)
errMu.Unlock()
return nil // continue processing other files
}
// Send to DB writer
select {
case resultChan <- result:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
})
}
_ = g.Wait() // Wait for all metadata extraction to complete
close(resultChan) // Signal DB writer to finish
dbWg.Wait() // Wait for all DB writes to complete
// Orphan cleanup: any entries remaining in existingPaths are files deleted from disk
var removed atomic.Int64
existingPaths.Range(func(key, value any) bool {
path := key.(string)
audioFile := value.(sqlcgen.AudioFile)
l.logger.Debug("removing orphaned database entry", "path", path, "id", audioFile.ID)
if err := l.db.Queries.DeleteAudioFile(l.ctx, audioFile.ID); err != nil {
l.logger.Warn(
"failed to delete orphaned audio file",
"path", path,
"id", audioFile.ID,
"err", err,
)
return true
}
removed.Add(1)
return true
})
l.logger.Info(
"library scan complete",
"added", added.Load(),
"updated", updated.Load(),
"removed", removed.Load(),
"skipped", skipped.Load(),
"library", l.conf.DirectoryPath,
)
return scanErr
}
// scanWorkerCount controls the number of concurrent file processors.
// TODO: make configurable via Config.
var scanWorkerCount = goruntime.NumCPU()
// scanWork represents a file to be processed by a worker.
type scanWork struct {
absolutePath string
fileType metadata.AudioFileExtension
existingFileID int64 // non-zero if this is an update
needsUpdate bool
existingLength int64 // existing length if updating
}
// importResult holds metadata extracted by workers, ready for DB insertion.
type importResult struct {
absolutePath string
fileType metadata.AudioFileExtension
lengthMillis int64
tags *metadata.TrackMetadata
existingFileID int64 // non-zero if this is an update
needsUpdate bool
}
// extractAudioMetadata reads and extracts metadata from an audio file.
func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
result := importResult{
absolutePath: work.absolutePath,
fileType: work.fileType,
existingFileID: work.existingFileID,
needsUpdate: work.needsUpdate,
}
// Get duration (skip if updating and we already have it)
if work.needsUpdate && work.existingLength > 0 {
result.lengthMillis = work.existingLength
} else {
trackLengthMillis, err := metadata.GetTrackLengthMillis(work.absolutePath)
if err != nil {
return result, fmt.Errorf(
"could not get track length for %s: %w",
work.absolutePath,
err,
)
}
result.lengthMillis = trackLengthMillis
}
// Extract tags
tags, err := metadata.ExtractTags(work.absolutePath)
if err != nil {
l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err)
// Continue with empty tags - not a fatal error
tags = &metadata.TrackMetadata{}
}
result.tags = tags
return result, nil
}
// saveAudioFile writes audio file metadata to the database (new files).
func (l *Library) saveAudioFile(result importResult) error {
l.logger.Debug(
"saving audio file to db",
"absolute-path", result.absolutePath,
"track-length-millis", result.lengthMillis,
"file-type", int64(slices.Index(metadata.SupportedFileExtensions, result.fileType)),
)
// Process metadata and create related records
recordingID, err := l.processMetadata(result)
if err != nil {
return fmt.Errorf("could not process metadata: %w", err)
}
if _, err := l.db.Queries.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
FileTypeID: int64(
slices.Index(metadata.SupportedFileExtensions, result.fileType),
),
RecordingID: recordingID,
}); err != nil {
return fmt.Errorf("could not save audio file to db: %w", err)
}
l.logger.Debug("added audio file to library", "path", result.absolutePath)
return nil
}
func (l *Library) GetDir() (string, error) {
return l.conf.DirectoryPath, nil
}
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
func (l *Library) updateAudioFileMetadata(result importResult) error {
l.logger.Debug(
"updating audio file metadata",
"absolute-path", result.absolutePath,
"file-id", result.existingFileID,
)
func (l *Library) SetDir(dirPath string) error {
fileInfo, err := os.Stat(dirPath)
// Process metadata and create related records
recordingID, err := l.processMetadata(result)
if err != nil {
return fmt.Errorf("could not stat %s: %w", dirPath, err)
}
if !fileInfo.IsDir() {
return fmt.Errorf("dirPath is not a directory: %s", dirPath)
return fmt.Errorf("could not process metadata: %w", err)
}
l.conf.DirectoryPath = dirPath
err = l.conf.SaveFunc()
if err != nil {
return fmt.Errorf("could not save library dir config: %w", err)
if err := l.db.Queries.UpdateAudioFileRecording(
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
RecordingID: recordingID,
ID: result.existingFileID,
}); err != nil {
return fmt.Errorf("could not update audio file recording: %w", err)
}
l.logger.Debug("updated audio file metadata", "path", result.absolutePath)
return nil
}
// processMetadata creates all related database records for metadata and returns the recording ID.
func (l *Library) processMetadata(result importResult) (int64, error) {
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
// 1. Handle cover art (if present)
var coverArtID sql.NullInt64
if tags.Picture != nil {
coverPath, err := l.saveCoverArt(tags.Picture)
if err != nil {
l.logger.Warn("could not save cover art", "err", err)
} else if coverPath != "" {
// Use upsert to avoid duplicates
ca, err := l.db.Queries.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{
IsEmbedded: true,
FilePath: coverPath,
MimeType: tags.Picture.MIMEType,
})
if err != nil {
l.logger.Warn("could not create cover art record", "err", err)
} else {
coverArtID = sql.NullInt64{Int64: ca.ID, Valid: true}
}
}
}
// 2. Get or create artist credit for track artist
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
if err != nil {
return 0, fmt.Errorf("could not upsert artist credit: %w", err)
}
// Also create the artist record and link (best effort)
artist, err := l.db.Queries.UpsertArtist(l.ctx, artistName)
if err != nil {
l.logger.Warn("could not upsert artist", "err", err)
} else {
// Link artist to credit (ignore error if already linked)
_, _ = l.db.Queries.CreateArtistCreditArtist(l.ctx, sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID,
CreditID: artistCredit.ID,
})
}
// 3. Get or create artist credit for album artist (if different)
var albumArtistCreditID sql.NullInt64
if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist {
albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, tags.AlbumArtist)
if err != nil {
l.logger.Warn("could not upsert album artist credit", "err", err)
} else {
albumArtistCreditID = sql.NullInt64{Int64: albumArtistCredit.ID, Valid: true}
// Also create the artist record and link
albumArtist, err := l.db.Queries.UpsertArtist(l.ctx, tags.AlbumArtist)
if err != nil {
l.logger.Warn("could not upsert album artist", "err", err)
} else {
_, _ = l.db.Queries.CreateArtistCreditArtist(
l.ctx,
sqlcgen.CreateArtistCreditArtistParams{
ArtistID: albumArtist.ID,
CreditID: albumArtistCredit.ID,
},
)
}
}
}
// 4. Get or create release group (album)
var releaseGroupID sql.NullInt64
if tags.Album != "" {
rg, err := l.db.Queries.UpsertReleaseGroup(l.ctx, sqlcgen.UpsertReleaseGroupParams{
Name: tags.Album,
AlbumArtistCreditID: albumArtistCreditID,
Year: toNullInt64(tags.Year),
})
if err != nil {
l.logger.Warn("could not upsert release group", "err", err)
} else {
releaseGroupID = sql.NullInt64{Int64: rg.ID, Valid: true}
// Update cover art if this album doesn't have one yet
if coverArtID.Valid && !rg.CoverArtID.Valid {
err := l.db.Queries.UpdateReleaseGroupCoverArt(
l.ctx,
sqlcgen.UpdateReleaseGroupCoverArtParams{
CoverArtID: coverArtID,
ID: rg.ID,
},
)
if err != nil {
l.logger.Warn("could not update release group cover art", "err", err)
}
}
}
}
// 5. Create recording
recording, err := l.db.Queries.CreateRecordingFull(l.ctx, sqlcgen.CreateRecordingFullParams{
Name: l.getRecordingName(tags, result.absolutePath),
ArtistCreditID: artistCredit.ID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
Year: toNullInt64(tags.Year),
Genre: toNullString(tags.Genre),
Composer: toNullString(tags.Composer),
Lyrics: toNullString(tags.Lyrics),
Comment: toNullString(tags.Comment),
})
if err != nil {
return 0, fmt.Errorf("could not create recording: %w", err)
}
// 6. Link recording to release group
if releaseGroupID.Valid {
_, err = l.db.Queries.CreateReleaseGroupRecording(
l.ctx,
sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: releaseGroupID.Int64,
RecordingID: recording.ID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
},
)
if err != nil {
l.logger.Warn("could not link recording to release group", "err", err)
}
}
return recording.ID, nil
}
// getRecordingName returns the track title, or falls back to the filename.
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
if tags.Title != "" {
return tags.Title
}
// Fallback to filename without extension
base := filepath.Base(filePath)
return strings.TrimSuffix(base, filepath.Ext(base))
}
// toNullInt64 converts an int to sql.NullInt64, treating 0 as null.
func toNullInt64(v int) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(v), Valid: true}
}
// toNullString converts a string to sql.NullString, treating empty as null.
func toNullString(v string) sql.NullString {
if v == "" {
return sql.NullString{}
}
return sql.NullString{String: v, Valid: true}
}
func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
l.logger.Info("handling config update", "updated", updatedConfigValues)
var updateErr error
if l.conf.DirectoryPath != updatedConfigValues.DirectoryPath {
l.logger.Info("new library, scanning")
l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
if err := l.Scan(); err != nil {
updateErr = errors.Join(
updateErr,
fmt.Errorf("problem scanning library on config update: %w", err),
)
}
}
return updateErr
}
+121
View File
@@ -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
}
+88 -5
View File
@@ -1,11 +1,94 @@
// Package logging provides a slog-based logger adapter for Wails.
package logging
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
)
// TODO check that error
func PrettyJSON(obj interface{}) string {
bytes, _ := json.MarshalIndent(obj, "\t", "\t")
return string(bytes)
// Logger wraps slog to implement the Wails logger interface.
type Logger struct {
slogger *slog.Logger
moduleFilters []string
}
// NewLogger creates a logger with optional message filters.
func NewLogger(slogger *slog.Logger, filters []string) *Logger {
return &Logger{
slogger: slogger,
moduleFilters: filters,
}
}
// Print outputs a message if not filtered.
func (l *Logger) Print(message string) {
if l.isFilteredOut(message) {
return
}
fmt.Printf("[Print] %s\n", message)
}
// Trace logs a trace-level message if not filtered.
func (l *Logger) Trace(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Debug("[Trace] " + message)
}
// Debug logs a debug-level message if not filtered.
func (l *Logger) Debug(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Debug(message)
}
// Info logs an info-level message if not filtered.
func (l *Logger) Info(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Info(message)
}
// Warning logs a warning-level message if not filtered.
func (l *Logger) Warning(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Warn(message)
}
func (l *Logger) Error(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Error(message)
}
// Fatal logs a fatal-level message if not filtered.
func (l *Logger) Fatal(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Error("[Trace] " + message)
}
func (l *Logger) isFilteredOut(message string) bool {
for _, f := range l.moduleFilters {
if strings.HasPrefix(message, fmt.Sprintf("[%s]", f)) {
return true
}
}
return false
}
+36
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+102
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
// Package models defines domain types for music data.
package models
// Art holds album artwork data.
type Art struct{}
+21
View File
@@ -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
}
+21
View File
@@ -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
View File
@@ -1,63 +1,231 @@
// Package player provides audio playback functionality.
package player
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"time"
"github.com/gopxl/beep"
"github.com/gopxl/beep/effects"
"github.com/gopxl/beep/generators"
"github.com/gopxl/beep/mp3"
"github.com/gopxl/beep/speaker"
"github.com/TheCodeOfCaleb/beep/v2"
"github.com/TheCodeOfCaleb/beep/v2/effects"
"github.com/TheCodeOfCaleb/beep/v2/generators"
"github.com/TheCodeOfCaleb/beep/v2/speaker"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/metadata"
)
// Player handles audio playback and state management.
type Player struct {
ctx context.Context
state PlayerState
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
ctx context.Context
logger *slog.Logger
db *database.DB
state PlayerState
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
playbackFinishedHandler func()
}
type PlayerState int
// PlayerState represents the current playback state.
type PlayerState string
// Playback state values.
const (
Playing PlayerState = iota
Paused
Stopped
Playing PlayerState = "playing"
Paused PlayerState = "paused"
Stopped PlayerState = "stopped"
)
var speakerSampleRate = beep.SampleRate(44100)
func NewPlayer() (*Player, error) {
return &Player{
// NewPlayer creates a player and initializes the audio speaker.
func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) {
player := &Player{
ctx: ctx,
logger: logger,
db: db,
state: Stopped,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
},
}, nil
}
func (p *Player) Init(ctx context.Context) error {
p.ctx = ctx
// Initialize speaker
// TODO: allow user to change buffer size and speaker sample rate
err := speaker.Init(p.format.SampleRate, p.format.SampleRate.N(time.Second/10))
if err != nil {
return fmt.Errorf("failed to initialize speaker %w", err)
}
return nil
// TODO: allow user to change buffer size and speaker sample rate
err := speaker.Init(player.format.SampleRate, player.format.SampleRate.N(time.Second/10))
if err != nil {
return nil, fmt.Errorf("failed to initialize speaker %w", err)
}
return player, nil
}
// SetPlaybackFinishedHandler sets a callback that is invoked when a track finishes naturally.
// This allows the queue to drive auto-advance without circular imports.
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
p.playbackFinishedHandler = handler
}
// SetContext sets the Wails context, registers event handlers, and restores persisted state.
func (p *Player) SetContext(ctx context.Context) {
p.ctx = ctx
p.registerEventHandlers()
p.RestoreState()
}
func (p *Player) registerEventHandlers() {
if p.ctx == nil {
p.logger.Error("Context is nil, cannot register event handlers")
return
}
runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) {
p.logger.Info("Received RequestPlayEvent")
p.Play()
})
runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
p.logger.Info("Received RequestPauseEvent")
p.Pause()
})
runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
p.logger.Info("Received RequestLoadFileEvent")
filePath := data[0].(string)
p.logger.Info(filePath)
err := p.LoadFile(filePath)
if err != nil {
p.logger.Error(err.Error())
} else {
p.logger.Info(p.currentFile.Name())
}
})
runtime.EventsOn(p.ctx, events.Seek, func(data ...any) {
p.logger.Info("Received SeekEvent", "Data", data[0])
seekValue := int(data[0].(float64))
err := p.Seek(seekValue)
if err != nil {
p.logger.Error("cannot seek", "error", err)
}
})
runtime.EventsOn(p.ctx, events.RequestSetVolume, func(data ...any) {
desiredVolume := UserVolume(data[0].(float64))
p.logger.Info("Received RequestSetVolumeEvent", "volume", desiredVolume)
err := p.SetVolume(desiredVolume)
if err != nil {
p.logger.Error("cannot set volume", "error", err)
return
}
p.emitVolumeChanged()
})
}
// emitPlaybackStateChanged emits a playback state change event.
func (p *Player) emitPlaybackStateChanged(state PlayerState) {
if p.ctx == nil {
p.logger.Error("Context is nil, cannot emit event")
return
}
p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state)
runtime.EventsEmit(
p.ctx,
events.PlaybackStateChanged,
map[string]string{"state": string(state)},
)
}
func (p *Player) emitPlaybackFinished() {
if p.ctx == nil {
p.logger.Error("Context is nil, cannot emit event")
return
}
p.logger.Info("Emitting PlaybackFinishedEvent")
runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil)
}
func (p *Player) emitVolumeChanged() {
if p.ctx == nil {
p.logger.Error("Context is nil, cannot emit event")
return
}
volume := int(p.getUserVolume())
p.logger.Info("Emitting VolumeChangedEvent", "volume", volume)
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
}
func (p *Player) emitTrackChanged() {
if p.ctx == nil {
p.logger.Error("Context is nil, cannot emit event")
return
}
trackLengthSecs, err := p.TrackLengthInSeconds()
if err != nil {
p.logger.Error("Cannot get track length")
}
trackInfo, err := p.GetCurrentTrackInfo()
if err != nil {
p.logger.Error("Cannot get track info")
trackInfo = map[string]interface{}{
"fileName": "",
"filePath": "",
"state": string(p.state),
}
}
// Compute current seek position in seconds.
seekPosition := 0
if p.seeker != nil {
speaker.Lock()
seekPosition = p.seeker.Position() / int(p.format.SampleRate)
speaker.Unlock()
}
// Emit comprehensive track info
trackInfo["trackLength"] = trackLengthSecs
trackInfo["seekPosition"] = seekPosition
runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
p.logger.Info("Emitting TrackChangedEvent with track info", "trackInfo", trackInfo)
}
// EmitCurrentState pushes the current player state to the frontend.
// This is intended to be called after the frontend is ready to receive events,
// separately from RestoreState which does the heavy lifting during OnStartup.
func (p *Player) EmitCurrentState() {
p.emitVolumeChanged()
if p.currentFile != nil {
p.emitPlaybackStateChanged(p.state)
p.emitTrackChanged()
}
}
func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.SampleRate) error {
@@ -72,12 +240,21 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.resampled}
// Preserve existing volume settings across track changes.
prevVolume := 0.0
prevSilent := false
if p.volume != nil {
prevVolume = p.volume.Volume
prevSilent = p.volume.Silent
}
// wrap in volume streamer
p.volume = &effects.Volume{
Streamer: p.control,
Base: 2,
Volume: 0,
Silent: false,
Volume: prevVolume,
Silent: prevSilent,
}
// set "final" streamer
@@ -86,68 +263,153 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
return nil
}
// TODO: proper state management extracted to function
func (p *Player) changeState(desiredState PlayerState) error {
return nil
// startPaused registers the current streamer chain with the speaker in a
// paused state. This keeps the speaker always active when a file is loaded,
// so Play() only ever needs to unpause the control gate.
func (p *Player) startPaused() {
speaker.Lock()
p.control.Paused = true
speaker.Unlock()
speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
p.state = Stopped
p.emitPlaybackStateChanged(p.state)
p.emitPlaybackFinished()
p.logger.Info("Playback finished naturally")
// Notify queue for auto-advance.
if p.playbackFinishedHandler != nil {
p.playbackFinishedHandler()
}
})))
p.state = Paused
}
// reads a file and creates a streamer, also wraps necessary streamers
// LoadFile opens and decodes an audio file for playback.
func (p *Player) LoadFile(filePath string) error {
// opening file
f, err := os.Open(filePath)
if err != nil {
p.logger.Error("Failed to open file")
return fmt.Errorf("failed to open file %w", err)
}
// attempt to decode mp3 file and create streamer and format data
streamer, format, err := mp3.Decode(f)
streamer, format, err := metadata.DecodeFile(f)
if err != nil {
return fmt.Errorf("failed to decode mp3 %w", err)
p.logger.Error("failed to decode audio file", "path", filePath, "err", err)
return fmt.Errorf("failed to decode audio file: %w", err)
}
// Stop existing playback before loading new file.
speaker.Lock()
if p.control != nil {
p.control.Paused = true
}
p.state = Stopped
speaker.Unlock()
if p.currentFile != nil {
p.currentFile.Close()
}
p.currentFile = f
p.updateStreamers(streamer, format.SampleRate)
p.startPaused()
p.emitPlaybackStateChanged(p.state)
p.emitTrackChanged()
p.logger.Info("File loaded, state set to paused", "file", filePath)
return nil
}
func (p *Player) validateReadyToPlay() error {
if p.control == nil {
return errors.New("no control streamer")
}
if p.currentFile == nil {
return errors.New("no audio file loaded")
}
if p.speakerStreamer == nil {
return errors.New("no streamer to play")
}
return nil
}
// Play starts or resumes audio playback.
func (p *Player) Play() error {
p.state = Playing
// hangs until song finishes playing
done := make(chan bool)
speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
p.state = Paused //called when streamer finishes
done <- true
})))
if err := p.validateReadyToPlay(); err != nil {
return err
}
<-done
return nil
}
if p.state == Playing {
p.logger.Info("Already playing")
// TODO: reduce dupilcation in pause/resume functions
func (p *Player) Pause() error {
speaker.Lock()
p.control.Paused = true
speaker.Unlock()
return nil
}
return nil
}
// TODO: reduce dupilcation in pause/resume functions
func (p *Player) Resume() error {
// Track finished naturally — seek to the beginning and re-register
// a paused stream with the speaker so the unpause below starts it.
if p.state == Stopped && p.seeker != nil {
speaker.Lock()
err := p.seeker.Seek(0)
speaker.Unlock()
if err != nil {
return fmt.Errorf("failed to seek to beginning: %w", err)
}
p.updateStreamers(p.seeker, p.format.SampleRate)
p.startPaused()
p.logger.Info("Rebuilt streamers for replay")
}
// Unpause — works for both resume-from-pause and replay-from-stopped.
speaker.Lock()
p.control.Paused = false
speaker.Unlock()
p.state = Playing
p.emitPlaybackStateChanged(p.state)
p.logger.Info("Started playback")
return nil
}
//Paraprasing info from the beep docs here:
/*
To INCREASE volume by 1 means to multiply the signal by Base.
Volume = 0 means unchanged volume.
Positive Volume value means increasing volume
Negative Volume value means decreasing volume
*/
// Pause pauses the current playback.
func (p *Player) Pause() error {
if p.control == nil {
return errors.New("no audio stream to pause")
}
if p.state == Paused {
p.logger.Info("Already paused")
return nil
}
if p.state == Playing {
speaker.Lock()
p.control.Paused = true
speaker.Unlock()
p.state = Paused
p.logger.Info("Paused playback")
p.emitPlaybackStateChanged(p.state)
} else {
p.logger.Info("Already paused or not playing")
}
return nil
}
// SetVolume sets the playback volume (0-100).
func (p *Player) SetVolume(desiredVolume UserVolume) error {
speaker.Lock()
// clamp value between 1 and 100
@@ -160,6 +422,8 @@ func (p *Player) SetVolume(desiredVolume UserVolume) error {
return nil
}
// ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error {
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume))
}
@@ -168,27 +432,258 @@ func (p *Player) getUserVolume() UserVolume {
return PlayerVolume(p.volume.Volume).ToUserVolume()
}
// MuteToggle toggles the mute state.
func (p *Player) MuteToggle() error {
p.volume.Silent = !p.volume.Silent
return nil
}
// return the current position as an int between 0 and 100 to work with progress bar easily.
// CurrentPositionSeconds returns the current playback position in seconds.
func (p *Player) CurrentPositionSeconds() (int, error) {
if p.seeker == nil {
return 0, errors.New("no audio file loaded")
}
speaker.Lock()
pos := p.seeker.Position() / int(p.format.SampleRate)
speaker.Unlock()
return pos, nil
}
// CurrentPosition returns the playback position as a percentage (0-100).
func (p *Player) CurrentPosition() (int, error) {
if p.seeker == nil {
return 0, errors.New("no audio file loaded")
}
speaker.Lock()
pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len()))
speaker.Unlock()
return int(pos), nil
}
// TODO: double check best type for percentage parameter
// percentage comes from the progress bar as a value between 0 and 100
func (p *Player) Seek(percentage int) error {
//take percentage value (0-100), make 0-1, multiply by total number of samples in stream
samples := int(math.Round((float64(percentage) / 100.0) * float64(p.seeker.Len())))
// Seek jumps to a specific position in seconds.
func (p *Player) Seek(targetSeconds int) error {
if p.seeker == nil {
runtime.EventsEmit(p.ctx, events.SeekFailed)
return errors.New("no audio file loaded")
}
lengthSecs, err := p.TrackLengthInSeconds()
if err != nil {
return fmt.Errorf("cannot get track length: %w", err)
}
speaker.Lock()
samples := int(
math.Round((float64(targetSeconds) / float64(lengthSecs)) * float64(p.seeker.Len())),
)
p.logger.Debug(
"attempting to seek",
"target-seconds",
targetSeconds,
"song-length",
lengthSecs,
"samples",
samples,
)
p.seeker.Seek(samples)
speaker.Unlock()
return nil
}
// GetCurrentTrackInfo returns information about the currently loaded track.
func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
if p.currentFile == nil {
return map[string]interface{}{
"fileName": "",
"filePath": "",
"state": string(p.state),
"title": "",
"artist": "",
"album": "",
"coverArt": "",
}, nil
}
fileName := filepath.Base(p.currentFile.Name())
filePath := p.currentFile.Name()
// Default values
title := fileName
artist := ""
album := ""
coverArt := ""
// Try to get metadata from database
if p.db != nil {
meta, err := p.db.Queries.GetTrackMetadataByPath(p.ctx, filePath)
if err == nil {
if meta.Title != "" {
title = meta.Title
}
artist = meta.Artist
album = meta.Album
if meta.CoverArtPath != "" {
coverArt = "/covers/" + filepath.Base(meta.CoverArtPath)
}
} else {
p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err)
}
}
return map[string]interface{}{
"fileName": fileName,
"filePath": filePath,
"state": string(p.state),
"title": title,
"artist": artist,
"album": album,
"coverArt": coverArt,
}, nil
}
// TrackLengthInSeconds returns the duration of the current track.
func (p *Player) TrackLengthInSeconds() (int, error) {
len := p.seeker.Len() / int(p.format.SampleRate)
return len, nil
if p.seeker == nil {
return 0, errors.New("no audio file loaded")
}
speaker.Lock()
length := p.seeker.Len() / int(p.format.SampleRate)
speaker.Unlock()
return length, nil
}
// SaveState persists the current player state to the database.
func (p *Player) SaveState() {
if p.db == nil {
p.logger.Warn("No database available, cannot save player state")
return
}
volume := int64(MaxUserVol)
muted := false
if p.volume != nil {
volume = int64(p.getUserVolume())
muted = p.volume.Silent
}
trackPath := ""
if p.currentFile != nil {
trackPath = p.currentFile.Name()
}
positionSeconds := int64(0)
if p.seeker != nil {
speaker.Lock()
positionSeconds = int64(p.seeker.Position()) / int64(p.format.SampleRate)
speaker.Unlock()
}
err := p.db.Queries.UpdatePlayerState(p.db.Ctx, sqlcgen.UpdatePlayerStateParams{
Volume: volume,
Muted: muted,
LastTrackPath: trackPath,
LastPositionSeconds: positionSeconds,
})
if err != nil {
p.logger.Error("Failed to save player state", "err", err)
return
}
p.logger.Info("Player state saved",
"volume", volume,
"muted", muted,
"trackPath", trackPath,
"positionSeconds", positionSeconds,
)
}
// RestoreState loads the persisted player state from the database.
func (p *Player) RestoreState() {
if p.db == nil {
p.logger.Warn("No database available, cannot restore player state")
return
}
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
if err != nil {
p.logger.Error("Failed to load player state", "err", err)
return
}
// Restore volume.
// Ensure volume is initialized before restoring settings. The volume
// effect is normally created by updateStreamers during LoadFile, but
// RestoreState runs before any file is loaded.
if p.volume == nil {
p.volume = &effects.Volume{
Streamer: p.control,
Base: 2,
}
}
vol := clampVolume(UserVolume(state.Volume))
err = p.SetVolume(vol)
if err != nil {
p.logger.Error("Failed to restore volume", "err", err)
}
if state.Muted {
p.volume.Silent = true
}
// Restore last track if the file still exists.
if state.LastTrackPath != "" {
if _, statErr := os.Stat(state.LastTrackPath); statErr != nil {
p.logger.Warn("Last track file no longer exists, skipping restore",
"path", state.LastTrackPath,
"err", statErr,
)
return
}
err = p.LoadFile(state.LastTrackPath)
if err != nil {
p.logger.Error("Failed to restore last track", "path", state.LastTrackPath, "err", err)
return
}
// Restore playback position.
if state.LastPositionSeconds > 0 {
err = p.Seek(int(state.LastPositionSeconds))
if err != nil {
p.logger.Error("Failed to restore playback position",
"seconds", state.LastPositionSeconds,
"err", err,
)
}
}
}
p.logger.Info("Player state restored",
"volume", vol,
"muted", state.Muted,
"trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds,
)
}
+7 -7
View File
@@ -2,6 +2,7 @@ package player
import (
"context"
"log/slog"
"testing"
)
@@ -13,30 +14,29 @@ var testQueue = []string{
func TestPlayer(t *testing.T) {
t.Logf("Starting test")
p, err := NewPlayer()
p, err := NewPlayer(context.Background(), slog.Default(), nil)
if err != nil {
t.Errorf("could not create player\n%s", err.Error())
t.Failed()
}
p.SetContext(t.Context())
t.Logf("initializing player")
err = p.Init(context.Background())
if err != nil {
t.Errorf("could not initialize player\n%s", err.Error())
t.Failed()
}
for _, track := range testQueue {
t.Logf("loading file: %s", track)
err = p.LoadFile(track)
if err != nil {
t.Errorf("could not load file\n%s\n%s", track, err.Error())
t.Failed()
}
err = p.Play()
if err != nil {
t.Errorf("could not play file\n%s\n%s", track, err.Error())
t.Failed()
}
}
}
+19 -4
View File
@@ -1,14 +1,24 @@
package player
// UserVolume represents volume on a user-facing scale (0-100).
type UserVolume int
// PlayerVolume represents volume on an internal scale (-10 to 10).
type PlayerVolume float64
const MinUserVol UserVolume = 0
const MaxUserVol UserVolume = 100
// User volume range bounds.
const (
MinUserVol UserVolume = 0
MaxUserVol UserVolume = 100
)
const MinPlayerVol PlayerVolume = -10
const MaxPlayerVol PlayerVolume = 10
// Player volume range bounds.
const (
MinPlayerVol PlayerVolume = -4
MaxPlayerVol PlayerVolume = 0
)
// ToPlayerVolume converts user volume to internal player volume.
func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
var newVol PlayerVolume
@@ -16,9 +26,11 @@ func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
ratio := PlayerVolume(oldVol-MinUserVol) / PlayerVolume(MaxUserVol-MinUserVol)
newVol = ratio*(MaxPlayerVol-MinPlayerVol) + MinPlayerVol
}
return newVol
}
// ToUserVolume converts internal player volume to user volume.
func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
var newVol UserVolume
@@ -26,6 +38,7 @@ func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
ratio := (oldVolFloat - MinPlayerVol) / (MaxPlayerVol - MinPlayerVol)
newVol = UserVolume(ratio*PlayerVolume(MaxUserVol-MinUserVol)) + MinUserVol
}
return newVol
}
@@ -33,8 +46,10 @@ func clampVolume(v UserVolume) UserVolume {
if v > MaxUserVol {
return MaxUserVol
}
if v < MinUserVol {
return MinUserVol
}
return v
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -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)
}