Config saved
-} diff --git a/backend/config/config-form_templ.go b/backend/config/config-form_templ.go deleted file mode 100644 index 593600d..0000000 --- a/backend/config/config-form_templ.go +++ /dev/null @@ -1,113 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.977 -package config - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -import "yellowjacket/pkg/templcomp" - -func (c *Config) form() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templcomp.ToForm(c, templ.URL("/config"), "config").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func (c *Config) formSubmitError(msg string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Error: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `config/config-form.templ`, Line: 10, Col: 21} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func (c *Config) formSubmitSuccess() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Config saved
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/backend/config/config.go b/backend/config/config.go index 5cf67a7..45bab72 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -6,23 +6,30 @@ import ( "errors" "fmt" "log/slog" - "net/http" "os" "path" "github.com/BurntSushi/toml" + "github.com/wailsapp/wails/v2/pkg/runtime" + "yellowjacket/backend/events" + "yellowjacket/backend/favorites" "yellowjacket/backend/library" "yellowjacket/backend/system" + "yellowjacket/backend/theme" + "yellowjacket/backend/tracklist" ) // Config represents the application configuration. type Config struct { - ctx context.Context - logger *slog.Logger - serveMux *http.ServeMux - filePath string // required - Library *library.Config `form:"Library" schema:"library,required"` + ctx context.Context + logger *slog.Logger + filePath string // required + Library *library.Config `toml:"Library"` + Theme *theme.Config `toml:"Theme"` + Window *WindowConfig `toml:"Window"` + TrackList *tracklist.Config `toml:"TrackList"` + Favorites *favorites.Config `toml:"Favorites"` } // NewConfig creates a new config by loading it from disk. @@ -34,10 +41,9 @@ func NewConfig(logger *slog.Logger) (*Config, error) { conf := &Config{ filePath: path.Join(confDir, "config.toml"), - serveMux: http.NewServeMux(), } + conf.applyDefaults() 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) @@ -62,8 +68,29 @@ func (c *Config) Validate() error { } } + if c.Theme != nil { + if err := c.Theme.Validate(); err != nil { + configErrs = errors.Join(configErrs, err) + } + } + + if c.TrackList != nil { + if err := c.TrackList.Validate(); err != nil { + configErrs = errors.Join(configErrs, err) + } + } + + if c.Favorites != nil { + if err := c.Favorites.Validate(); err != nil { + configErrs = errors.Join(configErrs, err) + } + } + if configErrs != nil { - return fmt.Errorf("one or more config parts are invalid: %w", configErrs) + return fmt.Errorf( + "one or more config parts are invalid: %w", + configErrs, + ) } return nil @@ -99,6 +126,8 @@ func (c *Config) Load() error { return fmt.Errorf("problem parsing config file %s: %w", c.filePath, err) } + c.applyDefaults() + // validate the config if err = c.Validate(); err != nil { return fmt.Errorf("invalid config file at %s: %w", c.filePath, err) @@ -120,7 +149,7 @@ func (c *Config) Save() error { return fmt.Errorf("could not marshal config struct: %w", err) } - err = os.WriteFile(c.filePath, confFileData, os.FileMode(int(0o666))) + err = os.WriteFile(c.filePath, confFileData, 0o644) if err != nil { return fmt.Errorf("could not write config file (%s): %w", c.filePath, err) } @@ -130,7 +159,426 @@ func (c *Config) Save() error { return nil } +// applyDefaults ensures all config sections have valid defaults. +func (c *Config) applyDefaults() { + if c.Window == nil { + c.Window = NewDefaultWindowConfig() + } else { + c.Window.applyDefaults() + } + + if c.Library != nil { + c.Library.ApplyDefaults() + } + + if c.Theme == nil { + c.Theme = &theme.Config{} + } + + c.Theme.ApplyDefaults() + + if c.TrackList == nil { + c.TrackList = &tracklist.Config{} + } + + c.TrackList.ApplyDefaults() + + if c.Favorites == nil { + c.Favorites = &favorites.Config{ + PinDefault: true, + } + } + + c.Favorites.ApplyDefaults() +} + // SetContext sets the Wails runtime context for event emission. func (c *Config) SetContext(ctx context.Context) { c.ctx = ctx } + +// GetLibraryDirectory returns the currently configured library directory path. +func (c *Config) GetLibraryDirectory() string { + if c.Library == nil { + return "" + } + + return string(c.Library.DirectoryPath) +} + +// SetLibraryDirectory validates and saves a new library directory, +// then emits the LibraryConfigChanged event so listeners (e.g. the +// Library scanner) can react. +func (c *Config) SetLibraryDirectory(dir string) error { + newLibConf, err := library.NewConfig(dir) + if err != nil { + return fmt.Errorf( + "invalid library directory: %w", err, + ) + } + + // Preserve existing scan concurrency setting. + if c.Library != nil { + newLibConf.ScanConcurrency = c.Library.ScanConcurrency + } + + c.Library = newLibConf + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config after directory change: %w", err, + ) + } + + if c.ctx != nil { + runtime.EventsEmit( + c.ctx, + events.LibraryConfigChanged, + map[string]any{ + "DirectoryPath": dir, + }, + ) + } + + c.logger.Info( + "library directory updated", + "directory", dir, + ) + + return nil +} + +// GetScanConcurrency returns the configured scan concurrency mode. +func (c *Config) GetScanConcurrency() string { + if c.Library == nil { + return string(library.DefaultScanConcurrency) + } + + return string(c.Library.ScanConcurrency) +} + +// SetScanConcurrency validates and saves a new scan concurrency +// mode. The change takes effect on the next scan. +func (c *Config) SetScanConcurrency(mode string) error { + if c.Library == nil { + c.Library = &library.Config{} + c.Library.ApplyDefaults() + } + + c.Library.ScanConcurrency = library.ScanConcurrency( + mode, + ) + + if err := c.Library.Validate(); err != nil { + return fmt.Errorf( + "invalid scan concurrency mode: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.logger.Info( + "scan concurrency updated", "mode", mode, + ) + + return nil +} + +// GetThemeAccentColor returns the configured accent colour. +func (c *Config) GetThemeAccentColor() string { + if c.Theme == nil { + return theme.DefaultAccentColor + } + + return c.Theme.AccentColor +} + +// GetThemeBackgroundShade returns the configured background shade. +func (c *Config) GetThemeBackgroundShade() string { + if c.Theme == nil { + return string(theme.DefaultBackgroundShade) + } + + return string(c.Theme.BackgroundShade) +} + +// SetThemeAccentColor validates and saves a new accent colour. +func (c *Config) SetThemeAccentColor( + color string, +) error { + if c.Theme == nil { + c.Theme = &theme.Config{} + c.Theme.ApplyDefaults() + } + + c.Theme.AccentColor = color + + if err := c.Theme.Validate(); err != nil { + return fmt.Errorf( + "invalid theme accent color: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitThemeChanged() + + c.logger.Info( + "theme accent color updated", + "color", color, + ) + + return nil +} + +// SetThemeBackgroundShade validates and saves a new background shade. +func (c *Config) SetThemeBackgroundShade( + shade string, +) error { + if c.Theme == nil { + c.Theme = &theme.Config{} + c.Theme.ApplyDefaults() + } + + c.Theme.BackgroundShade = theme.BackgroundShade(shade) + + if err := c.Theme.Validate(); err != nil { + return fmt.Errorf( + "invalid theme background shade: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitThemeChanged() + + c.logger.Info( + "theme background shade updated", + "shade", shade, + ) + + return nil +} + +// emitThemeChanged sends the ThemeConfigChanged event to the frontend. +func (c *Config) emitThemeChanged() { + if c.ctx == nil || c.Theme == nil { + return + } + + runtime.EventsEmit( + c.ctx, + events.ThemeConfigChanged, + map[string]any{ + "AccentColor": c.Theme.AccentColor, + "BackgroundShade": string(c.Theme.BackgroundShade), + }, + ) +} + +// GetTrackListColumns returns the configured track-list columns. +func (c *Config) GetTrackListColumns() []tracklist.Column { + if c.TrackList == nil { + return tracklist.DefaultColumns + } + + return c.TrackList.Columns +} + +// SetTrackListColumns validates and saves a new column layout. +func (c *Config) SetTrackListColumns( + columns []tracklist.Column, +) error { + if c.TrackList == nil { + c.TrackList = &tracklist.Config{} + } + + c.TrackList.Columns = columns + + if err := c.TrackList.Validate(); err != nil { + return fmt.Errorf( + "invalid track-list columns: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitTrackListChanged() + + c.logger.Info( + "track-list columns updated", + "count", len(columns), + ) + + return nil +} + +// emitTrackListChanged sends the TrackListConfigChanged event +// to the frontend. +func (c *Config) emitTrackListChanged() { + if c.ctx == nil || c.TrackList == nil { + return + } + + cols := make([]map[string]any, 0, len(c.TrackList.Columns)) + + for _, col := range c.TrackList.Columns { + cols = append(cols, map[string]any{ + "id": string(col.ID), + }) + } + + runtime.EventsEmit( + c.ctx, + events.TrackListConfigChanged, + map[string]any{ + "columns": cols, + }, + ) +} + +// GetFavoritesPlaylistID returns the configured default playlist ID. +func (c *Config) GetFavoritesPlaylistID() int64 { + if c.Favorites == nil { + return 0 + } + + return c.Favorites.PlaylistID +} + +// SetFavoritesPlaylistID saves a new default playlist ID. +func (c *Config) SetFavoritesPlaylistID(id int64) error { + if c.Favorites == nil { + c.Favorites = &favorites.Config{} + c.Favorites.ApplyDefaults() + } + + c.Favorites.PlaylistID = id + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitFavoritesChanged() + + c.logger.Info( + "favorites playlist ID updated", + "playlistId", id, + ) + + return nil +} + +// GetFavoritesIconStyle returns the configured icon style. +func (c *Config) GetFavoritesIconStyle() string { + if c.Favorites == nil { + return string(favorites.DefaultIconStyle) + } + + return string(c.Favorites.IconStyle) +} + +// SetFavoritesIconStyle validates and saves a new icon style. +func (c *Config) SetFavoritesIconStyle( + style string, +) error { + if c.Favorites == nil { + c.Favorites = &favorites.Config{} + c.Favorites.ApplyDefaults() + } + + c.Favorites.IconStyle = favorites.IconStyle(style) + + if err := c.Favorites.Validate(); err != nil { + return fmt.Errorf( + "invalid favorites icon style: %w", err, + ) + } + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitFavoritesChanged() + + c.logger.Info( + "favorites icon style updated", + "style", style, + ) + + return nil +} + +// GetPinDefaultPlaylist returns whether the default playlist +// is pinned to the top of the playlist view. +func (c *Config) GetPinDefaultPlaylist() bool { + if c.Favorites == nil { + return true // default: pinned + } + + return c.Favorites.PinDefault +} + +// SetPinDefaultPlaylist saves whether the default playlist +// should be pinned to the top of the playlist view. +func (c *Config) SetPinDefaultPlaylist(pin bool) error { + if c.Favorites == nil { + c.Favorites = &favorites.Config{} + c.Favorites.ApplyDefaults() + } + + c.Favorites.PinDefault = pin + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config: %w", err, + ) + } + + c.emitFavoritesChanged() + + c.logger.Info( + "pin default playlist updated", + "pin", pin, + ) + + return nil +} + +// emitFavoritesChanged sends the FavoritesConfigChanged event +// to the frontend. +func (c *Config) emitFavoritesChanged() { + if c.ctx == nil || c.Favorites == nil { + return + } + + runtime.EventsEmit( + c.ctx, + events.FavoritesConfigChanged, + map[string]any{ + "PlaylistID": c.Favorites.PlaylistID, + "IconStyle": string(c.Favorites.IconStyle), + "PinDefault": c.Favorites.PinDefault, + }, + ) +} diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..329f5b2 --- /dev/null +++ b/backend/config/config_test.go @@ -0,0 +1,236 @@ +package config + +import ( + "log/slog" + "path/filepath" + "testing" + + "yellowjacket/backend/favorites" + "yellowjacket/backend/library" + "yellowjacket/backend/theme" + "yellowjacket/backend/tracklist" +) + +func TestConfig_LoadSave_Roundtrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + libDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + + // Build a config with all non-default values. + original := &Config{ + logger: slog.Default(), + filePath: configPath, + Theme: &theme.Config{ + AccentColor: "#ff0000", + BackgroundShade: theme.BackgroundLight, + }, + TrackList: &tracklist.Config{ + Columns: []tracklist.Column{ + {ID: tracklist.ColTrackName}, + {ID: tracklist.ColArtistName}, + {ID: tracklist.ColAlbum}, + {ID: tracklist.ColGenre}, + {ID: tracklist.ColTrackLength}, + }, + }, + Favorites: &favorites.Config{ + IconStyle: favorites.IconStar, + PinDefault: false, + }, + Library: &library.Config{ + DirectoryPath: library.Directory(libDir), + ScanConcurrency: library.ScanConcurrencySSD, + }, + Window: &WindowConfig{ + Width: 800, + Height: 600, + }, + } + + original.applyDefaults() + + if err := original.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Load into a new Config struct. + loaded := &Config{ + logger: slog.Default(), + filePath: configPath, + } + loaded.applyDefaults() + + if err := loaded.Load(); err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Verify theme. + if loaded.Theme.AccentColor != "#ff0000" { + t.Errorf("Theme.AccentColor = %q, want %q", loaded.Theme.AccentColor, "#ff0000") + } + + if loaded.Theme.BackgroundShade != theme.BackgroundLight { + t.Errorf( + "Theme.BackgroundShade = %q, want %q", + loaded.Theme.BackgroundShade, theme.BackgroundLight, + ) + } + + // Verify tracklist. + if len(loaded.TrackList.Columns) != 5 { + t.Fatalf("TrackList.Columns length = %d, want 5", len(loaded.TrackList.Columns)) + } + + wantColumns := []tracklist.ColumnID{ + tracklist.ColTrackName, tracklist.ColArtistName, + tracklist.ColAlbum, tracklist.ColGenre, tracklist.ColTrackLength, + } + for i, want := range wantColumns { + if loaded.TrackList.Columns[i].ID != want { + t.Errorf( + "TrackList.Columns[%d].ID = %q, want %q", + i, loaded.TrackList.Columns[i].ID, want, + ) + } + } + + // Verify favorites. + if loaded.Favorites.IconStyle != favorites.IconStar { + t.Errorf( + "Favorites.IconStyle = %q, want %q", + loaded.Favorites.IconStyle, favorites.IconStar, + ) + } + + if loaded.Favorites.PinDefault != false { + t.Errorf("Favorites.PinDefault = %v, want false", loaded.Favorites.PinDefault) + } + + // Verify library. + if string(loaded.Library.DirectoryPath) != libDir { + t.Errorf("Library.DirectoryPath = %q, want %q", loaded.Library.DirectoryPath, libDir) + } + + if loaded.Library.ScanConcurrency != library.ScanConcurrencySSD { + t.Errorf( + "Library.ScanConcurrency = %q, want %q", + loaded.Library.ScanConcurrency, library.ScanConcurrencySSD, + ) + } + + // Verify window. + if loaded.Window.Width != 800 { + t.Errorf("Window.Width = %d, want 800", loaded.Window.Width) + } + + if loaded.Window.Height != 600 { + t.Errorf("Window.Height = %d, want 600", loaded.Window.Height) + } +} + +func TestConfig_Load_MissingFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "nonexistent", "config.toml") + + c := &Config{ + logger: slog.Default(), + filePath: configPath, + } + c.applyDefaults() + + // Load should try to create the file. The parent directory + // doesn't exist, so Save inside Load will fail. + // Let's use a valid path instead so we can test the "create + // with defaults" behavior. + validPath := filepath.Join(tmpDir, "config.toml") + c.filePath = validPath + + if err := c.Load(); err != nil { + t.Fatalf("Load() error: %v", err) + } + + // File should exist after Load. + if _, err := filepath.Abs(validPath); err != nil { + t.Fatalf("filepath.Abs() error: %v", err) + } +} + +func TestConfig_Validate_ComposesSubConfigErrors(t *testing.T) { + t.Parallel() + + c := &Config{ + logger: slog.Default(), + filePath: filepath.Join(t.TempDir(), "config.toml"), + Theme: &theme.Config{ + AccentColor: "not-a-color", + BackgroundShade: theme.BackgroundDark, + }, + TrackList: &tracklist.Config{ + Columns: []tracklist.Column{ + {ID: "bogus_column"}, + }, + }, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for invalid sub-configs, got nil") + } + + errStr := err.Error() + + // Both theme and tracklist errors should be present. + if !containsSubstring(errStr, "invalid hex color") { + t.Errorf("error should contain 'invalid hex color', got: %s", errStr) + } + + if !containsSubstring(errStr, "unknown track-list column ID") { + t.Errorf("error should contain 'unknown track-list column ID', got: %s", errStr) + } +} + +func TestConfig_ApplyDefaults_NilSubConfigs(t *testing.T) { + t.Parallel() + + c := &Config{ + logger: slog.Default(), + filePath: filepath.Join(t.TempDir(), "config.toml"), + } + + c.applyDefaults() + + if c.Window == nil { + t.Error("Window should not be nil after applyDefaults") + } + + if c.Theme == nil { + t.Error("Theme should not be nil after applyDefaults") + } + + if c.TrackList == nil { + t.Error("TrackList should not be nil after applyDefaults") + } + + if c.Favorites == nil { + t.Error("Favorites should not be nil after applyDefaults") + } +} + +// containsSubstring is a test helper for checking error messages. +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} diff --git a/backend/config/httphandler.go b/backend/config/httphandler.go deleted file mode 100644 index 3e6ab7b..0000000 --- a/backend/config/httphandler.go +++ /dev/null @@ -1,79 +0,0 @@ -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()) - - if renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w); renderErr != nil { - c.logger.Error("problem rendering error response", "err", renderErr.Error()) - } - - w.WriteHeader(http.StatusInternalServerError) - - return - } - - if err := c.formSubmitSuccess().Render(r.Context(), w); err != nil { - c.logger.Error("problem rendering success response", "err", err.Error()) - } - - w.WriteHeader(http.StatusOK) - } -} - -func (c *Config) handleConfigPost(r *http.Request) error { - if err := r.ParseForm(); err != nil { - return fmt.Errorf("could not parse form data: %w", err) - } - - var postedConfig Config - - err := formDecoder.Decode(&postedConfig, r.PostForm) - if err != nil { - return fmt.Errorf("could not decode form data: %w", err) - } - - c.logger.Debug("decoded config post form data", "postedConfig", postedConfig) - - // Update local config and emit event for listeners - if postedConfig.Library != nil { - c.Library = postedConfig.Library - - if c.ctx != nil { - runtime.EventsEmit(c.ctx, events.LibraryConfigChanged, map[string]any{ - "DirectoryPath": string(c.Library.DirectoryPath), - }) - } - } - - if err := c.Save(); err != nil { - return fmt.Errorf("could not save posted config: %w", err) - } - - return nil -} diff --git a/backend/config/window.go b/backend/config/window.go new file mode 100644 index 0000000..f34faf8 --- /dev/null +++ b/backend/config/window.go @@ -0,0 +1,33 @@ +package config + +const ( + // DefaultWidth is the default window width in pixels. + DefaultWidth = 512 + // DefaultHeight is the default window height in pixels. + DefaultHeight = 384 +) + +// WindowConfig holds window size preferences. +type WindowConfig struct { + Width int `toml:"Width"` + Height int `toml:"Height"` +} + +// NewDefaultWindowConfig returns a WindowConfig with sensible defaults. +func NewDefaultWindowConfig() *WindowConfig { + return &WindowConfig{ + Width: DefaultWidth, + Height: DefaultHeight, + } +} + +// applyDefaults fills in zero-value fields with defaults. +func (w *WindowConfig) applyDefaults() { + if w.Width <= 0 { + w.Width = DefaultWidth + } + + if w.Height <= 0 { + w.Height = DefaultHeight + } +} diff --git a/backend/coverart/coverart.go b/backend/coverart/coverart.go new file mode 100644 index 0000000..21f46b5 --- /dev/null +++ b/backend/coverart/coverart.go @@ -0,0 +1,60 @@ +// Package coverart provides utilities for cover art filenames and URL resolution. +package coverart + +import ( + "fmt" + "path/filepath" + "strings" + + "yellowjacket/backend/system" +) + +// PathPrefix is the URL path prefix for cover art served by the asset handler. +const PathPrefix = "/covers/" + +// URLs holds the resolved URL paths for all cover art size variants. +type URLs struct { + Original string + Small string + Medium string + Large string +} + +// dirName is the subdirectory name under the user data directory +// where cover art files are stored. +const dirName = "covers" + +// CoversDir returns the absolute path to the cover art cache directory. +func CoversDir() (string, error) { + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return "", fmt.Errorf( + "could not get user data directory: %w", err, + ) + } + + return filepath.Join(dataDir, dirName), nil +} + +// SizedFilename derives a sized-variant filename from an original cover art +// filename and a size suffix. +// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg". +func SizedFilename(originalFilename, suffix string) string { + ext := filepath.Ext(originalFilename) + name := strings.TrimSuffix(originalFilename, ext) + + return name + suffix + ".jpg" +} + +// ResolveURLs converts a cover art filesystem path into URL paths +// for the original and all size variants (small, medium, large). +func ResolveURLs(filesystemPath string) URLs { + base := filepath.Base(filesystemPath) + + return URLs{ + Original: PathPrefix + base, + Small: PathPrefix + SizedFilename(base, "_sm"), + Medium: PathPrefix + SizedFilename(base, "_md"), + Large: PathPrefix + SizedFilename(base, "_lg"), + } +} diff --git a/backend/coverart/coverart_test.go b/backend/coverart/coverart_test.go new file mode 100644 index 0000000..7e32f2b --- /dev/null +++ b/backend/coverart/coverart_test.go @@ -0,0 +1,171 @@ +package coverart_test + +import ( + "path/filepath" + "strings" + "testing" + + "yellowjacket/backend/coverart" +) + +func TestCoversDir(t *testing.T) { + t.Parallel() + + dir, err := coverart.CoversDir() + if err != nil { + t.Fatalf("CoversDir() returned error: %v", err) + } + + if dir == "" { + t.Fatal("CoversDir() returned empty string") + } + + // The path must end with the "covers" directory name. + if filepath.Base(dir) != "covers" { + t.Errorf( + "CoversDir() = %q, want path ending in %q", + dir, "covers", + ) + } + + // Must be an absolute path. + if !filepath.IsAbs(dir) { + t.Errorf("CoversDir() = %q, want absolute path", dir) + } + + // Must contain the app name somewhere in the path. + if !strings.Contains(dir, "yellowjacket") { + t.Errorf( + "CoversDir() = %q, expected to contain %q", + dir, "yellowjacket", + ) + } +} + +func TestSizedFilename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filename string + suffix string + want string + }{ + { + name: "jpg with _sm suffix", + filename: "a1b2c3d4.jpg", + suffix: "_sm", + want: "a1b2c3d4_sm.jpg", + }, + { + name: "jpg with _md suffix", + filename: "a1b2c3d4.jpg", + suffix: "_md", + want: "a1b2c3d4_md.jpg", + }, + { + name: "jpg with _lg suffix", + filename: "a1b2c3d4.jpg", + suffix: "_lg", + want: "a1b2c3d4_lg.jpg", + }, + { + name: "png source outputs jpg", + filename: "abcdef01.png", + suffix: "_sm", + want: "abcdef01_sm.jpg", + }, + { + name: "no extension", + filename: "abcdef01", + suffix: "_md", + want: "abcdef01_md.jpg", + }, + { + name: "empty suffix", + filename: "a1b2c3d4.jpg", + suffix: "", + want: "a1b2c3d4.jpg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := coverart.SizedFilename(tt.filename, tt.suffix) + if got != tt.want { + t.Errorf( + "SizedFilename(%q, %q) = %q, want %q", + tt.filename, tt.suffix, got, tt.want, + ) + } + }) + } +} + +func TestResolveURLs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantOrig string + wantSm string + wantMd string + wantLg string + }{ + { + name: "absolute path", + path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg", + wantOrig: "/covers/a1b2c3d4.jpg", + wantSm: "/covers/a1b2c3d4_sm.jpg", + wantMd: "/covers/a1b2c3d4_md.jpg", + wantLg: "/covers/a1b2c3d4_lg.jpg", + }, + { + name: "bare filename", + path: "abcdef01.png", + wantOrig: "/covers/abcdef01.png", + wantSm: "/covers/abcdef01_sm.jpg", + wantMd: "/covers/abcdef01_md.jpg", + wantLg: "/covers/abcdef01_lg.jpg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + urls := coverart.ResolveURLs(tt.path) + + if urls.Original != tt.wantOrig { + t.Errorf( + "Original = %q, want %q", + urls.Original, tt.wantOrig, + ) + } + + if urls.Small != tt.wantSm { + t.Errorf( + "Small = %q, want %q", + urls.Small, tt.wantSm, + ) + } + + if urls.Medium != tt.wantMd { + t.Errorf( + "Medium = %q, want %q", + urls.Medium, tt.wantMd, + ) + } + + if urls.Large != tt.wantLg { + t.Errorf( + "Large = %q, want %q", + urls.Large, tt.wantLg, + ) + } + }) + } +} diff --git a/backend/coverart/handler.go b/backend/coverart/handler.go new file mode 100644 index 0000000..5b1be5f --- /dev/null +++ b/backend/coverart/handler.go @@ -0,0 +1,51 @@ +package coverart + +import ( + "fmt" + "net/http" + "path/filepath" +) + +// Handler serves cover art images via HTTP. +type Handler struct { + coversDir string +} + +// NewHandler creates an HTTP handler that serves cover art from the +// user data directory. +func NewHandler() (*Handler, error) { + dir, err := CoversDir() + if err != nil { + return nil, fmt.Errorf( + "could not resolve covers directory: %w", err, + ) + } + + return &Handler{coversDir: dir}, nil +} + +// ServeHTTP handles requests for cover art images. +func (h *Handler) 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 + } + + // Filenames are content-hashed (SHA-256), so they are immutable. + // Set aggressive cache headers to avoid redundant re-fetches. + w.Header().Set( + "Cache-Control", + "public, max-age=31536000, immutable", + ) + + filePath := filepath.Join(h.coversDir, filename) + http.ServeFile(w, r, filePath) +} diff --git a/backend/database/database.go b/backend/database/database.go index 19cc360..65b13b2 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -9,10 +9,12 @@ import ( "io/fs" "log/slog" "path" + "strings" _ "modernc.org/sqlite" // Register sqlite driver. "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/profiling" "yellowjacket/backend/system" ) @@ -31,6 +33,8 @@ type DB struct { // NewDB opens the database and applies schema migrations. func NewDB(logger *slog.Logger) (*DB, error) { + defer profiling.TimeOp(logger, "database.NewDB")() + dbCtx := context.Background() userDataDir, err := system.GetUserDataDirPath() @@ -49,6 +53,10 @@ func NewDB(logger *slog.Logger) (*DB, error) { db.SetMaxOpenConns(1) // SQLite only supports one writer at a time + if err := applyPRAGMAs(dbCtx, db); err != nil { + return nil, fmt.Errorf("could not apply PRAGMAs: %w", err) + } + // Execute SQL files from the embedded schemas directory logger.Debug("reading sql schema files from embedded directory") @@ -83,6 +91,32 @@ func NewDB(logger *slog.Logger) (*DB, error) { } } + // Run versioned schema migrations for columns that cannot be + // added with CREATE TABLE IF NOT EXISTS on existing databases. + if err := runMigrations(dbCtx, db, logger); err != nil { + return nil, fmt.Errorf( + "could not run schema migrations: %w", err, + ) + } + + // Remove orphaned playlist_tracks left behind by past deletes + // that ran without foreign key enforcement. + orphanResult, err := db.ExecContext( + dbCtx, + "DELETE FROM playlist_tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)", + ) + if err != nil { + logger.Warn( + "could not clean orphaned playlist tracks", + "err", err, + ) + } else if n, _ := orphanResult.RowsAffected(); n > 0 { + logger.Info( + "Cleaned orphaned playlist tracks", + "deleted", n, + ) + } + // Get generated queries queries := sqlcgen.New(db) @@ -93,3 +127,533 @@ func NewDB(logger *slog.Logger) (*DB, error) { logger: logger, }, err } + +// BeginTx starts a new database transaction. +func (d *DB) BeginTx() (*sql.Tx, error) { + return d.db.BeginTx(d.Ctx, nil) +} + +// ExecContext executes a query without returning any rows. +func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) { + return d.db.ExecContext(d.Ctx, query, args...) +} + +// QueryContext executes a query that returns rows. +func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) { + return d.db.QueryContext(d.Ctx, query, args...) +} + +// applyPRAGMAs configures SQLite connection settings. Called by both +// NewDB and NewTestDB to ensure identical behavior. +func applyPRAGMAs(ctx context.Context, db *sql.DB) error { + pragmas := []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA synchronous = NORMAL", + "PRAGMA cache_size = -8000", + "PRAGMA mmap_size = 67108864", + } + + for _, pragma := range pragmas { + if _, err := db.ExecContext(ctx, pragma); err != nil { + return fmt.Errorf( + "could not apply PRAGMA %q: %w", pragma, err, + ) + } + } + + return nil +} + +// runMigrations applies incremental schema changes using SQLite's +// PRAGMA user_version as the version tracker. Each migration runs +// once and bumps the version so it is never re-applied. +func runMigrations( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + var version int + + if err := db.QueryRowContext( + ctx, "PRAGMA user_version", + ).Scan(&version); err != nil { + return fmt.Errorf( + "could not read user_version: %w", err, + ) + } + + logger.Debug( + "current schema version", + "user_version", version, + ) + + // Migration 1: add audio-property columns to audio_files. + if version < 1 { + logger.Info("applying migration 1: audio file properties") + + cols := []string{ + "sample_rate int NOT NULL DEFAULT 0", + "bit_depth int NOT NULL DEFAULT 0", + "channels int NOT NULL DEFAULT 0", + "bitrate int NOT NULL DEFAULT 0", + "file_size int NOT NULL DEFAULT 0", + } + + for _, col := range cols { + stmt := "ALTER TABLE audio_files ADD COLUMN " + col + + if _, err := db.ExecContext(ctx, stmt); err != nil { + // Column may already exist on a fresh DB that + // ran the updated CREATE TABLE. SQLite returns + // "duplicate column name" in that case. + if isDuplicateColumnErr(err) { + continue + } + + return fmt.Errorf( + "migration 1 failed (%s): %w", col, err, + ) + } + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 1", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 1: %w", err, + ) + } + } + + // Migration 2: add basename column and populate search index. + if version < 2 { + if err := migration2BasenameAndFTS( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 3: add UNIQUE constraint to artist_credit_artist. + if version < 3 { + logger.Info( + "applying migration 3: artist_credit_artist unique constraint", + ) + + // Remove duplicates first (keep lowest ID per pair). + if _, err := db.ExecContext(ctx, ` + DELETE FROM artist_credit_artist + WHERE id NOT IN ( + SELECT MIN(id) + FROM artist_credit_artist + GROUP BY artist_id, credit_id + ) + `); err != nil { + return fmt.Errorf( + "migration 3: could not deduplicate: %w", err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS + idx_artist_credit_artist_unique + ON artist_credit_artist(artist_id, credit_id) + `); err != nil { + return fmt.Errorf( + "migration 3: could not create unique index: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 3", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 3: %w", err, + ) + } + + logger.Info("migration 3 complete") + } + + // Migration 4: create track_metadata VIEW. + if version < 4 { + if err := migration4TrackMetadataView( + ctx, db, logger, + ); err != nil { + return err + } + } + + // Migration 5: rebuild release_groups with composite unique + // constraint on (name, album_artist_credit_id) instead of + // name alone, so albums with the same name by different + // artists are stored as separate rows. + if version < 5 { + if err := migration5ReleaseGroupCompositeUnique( + ctx, db, logger, + ); err != nil { + return err + } + } + + return nil +} + +// migration2BasenameAndFTS adds the basename column to audio_files, +// backfills it from file_path, creates the basename index, and +// populates the FTS5 search_index table. +func migration2BasenameAndFTS( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 2: basename column + FTS5 search index", + ) + + // Add basename column (may already exist on fresh DBs). + if _, err := db.ExecContext( + ctx, + "ALTER TABLE audio_files ADD COLUMN basename text NOT NULL DEFAULT ''", + ); err != nil && !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 2: could not add basename column: %w", + err, + ) + } + + // Backfill basename from file_path for existing rows. + // SQLite doesn't have a basename function, so we use + // REPLACE to strip directories by finding everything + // after the last '/'. + if _, err := db.ExecContext(ctx, ` + UPDATE audio_files + SET basename = CASE + WHEN INSTR(file_path, '/') > 0 + THEN SUBSTR( + file_path, + LENGTH(file_path) + - LENGTH( + REPLACE(file_path, '/', '') + ) + + 1 + ) + ELSE file_path + END + WHERE basename = '' + `); err != nil { + return fmt.Errorf( + "migration 2: could not backfill basename: %w", + err, + ) + } + + // Create index (IF NOT EXISTS handles fresh DBs). + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_audio_files_basename + ON audio_files(basename) + `); err != nil { + return fmt.Errorf( + "migration 2: could not create basename index: %w", + err, + ) + } + + // Populate FTS5 search index from existing data. + if _, err := db.ExecContext(ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT + af.id, + af.file_path, + COALESCE(r.name, ''), + COALESCE(ac.text, ''), + COALESCE(rg.name, '') + 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 ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + `); err != nil { + return fmt.Errorf( + "migration 2: could not populate search index: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 2", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 2: %w", err, + ) + } + + logger.Info("migration 2 complete") + + return nil +} + +// migration4TrackMetadataView creates the track_metadata VIEW that +// consolidates the 5-table JOIN used by FTS5 search queries. +// Fresh databases get the VIEW from the embedded schema file; +// this migration covers databases created before the VIEW existed. +func migration4TrackMetadataView( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 4: track_metadata VIEW", + ) + + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size + FROM 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 ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf( + "migration 4: could not create track_metadata VIEW: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 4", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 4: %w", err, + ) + } + + logger.Info("migration 4 complete") + + return nil +} + +// migration5ReleaseGroupCompositeUnique rebuilds the release_groups +// table with UNIQUE(name, album_artist_credit_id) instead of +// UNIQUE(name). SQLite cannot ALTER a UNIQUE constraint, so we +// must rebuild the table. +// +// SAFETY: Hand-crafted SQL for schema migration. +func migration5ReleaseGroupCompositeUnique( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 5: release_groups composite unique constraint", + ) + + // Temporarily disable FK checks for table rebuild. + if _, err := db.ExecContext( + ctx, "PRAGMA foreign_keys = OFF", + ); err != nil { + return fmt.Errorf( + "migration 5: could not disable foreign keys: %w", + err, + ) + } + + // Drop the track_metadata VIEW that references release_groups + // so the table rebuild can proceed without SQLite complaining + // about a dangling VIEW reference. + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf( + "migration 5: could not drop track_metadata VIEW: %w", + err, + ) + } + + // Create new table with composite unique constraint. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE release_groups_new ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + 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), + UNIQUE(name, album_artist_credit_id) + ) + `); err != nil { + return fmt.Errorf( + "migration 5: could not create release_groups_new: %w", + err, + ) + } + + // Copy all data. + if _, err := db.ExecContext(ctx, ` + INSERT INTO release_groups_new + SELECT * FROM release_groups + `); err != nil { + return fmt.Errorf( + "migration 5: could not copy data: %w", err, + ) + } + + // Drop old table. + if _, err := db.ExecContext( + ctx, "DROP TABLE release_groups", + ); err != nil { + return fmt.Errorf( + "migration 5: could not drop old table: %w", err, + ) + } + + // Rename new table. + if _, err := db.ExecContext(ctx, ` + ALTER TABLE release_groups_new + RENAME TO release_groups + `); err != nil { + return fmt.Errorf( + "migration 5: could not rename table: %w", err, + ) + } + + // Recreate indexes. + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id + ON release_groups(cover_art_id) + `); err != nil { + return fmt.Errorf( + "migration 5: could not create cover_art_id index: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id + ON release_groups(album_artist_credit_id) + `); err != nil { + return fmt.Errorf( + "migration 5: could not create album_artist_credit_id index: %w", + err, + ) + } + + // Recreate the track_metadata VIEW that was dropped above. + // The definition must match the embedded schema file + // (sql/schemas/track_metadata_view.sql) exactly. + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size + FROM 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 ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf( + "migration 5: could not recreate track_metadata VIEW: %w", + err, + ) + } + + // Re-enable FK checks. + if _, err := db.ExecContext( + ctx, "PRAGMA foreign_keys = ON", + ); err != nil { + return fmt.Errorf( + "migration 5: could not re-enable foreign keys: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 5", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 5: %w", err, + ) + } + + logger.Info("migration 5 complete") + + return nil +} + +// isDuplicateColumnErr returns true when the error is SQLite's +// "duplicate column name" error from an ALTER TABLE ADD COLUMN +// on a column that already exists. +func isDuplicateColumnErr(err error) bool { + return err != nil && + strings.Contains( + err.Error(), "duplicate column name", + ) +} diff --git a/backend/database/errors.go b/backend/database/errors.go new file mode 100644 index 0000000..8be8edd --- /dev/null +++ b/backend/database/errors.go @@ -0,0 +1,19 @@ +package database + +import ( + "errors" + + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +// IsUniqueViolation reports whether err is a SQLite UNIQUE +// constraint violation (extended result code 2067). +func IsUniqueViolation(err error) bool { + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE + } + + return false +} diff --git a/backend/database/search.go b/backend/database/search.go new file mode 100644 index 0000000..a0fba59 --- /dev/null +++ b/backend/database/search.go @@ -0,0 +1,374 @@ +// Package database provides SQLite database access. +package database + +import ( + "database/sql" + "fmt" + "strings" +) + +// SearchRow holds a single result from an FTS5 or basename search. +type SearchRow struct { + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string +} + +// SearchFTS performs a full-text search across title, artist, album, +// and file_path using the FTS5 search_index. The query string is +// tokenised by FTS5's unicode61 tokeniser. +func (d *DB) SearchFTS( + query string, limit int, +) ([]SearchRow, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + // Escape double quotes and wrap each token in quotes so + // special characters are treated as literals. + ftsQuery := buildFTSQuery(query) + + // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.album + FROM search_index si + JOIN track_metadata tm ON tm.id = si.rowid + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + return scanSearchRows(rows) +} + +// SearchFTSByFilename searches the file_path column of the FTS5 +// index for tokens extracted from the given basename. +func (d *DB) SearchFTSByFilename( + basename string, limit int, +) ([]SearchRow, error) { + basename = strings.TrimSpace(basename) + if basename == "" { + return nil, nil + } + + // Strip extension and build an FTS query scoped to + // the file_path column. + stem := stripExtForSearch(basename) + tokens := tokeniseForFTS(stem) + + if len(tokens) == 0 { + return nil, nil + } + + ftsQuery := "file_path : " + + strings.Join(tokens, " ") + + // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.album + FROM search_index si + JOIN track_metadata tm ON tm.id = si.rowid + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS filename search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + return scanSearchRows(rows) +} + +// InsertSearchIndex adds a row to the FTS5 search_index. +func (d *DB) InsertSearchIndex( + rowid int64, + filePath, title, artist, album string, +) error { + // SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values are parameterized. + _, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?) + `, rowid, filePath, title, artist, album) + + return err +} + +// DeleteSearchIndex is a no-op for contentless FTS5 tables. +// Contentless FTS5 (content=”) does not support DELETE. +// Stale entries are harmless: they point to rowids that no longer +// match in track_metadata, so JOINs in search queries filter them +// out. The index is fully rebuilt during FullRescan. +func (d *DB) DeleteSearchIndex(_ int64) error { + return nil +} + +// ClearSearchIndex removes all rows from the FTS5 search_index. +// The search_index is a contentless FTS5 table (content=”), which +// does not support DELETE. We drop and recreate it instead. +func (d *DB) ClearSearchIndex() error { + // SAFETY: FTS5 contentless table cannot be DELETEd from. + // Drop + recreate is the only way to clear it. No parameters. + if _, err := d.db.ExecContext(d.Ctx, + `DROP TABLE IF EXISTS search_index`, + ); err != nil { + return fmt.Errorf("could not drop search_index: %w", err) + } + + if _, err := d.db.ExecContext(d.Ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + file_path, + title, + artist, + album, + content='', + tokenize='unicode61 remove_diacritics 2' + ) + `); err != nil { + return fmt.Errorf("could not recreate search_index: %w", err) + } + + return nil +} + +// RebuildSearchIndex repopulates the FTS5 search_index from +// scratch using current audio_files + recordings data. +func (d *DB) RebuildSearchIndex() error { + if err := d.ClearSearchIndex(); err != nil { + return fmt.Errorf( + "could not clear search index: %w", err, + ) + } + + // SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values sourced from track_metadata VIEW; no user input. + _, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT id, file_path, title, artist_name, album + FROM track_metadata + `) + if err != nil { + return fmt.Errorf( + "could not rebuild search index: %w", err, + ) + } + + return nil +} + +// SearchTrackRow holds a full track result from an FTS5 search, +// matching all 16 columns returned by GetAllTracksWithFullMetadata. +type SearchTrackRow struct { + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +// SearchFTSTracks performs a full-text search and returns full track +// metadata for each match. Unlike SearchFTS (which returns only 5 +// columns), this includes all 16 fields needed for library.Track. +func (d *DB) SearchFTSTracks( + query string, limit int, +) ([]SearchTrackRow, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + ftsQuery := buildFTSQuery(query) + + // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.track_number, + tm.disc_number, + tm.album, + tm.genre, + tm.year, + tm.composer, + tm.file_type, + tm.sample_rate, + tm.bit_depth, + tm.channels, + tm.bitrate, + tm.file_size + FROM search_index si + JOIN track_metadata tm ON tm.id = si.rowid + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS track search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var results []SearchTrackRow + + for rows.Next() { + var r SearchTrackRow + + if err := rows.Scan( + &r.FilePath, + &r.LengthMilliseconds, + &r.Title, + &r.ArtistName, + &r.TrackNumber, + &r.DiscNumber, + &r.Album, + &r.Genre, + &r.Year, + &r.Composer, + &r.FileType, + &r.SampleRate, + &r.BitDepth, + &r.Channels, + &r.Bitrate, + &r.FileSize, + ); err != nil { + return nil, fmt.Errorf( + "could not scan search track row: %w", + err, + ) + } + + results = append(results, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "search track row iteration error: %w", + err, + ) + } + + return results, nil +} + +// scanSearchRows reads all rows from a query result into a slice. +func scanSearchRows( + rows interface { + Next() bool + Scan(dest ...any) error + Err() error + }, +) ([]SearchRow, error) { + var results []SearchRow + + for rows.Next() { + var r SearchRow + + if err := rows.Scan( + &r.FilePath, + &r.LengthMilliseconds, + &r.Title, + &r.Artist, + &r.Album, + ); err != nil { + return nil, fmt.Errorf( + "could not scan search row: %w", err, + ) + } + + results = append(results, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "search row iteration error: %w", err, + ) + } + + return results, nil +} + +// buildFTSQuery converts a user query string into an FTS5 query. +// Each word is quoted to escape special characters and combined +// with implicit AND. +func buildFTSQuery(query string) string { + tokens := tokeniseForFTS(query) + if len(tokens) == 0 { + return query + } + + return strings.Join(tokens, " ") +} + +// tokeniseForFTS splits a string on whitespace and common +// separators, returning quoted FTS5 tokens. +func tokeniseForFTS(s string) []string { + // Split on whitespace, hyphens, underscores, dots. + fields := strings.FieldsFunc( + s, func(r rune) bool { + return r == ' ' || r == '-' || + r == '_' || r == '.' || + r == '/' || r == '\\' + }, + ) + + tokens := make([]string, 0, len(fields)) + + for _, f := range fields { + f = strings.TrimSpace(f) + if f == "" { + continue + } + + // Escape any double quotes inside the token. + f = strings.ReplaceAll(f, `"`, `""`) + tokens = append(tokens, `"`+f+`"`) + } + + return tokens +} + +// stripExtForSearch removes the file extension from a string. +func stripExtForSearch(s string) string { + if idx := strings.LastIndexByte(s, '.'); idx > 0 { + return s[:idx] + } + + return s +} diff --git a/backend/database/search_test.go b/backend/database/search_test.go new file mode 100644 index 0000000..d14e3de --- /dev/null +++ b/backend/database/search_test.go @@ -0,0 +1,867 @@ +package database + +import ( + "fmt" + "testing" +) + +// seedSearchData inserts ~7 tracks with the full FK chain required for +// FTS5 search tests: artist_credit → recordings → audio_files → +// release_groups → release_group_recordings → search_index. +// +// Track list: +// +// ID 1: "Bohemian Rhapsody" by "Queen" on "A Night at the Opera" +// ID 2: "Halo" by "Beyoncé" on "Lemonade" +// ID 3: "Back in Black" by "AC/DC" on "Back in Black" +// ID 4: "Comfortably Numb" by "Pink Floyd" on "The Dark Side of the Moon" +// ID 5: "Another One Bites the Dust" by "Queen" on "The Game" +// ID 6: "Thunderstruck" by "AC/DC" on "The Razors Edge" +// ID 7: "Queen of the Stone Age" by "Queens of the Stone Age" on "Rated R" +func seedSearchData(t *testing.T, db *DB) { + t.Helper() + + type track struct { + id int64 + filePath string + title string + artist string // artist_credit text + album string // release_group name + trackNum *int64 // recording track_number (nil = NULL) + discNum *int64 // recording disc_number (nil = NULL) + year int64 // recording year + genre string // genre name (empty = no genre) + composer string // recording composer + lenMs int64 // audio_files length_milliseconds + ftID int64 // file_type_id + sr int64 // sample_rate + bd int64 // bit_depth + ch int64 // channels + br int64 // bitrate + fsize int64 // file_size + } + + intPtr := func(v int64) *int64 { return &v } + + tracks := []track{ + { + 1, "/music/queen/bohemian_rhapsody.mp3", "Bohemian Rhapsody", "Queen", + "A Night at the Opera", intPtr(11), intPtr(1), 1975, "Rock", + "Freddie Mercury", 354000, 0, 44100, 16, 2, 320000, 8500000, + }, + { + 2, "/music/beyonce/halo.flac", "Halo", "Beyoncé", "Lemonade", + intPtr(1), intPtr(1), 2008, "Pop", "Ryan Tedder", 261000, 1, + 96000, 24, 2, 1411000, 42000000, + }, + { + 3, "/music/acdc/back_in_black.mp3", "Back in Black", "AC/DC", + "Back in Black", intPtr(1), intPtr(1), 1980, "Hard Rock", + "Angus Young", 255000, 0, 44100, 16, 2, 320000, 6100000, + }, + { + 4, "/music/pinkfloyd/comfortably_numb.flac", "Comfortably Numb", + "Pink Floyd", "The Dark Side of the Moon", intPtr(6), intPtr(1), + 1979, "Progressive Rock", "David Gilmour", 382000, 1, 96000, 24, + 2, 1411000, 54000000, + }, + { + 5, "/music/queen/another_one_bites_the_dust.mp3", + "Another One Bites the Dust", "Queen", "The Game", intPtr(3), + intPtr(1), 1980, "Funk Rock", "John Deacon", 215000, 0, 44100, + 16, 2, 320000, 5200000, + }, + { + 6, "/music/acdc/thunderstruck.mp3", "Thunderstruck", "AC/DC", + "The Razors Edge", intPtr(1), intPtr(1), 1990, "Hard Rock", + "Angus Young", 292000, 0, 44100, 16, 2, 320000, 7000000, + }, + { + 7, "/music/qotsa/queen_of_the_stone_age.mp3", + "Queen of the Stone Age", "Queens of the Stone Age", "Rated R", + intPtr(1), intPtr(1), 2000, "Stoner Rock", "Josh Homme", 310000, + 0, 44100, 16, 2, 320000, 7400000, + }, + } + + // Build unique sets. + artistMap := map[string]int64{} + albumMap := map[string]int64{} + + var artistID, albumID int64 + + for _, tr := range tracks { + if _, ok := artistMap[tr.artist]; !ok { + artistID++ + artistMap[tr.artist] = artistID + } + + if _, ok := albumMap[tr.album]; !ok { + albumID++ + albumMap[tr.album] = albumID + } + } + + // Insert artist_credit rows. + for text, id := range artistMap { + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (?, ?)", + id, text, + ) + if err != nil { + t.Fatalf("insert artist_credit %q: %v", text, err) + } + } + + // Insert release_groups. + for name, id := range albumMap { + _, err := db.ExecContext( + "INSERT INTO release_groups (id, name) VALUES (?, ?)", + id, name, + ) + if err != nil { + t.Fatalf("insert release_group %q: %v", name, err) + } + } + + // Insert genres + recording_genres. + genreMap := map[string]int64{} + + var genreID int64 + + for _, tr := range tracks { + if tr.genre == "" { + continue + } + + if _, ok := genreMap[tr.genre]; !ok { + genreID++ + genreMap[tr.genre] = genreID + + _, err := db.ExecContext( + "INSERT INTO genres (id, name) VALUES (?, ?)", + genreID, tr.genre, + ) + if err != nil { + t.Fatalf("insert genre %q: %v", tr.genre, err) + } + } + } + + for _, tr := range tracks { + acID := artistMap[tr.artist] + rgID := albumMap[tr.album] + + // Insert recording. + _, err := db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id, "+ + "track_number, disc_number, year, genre, composer) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tr.id, tr.title, acID, tr.trackNum, tr.discNum, + tr.year, tr.genre, tr.composer, + ) + if err != nil { + t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err) + } + + // Insert audio_files. + _, err = db.ExecContext( + "INSERT INTO audio_files (id, file_path, "+ + "length_milliseconds, file_type_id, recording_id, "+ + "sample_rate, bit_depth, channels, bitrate, file_size) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id, + tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, + ) + if err != nil { + t.Fatalf("insert audio_file %d: %v", tr.id, err) + } + + // Link recording to release_group. + _, err = db.ExecContext( + "INSERT INTO release_group_recordings "+ + "(release_group_id, recording_id, track_number, disc_number) "+ + "VALUES (?, ?, ?, ?)", + rgID, tr.id, tr.trackNum, tr.discNum, + ) + if err != nil { + t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err) + } + + // Insert search_index entry (rowid must match audio_files.id). + if err := db.InsertSearchIndex( + tr.id, tr.filePath, tr.title, tr.artist, tr.album, + ); err != nil { + t.Fatalf("insert search_index for %d: %v", tr.id, err) + } + + // Insert recording_genres link. + if tr.genre != "" { + gID := genreMap[tr.genre] + + _, err = db.ExecContext( + "INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)", + tr.id, gID, + ) + if err != nil { + t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err) + } + } + } +} + +// --------------------------------------------------------------------------- +// Pure helper tests (no database needed) +// --------------------------------------------------------------------------- + +func TestTokeniseForFTS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + }{ + {"simple word", "hello", []string{`"hello"`}}, + {"multiple words", "hello world", []string{`"hello"`, `"world"`}}, + {"hyphens split", "rock-pop", []string{`"rock"`, `"pop"`}}, + {"slashes split", "AC/DC", []string{`"AC"`, `"DC"`}}, + {"dots split", "01.track", []string{`"01"`, `"track"`}}, + {"underscores split", "my_song", []string{`"my"`, `"song"`}}, + { + "double quotes escaped", + `he"llo`, + []string{`"he""llo"`}, + }, + {"empty string", "", nil}, + {"only separators", "---", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tokeniseForFTS(tt.input) + + if len(got) != len(tt.want) { + t.Fatalf( + "tokeniseForFTS(%q): got %d tokens %v, want %d tokens %v", + tt.input, len(got), got, len(tt.want), tt.want, + ) + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf( + "tokeniseForFTS(%q)[%d] = %q, want %q", + tt.input, i, got[i], tt.want[i], + ) + } + } + }) + } +} + +func TestBuildFTSQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {"single word", "queen", `"queen"`}, + {"multi-word", "bohemian rhapsody", `"bohemian" "rhapsody"`}, + {"empty string returns original", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := buildFTSQuery(tt.input) + if got != tt.want { + t.Errorf( + "buildFTSQuery(%q) = %q, want %q", + tt.input, got, tt.want, + ) + } + }) + } +} + +func TestStripExtForSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {"mp3 extension", "song.mp3", "song"}, + {"double dot", "my.song.flac", "my.song"}, + {"no extension", "noextension", "noextension"}, + {"hidden file", ".hidden", ".hidden"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := stripExtForSearch(tt.input) + if got != tt.want { + t.Errorf( + "stripExtForSearch(%q) = %q, want %q", + tt.input, got, tt.want, + ) + } + }) + } +} + +// --------------------------------------------------------------------------- +// FTS5 search tests (require database + seeded data) +// --------------------------------------------------------------------------- + +func TestSearchFTS_BasicTerm(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + results, err := db.SearchFTS("queen", 10) + if err != nil { + t.Fatalf("SearchFTS(queen): %v", err) + } + + // Should find at least "Bohemian Rhapsody" and "Another One Bites the + // Dust" (artist=Queen) plus "Queen of the Stone Age" (title match). + if len(results) < 2 { + t.Fatalf("SearchFTS(queen): got %d results, want >= 2", len(results)) + } + + // Verify we got the expected Queen tracks by collecting titles. + titles := map[string]bool{} + for _, r := range results { + titles[r.Title] = true + } + + for _, want := range []string{"Bohemian Rhapsody", "Another One Bites the Dust"} { + if !titles[want] { + t.Errorf("SearchFTS(queen): missing expected title %q in results %v", + want, titles) + } + } +} + +func TestSearchFTS_EmptyQuery(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // Empty string. + results, err := db.SearchFTS("", 10) + if err != nil { + t.Fatalf("SearchFTS(empty): %v", err) + } + + if results != nil { + t.Errorf("SearchFTS(empty): got %v, want nil", results) + } + + // Whitespace-only. + results, err = db.SearchFTS(" ", 10) + if err != nil { + t.Fatalf("SearchFTS(whitespace): %v", err) + } + + if results != nil { + t.Errorf("SearchFTS(whitespace): got %v, want nil", results) + } +} + +func TestSearchFTS_SpecialCharacters(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // "AC/DC" — the tokeniser splits on '/', so "AC" and "DC" both become + // search tokens and match the AC/DC artist in the index. + results, err := db.SearchFTS("AC/DC", 10) + if err != nil { + t.Fatalf("SearchFTS(AC/DC): %v", err) + } + + if len(results) < 1 { + t.Fatalf("SearchFTS(AC/DC): got 0 results, want >= 1") + } + + // Verify at least one AC/DC track is present. + found := false + + for _, r := range results { + if r.Artist == "AC/DC" { + found = true + + break + } + } + + if !found { + t.Errorf("SearchFTS(AC/DC): no results with Artist='AC/DC'") + } + + // Query with embedded double quote — should not error. + results, err = db.SearchFTS(`back"in`, 10) + if err != nil { + t.Fatalf("SearchFTS(quote): %v", err) + } + + // We don't assert exact results for the quote test, just no error. + _ = results +} + +func TestSearchFTS_MultiWord(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + results, err := db.SearchFTS("bohemian rhapsody", 10) + if err != nil { + t.Fatalf("SearchFTS(multi-word): %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS(bohemian rhapsody): got 0 results") + } + + // Top result should be the exact title match. + if results[0].Title != "Bohemian Rhapsody" { + t.Errorf( + "SearchFTS(bohemian rhapsody): top result Title = %q, want %q", + results[0].Title, "Bohemian Rhapsody", + ) + } +} + +func TestSearchFTS_Diacritics(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // Search without diacritic — should find "Beyoncé" due to + // unicode61 remove_diacritics 2 tokeniser configuration. + results, err := db.SearchFTS("Beyonce", 10) + if err != nil { + t.Fatalf("SearchFTS(Beyonce): %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS(Beyonce): got 0 results, want Beyoncé track") + } + + found := false + + for _, r := range results { + if r.Artist == "Beyoncé" { + found = true + + break + } + } + + if !found { + t.Error("SearchFTS(Beyonce): no result with Artist='Beyoncé'") + } +} + +func TestSearchFTS_Ranking(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // "Back in Black" appears as both title AND album for track ID 3, + // so it should rank higher than tracks where "black" only appears + // in one column. + results, err := db.SearchFTS("back in black", 10) + if err != nil { + t.Fatalf("SearchFTS(ranking): %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS(back in black): got 0 results") + } + + // First result should be the "Back in Black" track (title + album match). + if results[0].Title != "Back in Black" { + t.Errorf( + "SearchFTS(ranking): top result = %q by %q, want %q", + results[0].Title, results[0].Artist, "Back in Black", + ) + } +} + +func TestSearchFTSByFilename(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // Search by basename — extension is stripped, underscores split. + results, err := db.SearchFTSByFilename("bohemian_rhapsody.mp3", 10) + if err != nil { + t.Fatalf("SearchFTSByFilename: %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTSByFilename(bohemian_rhapsody.mp3): got 0 results") + } + + found := false + + for _, r := range results { + if r.Title == "Bohemian Rhapsody" { + found = true + + break + } + } + + if !found { + t.Error("SearchFTSByFilename: Bohemian Rhapsody not found") + } + + // Empty basename. + results, err = db.SearchFTSByFilename("", 10) + if err != nil { + t.Fatalf("SearchFTSByFilename(empty): %v", err) + } + + if results != nil { + t.Errorf("SearchFTSByFilename(empty): got %v, want nil", results) + } +} + +func TestSearchFTSTracks(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + results, err := db.SearchFTSTracks("queen", 10) + if err != nil { + t.Fatalf("SearchFTSTracks: %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTSTracks(queen): got 0 results") + } + + // Find the Bohemian Rhapsody result and verify all 16 fields. + var br *SearchTrackRow + + for i, r := range results { + if r.Title == "Bohemian Rhapsody" { + br = &results[i] + + break + } + } + + if br == nil { + t.Fatal("SearchFTSTracks: Bohemian Rhapsody not found") + } + + // Verify all fields are populated. + checks := []struct { + field string + got any + want any + }{ + {"FilePath", br.FilePath, "/music/queen/bohemian_rhapsody.mp3"}, + {"LengthMilliseconds", br.LengthMilliseconds, int64(354000)}, + {"Title", br.Title, "Bohemian Rhapsody"}, + {"ArtistName", br.ArtistName, "Queen"}, + {"Album", br.Album, "A Night at the Opera"}, + {"Year", br.Year, int64(1975)}, + {"Composer", br.Composer, "Freddie Mercury"}, + {"SampleRate", br.SampleRate, int64(44100)}, + {"BitDepth", br.BitDepth, int64(16)}, + {"Channels", br.Channels, int64(2)}, + {"Bitrate", br.Bitrate, int64(320000)}, + {"FileSize", br.FileSize, int64(8500000)}, + } + + for _, c := range checks { + if fmt.Sprintf("%v", c.got) != fmt.Sprintf("%v", c.want) { + t.Errorf("SearchFTSTracks: %s = %v, want %v", c.field, c.got, c.want) + } + } + + // TrackNumber and DiscNumber are sql.NullInt64. + if !br.TrackNumber.Valid || br.TrackNumber.Int64 != 11 { + t.Errorf("SearchFTSTracks: TrackNumber = %v, want 11", br.TrackNumber) + } + + if !br.DiscNumber.Valid || br.DiscNumber.Int64 != 1 { + t.Errorf("SearchFTSTracks: DiscNumber = %v, want 1", br.DiscNumber) + } + + // Genre (via recording_genres + genres tables GROUP_CONCAT). + if br.Genre != "Rock" { + t.Errorf("SearchFTSTracks: Genre = %q, want %q", br.Genre, "Rock") + } + + // FileType (from file_types table, id=0 → ".mp3"). + if br.FileType != ".mp3" { + t.Errorf("SearchFTSTracks: FileType = %q, want %q", br.FileType, ".mp3") + } +} + +// --------------------------------------------------------------------------- +// Search index operation tests +// --------------------------------------------------------------------------- + +func TestInsertAndDeleteSearchIndex(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Set up minimal FK chain for a single track. + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)", + ) + if err != nil { + t.Fatalf("insert recording: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)", + ) + if err != nil { + t.Fatalf("insert audio_file: %v", err) + } + + // Insert into search index. + if err := db.InsertSearchIndex( + 1, "/test/track.mp3", "Test Track", "Test Artist", "Test Album", + ); err != nil { + t.Fatalf("InsertSearchIndex: %v", err) + } + + // Verify it's findable. + results, err := db.SearchFTS("Test Track", 10) + if err != nil { + t.Fatalf("SearchFTS after insert: %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS after insert: got 0 results") + } + + // DeleteSearchIndex on contentless FTS5 table (content='') is + // expected to error. The production orphan cleanup code in + // library.go logs this as a warning — stale index entries are + // harmless because JOINs on non-existent audio_file IDs return + // no results. RebuildSearchIndex handles bulk cleanup. + err = db.DeleteSearchIndex(1) + if err == nil { + t.Log("DeleteSearchIndex succeeded (unexpected for contentless FTS5)") + } +} + +func TestRebuildSearchIndex(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Seed the full entity graph WITHOUT inserting into search_index. + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)", + ) + if err != nil { + t.Fatalf("insert recording: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)", + ) + if err != nil { + t.Fatalf("insert audio_file: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')", + ) + if err != nil { + t.Fatalf("insert release_group: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)", + ) + if err != nil { + t.Fatalf("insert release_group_recordings: %v", err) + } + + // Search should return nothing before rebuild. + results, err := db.SearchFTS("Rebuild", 10) + if err != nil { + t.Fatalf("SearchFTS before rebuild: %v", err) + } + + if len(results) != 0 { + t.Fatalf("SearchFTS before rebuild: got %d results, want 0", len(results)) + } + + // Rebuild search index. + if err := db.RebuildSearchIndex(); err != nil { + t.Fatalf("RebuildSearchIndex: %v", err) + } + + // Search should now return the track. + results, err = db.SearchFTS("Rebuild", 10) + if err != nil { + t.Fatalf("SearchFTS after rebuild: %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS after rebuild: got 0 results, want >= 1") + } + + if results[0].Title != "Rebuild Track" { + t.Errorf( + "SearchFTS after rebuild: Title = %q, want %q", + results[0].Title, "Rebuild Track", + ) + } + + if results[0].Album != "Rebuild Album" { + t.Errorf( + "SearchFTS after rebuild: Album = %q, want %q", + results[0].Album, "Rebuild Album", + ) + } +} + +func TestClearSearchIndex(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + seedSearchData(t, db) + + // Verify data exists. + results, err := db.SearchFTS("queen", 10) + if err != nil { + t.Fatalf("SearchFTS before clear: %v", err) + } + + if len(results) == 0 { + t.Fatal("SearchFTS before clear: got 0 results") + } + + // ClearSearchIndex drops and recreates the contentless FTS5 + // table, which is the only way to clear a content='' table. + err = db.ClearSearchIndex() + if err != nil { + t.Fatalf("ClearSearchIndex: %v", err) + } + + // Verify the index is empty after clear. + results, err = db.SearchFTS("queen", 10) + if err != nil { + t.Fatalf("SearchFTS after clear: %v", err) + } + + if len(results) != 0 { + t.Fatalf("SearchFTS after clear: got %d results, want 0", len(results)) + } +} + +// --------------------------------------------------------------------------- +// Migration test +// --------------------------------------------------------------------------- + +func TestMigrationsApplied(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Verify user_version >= 3 (all 3 migrations applied). + // Use QueryContext + immediate Scan + Close to release the + // single connection before subsequent ExecContext calls. + var version int + + rows, err := db.QueryContext("PRAGMA user_version") + if err != nil { + t.Fatalf("PRAGMA user_version: %v", err) + } + + if !rows.Next() { + _ = rows.Close() + + t.Fatal("PRAGMA user_version: no row returned") + } + + if err := rows.Scan(&version); err != nil { + _ = rows.Close() + + t.Fatalf("scan user_version: %v", err) + } + + _ = rows.Close() + + if version < 3 { + t.Errorf("user_version = %d, want >= 3", version) + } + + // Verify the UNIQUE index from migration 3 exists by attempting + // a duplicate insert. First, create the prerequisite rows. + _, err = db.ExecContext( + "INSERT INTO artists (id, name) VALUES (1, 'Test')", + ) + if err != nil { + t.Fatalf("insert artist: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)", + ) + if err != nil { + t.Fatalf("first insert artist_credit_artist: %v", err) + } + + // Duplicate insert should fail with UNIQUE constraint. + _, err = db.ExecContext( + "INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)", + ) + if err == nil { + t.Error("duplicate artist_credit_artist insert should fail, got nil error") + } +} diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql index 73e6219..b659668 100644 --- a/backend/database/sql/queries/artist_credit.sql +++ b/backend/database/sql/queries/artist_credit.sql @@ -23,3 +23,6 @@ WHERE id = ?; -- name: DeleteArtistCredit :exec DELETE FROM artist_credit WHERE id = ?; + +-- name: DeleteAllArtistCredits :exec +DELETE FROM artist_credit; diff --git a/backend/database/sql/queries/artist_credit_artists.sql b/backend/database/sql/queries/artist_credit_artists.sql index 532f42a..617157e 100644 --- a/backend/database/sql/queries/artist_credit_artists.sql +++ b/backend/database/sql/queries/artist_credit_artists.sql @@ -15,3 +15,6 @@ WHERE id =?; DELETE FROM artist_credit_artist WHERE id =?; +-- name: DeleteAllArtistCreditArtists :exec +DELETE FROM artist_credit_artist; + diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql index 36b2933..a16b201 100644 --- a/backend/database/sql/queries/artists.sql +++ b/backend/database/sql/queries/artists.sql @@ -24,6 +24,17 @@ WHERE id = ?; DELETE FROM artists WHERE id = ?; +-- name: DeleteAllArtists :exec +DELETE FROM artists; + -- name: GetAllArtists :many SELECT * FROM artists ORDER BY name; + +-- name: GetAlbumArtists :many +SELECT DISTINCT a.id, a.name +FROM artists a +JOIN artist_credit_artist aca ON aca.artist_id = a.id +JOIN artist_credit ac ON ac.id = aca.credit_id +JOIN release_groups rg ON rg.album_artist_credit_id = ac.id +ORDER BY a.name; diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index eea0aef..f7f73c9 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -1,5 +1,5 @@ -- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, ?, ?) +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetAudioFile :one @@ -12,12 +12,12 @@ WHERE file_path = ? LIMIT 1; -- name: UpdateAudioFile :exec UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ? +SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? WHERE id = ?; -- name: UpdateAudioFileRecording :exec UPDATE audio_files -SET recording_id = ? +SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? WHERE id = ?; -- name: DeleteAudioFile :exec @@ -58,6 +58,7 @@ JOIN artist_credit ac ON r.artist_credit_id = ac.id; -- name: GetTrackMetadataByPath :one SELECT af.file_path, + af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, @@ -71,6 +72,64 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id WHERE af.file_path = ? LIMIT 1; +-- name: GetAllTracksWithFullMetadata :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM audio_files af +JOIN recordings r ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id; + +-- name: SearchAudioFilesByBasename :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album +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 ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +WHERE af.basename = ? +LIMIT ?; + +-- name: LookupTrackMetaByPaths :many +SELECT id, file_path, title, artist_name +FROM track_metadata +WHERE file_path IN (sqlc.slice('paths')); + +-- name: DeleteAllAudioFiles :exec +DELETE FROM audio_files; + -- name: GetAudioFilesByReleaseGroup :many SELECT af.file_path, @@ -78,10 +137,28 @@ SELECT COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, rgr.track_number, - rgr.disc_number + rgr.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size FROM 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 +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? ORDER BY rgr.disc_number, rgr.track_number; diff --git a/backend/database/sql/queries/cover_art.sql b/backend/database/sql/queries/cover_art.sql index 4006232..1838d22 100644 --- a/backend/database/sql/queries/cover_art.sql +++ b/backend/database/sql/queries/cover_art.sql @@ -26,3 +26,6 @@ WHERE id = ?; -- name: DeleteCoverArt :exec DELETE FROM cover_art WHERE id = ?; + +-- name: DeleteAllCoverArt :exec +DELETE FROM cover_art; diff --git a/backend/database/sql/queries/genres.sql b/backend/database/sql/queries/genres.sql new file mode 100644 index 0000000..0b3d01e --- /dev/null +++ b/backend/database/sql/queries/genres.sql @@ -0,0 +1,71 @@ +-- name: UpsertGenre :one +INSERT INTO genres (name) VALUES (?) +ON CONFLICT(name) DO UPDATE SET name = name +RETURNING *; + +-- name: CreateRecordingGenre :exec +INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) +VALUES (?, ?); + +-- name: DeleteRecordingGenres :exec +DELETE FROM recording_genres +WHERE recording_id = ?; + +-- name: GetGenresByRecordingID :many +SELECT g.* +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +WHERE rg.recording_id = ?; + +-- name: DeleteAllRecordingGenres :exec +DELETE FROM recording_genres; + +-- name: DeleteAllGenres :exec +DELETE FROM genres; + +-- name: GetTracksByGenre :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rlg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g2.name, '||') + FROM recording_genres rg2 + JOIN genres g2 ON rg2.genre_id = g2.id + WHERE rg2.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +JOIN recordings r ON rg.recording_id = r.id +JOIN audio_files af ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE g.name = ? +ORDER BY r.name; + +-- name: GetAllGenresWithCounts :many +SELECT g.name, COUNT(rg.recording_id) AS track_count +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +GROUP BY g.id, g.name +ORDER BY g.name; diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index 36156cc..2f94a7f 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -14,6 +14,9 @@ UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; -- name: DeletePlaylist :exec DELETE FROM playlists WHERE id = ?; +-- name: CountPlaylistsByName :one +SELECT COUNT(*) AS count FROM playlists WHERE name = ?; + -- name: AddPlaylistTrack :one INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?) RETURNING *; @@ -30,3 +33,81 @@ DELETE FROM playlist_tracks WHERE id = ?; -- name: ClearPlaylistTracks :exec DELETE FROM playlist_tracks WHERE playlist_id = ?; + +-- name: GetPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.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 +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) 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 pt.playlist_id = ? +ORDER BY pt.position; + +-- name: GetAllPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.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 +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) 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 +ORDER BY pt.playlist_id, pt.position; + +-- name: DeleteAllPlaylistTracks :exec +DELETE FROM playlist_tracks; + +-- name: GetNextPlaylistTrackPosition :one +SELECT COALESCE(MAX(position), -1) + 1 AS next_position +FROM playlist_tracks WHERE playlist_id = ?; + +-- name: GetPlaylistTrackFilePaths :many +SELECT 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: IsTrackInPlaylist :one +SELECT EXISTS( + SELECT 1 FROM playlist_tracks pt + JOIN audio_files af ON pt.audio_file_id = af.id + WHERE pt.playlist_id = ? AND af.file_path = ? +) AS in_playlist; + +-- name: RemovePlaylistTrackByPath :exec +DELETE FROM playlist_tracks +WHERE playlist_id = ? AND audio_file_id = ( + SELECT id FROM audio_files WHERE file_path = ? +); diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql index 4b21e67..31bab1a 100644 --- a/backend/database/sql/queries/recordings.sql +++ b/backend/database/sql/queries/recordings.sql @@ -28,6 +28,9 @@ WHERE id = ?; DELETE FROM recordings WHERE id = ?; +-- name: DeleteAllRecordings :exec +DELETE FROM recordings; + -- name: GetAllRecordings :many SELECT * FROM recordings ORDER BY name; diff --git a/backend/database/sql/queries/release_group_recordings.sql b/backend/database/sql/queries/release_group_recordings.sql index 500caf9..6198bce 100644 --- a/backend/database/sql/queries/release_group_recordings.sql +++ b/backend/database/sql/queries/release_group_recordings.sql @@ -23,3 +23,6 @@ WHERE id = ?; -- name: DeleteReleaseGroupRecordingByFK :exec DELETE FROM release_group_recordings WHERE release_group_id = ? AND recording_id = ?; + +-- name: DeleteAllReleaseGroupRecordings :exec +DELETE FROM release_group_recordings; diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql index 7e59491..abb0ae3 100644 --- a/backend/database/sql/queries/release_groups.sql +++ b/backend/database/sql/queries/release_groups.sql @@ -12,14 +12,14 @@ RETURNING *; SELECT * FROM release_groups WHERE id = ? LIMIT 1; --- name: GetReleaseGroupByName :one +-- name: GetReleaseGroupByNameAndArtist :one SELECT * FROM release_groups -WHERE name = ? LIMIT 1; +WHERE name = ? AND album_artist_credit_id = ? LIMIT 1; -- name: UpsertReleaseGroup :one INSERT INTO release_groups (name, album_artist_credit_id, year) VALUES (?, ?, ?) -ON CONFLICT(name) DO UPDATE SET +ON CONFLICT(name, album_artist_credit_id) 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 *; @@ -38,18 +38,49 @@ WHERE id = ?; DELETE FROM release_groups WHERE id = ?; +-- name: DeleteAllReleaseGroups :exec +DELETE FROM release_groups; + -- name: GetAllReleaseGroups :many SELECT * FROM release_groups ORDER BY name; -- name: GetAllAlbumsWithDetails :many -SELECT +SELECT rg.id, rg.name, rg.year, - COALESCE(ac.text, '') as artist_name, + COALESCE(ac.text, fallback_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 +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id +ORDER BY rg.name; + +-- name: GetAlbumsByArtist :many +SELECT + rg.id, + rg.name, + rg.year, + COALESCE(ac.text, fallback_ac.text, '') as artist_name, + COALESCE(ca.file_path, '') as cover_art_path +FROM release_groups rg +JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id +JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id +WHERE aca.artist_id = ? ORDER BY rg.name; diff --git a/backend/database/sql/schemas/artist_credit_artist.sql b/backend/database/sql/schemas/artist_credit_artist.sql index 11a8cc5..730ad0c 100644 --- a/backend/database/sql/schemas/artist_credit_artist.sql +++ b/backend/database/sql/schemas/artist_credit_artist.sql @@ -5,3 +5,9 @@ CREATE TABLE IF NOT EXISTS artist_credit_artist ( FOREIGN KEY(artist_id) REFERENCES artists(id), FOREIGN KEY(credit_id) REFERENCES artist_credit(id) ); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id + ON artist_credit_artist(artist_id); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id + ON artist_credit_artist(credit_id); diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index fc4d7ef..4f3436c 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -4,6 +4,15 @@ CREATE TABLE IF NOT EXISTS audio_files ( length_milliseconds int NOT NULL, file_type_id int NOT NULL, recording_id int NOT NULL, + sample_rate int NOT NULL DEFAULT 0, + bit_depth int NOT NULL DEFAULT 0, + channels int NOT NULL DEFAULT 0, + bitrate int NOT NULL DEFAULT 0, + file_size int NOT NULL DEFAULT 0, + basename text NOT NULL DEFAULT '', FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); + +CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id + ON audio_files(recording_id); diff --git a/backend/database/sql/schemas/file_types.sql b/backend/database/sql/schemas/file_types.sql index 073d613..d71b7c5 100644 --- a/backend/database/sql/schemas/file_types.sql +++ b/backend/database/sql/schemas/file_types.sql @@ -2,3 +2,8 @@ CREATE TABLE IF NOT EXISTS file_types ( id integer PRIMARY KEY, extension text NOT NULL UNIQUE ); + +INSERT OR IGNORE INTO file_types (id, extension) VALUES (0, '.mp3'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (1, '.flac'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (2, '.ogg'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (3, '.wav'); diff --git a/backend/database/sql/schemas/genre_recordings.sql b/backend/database/sql/schemas/genre_recordings.sql new file mode 100644 index 0000000..64fc0fe --- /dev/null +++ b/backend/database/sql/schemas/genre_recordings.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS recording_genres ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + FOREIGN KEY(recording_id) REFERENCES recordings(id), + FOREIGN KEY(genre_id) REFERENCES genres(id), + UNIQUE(recording_id, genre_id) +); + +CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id + ON recording_genres(recording_id); + +CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id + ON recording_genres(genre_id); diff --git a/backend/database/sql/schemas/genres.sql b/backend/database/sql/schemas/genres.sql new file mode 100644 index 0000000..163a0fb --- /dev/null +++ b/backend/database/sql/schemas/genres.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS genres ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); diff --git a/backend/database/sql/schemas/player_state.sql b/backend/database/sql/schemas/player_state.sql index ea4c2aa..5cfe9ed 100644 --- a/backend/database/sql/schemas/player_state.sql +++ b/backend/database/sql/schemas/player_state.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS player_state ( id INTEGER PRIMARY KEY CHECK(id = 1), - volume INTEGER NOT NULL DEFAULT 100, + volume INTEGER NOT NULL DEFAULT 50, muted BOOLEAN NOT NULL DEFAULT false, last_track_path TEXT NOT NULL DEFAULT '', last_position_seconds INTEGER NOT NULL DEFAULT 0 diff --git a/backend/database/sql/schemas/playlist_tracks.sql b/backend/database/sql/schemas/playlist_tracks.sql index ad431c3..0d0bb9d 100644 --- a/backend/database/sql/schemas/playlist_tracks.sql +++ b/backend/database/sql/schemas/playlist_tracks.sql @@ -6,3 +6,9 @@ CREATE TABLE IF NOT EXISTS playlist_tracks ( FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); + +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id + ON playlist_tracks(playlist_id); + +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id + ON playlist_tracks(audio_file_id); diff --git a/backend/database/sql/schemas/queue_tracks.sql b/backend/database/sql/schemas/queue_tracks.sql index 9d8f7bd..5f2026f 100644 --- a/backend/database/sql/schemas/queue_tracks.sql +++ b/backend/database/sql/schemas/queue_tracks.sql @@ -4,3 +4,6 @@ CREATE TABLE IF NOT EXISTS queue_tracks ( position INTEGER NOT NULL, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); + +CREATE INDEX IF NOT EXISTS idx_queue_tracks_audio_file_id + ON queue_tracks(audio_file_id); diff --git a/backend/database/sql/schemas/recordings.sql b/backend/database/sql/schemas/recordings.sql index bcdd322..78bf85b 100644 --- a/backend/database/sql/schemas/recordings.sql +++ b/backend/database/sql/schemas/recordings.sql @@ -11,3 +11,6 @@ CREATE TABLE IF NOT EXISTS recordings ( comment TEXT, FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id) ); + +CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id + ON recordings(artist_credit_id); diff --git a/backend/database/sql/schemas/release_group_recordings.sql b/backend/database/sql/schemas/release_group_recordings.sql index 0c7102b..17cdbb6 100644 --- a/backend/database/sql/schemas/release_group_recordings.sql +++ b/backend/database/sql/schemas/release_group_recordings.sql @@ -7,3 +7,9 @@ CREATE TABLE IF NOT EXISTS release_group_recordings ( FOREIGN KEY(release_group_id) REFERENCES release_groups(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id + ON release_group_recordings(recording_id); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id + ON release_group_recordings(release_group_id); diff --git a/backend/database/sql/schemas/release_groups.sql b/backend/database/sql/schemas/release_groups.sql index 7fc4b0b..78f0e8e 100644 --- a/backend/database/sql/schemas/release_groups.sql +++ b/backend/database/sql/schemas/release_groups.sql @@ -1,11 +1,18 @@ CREATE TABLE IF NOT EXISTS release_groups ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, 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) + FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id), + UNIQUE(name, album_artist_credit_id) ); + +CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id + ON release_groups(cover_art_id); + +CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id + ON release_groups(album_artist_credit_id); diff --git a/backend/database/sql/schemas/search_index.sql b/backend/database/sql/schemas/search_index.sql new file mode 100644 index 0000000..d2f4f2c --- /dev/null +++ b/backend/database/sql/schemas/search_index.sql @@ -0,0 +1,8 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + file_path, + title, + artist, + album, + content='', + tokenize='unicode61 remove_diacritics 2' +); diff --git a/backend/database/sql/schemas/track_metadata_view.sql b/backend/database/sql/schemas/track_metadata_view.sql new file mode 100644 index 0000000..68f0879 --- /dev/null +++ b/backend/database/sql/schemas/track_metadata_view.sql @@ -0,0 +1,36 @@ +CREATE VIEW IF NOT EXISTS track_metadata AS +SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM 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 ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id; diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go index b19c3a8..5d17bc0 100644 --- a/backend/database/sql/sqlcgen/artist_credit.sql.go +++ b/backend/database/sql/sqlcgen/artist_credit.sql.go @@ -21,6 +21,15 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCr return i, err } +const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec +DELETE FROM artist_credit +` + +func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtistCredits) + return err +} + const deleteArtistCredit = `-- name: DeleteArtistCredit :exec DELETE FROM artist_credit WHERE id = ? diff --git a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go index 851418a..f762fbd 100644 --- a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go +++ b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go @@ -26,6 +26,15 @@ func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtist return i, err } +const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec +DELETE FROM artist_credit_artist +` + +func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists) + return err +} + const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec DELETE FROM artist_credit_artist WHERE id =? diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go index a74e46f..a4524d4 100644 --- a/backend/database/sql/sqlcgen/artists.sql.go +++ b/backend/database/sql/sqlcgen/artists.sql.go @@ -21,6 +21,15 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) return i, err } +const deleteAllArtists = `-- name: DeleteAllArtists :exec +DELETE FROM artists +` + +func (q *Queries) DeleteAllArtists(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtists) + return err +} + const deleteArtist = `-- name: DeleteArtist :exec DELETE FROM artists WHERE id = ? @@ -31,6 +40,38 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error { return err } +const getAlbumArtists = `-- name: GetAlbumArtists :many +SELECT DISTINCT a.id, a.name +FROM artists a +JOIN artist_credit_artist aca ON aca.artist_id = a.id +JOIN artist_credit ac ON ac.id = aca.credit_id +JOIN release_groups rg ON rg.album_artist_credit_id = ac.id +ORDER BY a.name +` + +func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) { + rows, err := q.db.QueryContext(ctx, getAlbumArtists) + 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 getAllArtists = `-- name: GetAllArtists :many SELECT id, name FROM artists ORDER BY name diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index 463b403..ca8f676 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -8,6 +8,7 @@ package sqlcgen import ( "context" "database/sql" + "strings" ) const countAudioFiles = `-- name: CountAudioFiles :one @@ -22,8 +23,8 @@ func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) { } 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 +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename ` type CreateAudioFileParams struct { @@ -31,6 +32,12 @@ type CreateAudioFileParams struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 + Basename string } func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) { @@ -39,6 +46,12 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams arg.LengthMilliseconds, arg.FileTypeID, arg.RecordingID, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, + arg.Basename, ) var i AudioFile err := row.Scan( @@ -47,10 +60,25 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.Basename, ) return i, err } +const deleteAllAudioFiles = `-- name: DeleteAllAudioFiles :exec +DELETE FROM audio_files +` + +func (q *Queries) DeleteAllAudioFiles(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllAudioFiles) + return err +} + const deleteAudioFile = `-- name: DeleteAudioFile :exec DELETE FROM audio_files WHERE id = ? @@ -94,7 +122,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa } const getAllAudioFiles = `-- name: GetAllAudioFiles :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files ` func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { @@ -112,6 +140,12 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.Basename, ); err != nil { return nil, err } @@ -181,8 +215,99 @@ func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudio return items, nil } +const getAllTracksWithFullMetadata = `-- name: GetAllTracksWithFullMetadata :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM audio_files af +JOIN recordings r ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +` + +type GetAllTracksWithFullMetadataRow struct { + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getAllTracksWithFullMetadata) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllTracksWithFullMetadataRow + for rows.Next() { + var i GetAllTracksWithFullMetadataRow + if err := rows.Scan( + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + ); 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 +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE id = ? LIMIT 1 ` @@ -195,12 +320,18 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error) &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.Basename, ) return i, err } const getAudioFileByPath = `-- name: GetAudioFileByPath :one -SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE file_path = ? LIMIT 1 ` @@ -213,6 +344,12 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.Basename, ) return i, err } @@ -224,11 +361,29 @@ SELECT COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, rgr.track_number, - rgr.disc_number + rgr.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size FROM 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 +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id WHERE rgr.release_group_id = ? ORDER BY rgr.disc_number, rgr.track_number ` @@ -240,6 +395,16 @@ type GetAudioFilesByReleaseGroupRow struct { ArtistName string TrackNumber sql.NullInt64 DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 } func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupID int64) ([]GetAudioFilesByReleaseGroupRow, error) { @@ -258,6 +423,16 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI &i.ArtistName, &i.TrackNumber, &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, ); err != nil { return nil, err } @@ -273,7 +448,7 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI } const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE recording_id = 0 ` @@ -292,6 +467,12 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile &i.LengthMilliseconds, &i.FileTypeID, &i.RecordingID, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.Basename, ); err != nil { return nil, err } @@ -322,6 +503,7 @@ func (q *Queries) GetRandomAudioFilePath(ctx context.Context) (string, error) { const getTrackMetadataByPath = `-- name: GetTrackMetadataByPath :one SELECT af.file_path, + af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, @@ -337,11 +519,12 @@ LIMIT 1 ` type GetTrackMetadataByPathRow struct { - FilePath string - Title string - Artist string - Album string - CoverArtPath string + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string + CoverArtPath string } func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (GetTrackMetadataByPathRow, error) { @@ -349,6 +532,7 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) ( var i GetTrackMetadataByPathRow err := row.Scan( &i.FilePath, + &i.LengthMilliseconds, &i.Title, &i.Artist, &i.Album, @@ -357,9 +541,122 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) ( return i, err } +const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many +SELECT id, file_path, title, artist_name +FROM track_metadata +WHERE file_path IN (/*SLICE:paths*/?) +` + +type LookupTrackMetaByPathsRow struct { + ID int64 + FilePath string + Title string + ArtistName string +} + +func (q *Queries) LookupTrackMetaByPaths(ctx context.Context, paths []string) ([]LookupTrackMetaByPathsRow, error) { + query := lookupTrackMetaByPaths + var queryParams []interface{} + if len(paths) > 0 { + for _, v := range paths { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LookupTrackMetaByPathsRow + for rows.Next() { + var i LookupTrackMetaByPathsRow + if err := rows.Scan( + &i.ID, + &i.FilePath, + &i.Title, + &i.ArtistName, + ); 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 searchAudioFilesByBasename = `-- name: SearchAudioFilesByBasename :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album +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 ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +WHERE af.basename = ? +LIMIT ? +` + +type SearchAudioFilesByBasenameParams struct { + Basename string + Limit int64 +} + +type SearchAudioFilesByBasenameRow struct { + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string +} + +func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudioFilesByBasenameParams) ([]SearchAudioFilesByBasenameRow, error) { + rows, err := q.db.QueryContext(ctx, searchAudioFilesByBasename, arg.Basename, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchAudioFilesByBasenameRow + for rows.Next() { + var i SearchAudioFilesByBasenameRow + if err := rows.Scan( + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + ); 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 updateAudioFile = `-- name: UpdateAudioFile :exec UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ? +SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? WHERE id = ? ` @@ -368,6 +665,12 @@ type UpdateAudioFileParams struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 + Basename string ID int64 } @@ -377,6 +680,12 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams arg.LengthMilliseconds, arg.FileTypeID, arg.RecordingID, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, + arg.Basename, arg.ID, ) return err @@ -384,16 +693,29 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec UPDATE audio_files -SET recording_id = ? +SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? WHERE id = ? ` type UpdateAudioFileRecordingParams struct { RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 ID int64 } func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error { - _, err := q.db.ExecContext(ctx, updateAudioFileRecording, arg.RecordingID, arg.ID) + _, err := q.db.ExecContext(ctx, updateAudioFileRecording, + arg.RecordingID, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, + arg.ID, + ) return err } diff --git a/backend/database/sql/sqlcgen/cover_art.sql.go b/backend/database/sql/sqlcgen/cover_art.sql.go index 13277ac..3184ad9 100644 --- a/backend/database/sql/sqlcgen/cover_art.sql.go +++ b/backend/database/sql/sqlcgen/cover_art.sql.go @@ -32,6 +32,15 @@ func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams) return i, err } +const deleteAllCoverArt = `-- name: DeleteAllCoverArt :exec +DELETE FROM cover_art +` + +func (q *Queries) DeleteAllCoverArt(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllCoverArt) + return err +} + const deleteCoverArt = `-- name: DeleteCoverArt :exec DELETE FROM cover_art WHERE id = ? diff --git a/backend/database/sql/sqlcgen/genres.sql.go b/backend/database/sql/sqlcgen/genres.sql.go new file mode 100644 index 0000000..07082d9 --- /dev/null +++ b/backend/database/sql/sqlcgen/genres.sql.go @@ -0,0 +1,233 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: genres.sql + +package sqlcgen + +import ( + "context" + "database/sql" +) + +const createRecordingGenre = `-- name: CreateRecordingGenre :exec +INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) +VALUES (?, ?) +` + +type CreateRecordingGenreParams struct { + RecordingID int64 + GenreID int64 +} + +func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error { + _, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID) + return err +} + +const deleteAllGenres = `-- name: DeleteAllGenres :exec +DELETE FROM genres +` + +func (q *Queries) DeleteAllGenres(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllGenres) + return err +} + +const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec +DELETE FROM recording_genres +` + +func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllRecordingGenres) + return err +} + +const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec +DELETE FROM recording_genres +WHERE recording_id = ? +` + +func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error { + _, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID) + return err +} + +const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many +SELECT g.name, COUNT(rg.recording_id) AS track_count +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +GROUP BY g.id, g.name +ORDER BY g.name +` + +type GetAllGenresWithCountsRow struct { + Name string + TrackCount int64 +} + +func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllGenresWithCountsRow + for rows.Next() { + var i GetAllGenresWithCountsRow + if err := rows.Scan(&i.Name, &i.TrackCount); 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 getGenresByRecordingID = `-- name: GetGenresByRecordingID :many +SELECT g.id, g.name +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +WHERE rg.recording_id = ? +` + +func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) { + rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Genre + for rows.Next() { + var i Genre + 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 getTracksByGenre = `-- name: GetTracksByGenre :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rlg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g2.name, '||') + FROM recording_genres rg2 + JOIN genres g2 ON rg2.genre_id = g2.id + WHERE rg2.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +JOIN recordings r ON rg.recording_id = r.id +JOIN audio_files af ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE g.name = ? +ORDER BY r.name +` + +type GetTracksByGenreRow struct { + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) { + rows, err := q.db.QueryContext(ctx, getTracksByGenre, name) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTracksByGenreRow + for rows.Next() { + var i GetTracksByGenreRow + if err := rows.Scan( + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + ); 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 upsertGenre = `-- name: UpsertGenre :one +INSERT INTO genres (name) VALUES (?) +ON CONFLICT(name) DO UPDATE SET name = name +RETURNING id, name +` + +func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) { + row := q.db.QueryRowContext(ctx, upsertGenre, name) + var i Genre + err := row.Scan(&i.ID, &i.Name) + return i, err +} diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index c29d407..6a40a57 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -31,6 +31,12 @@ type AudioFile struct { LengthMilliseconds int64 FileTypeID int64 RecordingID int64 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 + Basename string } type CoverArt struct { @@ -45,6 +51,11 @@ type FileType struct { Extension string } +type Genre struct { + ID int64 + Name string +} + type PlayerState struct { ID int64 Volume int64 @@ -95,6 +106,12 @@ type Recording struct { Comment sql.NullString } +type RecordingGenre struct { + ID int64 + RecordingID int64 + GenreID int64 +} + type ReleaseGroup struct { ID int64 Name string @@ -112,3 +129,30 @@ type ReleaseGroupRecording struct { TrackNumber sql.NullInt64 DiscNumber sql.NullInt64 } + +type SearchIndex struct { + FilePath string + Title string + Artist string + Album string +} + +type TrackMetadatum struct { + ID int64 + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index 45f5d34..56a1000 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -41,6 +41,17 @@ func (q *Queries) ClearPlaylistTracks(ctx context.Context, playlistID int64) err return err } +const countPlaylistsByName = `-- name: CountPlaylistsByName :one +SELECT COUNT(*) AS count FROM playlists WHERE name = ? +` + +func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64, error) { + row := q.db.QueryRowContext(ctx, countPlaylistsByName, name) + var count int64 + err := row.Scan(&count) + return count, err +} + const createPlaylist = `-- name: CreatePlaylist :one INSERT INTO playlists (name) VALUES (?) RETURNING id, name, created_at, updated_at @@ -58,6 +69,15 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er return i, err } +const deleteAllPlaylistTracks = `-- name: DeleteAllPlaylistTracks :exec +DELETE FROM playlist_tracks +` + +func (q *Queries) DeleteAllPlaylistTracks(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllPlaylistTracks) + return err +} + const deletePlaylist = `-- name: DeletePlaylist :exec DELETE FROM playlists WHERE id = ? ` @@ -67,6 +87,79 @@ func (q *Queries) DeletePlaylist(ctx context.Context, id int64) error { return err } +const getAllPlaylistTracksWithMetadata = `-- name: GetAllPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.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 +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) 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 +ORDER BY pt.playlist_id, pt.position +` + +type GetAllPlaylistTracksWithMetadataRow struct { + ID int64 + PlaylistID int64 + AudioFileID int64 + Position int64 + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string + CoverArtPath string +} + +func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAllPlaylistTracksWithMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getAllPlaylistTracksWithMetadata) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllPlaylistTracksWithMetadataRow + for rows.Next() { + var i GetAllPlaylistTracksWithMetadataRow + if err := rows.Scan( + &i.ID, + &i.PlaylistID, + &i.AudioFileID, + &i.Position, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + &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 getAllPlaylists = `-- name: GetAllPlaylists :many SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC ` @@ -99,6 +192,18 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) { return items, nil } +const getNextPlaylistTrackPosition = `-- name: GetNextPlaylistTrackPosition :one +SELECT COALESCE(MAX(position), -1) + 1 AS next_position +FROM playlist_tracks WHERE playlist_id = ? +` + +func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, getNextPlaylistTrackPosition, playlistID) + var next_position int64 + err := row.Scan(&next_position) + return next_position, err +} + const getPlaylist = `-- name: GetPlaylist :one SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1 ` @@ -115,6 +220,37 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) { return i, err } +const getPlaylistTrackFilePaths = `-- name: GetPlaylistTrackFilePaths :many +SELECT 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 +` + +func (q *Queries) GetPlaylistTrackFilePaths(ctx context.Context, playlistID int64) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getPlaylistTrackFilePaths, playlistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var file_path string + if err := rows.Scan(&file_path); err != nil { + return nil, err + } + items = append(items, file_path) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getPlaylistTracks = `-- name: GetPlaylistTracks :many SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, af.file_path FROM playlist_tracks pt @@ -160,6 +296,100 @@ func (q *Queries) GetPlaylistTracks(ctx context.Context, playlistID int64) ([]Ge return items, nil } +const getPlaylistTracksWithMetadata = `-- name: GetPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.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 +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) 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 pt.playlist_id = ? +ORDER BY pt.position +` + +type GetPlaylistTracksWithMetadataRow struct { + ID int64 + PlaylistID int64 + AudioFileID int64 + Position int64 + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string + CoverArtPath string +} + +func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID int64) ([]GetPlaylistTracksWithMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getPlaylistTracksWithMetadata, playlistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPlaylistTracksWithMetadataRow + for rows.Next() { + var i GetPlaylistTracksWithMetadataRow + if err := rows.Scan( + &i.ID, + &i.PlaylistID, + &i.AudioFileID, + &i.Position, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + &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 isTrackInPlaylist = `-- name: IsTrackInPlaylist :one +SELECT EXISTS( + SELECT 1 FROM playlist_tracks pt + JOIN audio_files af ON pt.audio_file_id = af.id + WHERE pt.playlist_id = ? AND af.file_path = ? +) AS in_playlist +` + +type IsTrackInPlaylistParams struct { + PlaylistID int64 + FilePath string +} + +func (q *Queries) IsTrackInPlaylist(ctx context.Context, arg IsTrackInPlaylistParams) (int64, error) { + row := q.db.QueryRowContext(ctx, isTrackInPlaylist, arg.PlaylistID, arg.FilePath) + var in_playlist int64 + err := row.Scan(&in_playlist) + return in_playlist, err +} + const removePlaylistTrack = `-- name: RemovePlaylistTrack :exec DELETE FROM playlist_tracks WHERE id = ? ` @@ -169,6 +399,23 @@ func (q *Queries) RemovePlaylistTrack(ctx context.Context, id int64) error { return err } +const removePlaylistTrackByPath = `-- name: RemovePlaylistTrackByPath :exec +DELETE FROM playlist_tracks +WHERE playlist_id = ? AND audio_file_id = ( + SELECT id FROM audio_files WHERE file_path = ? +) +` + +type RemovePlaylistTrackByPathParams struct { + PlaylistID int64 + FilePath string +} + +func (q *Queries) RemovePlaylistTrackByPath(ctx context.Context, arg RemovePlaylistTrackByPathParams) error { + _, err := q.db.ExecContext(ctx, removePlaylistTrackByPath, arg.PlaylistID, arg.FilePath) + return err +} + const updatePlaylistName = `-- name: UpdatePlaylistName :exec UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ` diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go index cc41590..4519a8b 100644 --- a/backend/database/sql/sqlcgen/recordings.sql.go +++ b/backend/database/sql/sqlcgen/recordings.sql.go @@ -86,6 +86,15 @@ func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFu return i, err } +const deleteAllRecordings = `-- name: DeleteAllRecordings :exec +DELETE FROM recordings +` + +func (q *Queries) DeleteAllRecordings(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllRecordings) + return err +} + const deleteRecording = `-- name: DeleteRecording :exec DELETE FROM recordings WHERE id = ? diff --git a/backend/database/sql/sqlcgen/release_group_recordings.sql.go b/backend/database/sql/sqlcgen/release_group_recordings.sql.go index 5fee597..22b9b48 100644 --- a/backend/database/sql/sqlcgen/release_group_recordings.sql.go +++ b/backend/database/sql/sqlcgen/release_group_recordings.sql.go @@ -41,6 +41,15 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel return i, err } +const deleteAllReleaseGroupRecordings = `-- name: DeleteAllReleaseGroupRecordings :exec +DELETE FROM release_group_recordings +` + +func (q *Queries) DeleteAllReleaseGroupRecordings(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllReleaseGroupRecordings) + return err +} + const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec DELETE FROM release_group_recordings WHERE id = ? diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go index f765973..3316f56 100644 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ b/backend/database/sql/sqlcgen/release_groups.sql.go @@ -68,6 +68,15 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG return i, err } +const deleteAllReleaseGroups = `-- name: DeleteAllReleaseGroups :exec +DELETE FROM release_groups +` + +func (q *Queries) DeleteAllReleaseGroups(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllReleaseGroups) + return err +} + const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec DELETE FROM release_groups WHERE id = ? @@ -78,16 +87,82 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error { return err } -const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many -SELECT +const getAlbumsByArtist = `-- name: GetAlbumsByArtist :many +SELECT rg.id, rg.name, rg.year, - COALESCE(ac.text, '') as artist_name, + COALESCE(ac.text, fallback_ac.text, '') as artist_name, + COALESCE(ca.file_path, '') as cover_art_path +FROM release_groups rg +JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id +JOIN artist_credit_artist aca ON aca.credit_id = ac.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id +WHERE aca.artist_id = ? +ORDER BY rg.name +` + +type GetAlbumsByArtistRow struct { + ID int64 + Name string + Year sql.NullInt64 + ArtistName string + CoverArtPath string +} + +func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetAlbumsByArtistRow, error) { + rows, err := q.db.QueryContext(ctx, getAlbumsByArtist, artistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAlbumsByArtistRow + for rows.Next() { + var i GetAlbumsByArtistRow + 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 getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many +SELECT + rg.id, + rg.name, + rg.year, + COALESCE(ac.text, fallback_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 +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id ORDER BY rg.name ` @@ -184,13 +259,18 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, return i, err } -const getReleaseGroupByName = `-- name: GetReleaseGroupByName :one +const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs FROM release_groups -WHERE name = ? LIMIT 1 +WHERE name = ? AND album_artist_credit_id = ? LIMIT 1 ` -func (q *Queries) GetReleaseGroupByName(ctx context.Context, name string) (ReleaseGroup, error) { - row := q.db.QueryRowContext(ctx, getReleaseGroupByName, name) +type GetReleaseGroupByNameAndArtistParams struct { + Name string + AlbumArtistCreditID sql.NullInt64 +} + +func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetReleaseGroupByNameAndArtistParams) (ReleaseGroup, error) { + row := q.db.QueryRowContext(ctx, getReleaseGroupByNameAndArtist, arg.Name, arg.AlbumArtistCreditID) var i ReleaseGroup err := row.Scan( &i.ID, @@ -239,7 +319,7 @@ func (q *Queries) UpdateReleaseGroupCoverArt(ctx context.Context, arg UpdateRele const upsertReleaseGroup = `-- name: UpsertReleaseGroup :one INSERT INTO release_groups (name, album_artist_credit_id, year) VALUES (?, ?, ?) -ON CONFLICT(name) DO UPDATE SET +ON CONFLICT(name, album_artist_credit_id) 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 diff --git a/backend/database/testhelper.go b/backend/database/testhelper.go new file mode 100644 index 0000000..b07f869 --- /dev/null +++ b/backend/database/testhelper.go @@ -0,0 +1,74 @@ +package database + +import ( + "database/sql" + "io/fs" + "log/slog" + "path" + "testing" + + _ "modernc.org/sqlite" // Register sqlite driver. + + "yellowjacket/backend/database/sql/sqlcgen" +) + +// NewTestDB returns an in-memory SQLite database that mirrors the +// production setup (PRAGMAs + all migrations). The database is +// automatically closed when the test completes via t.Cleanup. +func NewTestDB(t *testing.T) *DB { + t.Helper() + + db, err := sql.Open( + "sqlite", + ":memory:?_busy_timeout=5000&_journal_mode=WAL", + ) + if err != nil { + t.Fatalf("could not open test database: %v", err) + } + + db.SetMaxOpenConns(1) + + ctx := t.Context() + + if err := applyPRAGMAs(ctx, db); err != nil { + t.Fatalf("could not apply PRAGMAs: %v", err) + } + + dirEntries, err := schemas.ReadDir("sql/schemas") + if err != nil { + t.Fatalf("could not read schemas directory: %v", err) + } + + for _, dirEntry := range dirEntries { + if !dirEntry.IsDir() { + filePath := path.Join("sql/schemas", dirEntry.Name()) + + sqlContent, err := fs.ReadFile(schemas, filePath) + if err != nil { + t.Fatalf("could not read file %s: %v", filePath, err) + } + + if _, err = db.ExecContext(ctx, string(sqlContent)); err != nil { + t.Fatalf( + "error executing sql from file %s: %v", + filePath, err, + ) + } + } + } + + if err := runMigrations(ctx, db, slog.Default()); err != nil { + t.Fatalf("could not run migrations: %v", err) + } + + queries := sqlcgen.New(db) + + t.Cleanup(func() { _ = db.Close() }) + + return &DB{ + db: db, + Ctx: ctx, + Queries: queries, + logger: slog.Default(), + } +} diff --git a/backend/events/cmd/genevents/main.go b/backend/events/cmd/genevents/main.go new file mode 100644 index 0000000..7bea094 --- /dev/null +++ b/backend/events/cmd/genevents/main.go @@ -0,0 +1,178 @@ +// Command genevents reads Go event constants from events.go using go/ast +// and generates the corresponding TypeScript constants file. +// +// Usage: +// +// go run ./cmd/genevents -source events.go -output ../../frontend/src/events.ts +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +func main() { + source := flag.String("source", "events.go", "path to Go events source file") + output := flag.String("output", "", "path to TypeScript output file (stdout if empty)") + flag.Parse() + + consts, err := parseEvents(*source) + if err != nil { + fmt.Fprintf(os.Stderr, "genevents: %v\n", err) + os.Exit(1) + } + + ts := generateTypeScript(consts) + + if *output == "" || *output == "/dev/stdout" { + fmt.Print(ts) + + return + } + + if err := writeAtomic(*output, ts); err != nil { + fmt.Fprintf(os.Stderr, "genevents: write %s: %v\n", *output, err) + os.Exit(1) + } +} + +// constGroup holds a block of related constants with its doc comment. +type constGroup struct { + Comment string // doc comment text (empty if none) + Consts []constEntry +} + +// constEntry holds one constant name and its string value. +type constEntry struct { + Name string + Value string +} + +// parseEvents parses the Go source file and extracts typed string constant +// groups in declaration order. +func parseEvents(path string) ([]constGroup, error) { + fset := token.NewFileSet() + + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + var groups []constGroup + + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + + var g constGroup + + // Extract doc comment from the const block. + if gd.Doc != nil { + g.Comment = cleanComment(gd.Doc.Text()) + } + + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for i, name := range vs.Names { + if i >= len(vs.Values) { + continue + } + + bl, ok := vs.Values[i].(*ast.BasicLit) + + if !ok || bl.Kind != token.STRING { + continue + } + // Strip quotes from the string literal value. + val := strings.Trim(bl.Value, `"`) + g.Consts = append(g.Consts, constEntry{Name: name.Name, Value: val}) + } + } + + if len(g.Consts) > 0 { + groups = append(groups, g) + } + } + + return groups, nil +} + +// cleanComment trims whitespace and strips trailing periods from Go doc +// comment text (Go convention uses periods; TypeScript comments typically +// do not). +func cleanComment(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, ".") + + return s +} + +// generateTypeScript produces the full TypeScript source from the parsed +// constant groups. +func generateTypeScript(groups []constGroup) string { + var b strings.Builder + + b.WriteString("// Code generated by genevents from backend/events/events.go. DO NOT EDIT.\n") + b.WriteString("\n") + b.WriteString("export const Events = {\n") + + for i, g := range groups { + if g.Comment != "" { + b.WriteString(" // " + g.Comment + "\n") + } + + for _, c := range g.Consts { + fmt.Fprintf(&b, " %s: %q,\n", c.Name, c.Value) + } + // Blank line between groups, but not after the last one. + if i < len(groups)-1 { + b.WriteString("\n") + } + } + + b.WriteString("} as const;\n") + b.WriteString("\n") + b.WriteString("export type EventName = (typeof Events)[keyof typeof Events];\n") + + return b.String() +} + +// writeAtomic writes data to a temporary file in the same directory as path, +// then renames it into place for atomic replacement. +func writeAtomic(path, data string) error { + dir := filepath.Dir(path) + + tmp, err := os.CreateTemp(dir, ".genevents-*.tmp") + if err != nil { + return err + } + + tmpName := tmp.Name() + + if _, err := tmp.WriteString(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + + return err + } + + return os.Rename(tmpName, path) +} diff --git a/backend/events/events.go b/backend/events/events.go index 3c6d7b5..5fbdb9d 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -3,48 +3,46 @@ // the corresponding event names in the TypeScript frontend. package events -// Playback control events. +//go:generate go run ./cmd/genevents -source events.go -output ../../frontend/src/events.ts + +// Playback events (backend → frontend push). const ( PlaybackStateChanged = "PlaybackStateChanged" PlaybackFinished = "PlaybackFinished" - RequestPlay = "RequestPlay" - RequestPause = "RequestPause" - RequestLoadFile = "RequestLoadFile" + TrackChanged = "TrackChanged" + SeekFailed = "SeekFailed" + VolumeChanged = "VolumeChanged" ) -// Track events. +// Queue events (backend → frontend push). 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" + QueueChanged = "QueueChanged" + QueueIndexChanged = "QueueIndexChanged" + QueueModeChanged = "QueueModeChanged" + QueueTracksModified = "QueueTracksModified" ) // Config events. const ( - LibraryConfigChanged = "LibraryConfigChanged" + LibraryConfigChanged = "LibraryConfigChanged" + ThemeConfigChanged = "ThemeConfigChanged" + TrackListConfigChanged = "TrackListConfigChanged" + FavoritesConfigChanged = "FavoritesConfigChanged" +) + +// Playlist events. +const ( + PlaylistCreated = "PlaylistCreated" + PlaylistDeleted = "PlaylistDeleted" + PlaylistRenamed = "PlaylistRenamed" + PlaylistTracksChanged = "PlaylistTracksChanged" + PlaylistsRestored = "PlaylistsRestored" + DefaultPlaylistChanged = "DefaultPlaylistChanged" +) + +// Library events. +const ( + LibraryScanStarted = "LibraryScanStarted" + LibraryScanProgress = "LibraryScanProgress" + LibraryScanComplete = "LibraryScanComplete" ) diff --git a/backend/favorites/config.go b/backend/favorites/config.go new file mode 100644 index 0000000..d13746f --- /dev/null +++ b/backend/favorites/config.go @@ -0,0 +1,62 @@ +// Package favorites manages the default playlist configuration. +package favorites + +import ( + "errors" + "fmt" +) + +var errUnknownIconStyle = errors.New( + "unknown favorites icon style", +) + +// IconStyle controls the icon used to indicate favourited tracks. +type IconStyle string + +// Valid IconStyle values. +const ( + // IconHeart uses a heart icon. + IconHeart IconStyle = "heart" + + // IconStar uses a star icon. + IconStar IconStyle = "star" +) + +// DefaultIconStyle is applied when no value has been set. +const DefaultIconStyle = IconHeart + +// DefaultPlaylistName is the name given to the auto-created +// default playlist. +const DefaultPlaylistName = "Favorites" + +// Config holds favourites preferences. +type Config struct { + PlaylistID int64 `toml:"PlaylistID"` + IconStyle IconStyle `toml:"IconStyle"` + PinDefault bool `toml:"PinDefault"` +} + +// ApplyDefaults fills zero-value fields with sensible defaults. +func (c *Config) ApplyDefaults() { + if c.IconStyle == "" { + c.IconStyle = DefaultIconStyle + } +} + +// Validate checks that all values are well-formed. +func (c *Config) Validate() error { + c.ApplyDefaults() + + switch c.IconStyle { + case IconHeart, IconStar: + // Valid. + default: + return fmt.Errorf( + "%w: %q", + errUnknownIconStyle, + c.IconStyle, + ) + } + + return nil +} diff --git a/backend/favorites/config_test.go b/backend/favorites/config_test.go new file mode 100644 index 0000000..8b1605e --- /dev/null +++ b/backend/favorites/config_test.go @@ -0,0 +1,50 @@ +package favorites + +import ( + "testing" +) + +func TestFavoritesConfig_Validate_ValidIconStyles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style IconStyle + }{ + {"heart", IconHeart}, + {"star", IconStar}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Config{IconStyle: tt.style} + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } + }) + } +} + +func TestFavoritesConfig_Validate_InvalidIconStyle(t *testing.T) { + t.Parallel() + + c := &Config{IconStyle: "diamond"} + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown icon style, got nil") + } +} + +func TestFavoritesConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if c.IconStyle != DefaultIconStyle { + t.Errorf("IconStyle = %q, want %q", c.IconStyle, DefaultIconStyle) + } +} diff --git a/backend/frontendutil/frontendutil.go b/backend/frontendutil/frontendutil.go index e06fce1..ada82a7 100644 --- a/backend/frontendutil/frontendutil.go +++ b/backend/frontendutil/frontendutil.go @@ -31,8 +31,39 @@ func (fe *FrontendUtil) DirectoryPicker() (string, error) { fe.ctx, runtime.OpenDialogOptions{}) if err != nil { - return "", fmt.Errorf("could not open directory dialog\n%w", err) + return "", fmt.Errorf( + "could not open directory dialog\n%w", err, + ) } return dir, nil } + +// PlaylistFilePicker opens a file selection dialog filtered +// to M3U/M3U8 playlist files. Multiple files may be selected. +func (fe *FrontendUtil) PlaylistFilePicker() ( + []string, + error, +) { + runtime.LogInfo(fe.ctx, "selecting playlist files") + + files, err := runtime.OpenMultipleFilesDialog( + fe.ctx, + runtime.OpenDialogOptions{ + Title: "Import Playlist", + Filters: []runtime.FileFilter{ + { + DisplayName: "Playlist Files (*.m3u, *.m3u8)", + Pattern: "*.m3u;*.m3u8", + }, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "could not open file dialog: %w", err, + ) + } + + return files, nil +} diff --git a/backend/library/config.go b/backend/library/config.go index a834d55..6dac46c 100644 --- a/backend/library/config.go +++ b/backend/library/config.go @@ -7,11 +7,39 @@ import ( "os" ) -var errNotDirectory = errors.New("path is not a directory") +var ( + errNotDirectory = errors.New("path is not a directory") + errUnknownScanConcurrency = errors.New("unknown scan concurrency mode") +) + +// ScanConcurrency controls how many parallel workers the scanner +// uses for metadata extraction. The choice directly affects I/O +// throughput on spinning disks vs SSDs. +type ScanConcurrency string + +// Valid ScanConcurrency modes. +const ( + // ScanConcurrencyAuto detects whether the library resides on + // a rotational disk and chooses workers accordingly. + ScanConcurrencyAuto ScanConcurrency = "auto" + + // ScanConcurrencySSD uses runtime.NumCPU() workers, maximising + // throughput on solid-state storage. + ScanConcurrencySSD ScanConcurrency = "ssd" + + // ScanConcurrencyHDD uses a small number of workers to limit + // I/O contention on spinning disks. + ScanConcurrencyHDD ScanConcurrency = "hdd" +) + +// DefaultScanConcurrency is the mode used when no value is +// configured. +const DefaultScanConcurrency = ScanConcurrencyAuto // Config holds Library config data. type Config struct { - DirectoryPath Directory `form:"Directory" schema:"directory,required"` + DirectoryPath Directory `toml:"DirectoryPath"` + ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"` } // Directory represents a filesystem path to a music directory. @@ -23,24 +51,54 @@ func NewConfig(dir string) (*Config, error) { DirectoryPath: Directory(dir), } if err := config.Validate(); err != nil { - return nil, fmt.Errorf("validation error for new library config: %w", err) + return nil, fmt.Errorf( + "validation error for new library config: %w", + err, + ) } return config, nil } -// Validate checks that the configured directory exists. +// ApplyDefaults fills zero-value fields with sensible defaults. +func (c *Config) ApplyDefaults() { + if c.ScanConcurrency == "" { + c.ScanConcurrency = DefaultScanConcurrency + } +} + +// Validate checks that the configured directory exists and that +// the scan concurrency mode is recognised. func (c *Config) Validate() error { + c.ApplyDefaults() + 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) + return fmt.Errorf( + "problem getting info on library dir (%s): %w", + c.DirectoryPath, err, + ) } if !dirInfo.IsDir() { - return fmt.Errorf("%s: %w", c.DirectoryPath, errNotDirectory) + return fmt.Errorf( + "%s: %w", c.DirectoryPath, errNotDirectory, + ) } } + switch c.ScanConcurrency { + case ScanConcurrencyAuto, + ScanConcurrencySSD, + ScanConcurrencyHDD: + // Valid. + default: + return fmt.Errorf( + "%w: %q", errUnknownScanConcurrency, + c.ScanConcurrency, + ) + } + return nil } diff --git a/backend/library/config.templ b/backend/library/config.templ deleted file mode 100644 index f772558..0000000 --- a/backend/library/config.templ +++ /dev/null @@ -1,39 +0,0 @@ -package library - -templ (d Directory) ToFormElement() { - - - - -} diff --git a/backend/library/config_templ.go b/backend/library/config_templ.go deleted file mode 100644 index cef6e8b..0000000 --- a/backend/library/config_templ.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.977 -package library - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func (d Directory) ToFormElement() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/backend/library/config_test.go b/backend/library/config_test.go new file mode 100644 index 0000000..744bb13 --- /dev/null +++ b/backend/library/config_test.go @@ -0,0 +1,83 @@ +package library + +import ( + "testing" +) + +func TestLibraryConfig_Validate_ValidDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + modes := []ScanConcurrency{ + ScanConcurrencyAuto, + ScanConcurrencySSD, + ScanConcurrencyHDD, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: Directory(dir), + ScanConcurrency: mode, + } + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } + }) + } +} + +func TestLibraryConfig_Validate_NonexistentDirectory(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: "/nonexistent/path/xyz", + ScanConcurrency: ScanConcurrencyAuto, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for nonexistent directory, got nil") + } +} + +func TestLibraryConfig_Validate_InvalidScanConcurrency(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: Directory(t.TempDir()), + ScanConcurrency: "turbo", + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown scan concurrency, got nil") + } +} + +func TestLibraryConfig_Validate_EmptyDirectory(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: "", + ScanConcurrency: ScanConcurrencyAuto, + } + + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error for empty directory: %v", err) + } +} + +func TestLibraryConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if c.ScanConcurrency != DefaultScanConcurrency { + t.Errorf("ScanConcurrency = %q, want %q", c.ScanConcurrency, DefaultScanConcurrency) + } +} diff --git a/backend/library/coverart.go b/backend/library/coverart.go index c2b62dd..0bb4aea 100644 --- a/backend/library/coverart.go +++ b/backend/library/coverart.go @@ -1,66 +1,484 @@ package library import ( + "bytes" "crypto/sha256" "encoding/hex" "fmt" + "image" + "image/jpeg" + _ "image/png" // Register PNG decoder. "os" "path/filepath" + "strings" + "time" + "golang.org/x/image/draw" + + "yellowjacket/backend/coverart" "yellowjacket/backend/metadata" - "yellowjacket/backend/system" ) +// thumbnailTier defines a single size tier for generated cover art thumbnails. +type thumbnailTier struct { + // Suffix appended to the content hash (e.g. "_sm", "_md", "_lg"). + Suffix string + // MaxSize is the maximum width or height in pixels. + MaxSize int + // Quality is the JPEG encoding quality (1-100). + Quality int +} + +// thumbnailWork is a unit of work for the async thumbnail worker pool. +type thumbnailWork struct { + imgData []byte + dir string + hashStr string + metrics *ScanMetrics +} + +// thumbnailTiers lists all generated size variants, ordered smallest to largest. +var thumbnailTiers = []thumbnailTier{ + {Suffix: "_sm", MaxSize: 100, Quality: 75}, + {Suffix: "_md", MaxSize: 200, Quality: 80}, + {Suffix: "_lg", MaxSize: 400, Quality: 85}, +} + +// legacyThumbSuffix is the old single-thumbnail suffix used before the +// multi-tier system. Kept for migration purposes only. +const legacyThumbSuffix = "_thumb" + +// isSizedVariant reports whether a filename contains any known size suffix +// (current tiers or legacy). +func isSizedVariant(name string) bool { + if strings.Contains(name, legacyThumbSuffix) { + return true + } + + for _, tier := range thumbnailTiers { + if strings.Contains(name, tier.Suffix) { + return true + } + } + + return false +} + // 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) { +// Returns the file path where the art was saved, or empty string +// if no picture data. Timing is recorded in the provided metrics. +// When thumbChan is non-nil, thumbnail generation is dispatched +// asynchronously to a worker pool instead of running inline. +func (l *Library) saveCoverArt( + pic *metadata.PictureData, + metrics *ScanMetrics, + thumbChan chan<- thumbnailWork, +) (string, error) { if pic == nil || len(pic.Data) == 0 { return "", nil } - // Get the data directory for storing cover art - dataDir, err := system.GetUserDataDirPath() + saveStart := time.Now() + + // Get the covers directory for storing cover art. + coverDir, err := coverart.CoversDir() if err != nil { - return "", fmt.Errorf("could not get user data directory: %w", err) + return "", fmt.Errorf( + "could not resolve covers directory: %w", err, + ) } - coverDir := filepath.Join(dataDir, "covers") - - // Ensure directory exists + // Ensure directory exists. if err := os.MkdirAll(coverDir, 0o755); err != nil { - return "", fmt.Errorf("could not create covers directory: %w", err) + return "", fmt.Errorf( + "could not create covers directory: %w", err, + ) } - // Generate filename from content hash (deduplication) + // Generate filename from content hash (deduplication). hash := sha256.Sum256(pic.Data) - hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars + 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) + // Skip if already exists (same content hash). + // Missing sized variants are handled by + // generateMissingSizedVariants() at the end of a scan. if _, err := os.Stat(filePath); err == nil { - l.logger.Debug("cover art already exists", "path", filePath) + 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) + // 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)) + metrics.addCoverArtSave(time.Since(saveStart)) + + l.logger.Debug( + "saved cover art", + "path", filePath, "size", len(pic.Data), + ) + + // Dispatch thumbnail generation to the async worker pool + // if available, otherwise generate inline. + if thumbChan != nil { + thumbChan <- thumbnailWork{ + imgData: pic.Data, + dir: coverDir, + hashStr: hashStr, + metrics: metrics, + } + } else { + if err := l.generateSizedVariantsWithMetrics( + pic.Data, coverDir, hashStr, metrics, + ); err != nil { + l.logger.Warn( + "could not generate sized variants", + "path", filePath, "err", err, + ) + } + } return filePath, nil } +// generateSizedVariants creates all thumbnail tiers for the given image data. +// Each tier is saved as {hashStr}{suffix}.jpg in the given directory. +func (l *Library) generateSizedVariants( + imgData []byte, + dir, hashStr string, +) error { + src, _, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return fmt.Errorf( + "could not decode image for thumbnails: %w", err, + ) + } + + l.generateTiersFromImage(src, dir, hashStr) + + return nil +} + +// generateSizedVariantsWithMetrics is like generateSizedVariants +// but records per-tier timing in the provided metrics. +func (l *Library) generateSizedVariantsWithMetrics( + imgData []byte, + dir, hashStr string, + metrics *ScanMetrics, +) error { + src, _, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return fmt.Errorf( + "could not decode image for thumbnails: %w", err, + ) + } + + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + + for _, tier := range thumbnailTiers { + tierStart := time.Now() + + tierPath := filepath.Join( + dir, + fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix), + ) + + w, h := fitDimensions(srcW, srcH, tier.MaxSize) + + if err := encodeAndSaveImage( + src, tierPath, w, h, tier.Quality, + ); err != nil { + l.logger.Warn( + "could not generate sized variant", + "tier", tier.Suffix, + "path", tierPath, + "err", err, + ) + + continue + } + + metrics.addThumbnailTier( + tier.Suffix, time.Since(tierStart), + ) + + l.logger.Debug( + "saved sized variant", + "tier", tier.Suffix, + "path", tierPath, + "dimensions", fmt.Sprintf("%dx%d", w, h), + ) + } + + return nil +} + +// generateTiersFromImage creates all thumbnail tiers from an +// already-decoded image. +func (l *Library) generateTiersFromImage( + src image.Image, + dir, hashStr string, +) { + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + + for _, tier := range thumbnailTiers { + tierPath := filepath.Join( + dir, + fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix), + ) + + w, h := fitDimensions(srcW, srcH, tier.MaxSize) + + if err := encodeAndSaveImage( + src, tierPath, w, h, tier.Quality, + ); err != nil { + l.logger.Warn( + "could not generate sized variant", + "tier", tier.Suffix, + "path", tierPath, + "err", err, + ) + + continue + } + + l.logger.Debug( + "saved sized variant", + "tier", tier.Suffix, + "path", tierPath, + "dimensions", fmt.Sprintf("%dx%d", w, h), + ) + } +} + +// fitDimensions calculates the output dimensions that fit within maxSize +// while preserving the aspect ratio. If the source is already smaller +// than maxSize, the original dimensions are returned unchanged. +func fitDimensions(srcW, srcH, maxSize int) (int, int) { + if srcW <= maxSize && srcH <= maxSize { + return srcW, srcH + } + + w, h := maxSize, maxSize + if srcW > srcH { + h = srcH * maxSize / srcW + } else { + w = srcW * maxSize / srcH + } + + return w, h +} + +// encodeAndSaveImage scales the source image to the given dimensions +// and saves it as a JPEG with the specified quality. +func encodeAndSaveImage( + src image.Image, + path string, + w, h, quality int, +) error { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.ApproxBiLinear.Scale( + dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil, + ) + + var buf bytes.Buffer + + if err := jpeg.Encode( + &buf, dst, &jpeg.Options{Quality: quality}, + ); err != nil { + return fmt.Errorf("could not encode image: %w", err) + } + + if err := os.WriteFile( + path, buf.Bytes(), 0o644, + ); err != nil { + return fmt.Errorf("could not write image: %w", err) + } + + return nil +} + +// generateMissingSizedVariants scans the covers directory, migrates legacy +// _thumb files to _md, and generates any missing sized variants for each +// original cover art file. +func (l *Library) generateMissingSizedVariants() error { + coverDir, err := coverart.CoversDir() + if err != nil { + return fmt.Errorf( + "could not resolve covers directory: %w", err, + ) + } + + entries, err := os.ReadDir(coverDir) + if err != nil { + return fmt.Errorf( + "could not read covers directory: %w", err, + ) + } + + // Build a set of existing filenames for quick lookup. + existing := make(map[string]struct{}, len(entries)) + + for _, entry := range entries { + if !entry.IsDir() { + existing[entry.Name()] = struct{}{} + } + } + + // First pass: migrate legacy _thumb files to _md. + migrated := l.migrateLegacyThumbs( + coverDir, existing, + ) + + // Second pass: generate missing sized variants. + var generated, skipped int + + for _, entry := range entries { + name := entry.Name() + + // Skip directories and any sized variants. + if entry.IsDir() || isSizedVariant(name) { + continue + } + + hashStr := strings.SplitN(name, ".", 2)[0] + + // Check which tiers are missing. + allPresent := true + + for _, tier := range thumbnailTiers { + tierName := fmt.Sprintf( + "%s%s.jpg", hashStr, tier.Suffix, + ) + if _, exists := existing[tierName]; !exists { + allPresent = false + + break + } + } + + if allPresent { + skipped++ + + continue + } + + // Read the original and generate missing tiers. + imgData, err := os.ReadFile( + filepath.Join(coverDir, name), + ) + if err != nil { + l.logger.Warn( + "could not read cover art for variant generation", + "file", name, "err", err, + ) + + continue + } + + if err := l.generateSizedVariants( + imgData, coverDir, hashStr, + ); err != nil { + l.logger.Warn( + "could not generate sized variants", + "file", name, "err", err, + ) + + continue + } + + generated++ + } + + l.logger.Info( + "sized variant generation complete", + "generated", generated, + "skipped", skipped, + "migrated", migrated, + ) + + return nil +} + +// migrateLegacyThumbs renames _thumb.jpg files to _md.jpg. +// Returns the number of files migrated. +func (l *Library) migrateLegacyThumbs( + coverDir string, + existing map[string]struct{}, +) int { + var migrated int + + for name := range existing { + if !strings.Contains(name, legacyThumbSuffix) { + continue + } + + // Derive the _md name from the legacy name. + mdName := strings.Replace( + name, legacyThumbSuffix, "_md", 1, + ) + + oldPath := filepath.Join(coverDir, name) + newPath := filepath.Join(coverDir, mdName) + + // Only rename if _md doesn't already exist. + if _, exists := existing[mdName]; exists { + // Both exist; remove the legacy file. + if err := os.Remove(oldPath); err != nil { + l.logger.Warn( + "could not remove legacy thumbnail", + "file", name, "err", err, + ) + } + + continue + } + + if err := os.Rename(oldPath, newPath); err != nil { + l.logger.Warn( + "could not migrate legacy thumbnail", + "from", name, "to", mdName, "err", err, + ) + + continue + } + + // Update the existing set so subsequent lookups + // see the new name. + delete(existing, name) + existing[mdName] = struct{}{} + + migrated++ + + l.logger.Debug( + "migrated legacy thumbnail", + "from", name, "to", mdName, + ) + } + + return migrated +} + // extensionFromMIME returns a file extension for common image MIME types. func extensionFromMIME(mimeType string) string { switch mimeType { @@ -75,6 +493,6 @@ func extensionFromMIME(mimeType string) string { case "image/bmp": return "bmp" default: - return "jpg" // Default to jpg + return "jpg" // Default to jpg. } } diff --git a/backend/library/coverart_handler.go b/backend/library/coverart_handler.go deleted file mode 100644 index 2ab3e89..0000000 --- a/backend/library/coverart_handler.go +++ /dev/null @@ -1,42 +0,0 @@ -package library - -import ( - "fmt" - "net/http" - "path/filepath" - - "yellowjacket/backend/system" -) - -// CoverArtHandler serves cover art images via HTTP. -type CoverArtHandler struct { - coversDir string -} - -// NewCoverArtHandler creates a handler that serves cover art from the user data directory. -func NewCoverArtHandler() (*CoverArtHandler, error) { - dataDir, err := system.GetUserDataDirPath() - if err != nil { - return nil, fmt.Errorf("could not get user data directory: %w", err) - } - - return &CoverArtHandler{ - coversDir: filepath.Join(dataDir, "covers"), - }, nil -} - -// ServeHTTP handles requests for cover art images. -func (h *CoverArtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Extract filename from path like "/covers/abc123.jpg" - filename := filepath.Base(r.URL.Path) - - // Prevent directory traversal - if filename == "." || filename == ".." { - http.NotFound(w, r) - - return - } - - filePath := filepath.Join(h.coversDir, filename) - http.ServeFile(w, r, filePath) -} diff --git a/backend/library/library.go b/backend/library/library.go index 0d739bd..9adac80 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/sync/errgroup" @@ -22,16 +23,76 @@ import ( "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" "yellowjacket/backend/metadata" + "yellowjacket/backend/system" ) var errLibraryDirNotConfigured = errors.New("library directory not configured") +// scanBatchSize controls how many files are committed in a single +// database transaction during a scan. Larger batches amortize +// SQLite's fsync cost but increase the blast radius of a failed commit. +const scanBatchSize = 50 + +// entityCache holds recently resolved database entities so that +// repeated upserts for the same artist/album/cover art within a scan +// can be served from memory instead of hitting the database. +// It is only accessed from the single DB-writer goroutine and +// therefore needs no synchronisation. +type entityCache struct { + artistCredits map[string]sqlcgen.ArtistCredit + artists map[string]sqlcgen.Artist + releaseGroups map[string]sqlcgen.ReleaseGroup + coverArt map[string]sqlcgen.CoverArt + genres map[string]sqlcgen.Genre + // linkedCredits tracks artist-credit-artist links already created + // so we skip the duplicate INSERT. Key is "artistID:creditID". + linkedCredits map[string]struct{} +} + +func newEntityCache() *entityCache { + return &entityCache{ + artistCredits: make(map[string]sqlcgen.ArtistCredit), + artists: make(map[string]sqlcgen.Artist), + releaseGroups: make(map[string]sqlcgen.ReleaseGroup), + coverArt: make(map[string]sqlcgen.CoverArt), + genres: make(map[string]sqlcgen.Genre), + linkedCredits: make(map[string]struct{}), + } +} + +// RescanHooks holds optional callbacks that run before and after +// the library-clear-and-scan phase of a full rescan. The app +// layer sets these to coordinate cross-cutting concerns (e.g. +// clearing the queue, restoring playlists) without the library +// needing to know about those packages. +type RescanHooks struct { + // PreClear runs before library data is wiped + // (e.g. clear queue and stop playback). + PreClear func() + // PostScan runs after the scan completes + // (e.g. restore playlists from M3U8 files). + PostScan func() +} + // Library manages scanning and querying the music collection. type Library struct { - ctx context.Context - logger *slog.Logger - conf *Config - db *database.DB + // mu protects ctx, conf, and rescanHooks from concurrent + // access during initialization. + mu sync.Mutex + ctx context.Context + logger *slog.Logger + conf *Config + db *database.DB + rescanHooks RescanHooks +} + +// SetRescanHooks provides optional hooks for cross-cutting +// orchestration during FullRescan. +func (l *Library) SetRescanHooks(h RescanHooks) { + l.mu.Lock() + defer l.mu.Unlock() + + l.rescanHooks = h } // NewLibrary creates a new library with the given configuration. @@ -63,7 +124,10 @@ func NewLibrary( // SetContext sets the Wails runtime context and registers event handlers. func (l *Library) SetContext(ctx context.Context) { + l.mu.Lock() l.ctx = ctx + l.mu.Unlock() + l.registerEventHandlers() } @@ -105,20 +169,52 @@ func (l *Library) registerEventHandlers() { } // 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) +// Files that exist but have incomplete metadata (recording_id = 0) +// will be updated. The returned ScanMetrics contains timing and +// count data for every phase of the scan. +func (l *Library) Scan() (*ScanMetrics, error) { + metrics := newScanMetrics() + scanStart := time.Now() if len(l.conf.DirectoryPath) == 0 { - return errLibraryDirNotConfigured + return metrics, errLibraryDirNotConfigured } - // 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). + workerCount := resolveScanWorkerCount( + l.conf.ScanConcurrency, + string(l.conf.DirectoryPath), + ) + + l.logger.Info( + "beginning library scan", + "workers", workerCount, + "concurrencyMode", l.conf.ScanConcurrency, + ) + + runtime.EventsEmit(l.ctx, events.LibraryScanStarted) + + basePath := string(l.conf.DirectoryPath) + + // --- Pre-walk: count audio files for progress reporting --- + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, + ScanProgress{Phase: "counting"}, + ) + + totalFiles := countAudioFiles(basePath) + + l.logger.Debug( + "pre-walk file count complete", + "total", totalFiles, + ) + + // --- Phase 1: load existing files from DB --- + loadStart := time.Now() + existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx) if err != nil { - return fmt.Errorf("could not load existing audio files: %w", err) + return metrics, fmt.Errorf( + "could not load existing audio files: %w", err, + ) } existingPaths := &sync.Map{} @@ -126,13 +222,14 @@ func (l *Library) Scan() error { existingPaths.Store(f.FilePath, f) } + metrics.LoadExisting = time.Since(loadStart) + 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) @@ -142,16 +239,25 @@ func (l *Library) Scan() error { var errMu sync.Mutex - // Walker goroutine: traverse directory and send work items to workers + // --- Phase 2: directory walk --- + walkStart := time.Now() + go func() { - defer close(workChan) + defer func() { + metrics.WalkDuration = time.Since(walkStart) + + 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) + l.logger.Error( + "problem walking directory", + "path", path, "err", err, + ) return nil // continue walking } @@ -160,7 +266,9 @@ func (l *Library) Scan() error { return nil } - absoluteFilePath := filepath.Join(basePath, path) + absoluteFilePath := filepath.Join( + basePath, path, + ) fileExt := filepath.Ext(d.Name()) fileType, isSupportedAudioFile := metadata.GetSupportedFileType(fileExt) @@ -168,13 +276,15 @@ func (l *Library) Scan() error { return nil } - // Check if file already exists in database + // 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) + l.logger.Debug( + "file needs metadata update", + "path", absoluteFilePath, + ) select { case workChan <- scanWork{ @@ -201,11 +311,16 @@ func (l *Library) Scan() error { return nil } - l.logger.Debug("queueing file for import", "path", absoluteFilePath) + l.logger.Debug( + "queueing file for import", + "path", absoluteFilePath, + ) - // Send to workers for processing select { - case workChan <- scanWork{absolutePath: absoluteFilePath, fileType: fileType}: + case workChan <- scanWork{ + absolutePath: absoluteFilePath, + fileType: fileType, + }: case <-l.ctx.Done(): return l.ctx.Err() } @@ -215,16 +330,84 @@ func (l *Library) Scan() error { ) if walkErr != nil { - errMu.Lock() - scanErr = errors.Join( - scanErr, - fmt.Errorf("problem walking library directory: %w", walkErr), + metrics.addWarning( + "", "walk", + fmt.Errorf( + "problem walking library directory: %w", + walkErr, + ), ) - errMu.Unlock() } }() - // DB writer goroutine: serialize all database writes to avoid SQLite contention + // --- Thumbnail worker pool (async, decoupled from DB writer) --- + thumbChan := make(chan thumbnailWork, 100) + + var thumbWg sync.WaitGroup + + for range workerCount { + thumbWg.Add(1) + + go func() { + defer thumbWg.Done() + + for work := range thumbChan { + if err := l.generateSizedVariantsWithMetrics( + work.imgData, + work.dir, + work.hashStr, + work.metrics, + ); err != nil { + l.logger.Warn( + "could not generate thumbnails", + "hash", work.hashStr, + "err", err, + ) + + metrics.addWarning( + "", "variant", err, + ) + } + } + }() + } + + // --- Progress ticker --- + // Periodically emits scan progress to the frontend. Stopped + // when the main scan phases (walk + extraction + DB writes) + // are complete, before orphan cleanup begins. + stopProgress := make(chan struct{}) + + go func() { + ticker := time.NewTicker(progressInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a := added.Load() + s := skipped.Load() + u := updated.Load() + + runtime.EventsEmit( + l.ctx, + events.LibraryScanProgress, + ScanProgress{ + Phase: "scanning", + Total: totalFiles, + Processed: a + s + u, + Added: a, + Skipped: s, + Updated: u, + }, + ) + case <-stopProgress: + return + } + } + }() + + // --- Phase 4: DB writer goroutine --- var dbWg sync.WaitGroup dbWg.Add(1) @@ -232,55 +415,82 @@ func (l *Library) Scan() error { go func() { defer dbWg.Done() - for result := range resultChan { - var saveErr error + cache := newEntityCache() - if result.needsUpdate { - saveErr = l.updateAudioFileMetadata(result) - if saveErr == nil { - updated.Add(1) - } - } else { - saveErr = l.saveAudioFile(result) - if saveErr == nil { - added.Add(1) - } + var ( + batch []importResult + dbStarted bool + dbStartVal time.Time + ) + + flushBatch := func() { + if len(batch) == 0 { + return } - if saveErr != nil { - l.logger.Warn( - "failed to save audio file", - "path", - result.absolutePath, - "err", - saveErr, - ) + batchStart := time.Now() + if batchErr := l.commitBatch( + batch, cache, metrics, + &added, &updated, + thumbChan, + ); batchErr != nil { errMu.Lock() - scanErr = errors.Join(scanErr, saveErr) + scanErr = errors.Join(scanErr, batchErr) errMu.Unlock() } + + metrics.BatchCommits += time.Since(batchStart) + batch = batch[:0] + } + + for result := range resultChan { + if !dbStarted { + dbStartVal = time.Now() + dbStarted = true + } + + batch = append(batch, result) + if len(batch) >= scanBatchSize { + flushBatch() + } + } + + flushBatch() + + if dbStarted { + metrics.DBWritesWallClock = time.Since( + dbStartVal, + ) } }() - // Worker pool: extract metadata concurrently, send results to DB writer + // --- Phase 3: worker pool --- + extractStart := time.Now() + g := new(errgroup.Group) - g.SetLimit(scanWorkerCount) + g.SetLimit(workerCount) for work := range workChan { g.Go(func() error { - result, err := l.extractAudioMetadata(work) + result, err := l.extractAudioMetadata( + work, metrics, + ) if err != nil { - l.logger.Warn("failed to extract metadata", "path", work.absolutePath, "err", err) + l.logger.Warn( + "failed to extract metadata", + "path", work.absolutePath, + "err", err, + ) - errMu.Lock() - scanErr = errors.Join(scanErr, err) - errMu.Unlock() + metrics.addWarning( + work.absolutePath, + "extraction", err, + ) - return nil // continue processing other files + return nil } - // Send to DB writer select { case resultChan <- result: case <-l.ctx.Done(): @@ -291,21 +501,73 @@ func (l *Library) Scan() error { }) } - _ = g.Wait() // Wait for all metadata extraction to complete + _ = g.Wait() - close(resultChan) // Signal DB writer to finish - dbWg.Wait() // Wait for all DB writes to complete + metrics.ExtractionWallClock = time.Since(extractStart) + + close(resultChan) + dbWg.Wait() + + // Stop the progress ticker — main scan phases are done. + close(stopProgress) + + // Emit a final "scanning" progress so the bar reaches 100%. + a := added.Load() + s := skipped.Load() + u := updated.Load() + + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, + ScanProgress{ + Phase: "scanning", + Total: totalFiles, + Processed: a + s + u, + Added: a, + Skipped: s, + Updated: u, + }, + ) + + // Close thumbnail channel and wait for all thumbnail workers + // to finish. The DB writer has stopped sending work at this + // point so it is safe to close. + thumbStart := time.Now() + + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, ScanProgress{ + Phase: "thumbnails", + Total: totalFiles, + Processed: a + s + u, + Added: a, + Skipped: s, + Updated: u, + }) + + close(thumbChan) + thumbWg.Wait() + + metrics.ThumbnailWallClock = time.Since(thumbStart) + + // --- Phase 5: orphan cleanup --- + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, ScanProgress{ + Phase: "orphans", Total: totalFiles, + Processed: a + s + u, Added: a, Skipped: s, Updated: u, + }) + + orphanStart := time.Now() - // 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) + l.logger.Debug( + "removing orphaned database entry", + "path", path, "id", audioFile.ID, + ) - if err := l.db.Queries.DeleteAudioFile(l.ctx, audioFile.ID); err != nil { + if err := l.db.Queries.DeleteAudioFile( + l.ctx, audioFile.ID, + ); err != nil { l.logger.Warn( "failed to delete orphaned audio file", "path", path, @@ -313,29 +575,124 @@ func (l *Library) Scan() error { "err", err, ) + metrics.addWarning(path, "orphan", err) + return true } + // Remove from FTS5 search index. + if err := l.db.DeleteSearchIndex( + audioFile.ID, + ); err != nil { + l.logger.Warn( + "failed to delete FTS entry for orphan", + "id", audioFile.ID, + "err", err, + ) + + metrics.addWarning(path, "orphan", err) + } + removed.Add(1) return true }) + metrics.OrphanCleanup = time.Since(orphanStart) + + // --- Phase 6: post-scan variant generation --- + variantStart := time.Now() + + if err := l.generateMissingSizedVariants(); err != nil { + l.logger.Warn( + "could not generate missing sized variants", + "err", err, + ) + + metrics.addWarning("", "variant", err) + } + + metrics.PostScanVariants = time.Since(variantStart) + + // --- Finalize --- + metrics.Added = added.Load() + metrics.Updated = updated.Load() + metrics.Skipped = skipped.Load() + metrics.Removed = removed.Load() + metrics.Total = time.Since(scanStart) + l.logger.Info( "library scan complete", - "added", added.Load(), - "updated", updated.Load(), - "removed", removed.Load(), - "skipped", skipped.Load(), + "added", metrics.Added, + "updated", metrics.Updated, + "removed", metrics.Removed, + "skipped", metrics.Skipped, + "total", metrics.Total, "library", l.conf.DirectoryPath, ) - return scanErr + runtime.EventsEmit( + l.ctx, events.LibraryScanComplete, metrics, + ) + + return metrics, scanErr } -// scanWorkerCount controls the number of concurrent file processors. -// TODO: make configurable via Config. -var scanWorkerCount = goruntime.NumCPU() +// progressInterval controls how often scan progress events are +// emitted to the frontend. +const progressInterval = 300 * time.Millisecond + +// countAudioFiles performs a fast walk of the library directory, +// counting only files with supported audio extensions. No per-file +// I/O is performed — this reads only directory entries. +func countAudioFiles(basePath string) int64 { + var count int64 + + _ = fs.WalkDir( + os.DirFS(basePath), ".", + func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + + ext := filepath.Ext(d.Name()) + if _, ok := metadata.GetSupportedFileType(ext); ok { + count++ + } + + return nil + }, + ) + + return count +} + +// hddWorkerCount is the maximum number of concurrent extraction +// workers when the library resides on a spinning disk. +const hddWorkerCount = 2 + +// resolveScanWorkerCount returns the number of concurrent +// extraction workers based on the configured concurrency mode +// and the storage type of the library directory. +func resolveScanWorkerCount( + mode ScanConcurrency, + libraryPath string, +) int { + switch mode { + case ScanConcurrencySSD: + return goruntime.NumCPU() + case ScanConcurrencyHDD: + return min(hddWorkerCount, goruntime.NumCPU()) + default: // auto + if system.IsRotationalDisk(libraryPath) { + return min( + hddWorkerCount, goruntime.NumCPU(), + ) + } + + return goruntime.NumCPU() + } +} // scanWork represents a file to be processed by a worker. type scanWork struct { @@ -352,12 +709,18 @@ type importResult struct { fileType metadata.AudioFileExtension lengthMillis int64 tags *metadata.TrackMetadata + audioProps *metadata.AudioProperties 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) { +// It opens the file once, extracting both tags and duration in a +// single pass, and records per-file timing in the shared metrics. +func (l *Library) extractAudioMetadata( + work scanWork, + metrics *ScanMetrics, +) (importResult, error) { result := importResult{ absolutePath: work.absolutePath, fileType: work.fileType, @@ -365,221 +728,374 @@ func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) { 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, - ) - } + // Skip duration decode if we already have it from a previous import. + skipDuration := work.needsUpdate && work.existingLength > 0 - result.lengthMillis = trackLengthMillis + tags, lengthMillis, audioProps, timing, err := metadata.ExtractAllMetadata( + work.absolutePath, skipDuration, + ) + + if timing != nil { + metrics.addExtraction( + string(work.fileType), + timing.TagExtraction, + timing.DurationExtraction, + ) } - // 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{} + return result, fmt.Errorf( + "could not extract metadata for %s: %w", + work.absolutePath, + err, + ) } result.tags = tags + result.audioProps = audioProps + + if skipDuration { + result.lengthMillis = work.existingLength + } else { + result.lengthMillis = lengthMillis + } return result, nil } +// commitBatch wraps a slice of import results in a single database +// transaction, creating all related records and audio file entries. +// Individual file failures are logged and accumulated but do not +// abort the entire batch. thumbChan dispatches thumbnail generation +// to the async worker pool. +func (l *Library) commitBatch( + batch []importResult, + cache *entityCache, + metrics *ScanMetrics, + added, updated *atomic.Int64, + thumbChan chan<- thumbnailWork, +) error { + tx, err := l.db.BeginTx() + if err != nil { + return fmt.Errorf("could not begin transaction: %w", err) + } + + txq := l.db.Queries.WithTx(tx) + + for i := range batch { + result := &batch[i] + + var saveErr error + + if result.needsUpdate { + saveErr = l.updateAudioFileMetadata( + txq, tx, cache, metrics, *result, + thumbChan, + ) + if saveErr == nil { + updated.Add(1) + } + } else { + saveErr = l.saveAudioFile( + txq, tx, cache, metrics, *result, + thumbChan, + ) + if saveErr == nil { + added.Add(1) + } + } + + if saveErr != nil { + l.logger.Warn( + "failed to save audio file", + "path", result.absolutePath, + "err", saveErr, + ) + + metrics.addWarning( + result.absolutePath, "commit", saveErr, + ) + } + } + + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf( + "could not commit batch of %d files: %w", + len(batch), commitErr, + ) + } + + return nil +} + // saveAudioFile writes audio file metadata to the database (new files). -func (l *Library) saveAudioFile(result importResult) error { +func (l *Library) saveAudioFile( + q *sqlcgen.Queries, + tx *sql.Tx, + cache *entityCache, + metrics *ScanMetrics, + result importResult, + thumbChan chan<- thumbnailWork, +) 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)), + "file-type", int64( + slices.Index( + metadata.SupportedFileExtensions, + result.fileType, + ), + ), ) - // Process metadata and create related records - recordingID, err := l.processMetadata(result) + // Process metadata and create related records. + recordingID, err := l.processMetadata( + q, cache, metrics, result, thumbChan, + ) if err != nil { return fmt.Errorf("could not process metadata: %w", err) } - if _, err := l.db.Queries.CreateAudioFile( + props := result.audioProps + if props == nil { + props = &metadata.AudioProperties{} + } + + tags := result.tags + if tags == nil { + tags = &metadata.TrackMetadata{} + } + + basename := filepath.Base(result.absolutePath) + + af, err := q.CreateAudioFile( l.ctx, sqlcgen.CreateAudioFileParams{ FilePath: result.absolutePath, LengthMilliseconds: result.lengthMillis, FileTypeID: int64( - slices.Index(metadata.SupportedFileExtensions, result.fileType), + slices.Index( + metadata.SupportedFileExtensions, + result.fileType, + ), ), RecordingID: recordingID, - }); err != nil { - return fmt.Errorf("could not save audio file to db: %w", err) + SampleRate: int64(props.SampleRate), + BitDepth: int64(props.BitDepth), + Channels: int64(props.Channels), + Bitrate: int64(props.Bitrate), + FileSize: props.FileSize, + Basename: basename, + }) + if 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) + // Index in FTS5 search_index. + title := l.getRecordingName(tags, result.absolutePath) + + artistName := tags.Artist + if artistName == "" { + artistName = "Unknown Artist" + } + + album := tags.Album + + // SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized. + if _, err := tx.ExecContext( + l.ctx, + `INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?)`, + af.ID, result.absolutePath, title, artistName, album, + ); err != nil { + l.logger.Warn( + "could not index audio file in FTS", + "path", result.absolutePath, + "err", err, + ) + + metrics.addWarning(result.absolutePath, "commit", err) + } + + l.logger.Debug( + "added audio file to library", + "path", result.absolutePath, + ) return nil } // updateAudioFileMetadata updates an existing audio file with extracted metadata. -func (l *Library) updateAudioFileMetadata(result importResult) error { +func (l *Library) updateAudioFileMetadata( + q *sqlcgen.Queries, + tx *sql.Tx, + cache *entityCache, + metrics *ScanMetrics, + result importResult, + thumbChan chan<- thumbnailWork, +) error { l.logger.Debug( "updating audio file metadata", "absolute-path", result.absolutePath, "file-id", result.existingFileID, ) - // Process metadata and create related records - recordingID, err := l.processMetadata(result) + // Process metadata and create related records. + recordingID, err := l.processMetadata( + q, cache, metrics, result, thumbChan, + ) if err != nil { return fmt.Errorf("could not process metadata: %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) + props := result.audioProps + if props == nil { + props = &metadata.AudioProperties{} } - l.logger.Debug("updated audio file metadata", "path", result.absolutePath) + if err := q.UpdateAudioFileRecording( + l.ctx, sqlcgen.UpdateAudioFileRecordingParams{ + RecordingID: recordingID, + SampleRate: int64(props.SampleRate), + BitDepth: int64(props.BitDepth), + Channels: int64(props.Channels), + Bitrate: int64(props.Bitrate), + FileSize: props.FileSize, + ID: result.existingFileID, + }); err != nil { + return fmt.Errorf( + "could not update audio file recording: %w", err, + ) + } - return nil -} - -// processMetadata creates all related database records for metadata and returns the recording ID. -func (l *Library) processMetadata(result importResult) (int64, error) { + // Re-index in FTS5 search_index. + // Contentless FTS5 (content='') does not support DELETE, so we + // cannot remove the old entry. Inserting a new row with the + // same rowid is accepted by FTS5 — the old entry becomes stale + // but harmless (search JOINs against track_metadata filter it). + // The index is fully rebuilt during FullRescan. tags := result.tags if tags == nil { tags = &metadata.TrackMetadata{} } - // 1. Handle cover art (if present) - var coverArtID sql.NullInt64 + title := l.getRecordingName(tags, result.absolutePath) - 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) + album := tags.Album + + // SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized. + if _, err := tx.ExecContext( + l.ctx, + `INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?)`, + result.existingFileID, + result.absolutePath, + title, + artistName, + album, + ); err != nil { + l.logger.Warn( + "could not index updated audio file in FTS", + "path", result.absolutePath, + "err", err, + ) + + metrics.addWarning(result.absolutePath, "commit", 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. It uses the provided queries object +// (which may be transaction-scoped) and the entity cache to avoid +// redundant upserts for repeated artist/album/cover-art values. +// When thumbChan is non-nil, thumbnail generation is dispatched +// asynchronously. +func (l *Library) processMetadata( + q *sqlcgen.Queries, + cache *entityCache, + metrics *ScanMetrics, + result importResult, + thumbChan chan<- thumbnailWork, +) (int64, error) { + tags := result.tags + if tags == nil { + tags = &metadata.TrackMetadata{} + } + + // 1. Handle cover art (if present). + coverArtID := l.processCoverArt( + q, cache, metrics, tags, thumbChan, + ) + + // 2. Get or create artist credit for track artist. + artistName := tags.Artist + if artistName == "" { + artistName = "Unknown Artist" + } + + artistCredit, err := l.cachedUpsertArtistCredit( + q, cache, artistName, + ) if err != nil { - return 0, fmt.Errorf("could not upsert artist credit: %w", err) + 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) + l.cachedLinkArtist(q, cache, metrics, artistName, artistCredit.ID) + + // 3. Get or create artist credit for album artist. + albumArtistCreditID := l.resolveAlbumArtistCredit( + q, cache, metrics, tags, artistCredit.ID, + ) + + // 4. Get or create release group (album). + releaseGroupID := l.resolveReleaseGroup( + q, cache, tags, albumArtistCreditID, coverArtID, + ) + + // 5. Create recording. + recording, err := q.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 { - 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, - }) + return 0, fmt.Errorf( + "could not create recording: %w", err, + ) } - // 3. Get or create artist credit for album artist (if different) - var albumArtistCreditID sql.NullInt64 + // 6. Link recording to genres. + l.linkRecordingGenres(q, cache, tags.Genre, recording.ID) - 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 + // 7. Link recording to release group. if releaseGroupID.Valid { - _, err = l.db.Queries.CreateReleaseGroupRecording( + _, err = q.CreateReleaseGroupRecording( l.ctx, sqlcgen.CreateReleaseGroupRecordingParams{ ReleaseGroupID: releaseGroupID.Int64, @@ -589,13 +1105,337 @@ func (l *Library) processMetadata(result importResult) (int64, error) { }, ) if err != nil { - l.logger.Warn("could not link recording to release group", "err", err) + l.logger.Warn( + "could not link recording to release group", + "err", err, + ) } } return recording.ID, nil } +// processCoverArt saves cover art to disk and upserts the DB record, +// using the cache to skip work for previously seen images. When +// thumbChan is non-nil, thumbnail generation is dispatched to the +// async worker pool. +func (l *Library) processCoverArt( + q *sqlcgen.Queries, + cache *entityCache, + metrics *ScanMetrics, + tags *metadata.TrackMetadata, + thumbChan chan<- thumbnailWork, +) sql.NullInt64 { + if tags.Picture == nil { + return sql.NullInt64{} + } + + coverPath, err := l.saveCoverArt( + tags.Picture, metrics, thumbChan, + ) + if err != nil { + l.logger.Warn("could not save cover art", "err", err) + + return sql.NullInt64{} + } + + if coverPath == "" { + return sql.NullInt64{} + } + + // Check cache first. + if cached, ok := cache.coverArt[coverPath]; ok { + return sql.NullInt64{Int64: cached.ID, Valid: true} + } + + ca, err := q.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, + ) + + return sql.NullInt64{} + } + + cache.coverArt[coverPath] = ca + + return sql.NullInt64{Int64: ca.ID, Valid: true} +} + +// cachedUpsertArtistCredit returns the artist credit for the given +// name, using the cache when possible. +func (l *Library) cachedUpsertArtistCredit( + q *sqlcgen.Queries, + cache *entityCache, + name string, +) (sqlcgen.ArtistCredit, error) { + if cached, ok := cache.artistCredits[name]; ok { + return cached, nil + } + + ac, err := q.UpsertArtistCredit(l.ctx, name) + if err != nil { + return sqlcgen.ArtistCredit{}, err + } + + cache.artistCredits[name] = ac + + return ac, nil +} + +// cachedLinkArtist upserts the artist record and creates the +// artist-credit-artist link, skipping work already done. +// UNIQUE constraint violations are silently ignored (link already +// exists in the database). Other errors are recorded as scan warnings. +func (l *Library) cachedLinkArtist( + q *sqlcgen.Queries, + cache *entityCache, + metrics *ScanMetrics, + name string, + creditID int64, +) { + artist, ok := cache.artists[name] + if !ok { + var err error + + artist, err = q.UpsertArtist(l.ctx, name) + if err != nil { + l.logger.Warn( + "could not upsert artist", "err", err, + ) + + return + } + + cache.artists[name] = artist + } + + linkKey := fmt.Sprintf("%d:%d", artist.ID, creditID) + if _, done := cache.linkedCredits[linkKey]; done { + return + } + + _, err := q.CreateArtistCreditArtist( + l.ctx, + sqlcgen.CreateArtistCreditArtistParams{ + ArtistID: artist.ID, + CreditID: creditID, + }, + ) + if err != nil { + if !database.IsUniqueViolation(err) { + l.logger.Warn( + "could not link artist to credit", + "artist", name, + "creditID", creditID, + "err", err, + ) + + metrics.addWarning( + name, "commit", + fmt.Errorf( + "artist-credit link failed for %q: %w", + name, err, + ), + ) + } + + // UNIQUE violation: link already exists in DB, not an error. + return + } + + cache.linkedCredits[linkKey] = struct{}{} +} + +// cachedUpsertGenre returns the genre for the given name, using +// the cache when possible. +func (l *Library) cachedUpsertGenre( + q *sqlcgen.Queries, + cache *entityCache, + name string, +) (sqlcgen.Genre, error) { + if cached, ok := cache.genres[name]; ok { + return cached, nil + } + + genre, err := q.UpsertGenre(l.ctx, name) + if err != nil { + return sqlcgen.Genre{}, err + } + + cache.genres[name] = genre + + return genre, nil +} + +// linkRecordingGenres parses the raw genre string, upserts each +// individual genre, and creates the recording-genre associations. +func (l *Library) linkRecordingGenres( + q *sqlcgen.Queries, + cache *entityCache, + rawGenre string, + recordingID int64, +) { + genres := metadata.ParseGenres(rawGenre) + + for _, name := range genres { + genre, err := l.cachedUpsertGenre(q, cache, name) + if err != nil { + l.logger.Warn( + "could not upsert genre", + "genre", name, + "err", err, + ) + + continue + } + + err = q.CreateRecordingGenre( + l.ctx, + sqlcgen.CreateRecordingGenreParams{ + RecordingID: recordingID, + GenreID: genre.ID, + }, + ) + if err != nil { + l.logger.Warn( + "could not link recording to genre", + "genre", name, + "recordingID", recordingID, + "err", err, + ) + } + } +} + +// resolveAlbumArtistCredit returns the album artist credit ID. +// When the AlbumArtist tag is absent or matches the track artist, +// the track artist credit is reused. +func (l *Library) resolveAlbumArtistCredit( + q *sqlcgen.Queries, + cache *entityCache, + metrics *ScanMetrics, + tags *metadata.TrackMetadata, + trackArtistCreditID int64, +) sql.NullInt64 { + if tags.AlbumArtist == "" || tags.AlbumArtist == tags.Artist { + return sql.NullInt64{ + Int64: trackArtistCreditID, Valid: true, + } + } + + albumArtistCredit, err := l.cachedUpsertArtistCredit( + q, cache, tags.AlbumArtist, + ) + if err != nil { + l.logger.Warn( + "could not upsert album artist credit", "err", err, + ) + + return sql.NullInt64{} + } + + l.cachedLinkArtist( + q, cache, metrics, tags.AlbumArtist, albumArtistCredit.ID, + ) + + return sql.NullInt64{ + Int64: albumArtistCredit.ID, Valid: true, + } +} + +// resolveReleaseGroup returns the release group ID for the album, +// using the cache when possible. +func (l *Library) resolveReleaseGroup( + q *sqlcgen.Queries, + cache *entityCache, + tags *metadata.TrackMetadata, + albumArtistCreditID sql.NullInt64, + coverArtID sql.NullInt64, +) sql.NullInt64 { + if tags.Album == "" { + return sql.NullInt64{} + } + + // Build composite cache key: "albumName\x00artistCreditID" + // (or "albumName\x00-1" if no artist). This prevents albums + // with the same name by different artists from colliding. + artistID := int64(-1) + if albumArtistCreditID.Valid { + artistID = albumArtistCreditID.Int64 + } + + cacheKey := fmt.Sprintf("%s\x00%d", tags.Album, artistID) + + // Check cache first. + if cached, ok := cache.releaseGroups[cacheKey]; ok { + // If the cached release group lacks cover art and we now + // have it, update it. + if coverArtID.Valid && !cached.CoverArtID.Valid { + err := q.UpdateReleaseGroupCoverArt( + l.ctx, + sqlcgen.UpdateReleaseGroupCoverArtParams{ + CoverArtID: coverArtID, + ID: cached.ID, + }, + ) + if err != nil { + l.logger.Warn( + "could not update release group cover art", + "err", err, + ) + } else { + cached.CoverArtID = coverArtID + cache.releaseGroups[cacheKey] = cached + } + } + + return sql.NullInt64{Int64: cached.ID, Valid: true} + } + + rg, err := q.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, + ) + + return sql.NullInt64{} + } + + // Update cover art if this album doesn't have one yet. + if coverArtID.Valid && !rg.CoverArtID.Valid { + err := q.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, + ) + } else { + rg.CoverArtID = coverArtID + } + } + + cache.releaseGroups[cacheKey] = rg + + return sql.NullInt64{Int64: rg.ID, Valid: true} +} + // getRecordingName returns the track title, or falls back to the filename. func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string { if tags.Title != "" { @@ -634,10 +1474,19 @@ func (l *Library) handleConfigUpdate(updatedConfigValues Config) error { l.logger.Info("new library, scanning") l.conf.DirectoryPath = updatedConfigValues.DirectoryPath - if err := l.Scan(); err != nil { + + if scanMetrics, err := l.Scan(); err != nil { updateErr = errors.Join( updateErr, - fmt.Errorf("problem scanning library on config update: %w", err), + fmt.Errorf( + "problem scanning library on config update: %w", + err, + ), + ) + } else if len(scanMetrics.Warnings) > 0 { + l.logger.Warn( + "library scan completed with warnings", + "warningCount", len(scanMetrics.Warnings), ) } } diff --git a/backend/library/metrics.go b/backend/library/metrics.go new file mode 100644 index 0000000..01835e4 --- /dev/null +++ b/backend/library/metrics.go @@ -0,0 +1,136 @@ +package library + +import ( + "sync" + "time" +) + +// ScanMetrics holds timing and count data collected during a library scan. +// Worker-pool fields are protected by a mutex; DB-writer fields are +// single-threaded and use plain addition. +type ScanMetrics struct { + mu sync.Mutex + + // Top-level phases (wall-clock). + Total time.Duration `json:"total"` + LoadExisting time.Duration `json:"loadExisting"` + WalkDuration time.Duration `json:"walkDuration"` + ExtractionWallClock time.Duration `json:"extractionWallClock"` + DBWritesWallClock time.Duration `json:"dbWritesWallClock"` + OrphanCleanup time.Duration `json:"orphanCleanup"` + PostScanVariants time.Duration `json:"postScanVariants"` + + // Per-format extraction (cumulative across workers). + FormatExtraction map[string]int64 `json:"formatExtraction"` + FormatCount map[string]int64 `json:"formatCount"` + + // Sub-operation cumulative times (across workers). + TagExtraction time.Duration `json:"tagExtraction"` + DurationExtraction time.Duration `json:"durationExtraction"` + + // DB sub-operations (cumulative, single-threaded DB writer). + BatchCommits time.Duration `json:"batchCommits"` + CoverArtSave time.Duration `json:"coverArtSave"` + + // Thumbnail generation (async worker pool). + ThumbnailWallClock time.Duration `json:"thumbnailWallClock"` + ThumbnailGeneration time.Duration `json:"thumbnailGeneration"` + ThumbnailSmall time.Duration `json:"thumbnailSmall"` + ThumbnailMedium time.Duration `json:"thumbnailMedium"` + ThumbnailLarge time.Duration `json:"thumbnailLarge"` + + // Full-rescan-specific phases. + ClearQueue time.Duration `json:"clearQueue"` + ClearDatabase time.Duration `json:"clearDatabase"` + ClearCoverFiles time.Duration `json:"clearCoverFiles"` + + // File counts. + Added int64 `json:"added"` + Updated int64 `json:"updated"` + Skipped int64 `json:"skipped"` + Removed int64 `json:"removed"` + + // Non-fatal issues encountered during scanning. + Warnings []ScanWarning `json:"warnings"` +} + +// ScanProgress is the payload emitted periodically during a scan to +// report live progress to the frontend. +type ScanProgress struct { + Phase string `json:"phase"` // "counting", "scanning", "orphans", "thumbnails" + Total int64 `json:"total"` // total audio files from pre-walk count + Processed int64 `json:"processed"` // added + skipped + updated so far + Added int64 `json:"added"` + Skipped int64 `json:"skipped"` + Updated int64 `json:"updated"` +} + +// ScanWarning represents a non-fatal issue encountered during scanning. +type ScanWarning struct { + FilePath string `json:"filePath"` + Phase string `json:"phase"` + Err error `json:"err"` +} + +func newScanMetrics() *ScanMetrics { + return &ScanMetrics{ + FormatExtraction: make(map[string]int64), + FormatCount: make(map[string]int64), + } +} + +// addExtraction records per-file extraction timing from a worker +// goroutine. It is safe for concurrent use. +func (m *ScanMetrics) addExtraction( + fileType string, + tagTime, durationTime time.Duration, +) { + m.mu.Lock() + defer m.mu.Unlock() + + total := tagTime + durationTime + m.FormatExtraction[fileType] += total.Milliseconds() + m.FormatCount[fileType]++ + m.TagExtraction += tagTime + m.DurationExtraction += durationTime +} + +// addCoverArtSave records the time spent saving an original cover +// art file. Called from the single-threaded DB writer. +func (m *ScanMetrics) addCoverArtSave(d time.Duration) { + m.CoverArtSave += d +} + +// addWarning records a non-fatal scan issue. Safe for concurrent use. +func (m *ScanMetrics) addWarning(filePath, phase string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.Warnings = append(m.Warnings, ScanWarning{ + FilePath: filePath, + Phase: phase, + Err: err, + }) +} + +// addThumbnailTier records the time spent generating a single +// thumbnail tier. Safe for concurrent use from the thumbnail +// worker pool. +func (m *ScanMetrics) addThumbnailTier( + suffix string, + d time.Duration, +) { + m.mu.Lock() + defer m.mu.Unlock() + + m.ThumbnailGeneration += d + + switch suffix { + case "_sm": + m.ThumbnailSmall += d + case "_md": + m.ThumbnailMedium += d + case "_lg": + m.ThumbnailLarge += d + } +} diff --git a/backend/library/query.go b/backend/library/query.go index ca33335..b40155c 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -1,10 +1,13 @@ package library import ( + "database/sql" "errors" "fmt" - "path/filepath" "strconv" + "strings" + + "yellowjacket/backend/coverart" ) // Sentinel errors for library queries. @@ -19,49 +22,183 @@ type Track struct { ArtistName string TrackLength string FilePath string + TrackNumber int64 + DiscNumber int64 + Album string + Genre []string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +// genreDelimiter is the separator used by GROUP_CONCAT in the +// GetAllTracksWithFullMetadata query. +const genreDelimiter = "||" + +// splitGenres splits a GROUP_CONCAT genre string into individual +// genre names. An empty string returns nil. +func splitGenres(concatenated string) []string { + if concatenated == "" { + return nil + } + + return strings.Split(concatenated, genreDelimiter) +} + +// mapTrackRow converts raw database column values into a Track. +// This is shared by GetAllTracks, SearchTracks, and GetTracksByGenre +// to avoid tripling the row-mapping code. +func mapTrackRow( + filePath string, + lengthMs int64, + title, artistName string, + trackNumber, discNumber sql.NullInt64, + album, genre string, + year int64, + composer, fileType string, + sampleRate, bitDepth, channels, bitrate, fileSize int64, +) Track { + return Track{ + TrackName: title, + ArtistName: artistName, + TrackLength: strconv.FormatInt(lengthMs, 10), + FilePath: filePath, + TrackNumber: trackNumber.Int64, + DiscNumber: discNumber.Int64, + Album: album, + Genre: splitGenres(genre), + Year: year, + Composer: composer, + FileType: fileType, + SampleRate: sampleRate, + BitDepth: bitDepth, + Channels: channels, + Bitrate: bitrate, + FileSize: fileSize, + } +} + +// Artist represents an artist in the library. +type Artist struct { + ID int64 + Name string } // Album represents an album for the cover grid display. type Album struct { - ID int64 - Name string - ArtistName string - CoverArtPath string - Year int64 + ID int64 + Name string + ArtistName string + CoverArtPath string + CoverArtSmall string + CoverArtMedium string + CoverArtLarge 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) + rows, err := l.db.Queries.GetAllTracksWithFullMetadata( + l.ctx, + ) if err != nil { - l.logger.Error("could not retrieve audio files", "error", err) + l.logger.Error( + "could not retrieve audio files", + "error", err, + ) return nil, err } - l.logger.Info("audio file list", "count", len(audioFiles)) + l.logger.Info("audio file list", "count", len(rows)) - if len(audioFiles) == 0 { + if len(rows) == 0 { l.logger.Error("no tracks in library") return nil, errNoTracksInLibrary } - var formattedTracks []Track + tracks := make([]Track, 0, len(rows)) - 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) + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) } - l.logger.Info("formatted tracks", "count", len(formattedTracks)) + l.logger.Info("formatted tracks", "count", len(tracks)) - return formattedTracks, nil + return tracks, nil +} + +// searchTrackLimit is the maximum number of results returned by +// a full-text search. +const searchTrackLimit = 200 + +// SearchTracks performs an FTS5 full-text search and returns +// matching tracks with full metadata. +func (l *Library) SearchTracks( + query string, +) ([]Track, error) { + rows, err := l.db.SearchFTSTracks( + query, searchTrackLimit, + ) + if err != nil { + l.logger.Error( + "FTS track search failed", + "query", query, + "error", err, + ) + + return nil, fmt.Errorf( + "search tracks failed: %w", err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil } // GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number. @@ -80,12 +217,24 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) { 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, - }) + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) } return tracks, nil @@ -115,9 +264,13 @@ func (l *Library) GetAllAlbums() ([]Album, error) { album.Year = row.Year.Int64 } - // Convert filesystem path to URL path for the asset handler + // Convert filesystem path to URL path for the asset handler. if row.CoverArtPath != "" { - album.CoverArtPath = "/covers/" + filepath.Base(row.CoverArtPath) + urls := coverart.ResolveURLs(row.CoverArtPath) + album.CoverArtPath = urls.Original + album.CoverArtSmall = urls.Small + album.CoverArtMedium = urls.Medium + album.CoverArtLarge = urls.Large } albums = append(albums, album) @@ -125,3 +278,168 @@ func (l *Library) GetAllAlbums() ([]Album, error) { return albums, nil } + +// GetAllArtists returns artists that are credited as album artists, ordered by name. +func (l *Library) GetAllArtists() ([]Artist, error) { + rows, err := l.db.Queries.GetAlbumArtists(l.ctx) + if err != nil { + l.logger.Error( + "could not retrieve artists", + "error", err, + ) + + return nil, fmt.Errorf( + "could not get artists: %w", + err, + ) + } + + l.logger.Info("artist list", "count", len(rows)) + + artists := make([]Artist, 0, len(rows)) + + for _, row := range rows { + artists = append(artists, Artist{ + ID: row.ID, + Name: row.Name, + }) + } + + return artists, nil +} + +// GetAlbumsByArtist returns all albums where the given artist is the album artist. +func (l *Library) GetAlbumsByArtist( + artistID int64, +) ([]Album, error) { + rows, err := l.db.Queries.GetAlbumsByArtist( + l.ctx, + artistID, + ) + if err != nil { + l.logger.Error( + "could not retrieve albums for artist", + "artistID", artistID, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get albums for artist: %w", + err, + ) + } + + l.logger.Info( + "albums for artist", + "artistID", artistID, + "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 != "" { + urls := coverart.ResolveURLs(row.CoverArtPath) + album.CoverArtPath = urls.Original + album.CoverArtSmall = urls.Small + album.CoverArtMedium = urls.Medium + album.CoverArtLarge = urls.Large + } + + albums = append(albums, album) + } + + return albums, nil +} + +// GenreWithCount holds a genre name and its associated track count. +type GenreWithCount struct { + Name string `json:"Name"` + TrackCount int64 `json:"TrackCount"` +} + +// GetTracksByGenre returns all tracks tagged with the given genre. +func (l *Library) GetTracksByGenre( + genreName string, +) ([]Track, error) { + rows, err := l.db.Queries.GetTracksByGenre( + l.ctx, genreName, + ) + if err != nil { + l.logger.Error( + "could not retrieve tracks for genre", + "genre", genreName, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get tracks for genre: %w", err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + +// GetAllGenresWithCounts returns all genres with their track counts. +func (l *Library) GetAllGenresWithCounts() ( + []GenreWithCount, error, +) { + rows, err := l.db.Queries.GetAllGenresWithCounts( + l.ctx, + ) + if err != nil { + l.logger.Error( + "could not retrieve genres with counts", + "error", err, + ) + + return nil, fmt.Errorf( + "could not get genres: %w", err, + ) + } + + genres := make([]GenreWithCount, 0, len(rows)) + + for _, row := range rows { + genres = append(genres, GenreWithCount{ + Name: row.Name, + TrackCount: row.TrackCount, + }) + } + + return genres, nil +} diff --git a/backend/library/rescan.go b/backend/library/rescan.go new file mode 100644 index 0000000..2e40948 --- /dev/null +++ b/backend/library/rescan.go @@ -0,0 +1,226 @@ +package library + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "yellowjacket/backend/coverart" +) + +// FullRescan clears the queue and player, wipes all library data +// (database records and cover art files), and performs a fresh +// scan from scratch. The returned ScanMetrics includes timing +// for the clear phases in addition to the normal scan metrics. +func (l *Library) FullRescan() (*ScanMetrics, error) { + l.logger.Info("beginning full library rescan") + + // Run the pre-clear hook (e.g. clear queue / stop playback) + // before wiping data so the player is not referencing + // now-deleted tracks. + clearQueueStart := time.Now() + + if l.rescanHooks.PreClear != nil { + l.rescanHooks.PreClear() + } + + clearQueueDur := time.Since(clearQueueStart) + + // Clear all library data (DB + cover art files). + clearDBStart := time.Now() + + if err := l.clearLibraryTables(); err != nil { + return nil, fmt.Errorf( + "could not clear library tables: %w", err, + ) + } + + clearDBDur := time.Since(clearDBStart) + + clearFilesStart := time.Now() + + if err := l.clearCoverArtFiles(); err != nil { + return nil, fmt.Errorf( + "could not clear cover art files: %w", err, + ) + } + + clearFilesDur := time.Since(clearFilesStart) + + l.logger.Info("library data cleared successfully") + + // Run the full scan and merge clear-phase times into + // the metrics it returns. + metrics, err := l.Scan() + if metrics != nil { + metrics.ClearQueue = clearQueueDur + metrics.ClearDatabase = clearDBDur + metrics.ClearCoverFiles = clearFilesDur + + // Include clear-phase durations in the total so the + // displayed value reflects true wall-clock time. + metrics.Total += clearQueueDur + + clearDBDur + clearFilesDur + } + + // Run the post-scan hook (e.g. restore playlists from M3U8 + // files) now that audio_files are populated again. + if l.rescanHooks.PostScan != nil { + l.rescanHooks.PostScan() + } + + return metrics, err +} + +// clearLibraryTables deletes all library-related rows in FK-safe +// order within a single transaction. +func (l *Library) clearLibraryTables() error { + tx, err := l.db.BeginTx() + if err != nil { + return fmt.Errorf("could not begin transaction: %w", err) + } + + defer func() { + _ = tx.Rollback() + }() + + txq := l.db.Queries.WithTx(tx) + + // Phase 1: leaf tables (nothing references these). + if err := txq.ClearQueueTracks(l.ctx); err != nil { + return fmt.Errorf("could not clear queue tracks: %w", err) + } + + if err := txq.DeleteAllPlaylistTracks(l.ctx); err != nil { + return fmt.Errorf( + "could not clear playlist tracks: %w", err, + ) + } + + if err := txq.DeleteAllRecordingGenres(l.ctx); err != nil { + return fmt.Errorf( + "could not clear recording genres: %w", err, + ) + } + + if err := txq.DeleteAllReleaseGroupRecordings(l.ctx); err != nil { + return fmt.Errorf( + "could not clear release group recordings: %w", err, + ) + } + + if err := txq.DeleteAllArtistCreditArtists(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artist credit artists: %w", err, + ) + } + + // Phase 2: mid-level tables. + if err := txq.DeleteAllAudioFiles(l.ctx); err != nil { + return fmt.Errorf( + "could not clear audio files: %w", err, + ) + } + + if err := txq.DeleteAllReleaseGroups(l.ctx); err != nil { + return fmt.Errorf( + "could not clear release groups: %w", err, + ) + } + + if err := txq.DeleteAllRecordings(l.ctx); err != nil { + return fmt.Errorf( + "could not clear recordings: %w", err, + ) + } + + // Phase 3: root tables. + if err := txq.DeleteAllCoverArt(l.ctx); err != nil { + return fmt.Errorf( + "could not clear cover art: %w", err, + ) + } + + if err := txq.DeleteAllArtistCredits(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artist credits: %w", err, + ) + } + + if err := txq.DeleteAllArtists(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artists: %w", err, + ) + } + + if err := txq.DeleteAllGenres(l.ctx); err != nil { + return fmt.Errorf( + "could not clear genres: %w", err, + ) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf( + "could not commit library clear transaction: %w", err, + ) + } + + // Clear FTS5 search index AFTER the transaction. + // ClearSearchIndex drops and recreates the contentless FTS5 + // virtual table, which cannot run inside a transaction. + if err := l.db.ClearSearchIndex(); err != nil { + return fmt.Errorf( + "could not clear search index: %w", err, + ) + } + + l.logger.Info("all library tables cleared") + + return nil +} + +// clearCoverArtFiles removes all files from the covers directory. +func (l *Library) clearCoverArtFiles() error { + coverDir, err := coverart.CoversDir() + if err != nil { + return fmt.Errorf( + "could not resolve covers directory: %w", err, + ) + } + + entries, err := os.ReadDir(coverDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf( + "could not read covers directory: %w", err, + ) + } + + var removed int + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + path := filepath.Join(coverDir, entry.Name()) + if err := os.Remove(path); err != nil { + l.logger.Warn( + "could not remove cover art file", + "path", path, "err", err, + ) + + continue + } + + removed++ + } + + l.logger.Info("cover art files removed", "count", removed) + + return nil +} diff --git a/backend/library/scan_test.go b/backend/library/scan_test.go new file mode 100644 index 0000000..2a760cf --- /dev/null +++ b/backend/library/scan_test.go @@ -0,0 +1,734 @@ +package library + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "testing" + + "yellowjacket/backend/database" + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/metadata" +) + +// --------------------------------------------------------------------------- +// Pure helper tests — no database dependency +// --------------------------------------------------------------------------- + +func TestGetRecordingName(t *testing.T) { + t.Parallel() + + lib := &Library{} // getRecordingName uses only tags + filePath + + tests := []struct { + name string + title string + filePath string + want string + }{ + { + name: "title present", + title: "Bohemian Rhapsody", + filePath: "/music/queen/bohemian.mp3", + want: "Bohemian Rhapsody", + }, + { + name: "title empty falls back to filename sans extension", + title: "", + filePath: "/music/song.mp3", + want: "song", + }, + { + name: "title empty with complex filename", + title: "", + filePath: "/music/Artist - Track.flac", + want: "Artist - Track", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tags := &metadata.TrackMetadata{Title: tt.title} + got := lib.getRecordingName(tags, tt.filePath) + + if got != tt.want { + t.Errorf("getRecordingName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestToNullInt64(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input int + want sql.NullInt64 + }{ + { + name: "zero is null", + input: 0, + want: sql.NullInt64{}, + }, + { + name: "positive is valid", + input: 5, + want: sql.NullInt64{Int64: 5, Valid: true}, + }, + { + name: "negative is valid", + input: -1, + want: sql.NullInt64{Int64: -1, Valid: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := toNullInt64(tt.input) + if got != tt.want { + t.Errorf("toNullInt64(%d) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestToNullString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want sql.NullString + }{ + { + name: "empty is null", + input: "", + want: sql.NullString{}, + }, + { + name: "non-empty is valid", + input: "rock", + want: sql.NullString{String: "rock", Valid: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := toNullString(tt.input) + if got != tt.want { + t.Errorf("toNullString(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestSplitGenres(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + }{ + { + name: "empty string returns nil", + input: "", + want: nil, + }, + { + name: "single genre", + input: "Rock", + want: []string{"Rock"}, + }, + { + name: "multiple genres", + input: "Rock||Jazz||Blues", + want: []string{"Rock", "Jazz", "Blues"}, + }, + { + name: "two genres", + input: "Electronic||Ambient", + want: []string{"Electronic", "Ambient"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := splitGenres(tt.input) + + if tt.want == nil { + if got != nil { + t.Errorf("splitGenres(%q) = %v, want nil", tt.input, got) + } + + return + } + + if len(got) != len(tt.want) { + t.Fatalf("splitGenres(%q) length = %d, want %d", tt.input, len(got), len(tt.want)) + } + + for i, v := range got { + if v != tt.want[i] { + t.Errorf("splitGenres(%q)[%d] = %q, want %q", tt.input, i, v, tt.want[i]) + } + } + }) + } +} + +func TestMapTrackRow(t *testing.T) { + t.Parallel() + + track := mapTrackRow( + "/music/queen/bohemian.flac", // filePath + 180000, // lengthMs + "Bohemian Rhapsody", // title + "Queen", // artistName + sql.NullInt64{Int64: 1, Valid: true}, // trackNumber + sql.NullInt64{Int64: 1, Valid: true}, // discNumber + "A Night at the Opera", // album + "Rock||Progressive Rock", // genre + 1975, // year + "Freddie Mercury", // composer + ".flac", // fileType + 44100, // sampleRate + 16, // bitDepth + 2, // channels + 1411, // bitrate + 35000000, // fileSize + ) + + // Verify all 16 fields. + if track.TrackName != "Bohemian Rhapsody" { + t.Errorf("TrackName = %q, want %q", track.TrackName, "Bohemian Rhapsody") + } + + if track.ArtistName != "Queen" { + t.Errorf("ArtistName = %q, want %q", track.ArtistName, "Queen") + } + + // TrackLength is string-formatted milliseconds. + if track.TrackLength != "180000" { + t.Errorf("TrackLength = %q, want %q", track.TrackLength, "180000") + } + + if track.FilePath != "/music/queen/bohemian.flac" { + t.Errorf("FilePath = %q, want %q", track.FilePath, "/music/queen/bohemian.flac") + } + + if track.TrackNumber != 1 { + t.Errorf("TrackNumber = %d, want %d", track.TrackNumber, 1) + } + + if track.DiscNumber != 1 { + t.Errorf("DiscNumber = %d, want %d", track.DiscNumber, 1) + } + + if track.Album != "A Night at the Opera" { + t.Errorf("Album = %q, want %q", track.Album, "A Night at the Opera") + } + + wantGenres := []string{"Rock", "Progressive Rock"} + if len(track.Genre) != len(wantGenres) { + t.Fatalf("Genre length = %d, want %d", len(track.Genre), len(wantGenres)) + } + + for i, g := range track.Genre { + if g != wantGenres[i] { + t.Errorf("Genre[%d] = %q, want %q", i, g, wantGenres[i]) + } + } + + if track.Year != 1975 { + t.Errorf("Year = %d, want %d", track.Year, 1975) + } + + if track.Composer != "Freddie Mercury" { + t.Errorf("Composer = %q, want %q", track.Composer, "Freddie Mercury") + } + + if track.FileType != ".flac" { + t.Errorf("FileType = %q, want %q", track.FileType, ".flac") + } + + if track.SampleRate != 44100 { + t.Errorf("SampleRate = %d, want %d", track.SampleRate, 44100) + } + + if track.BitDepth != 16 { + t.Errorf("BitDepth = %d, want %d", track.BitDepth, 16) + } + + if track.Channels != 2 { + t.Errorf("Channels = %d, want %d", track.Channels, 2) + } + + if track.Bitrate != 1411 { + t.Errorf("Bitrate = %d, want %d", track.Bitrate, 1411) + } + + if track.FileSize != 35000000 { + t.Errorf("FileSize = %d, want %d", track.FileSize, 35000000) + } + + // Verify NullInt64 with Valid=false yields 0. + trackNull := mapTrackRow( + "/music/unknown.mp3", 0, "Test", "Artist", + sql.NullInt64{}, sql.NullInt64{}, // invalid (null) + "", "", 0, "", "", 0, 0, 0, 0, 0, + ) + + if trackNull.TrackNumber != 0 { + t.Errorf("null TrackNumber = %d, want 0", trackNull.TrackNumber) + } + + if trackNull.DiscNumber != 0 { + t.Errorf("null DiscNumber = %d, want 0", trackNull.DiscNumber) + } +} + +// --------------------------------------------------------------------------- +// Test helper — constructs a Library backed by an in-memory test DB +// --------------------------------------------------------------------------- + +func setupTestLibrary(t *testing.T) (*Library, *database.DB) { + t.Helper() + + db := database.NewTestDB(t) + + // Construct Library directly (internal test) — avoids Config.Validate + // calling os.Stat on the directory. Entity cache functions only need + // l.ctx and l.db; they have no Wails runtime dependency. + lib := &Library{ + ctx: t.Context(), + logger: slog.Default(), + conf: &Config{}, + db: db, + } + + return lib, db +} + +// --------------------------------------------------------------------------- +// Entity cache tests — DB-backed +// --------------------------------------------------------------------------- + +func TestCachedUpsertArtistCredit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // First call — hits DB. + ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("first cachedUpsertArtistCredit: %v", err) + } + + if ac1.ID == 0 { + t.Fatal("expected non-zero ArtistCredit ID") + } + + // Second call — cache hit, same ID. + ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("second cachedUpsertArtistCredit: %v", err) + } + + if ac2.ID != ac1.ID { + t.Errorf("cache miss: got ID %d, want %d", ac2.ID, ac1.ID) + } + + // Different name — different ID. + ac3, err := lib.cachedUpsertArtistCredit(q, cache, "Beyoncé") + if err != nil { + t.Fatalf("cachedUpsertArtistCredit(Beyoncé): %v", err) + } + + if ac3.ID == ac1.ID { + t.Errorf("different name returned same ID %d", ac3.ID) + } + + // Cache should have 2 entries. + if len(cache.artistCredits) != 2 { + t.Errorf("cache entries = %d, want 2", len(cache.artistCredits)) + } +} + +func TestCachedLinkArtist(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Create an artist credit first. + ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + // First link — creates artist + artist-credit-artist link. + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) + + if len(cache.artists) != 1 { + t.Errorf("artists cache = %d, want 1", len(cache.artists)) + } + + if len(cache.linkedCredits) != 1 { + t.Errorf("linkedCredits cache = %d, want 1", len(cache.linkedCredits)) + } + + // Second call with same args — should skip (cache hit). + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) + + if len(cache.linkedCredits) != 1 { + t.Errorf( + "linkedCredits after duplicate = %d, want 1 (should skip)", + len(cache.linkedCredits), + ) + } +} + +func TestCachedLinkArtist_MultiCredit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Two different artist credits referencing the same artist name. + ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert credit 1: %v", err) + } + + ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen feat. David Bowie") + if err != nil { + t.Fatalf("upsert credit 2: %v", err) + } + + // Link "Queen" artist to both credits. + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac1.ID) + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac2.ID) + + // Artist cached once. + if len(cache.artists) != 1 { + t.Errorf("artists cache = %d, want 1 (same artist name)", len(cache.artists)) + } + + // Two distinct linked-credit entries. + if len(cache.linkedCredits) != 2 { + t.Errorf("linkedCredits = %d, want 2", len(cache.linkedCredits)) + } + + // Verify link keys are correct format. + queenArtist := cache.artists["Queen"] + key1 := fmt.Sprintf("%d:%d", queenArtist.ID, ac1.ID) + key2 := fmt.Sprintf("%d:%d", queenArtist.ID, ac2.ID) + + if _, ok := cache.linkedCredits[key1]; !ok { + t.Errorf("missing linked credit key %q", key1) + } + + if _, ok := cache.linkedCredits[key2]; !ok { + t.Errorf("missing linked credit key %q", key2) + } +} + +func TestCachedUpsertGenre(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // First call — creates genre. + g1, err := lib.cachedUpsertGenre(q, cache, "Rock") + if err != nil { + t.Fatalf("first cachedUpsertGenre: %v", err) + } + + if g1.ID == 0 { + t.Fatal("expected non-zero Genre ID") + } + + // Second call — cache hit. + g2, err := lib.cachedUpsertGenre(q, cache, "Rock") + if err != nil { + t.Fatalf("second cachedUpsertGenre: %v", err) + } + + if g2.ID != g1.ID { + t.Errorf("cache miss: got ID %d, want %d", g2.ID, g1.ID) + } + + if len(cache.genres) != 1 { + t.Errorf("genre cache entries = %d, want 1", len(cache.genres)) + } +} + +func TestResolveReleaseGroup(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // Need an album artist credit for the release group. + ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + albumArtistCreditID := sql.NullInt64{Int64: ac.ID, Valid: true} + + // First call — no cover art. + tags := &metadata.TrackMetadata{ + Album: "A Night at the Opera", + Year: 1975, + } + + rgID := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, sql.NullInt64{}) + if !rgID.Valid { + t.Fatal("expected valid release group ID") + } + + if rgID.Int64 == 0 { + t.Fatal("expected non-zero release group ID") + } + + // Verify cached. + if len(cache.releaseGroups) != 1 { + t.Errorf("releaseGroups cache = %d, want 1", len(cache.releaseGroups)) + } + + // Second call — same album with cover art → should update cover art on cached entry. + // First, create a cover art record in the DB. + coverArt, err := q.UpsertCoverArt(lib.ctx, sqlcgen.UpsertCoverArtParams{ + IsEmbedded: true, + FilePath: "/covers/opera.jpg", + MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("create cover art: %v", err) + } + + coverArtID := sql.NullInt64{Int64: coverArt.ID, Valid: true} + rgID2 := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, coverArtID) + + if rgID2.Int64 != rgID.Int64 { + t.Errorf("cache miss: got ID %d, want %d", rgID2.Int64, rgID.Int64) + } + + // Cover art should be updated on the cached release group. + // Cache key is composite: "albumName\x00artistCreditID". + cacheKey := fmt.Sprintf("%s\x00%d", "A Night at the Opera", ac.ID) + cachedRG := cache.releaseGroups[cacheKey] + + if !cachedRG.CoverArtID.Valid { + t.Error("expected CoverArtID to be set after update") + } + + if cachedRG.CoverArtID.Int64 != coverArt.ID { + t.Errorf("CoverArtID = %d, want %d", cachedRG.CoverArtID.Int64, coverArt.ID) + } + + // Empty album → invalid NullInt64. + emptyTags := &metadata.TrackMetadata{Album: ""} + rgEmpty := lib.resolveReleaseGroup(q, cache, emptyTags, albumArtistCreditID, sql.NullInt64{}) + + if rgEmpty.Valid { + t.Errorf("empty album should return invalid NullInt64, got valid with ID %d", rgEmpty.Int64) + } +} + +func TestResolveReleaseGroup_CacheHit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // Pre-populate cache with a known release group. + // Cache key is composite: "albumName\x00artistCreditID" (use -1 for no artist). + cache.releaseGroups[fmt.Sprintf("%s\x00%d", "Cached Album", int64(-1))] = sqlcgen.ReleaseGroup{ + ID: 42, + Name: "Cached Album", + } + + tags := &metadata.TrackMetadata{Album: "Cached Album"} + rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) + + if !rgID.Valid { + t.Fatal("expected valid release group ID from cache") + } + + if rgID.Int64 != 42 { + t.Errorf("resolveReleaseGroup() = %d, want 42 (cached)", rgID.Int64) + } +} + +// --------------------------------------------------------------------------- +// Orphan cleanup test — DB-level +// --------------------------------------------------------------------------- + +func TestOrphanDeletion(t *testing.T) { + t.Parallel() + + _, db := setupTestLibrary(t) + ctx := context.Background() + q := db.Queries + + // Seed an artist credit → recording → audio file chain. + ac, err := q.UpsertArtistCredit(ctx, "Test Artist") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ + Name: "Test Song", + ArtistCreditID: ac.ID, + }) + if err != nil { + t.Fatalf("create recording: %v", err) + } + + af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ + FilePath: "/music/test.mp3", + LengthMilliseconds: 180000, + FileTypeID: 0, + RecordingID: rec.ID, + Basename: "test.mp3", + }) + if err != nil { + t.Fatalf("create audio file: %v", err) + } + + // Add FTS search index entry. + if err := db.InsertSearchIndex( + af.ID, "/music/test.mp3", "Test Song", "Test Artist", "", + ); err != nil { + t.Fatalf("insert search index: %v", err) + } + + // Verify the search index entry exists before deletion. + results, err := db.SearchFTS("Test Song", 10) + if err != nil { + t.Fatalf("search before delete: %v", err) + } + + if len(results) != 1 { + t.Fatalf("search results before delete = %d, want 1", len(results)) + } + + // Delete audio file — this is the primary orphan cleanup step. + if err := q.DeleteAudioFile(ctx, af.ID); err != nil { + t.Fatalf("delete audio file: %v", err) + } + + // Verify audio file is gone by attempting to query all audio files. + allFiles, err := q.GetAllAudioFiles(ctx) + if err != nil { + t.Fatalf("get all audio files: %v", err) + } + + if len(allFiles) != 0 { + t.Errorf("audio files after delete = %d, want 0", len(allFiles)) + } + + // DeleteSearchIndex on contentless FTS5 table (content='') is + // expected to error. The production orphan cleanup code in + // library.go logs this as a warning — the search index entries + // become stale but harmless (they reference a non-existent + // audio_file ID, so JOINs return no results). + // ClearSearchIndex (used during full rescan) handles bulk cleanup. + // DeleteSearchIndex on contentless FTS5 is expected to error. + // Not a fatal error — documents the contentless FTS5 limitation. + err = db.DeleteSearchIndex(af.ID) + if err == nil { + t.Log("DeleteSearchIndex succeeded (unexpected for contentless FTS5)") + } +} + +// --------------------------------------------------------------------------- +// Empty/missing metadata tests +// --------------------------------------------------------------------------- + +func TestEntityCache_EmptyFields(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Empty artist credit name — documents behavior (creates "" credit). + ac, err := lib.cachedUpsertArtistCredit(q, cache, "") + if err != nil { + t.Fatalf("cachedUpsertArtistCredit with empty name: %v", err) + } + + if ac.ID == 0 { + t.Error("expected non-zero ID even for empty artist credit name") + } + + // Empty album → resolveReleaseGroup returns invalid NullInt64. + tags := &metadata.TrackMetadata{Album: ""} + rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) + + if rgID.Valid { + t.Errorf("empty album should return invalid NullInt64, got valid ID %d", rgID.Int64) + } + + // resolveAlbumArtistCredit with empty AlbumArtist reuses track artist credit. + trackTags := &metadata.TrackMetadata{ + Artist: "Queen", + AlbumArtist: "", + } + + trackAC, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert track artist credit: %v", err) + } + + albumACID := lib.resolveAlbumArtistCredit(q, cache, metrics, trackTags, trackAC.ID) + if !albumACID.Valid { + t.Fatal("expected valid album artist credit ID when AlbumArtist is empty") + } + + if albumACID.Int64 != trackAC.ID { + t.Errorf( + "empty AlbumArtist should reuse track credit: got %d, want %d", + albumACID.Int64, trackAC.ID, + ) + } + + // resolveAlbumArtistCredit when AlbumArtist matches Artist also reuses. + sameTags := &metadata.TrackMetadata{ + Artist: "Queen", + AlbumArtist: "Queen", + } + + sameACID := lib.resolveAlbumArtistCredit(q, cache, metrics, sameTags, trackAC.ID) + if sameACID.Int64 != trackAC.ID { + t.Errorf( + "matching AlbumArtist should reuse track credit: got %d, want %d", + sameACID.Int64, trackAC.ID, + ) + } +} diff --git a/backend/mediacontrols/mediacontrols.go b/backend/mediacontrols/mediacontrols.go new file mode 100644 index 0000000..ae7f5d6 --- /dev/null +++ b/backend/mediacontrols/mediacontrols.go @@ -0,0 +1,63 @@ +// Package mediacontrols provides OS media control integration. +// +// On Linux this registers a MPRIS2 D-Bus service so that desktop +// environments, playerctl, and media keys can control playback and +// see the currently playing track. Other platforms get a no-op stub. +package mediacontrols + +// PlaybackState represents the current playback state for the OS. +type PlaybackState int + +// Playback state values. +const ( + StateStopped PlaybackState = iota + StatePlaying + StatePaused +) + +// Metadata holds track information to display in the OS media overlay. +type Metadata struct { + Title string + Artist string + Album string + ArtFilePath string // Absolute filesystem path to cover art. + DurationSec int +} + +// Callbacks are invoked when the OS sends media commands. +type Callbacks struct { + OnPlay func() + OnPause func() + OnPlayPause func() + OnStop func() + OnNext func() + OnPrevious func() + OnSeek func(positionSec int) + OnVolume func(volume float64) // 0.0–1.0 linear scale. +} + +// Handler manages the OS media control integration. +type Handler interface { + // Init registers with the OS and wires incoming commands to + // the provided callbacks. It must be called once during startup. + Init(callbacks Callbacks) error + + // UpdateMetadata pushes new track metadata to the OS overlay. + UpdateMetadata(meta Metadata) + + // UpdatePlaybackState pushes the playback state and current + // position. The position is used as a new anchor; the OS + // interpolates from there while playing. + UpdatePlaybackState(state PlaybackState, positionSec int) + + // NotifySeek signals that the user seeked to a new position. + // This is separate from UpdatePlaybackState because MPRIS + // emits a distinct Seeked signal for this. + NotifySeek(positionSec int) + + // UpdateVolume pushes the current volume (0.0–1.0) to the OS. + UpdateVolume(volume float64) + + // Close tears down the OS registration and releases resources. + Close() +} diff --git a/backend/mediacontrols/mpris_linux.go b/backend/mediacontrols/mpris_linux.go new file mode 100644 index 0000000..ee3cd9d --- /dev/null +++ b/backend/mediacontrols/mpris_linux.go @@ -0,0 +1,637 @@ +//go:build linux + +package mediacontrols + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" +) + +const ( + busName = "org.mpris.MediaPlayer2.yellowjacket" + objectPath = "/org/mpris/MediaPlayer2" + playerIf = "org.mpris.MediaPlayer2.Player" + rootIf = "org.mpris.MediaPlayer2" + + usPerSec = 1_000_000 + + // updateChanSize is the buffer size for the async update + // channel. A small buffer avoids blocking callers while the + // D-Bus goroutine processes updates. + updateChanSize = 64 +) + +var errNotPrimaryOwner = errors.New( + "failed to become primary owner of bus name", +) + +// mprisRoot handles the org.mpris.MediaPlayer2 interface methods. +type mprisRoot struct{} + +// Raise is a no-op; YellowJacket does not support raising via MPRIS. +func (r *mprisRoot) Raise() *dbus.Error { return nil } + +// Quit is a no-op; shutdown is managed by the Wails lifecycle. +func (r *mprisRoot) Quit() *dbus.Error { return nil } + +// mprisPlayer handles the org.mpris.MediaPlayer2.Player +// interface methods. Every D-Bus method callback dispatches to a +// goroutine so that the godbus handler goroutine returns +// immediately and never blocks on player/queue mutexes. +type mprisPlayer struct { + callbacks Callbacks +} + +// Play requests playback start/resume. +func (p *mprisPlayer) Play() *dbus.Error { + if p.callbacks.OnPlay != nil { + go p.callbacks.OnPlay() + } + + return nil +} + +// Pause requests playback pause. +func (p *mprisPlayer) Pause() *dbus.Error { + if p.callbacks.OnPause != nil { + go p.callbacks.OnPause() + } + + return nil +} + +// PlayPause toggles between play and pause. +func (p *mprisPlayer) PlayPause() *dbus.Error { + if p.callbacks.OnPlayPause != nil { + go p.callbacks.OnPlayPause() + } + + return nil +} + +// Stop requests playback stop. +func (p *mprisPlayer) Stop() *dbus.Error { + if p.callbacks.OnStop != nil { + go p.callbacks.OnStop() + } + + return nil +} + +// Next requests skipping to the next track. +func (p *mprisPlayer) Next() *dbus.Error { + if p.callbacks.OnNext != nil { + go p.callbacks.OnNext() + } + + return nil +} + +// Previous requests skipping to the previous track. +func (p *mprisPlayer) Previous() *dbus.Error { + if p.callbacks.OnPrevious != nil { + go p.callbacks.OnPrevious() + } + + return nil +} + +// SeekTo requests a relative seek by offset microseconds. +// Exported on D-Bus as "Seek" via ExportWithMap; renamed in Go +// to avoid a false positive from go vet's stdmethods checker. +func (p *mprisPlayer) SeekTo(offsetUs int64) *dbus.Error { + if p.callbacks.OnSeek != nil { + secs := int(offsetUs / usPerSec) + + go p.callbacks.OnSeek(secs) + } + + return nil +} + +// SetPosition requests an absolute seek to positionUs on the +// given track. +func (p *mprisPlayer) SetPosition( + _ dbus.ObjectPath, + positionUs int64, +) *dbus.Error { + if p.callbacks.OnSeek != nil { + secs := int(positionUs / usPerSec) + + go p.callbacks.OnSeek(secs) + } + + return nil +} + +// OpenUri is required by the MPRIS2 spec but not supported. +// +//nolint:revive // D-Bus requires this exact method name. +func (p *mprisPlayer) OpenUri(_ string) *dbus.Error { + return nil +} + +// MPRISHandler is the Linux MPRIS2 implementation of Handler. +// +// All public update methods (UpdateMetadata, UpdatePlaybackState, +// NotifySeek, UpdateVolume) send work to a buffered channel that a +// dedicated goroutine drains. This avoids calling into godbus +// (which acquires props.mut and does D-Bus I/O) while the caller +// holds the player mutex, preventing a deadlock between p.mu and +// props.mut. +type MPRISHandler struct { + logger *slog.Logger + conn *dbus.Conn + props *prop.Properties + player *mprisPlayer + updates chan func() + done chan struct{} + mu sync.Mutex + trackID uint64 +} + +// NewHandler creates a new MPRIS2 handler. +func NewHandler(logger *slog.Logger) Handler { + return &MPRISHandler{ + logger: logger.WithGroup("mpris"), + } +} + +// Init connects to the D-Bus session bus, exports the MPRIS2 +// interfaces, and registers the well-known bus name. +func (h *MPRISHandler) Init(callbacks Callbacks) error { + conn, err := dbus.SessionBus() + if err != nil { + return fmt.Errorf( + "failed to connect to session bus: %w", err, + ) + } + + h.conn = conn + h.player = &mprisPlayer{callbacks: callbacks} + h.updates = make(chan func(), updateChanSize) + h.done = make(chan struct{}) + + go h.processUpdates() + + // Export properties for both interfaces. + h.props, err = prop.Export( + conn, + objectPath, + h.propertySpec(), + ) + if err != nil { + return fmt.Errorf( + "failed to export properties: %w", err, + ) + } + + // Export method handlers. + root := &mprisRoot{} + + if err := conn.Export( + root, objectPath, rootIf, + ); err != nil { + return fmt.Errorf( + "failed to export root interface: %w", err, + ) + } + + if err := conn.ExportWithMap( + h.player, + map[string]string{"SeekTo": "Seek"}, + objectPath, + playerIf, + ); err != nil { + return fmt.Errorf( + "failed to export player interface: %w", err, + ) + } + + // Export introspection. + if err := conn.Export( + introspect.NewIntrospectable(h.introspectNode()), + objectPath, + "org.freedesktop.DBus.Introspectable", + ); err != nil { + return fmt.Errorf( + "failed to export introspection: %w", err, + ) + } + + // Claim the well-known bus name. + reply, err := conn.RequestName( + busName, dbus.NameFlagReplaceExisting, + ) + if err != nil { + return fmt.Errorf( + "failed to request bus name: %w", err, + ) + } + + if reply != dbus.RequestNameReplyPrimaryOwner { + return fmt.Errorf( + "%w: %s (reply=%d)", + errNotPrimaryOwner, busName, reply, + ) + } + + h.logger.Info( + "MPRIS2 registered on D-Bus", "name", busName, + ) + + return nil +} + +// processUpdates drains the update channel on a dedicated +// goroutine. All props.SetMust and conn.Emit calls happen here, +// safely away from the player's mutex. +func (h *MPRISHandler) processUpdates() { + for fn := range h.updates { + fn() + } + + close(h.done) +} + +// enqueue sends a function to the update goroutine. If the +// channel is full the update is dropped to avoid blocking the +// caller (this is acceptable — the next update will overwrite +// stale state). +func (h *MPRISHandler) enqueue(fn func()) { + select { + case h.updates <- fn: + default: + h.logger.Debug("MPRIS update channel full, dropping") + } +} + +// UpdateMetadata pushes track metadata to D-Bus. +func (h *MPRISHandler) UpdateMetadata(meta Metadata) { + h.mu.Lock() + h.trackID++ + tid := h.trackID + h.mu.Unlock() + + m := map[string]interface{}{ + "mpris:trackid": dbus.ObjectPath( + fmt.Sprintf( + "/org/yellowjacket/Track/%d", tid, + ), + ), + } + + if meta.Title != "" { + m["xesam:title"] = meta.Title + } + + if meta.Artist != "" { + m["xesam:artist"] = []string{meta.Artist} + } + + if meta.Album != "" { + m["xesam:album"] = meta.Album + } + + if meta.ArtFilePath != "" { + m["mpris:artUrl"] = "file://" + meta.ArtFilePath + } + + if meta.DurationSec > 0 { + m["mpris:length"] = int64( + meta.DurationSec, + ) * usPerSec + } + + h.enqueue(func() { + h.props.SetMust(playerIf, "Metadata", m) + }) +} + +// UpdatePlaybackState pushes the playback state and position +// anchor. +func (h *MPRISHandler) UpdatePlaybackState( + state PlaybackState, + positionSec int, +) { + var status string + + switch state { + case StatePlaying: + status = "Playing" + case StatePaused: + status = "Paused" + default: + status = "Stopped" + } + + posUs := int64(positionSec) * usPerSec + + h.enqueue(func() { + // Update Position silently (EmitFalse) then + // PlaybackStatus loudly (EmitTrue). The DE + // re-anchors on the status change. + h.props.SetMust(playerIf, "Position", posUs) + h.props.SetMust( + playerIf, "PlaybackStatus", status, + ) + }) +} + +// NotifySeek emits the MPRIS Seeked signal. +func (h *MPRISHandler) NotifySeek(positionSec int) { + posUs := int64(positionSec) * usPerSec + + h.enqueue(func() { + h.props.SetMust(playerIf, "Position", posUs) + + if err := h.conn.Emit( + objectPath, + playerIf+".Seeked", + posUs, + ); err != nil { + h.logger.Error( + "Failed to emit Seeked signal", + "err", err, + ) + } + }) +} + +// UpdateVolume pushes the current volume (0.0-1.0) to D-Bus. +func (h *MPRISHandler) UpdateVolume(volume float64) { + h.enqueue(func() { + h.props.SetMust(playerIf, "Volume", volume) + }) +} + +// Close signals the update goroutine to stop, waits for it to +// drain, and closes the D-Bus connection. +func (h *MPRISHandler) Close() { + if h.updates != nil { + close(h.updates) + <-h.done + } + + if h.conn != nil { + if err := h.conn.Close(); err != nil { + h.logger.Error( + "Failed to close D-Bus connection", + "err", err, + ) + } + + h.logger.Info("MPRIS2 D-Bus connection closed") + } +} + +// onVolumeChanged is called when an external D-Bus client sets +// the Volume property. The callback runs under props.mut (held by +// godbus), so we dispatch to a goroutine to avoid acquiring p.mu +// under props.mut — which would invert the lock order with the +// update goroutine's SetMust calls. +func (h *MPRISHandler) onVolumeChanged( + c *prop.Change, +) *dbus.Error { + vol, ok := c.Value.(float64) + if !ok { + return nil + } + + if h.player.callbacks.OnVolume != nil { + go h.player.callbacks.OnVolume(vol) + } + + return nil +} + +// onLoopStatusChanged is called when an external D-Bus client +// sets the LoopStatus property. +func (h *MPRISHandler) onLoopStatusChanged( + _ *prop.Change, +) *dbus.Error { + // LoopStatus changes via D-Bus are acknowledged but not + // actively wired to the queue's CycleRepeat. The queue + // cycles through modes and MPRIS reflects the result. + return nil +} + +// onShuffleChanged is called when an external D-Bus client sets +// the Shuffle property. +func (h *MPRISHandler) onShuffleChanged( + _ *prop.Change, +) *dbus.Error { + // Shuffle changes via D-Bus are acknowledged but not + // actively wired to the queue's ToggleShuffle. The queue + // toggles and MPRIS reflects the result. + return nil +} + +// propertySpec builds the full property map for both MPRIS +// interfaces. +func (h *MPRISHandler) propertySpec() map[string]map[string]*prop.Prop { + noTrack := map[string]interface{}{ + "mpris:trackid": dbus.ObjectPath( + "/org/mpris/MediaPlayer2/TrackList/NoTrack", + ), + } + + return map[string]map[string]*prop.Prop{ + rootIf: { + "CanQuit": newReadOnlyProp(false), + "CanRaise": newReadOnlyProp(false), + "HasTrackList": newReadOnlyProp(false), + "Identity": newReadOnlyProp("YellowJacket"), + "DesktopEntry": newReadOnlyProp( + "yellowjacket", + ), + "SupportedUriSchemes": newReadOnlyProp( + []string{}, + ), + "SupportedMimeTypes": newReadOnlyProp( + []string{}, + ), + }, + playerIf: { + "PlaybackStatus": newReadOnlyProp("Stopped"), + "LoopStatus": { + Value: "None", + Writable: true, + Emit: prop.EmitTrue, + Callback: h.onLoopStatusChanged, + }, + "Rate": newReadOnlyProp(1.0), + "MinimumRate": newReadOnlyProp(1.0), + "MaximumRate": newReadOnlyProp(1.0), + "Shuffle": { + Value: false, + Writable: true, + Emit: prop.EmitTrue, + Callback: h.onShuffleChanged, + }, + "Metadata": newReadOnlyProp(noTrack), + "Volume": { + Value: 1.0, + Writable: true, + Emit: prop.EmitTrue, + Callback: h.onVolumeChanged, + }, + "Position": { + Value: int64(0), + Writable: false, + Emit: prop.EmitFalse, + }, + "CanGoNext": newReadOnlyProp(true), + "CanGoPrevious": newReadOnlyProp(true), + "CanPlay": newReadOnlyProp(true), + "CanPause": newReadOnlyProp(true), + "CanSeek": newReadOnlyProp(true), + "CanControl": newReadOnlyProp(true), + }, + } +} + +// newReadOnlyProp creates a read-only property with EmitTrue. +// Read-only here means external D-Bus clients cannot set it via +// the Properties.Set interface; the server updates it internally +// via SetMust. +func newReadOnlyProp(value interface{}) *prop.Prop { + return &prop.Prop{ + Value: value, + Writable: false, + Emit: prop.EmitTrue, + } +} + +// introspectNode builds the introspection data for the MPRIS +// object. +func (h *MPRISHandler) introspectNode() *introspect.Node { + return &introspect.Node{ + Name: busName, + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + { + Name: rootIf, + Properties: introspectProps( + roProp("CanQuit", "b"), + roProp("CanRaise", "b"), + roProp("HasTrackList", "b"), + roProp("Identity", "s"), + roProp("DesktopEntry", "s"), + roProp( + "SupportedUriSchemes", "as", + ), + roProp( + "SupportedMimeTypes", "as", + ), + ), + Methods: []introspect.Method{ + {Name: "Raise"}, + {Name: "Quit"}, + }, + }, + { + Name: playerIf, + Properties: introspectProps( + roProp("PlaybackStatus", "s"), + rwProp("LoopStatus", "s"), + rwProp("Rate", "d"), + rwProp("Shuffle", "b"), + roProp("Metadata", "a{sv}"), + rwProp("Volume", "d"), + roProp("Position", "x"), + roProp("MinimumRate", "d"), + roProp("MaximumRate", "d"), + roProp("CanGoNext", "b"), + roProp("CanGoPrevious", "b"), + roProp("CanPlay", "b"), + roProp("CanPause", "b"), + roProp("CanSeek", "b"), + roProp("CanControl", "b"), + ), + Signals: []introspect.Signal{ + { + Name: "Seeked", + Args: []introspect.Arg{ + { + Name: "Position", + Type: "x", + }, + }, + }, + }, + Methods: []introspect.Method{ + {Name: "Next"}, + {Name: "Previous"}, + {Name: "Pause"}, + {Name: "PlayPause"}, + {Name: "Stop"}, + {Name: "Play"}, + { + Name: "Seek", + Args: []introspect.Arg{ + { + Name: "Offset", + Type: "x", + Direction: "in", + }, + }, + }, + { + Name: "SetPosition", + Args: []introspect.Arg{ + { + Name: "TrackId", + Type: "o", + Direction: "in", + }, + { + Name: "Position", + Type: "x", + Direction: "in", + }, + }, + }, + { + Name: "OpenUri", + Args: []introspect.Arg{ + { + Name: "Uri", + Type: "s", + Direction: "in", + }, + }, + }, + }, + }, + }, + } +} + +func roProp(name, typ string) introspect.Property { + return introspect.Property{ + Name: name, + Type: typ, + Access: "read", + } +} + +func rwProp(name, typ string) introspect.Property { + return introspect.Property{ + Name: name, + Type: typ, + Access: "readwrite", + } +} + +func introspectProps( + props ...introspect.Property, +) []introspect.Property { + return props +} diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go new file mode 100644 index 0000000..0eccd12 --- /dev/null +++ b/backend/mediacontrols/stub.go @@ -0,0 +1,30 @@ +//go:build !linux + +package mediacontrols + +import "log/slog" + +// stubHandler is a no-op Handler for platforms without media control +// integration. +type stubHandler struct{} + +// NewHandler returns a no-op handler on unsupported platforms. +func NewHandler(_ *slog.Logger) Handler { + return &stubHandler{} +} + +func (s *stubHandler) Init(_ Callbacks) error { return nil } + +func (s *stubHandler) UpdateMetadata(_ Metadata) {} + +func (s *stubHandler) UpdatePlaybackState( + _ PlaybackState, + _ int, +) { +} + +func (s *stubHandler) NotifySeek(_ int) {} + +func (s *stubHandler) UpdateVolume(_ float64) {} + +func (s *stubHandler) Close() {} diff --git a/backend/metadata/decoder.go b/backend/metadata/decoder.go index da7edb6..028c784 100644 --- a/backend/metadata/decoder.go +++ b/backend/metadata/decoder.go @@ -7,11 +7,11 @@ import ( "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" + "github.com/gopxl/beep/v2" + "github.com/gopxl/beep/v2/flac" + "github.com/gopxl/beep/v2/mp3" + "github.com/gopxl/beep/v2/vorbis" + "github.com/gopxl/beep/v2/wav" ) // ErrUnsupportedFileType is returned when the audio file type is not supported. diff --git a/backend/metadata/duration.go b/backend/metadata/duration.go new file mode 100644 index 0000000..ceee57f --- /dev/null +++ b/backend/metadata/duration.go @@ -0,0 +1,50 @@ +package metadata + +import ( + "fmt" + "os" + "path/filepath" +) + +// getTrackDuration returns the duration of an audio file in +// milliseconds together with its audio stream properties. For MP3 +// files it uses a fast header-only parser (Xing/VBRI/CBR); for FLAC +// it reads the StreamInfo block; for other formats it falls back to +// beep which is already O(1) for OGG and WAV. +// +// The file position is undefined after this call. +func getTrackDuration( + f *os.File, +) (int64, *AudioProperties, error) { + ext := filepath.Ext(f.Name()) + + switch ext { + case ".mp3": + return getMP3Duration(f) + case ".flac": + return getFlacDuration(f) + } + + // OGG and WAV: beep's Decode() + Len() is already cheap + // (reads headers/metadata only, no full audio decode). + streamer, format, err := DecodeFile(f) + if err != nil { + return 0, nil, fmt.Errorf( + "error decoding file: %w", err, + ) + } + + lengthMillis := int64( + float64(streamer.Len()*1000) / + float64(format.SampleRate), + ) + _ = streamer.Close() + + props := &AudioProperties{ + SampleRate: int(format.SampleRate), + BitDepth: format.Precision * 8, + Channels: format.NumChannels, + } + + return lengthMillis, props, nil +} diff --git a/backend/metadata/flacduration.go b/backend/metadata/flacduration.go new file mode 100644 index 0000000..63d479b --- /dev/null +++ b/backend/metadata/flacduration.go @@ -0,0 +1,161 @@ +package metadata + +import ( + "encoding/binary" + "errors" + "fmt" + "os" +) + +// errInvalidFLACSignature is returned when the file does not contain +// a valid FLAC stream signature ("fLaC") at the expected position. +var errInvalidFLACSignature = errors.New( + "invalid FLAC signature", +) + +// errInvalidStreamInfo is returned when the first metadata block is +// not a StreamInfo block or has an unexpected length. +var errInvalidStreamInfo = errors.New( + "invalid StreamInfo metadata block", +) + +// errZeroSampleRate is returned when the StreamInfo block reports a +// sample rate of zero, which would cause a division by zero. +var errZeroSampleRate = errors.New( + "FLAC StreamInfo sample rate is zero", +) + +// flacSignatureBytes is the four-byte marker that begins every FLAC +// stream. +var flacSignatureBytes = [4]byte{'f', 'L', 'a', 'C'} + +// streamInfoLength is the fixed size of a FLAC StreamInfo body in +// bytes. +const streamInfoLength = 34 + +// streamInfoBlockType is the metadata block type for StreamInfo. +const streamInfoBlockType = 0 + +// getFlacDuration computes the duration of a FLAC file in +// milliseconds by reading only the StreamInfo metadata block header. +// It also extracts sample rate, bit depth, and channel count from +// the same header. It handles an optional prepended ID3v2 tag by +// seeking past it. +// +// This replaces the previous beep/mewkiz-flac decode path which has +// a bug in its ID3v2 skip logic (bufio over bufseekio causes a +// position overshoot). +// +// The file position is undefined after this call. +// +//nolint:mnd // byte offsets and bit shifts from the FLAC spec. +func getFlacDuration( + f *os.File, +) (int64, *AudioProperties, error) { + audioStart, err := skipID3v2(f) + if err != nil { + return 0, nil, fmt.Errorf("skipping ID3v2: %w", err) + } + + // Read the 4-byte FLAC signature. + var sig [4]byte + + if _, err := f.ReadAt(sig[:], audioStart); err != nil { + return 0, nil, fmt.Errorf( + "reading FLAC signature: %w", err, + ) + } + + if sig != flacSignatureBytes { + return 0, nil, fmt.Errorf( + "%w: expected %q, got %q", + errInvalidFLACSignature, flacSignatureBytes, sig, + ) + } + + // Read the metadata block header (4 bytes) immediately after + // the signature. + var mbh [4]byte + + if _, err := f.ReadAt( + mbh[:], audioStart+4, + ); err != nil { + return 0, nil, fmt.Errorf( + "reading metadata block header: %w", err, + ) + } + + blockType := mbh[0] & 0x7F + + blockLen := int64(mbh[1])<<16 | + int64(mbh[2])<<8 | + int64(mbh[3]) + + if blockType != streamInfoBlockType || + blockLen != streamInfoLength { + return 0, nil, fmt.Errorf( + "%w: type=%d, length=%d", + errInvalidStreamInfo, blockType, blockLen, + ) + } + + // Read the 34-byte StreamInfo body. + var si [streamInfoLength]byte + + if _, err := f.ReadAt( + si[:], audioStart+8, + ); err != nil { + return 0, nil, fmt.Errorf( + "reading StreamInfo block: %w", err, + ) + } + + sampleRate, totalSamples, channels, bitDepth := parseFlacStreamInfo(si) + + if sampleRate == 0 { + return 0, nil, errZeroSampleRate + } + + durationMS := int64(totalSamples) * 1000 / + int64(sampleRate) + + props := &AudioProperties{ + SampleRate: int(sampleRate), + BitDepth: int(bitDepth), + Channels: int(channels), + } + + return durationMS, props, nil +} + +// parseFlacStreamInfo extracts key fields from a 34-byte FLAC +// StreamInfo body. +// +// StreamInfo layout (bytes 10-17 contain the fields we need): +// +// bits 0-19: sample rate in Hz (20 bits) +// bits 20-22: number of channels -1 (3 bits) +// bits 23-27: bits per sample -1 (5 bits) +// bits 28-63: total samples (36 bits) +// +//nolint:mnd // bit offsets from the FLAC spec. +func parseFlacStreamInfo( + si [streamInfoLength]byte, +) (sampleRate uint32, totalSamples uint64, channels uint32, bitDepth uint32) { + // Bytes 10-13 packed as big-endian uint32 contain sample rate + // in the upper 20 bits, channels in bits 9-11, and bits per + // sample in bits 4-8. + packed := binary.BigEndian.Uint32(si[10:14]) + sampleRate = packed >> 12 + channels = (packed>>9)&0x07 + 1 + bitDepth = (packed>>4)&0x1F + 1 + + // Total samples: 4 low bits of byte 13, then bytes 14-17. + totalSamples = uint64(si[13]&0x0F)<<32 | + uint64(si[14])<<24 | + uint64(si[15])<<16 | + uint64(si[16])<<8 | + uint64(si[17]) + + return sampleRate, totalSamples, channels, bitDepth +} diff --git a/backend/metadata/flacduration_test.go b/backend/metadata/flacduration_test.go new file mode 100644 index 0000000..baa3b85 --- /dev/null +++ b/backend/metadata/flacduration_test.go @@ -0,0 +1,310 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + +// testFlacFiles returns the paths to all .flac files in the +// test_data directory. It skips the test if none are found. +func testFlacFiles(t *testing.T) []string { + t.Helper() + + root := filepath.Join("..", "..", "test_data") + + if _, err := os.Stat(root); os.IsNotExist(err) { + t.Skip("test_data directory not present, skipping") + } + + var files []string + + err := filepath.Walk(root, func( + path string, info os.FileInfo, err error, + ) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(path) == ".flac" { + files = append(files, path) + } + + return nil + }) + if err != nil { + t.Fatalf("walking test_data: %v", err) + } + + if len(files) == 0 { + t.Skip("no .flac test fixtures found in test_data/") + } + + return files +} + +// TestGetFlacDuration_BasicParsing verifies that getFlacDuration +// returns a positive duration for every FLAC test fixture. +func TestGetFlacDuration_BasicParsing(t *testing.T) { + for _, path := range testFlacFiles(t) { + t.Run(filepath.Base(path), func(t *testing.T) { + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + ms, props, err := getFlacDuration(f) + if err != nil { + t.Fatalf("getFlacDuration: %v", err) + } + + if ms <= 0 { + t.Errorf( + "expected positive duration, got %d", + ms, + ) + } + + if props == nil { + t.Fatal("expected non-nil AudioProperties") + } + + if props.SampleRate <= 0 { + t.Errorf( + "expected positive sample rate, got %d", + props.SampleRate, + ) + } + + if props.BitDepth <= 0 { + t.Errorf( + "expected positive bit depth, got %d", + props.BitDepth, + ) + } + + if props.Channels <= 0 { + t.Errorf( + "expected positive channels, got %d", + props.Channels, + ) + } + + t.Logf( + "duration: %dms rate: %dHz depth: %d ch: %d", + ms, props.SampleRate, props.BitDepth, + props.Channels, + ) + }) + } +} + +// TestGetFlacDuration_MatchesBeepDecode verifies that the fast +// header-only parser produces a duration within 1 second of the full +// decode via beep, for every FLAC test fixture. +func TestGetFlacDuration_MatchesBeepDecode(t *testing.T) { + for _, path := range testFlacFiles(t) { + t.Run(filepath.Base(path), func(t *testing.T) { + refMS, err := GetTrackLengthMillis(path) + if err != nil { + t.Fatalf("beep decode failed: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + fastMS, _, err := getFlacDuration(f) + if err != nil { + t.Fatalf("getFlacDuration: %v", err) + } + + diffMS := refMS - fastMS + if diffMS < 0 { + diffMS = -diffMS + } + + const toleranceMS = 1000 + + t.Logf( + "beep=%dms fast=%dms diff=%dms", + refMS, fastMS, diffMS, + ) + + if diffMS > toleranceMS { + t.Errorf( + "duration mismatch: beep=%dms "+ + "fast=%dms (diff %dms "+ + "exceeds %dms tolerance)", + refMS, fastMS, diffMS, toleranceMS, + ) + } + }) + } +} + +// TestGetFlacDuration_WithPrependedID3v2 creates a temporary FLAC +// file with a synthetic ID3v2 tag prepended and verifies that +// getFlacDuration correctly skips it and parses the duration. +func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) { + files := testFlacFiles(t) + + // Use the first test fixture as our source. + src := files[0] + + srcData, err := os.ReadFile(src) + if err != nil { + t.Fatalf("reading source: %v", err) + } + + // Build a minimal ID3v2.3 header with 256 bytes of padding. + //nolint:mnd // synthetic tag construction. + paddingSize := 256 + + id3Header := buildID3v2Header(paddingSize) + + // Write: ID3v2 header + padding + original FLAC data. + tmpDir := t.TempDir() + tmpPath := filepath.Join(tmpDir, "test_id3v2.flac") + + out := make([]byte, 0, len(id3Header)+paddingSize+len(srcData)) + out = append(out, id3Header...) + out = append(out, make([]byte, paddingSize)...) + out = append(out, srcData...) + + if err := os.WriteFile(tmpPath, out, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + // Get reference duration from original file. + origF, err := os.Open(src) + if err != nil { + t.Fatalf("open original: %v", err) + } + + defer func() { _ = origF.Close() }() + + origMS, _, err := getFlacDuration(origF) + if err != nil { + t.Fatalf("getFlacDuration on original: %v", err) + } + + // Parse the ID3v2-wrapped file. + tmpF, err := os.Open(tmpPath) + if err != nil { + t.Fatalf("open temp: %v", err) + } + + defer func() { _ = tmpF.Close() }() + + wrappedMS, _, err := getFlacDuration(tmpF) + if err != nil { + t.Fatalf( + "getFlacDuration on ID3v2-wrapped file: %v", err, + ) + } + + if origMS != wrappedMS { + t.Errorf( + "duration mismatch: original=%dms wrapped=%dms", + origMS, wrappedMS, + ) + } + + t.Logf( + "original=%dms wrapped=%dms", origMS, wrappedMS, + ) +} + +// TestParseFlacStreamInfo verifies the bit-level parsing of sample +// rate and total samples from a known StreamInfo block. +func TestParseFlacStreamInfo(t *testing.T) { + // Construct a 34-byte StreamInfo with known values. + // Layout of bytes 10-17 (64 bits, big-endian): + // bits 0-19: sample rate (20 bits) + // bits 20-22: channels - 1 (3 bits) + // bits 23-27: bps - 1 (5 bits) + // bits 28-63: total samples (36 bits) + // + // Test values: + // sample rate = 44100 (0x0AC44) + // channels = 2 (stored as 1, 0b001) + // bps = 16 (stored as 15, 0b01111) + // total samples = 11614366 (0x00B1389E) + // + // Packed: 0x0AC442F000B1389E + // byte 10 = 0x0A byte 14 = 0x00 + // byte 11 = 0xC4 byte 15 = 0xB1 + // byte 12 = 0x42 byte 16 = 0x38 + // byte 13 = 0xF0 byte 17 = 0x9E + // + //nolint:mnd // byte values from manual FLAC spec packing. + var si [streamInfoLength]byte + + si[10] = 0x0A + si[11] = 0xC4 + si[12] = 0x42 + si[13] = 0xF0 + si[14] = 0x00 + si[15] = 0xB1 + si[16] = 0x38 + si[17] = 0x9E + + sr, total, ch, bps := parseFlacStreamInfo(si) + + //nolint:mnd // expected test values. + const ( + wantSR = 44100 + wantTotal = 11614366 + wantChannels = 2 + wantBPS = 16 + ) + + if sr != wantSR { + t.Errorf("sample rate: got %d, want %d", sr, wantSR) + } + + if total != wantTotal { + t.Errorf( + "total samples: got %d, want %d", + total, wantTotal, + ) + } + + if ch != wantChannels { + t.Errorf( + "channels: got %d, want %d", ch, wantChannels, + ) + } + + if bps != wantBPS { + t.Errorf( + "bits per sample: got %d, want %d", bps, wantBPS, + ) + } +} + +// buildID3v2Header creates a minimal 10-byte ID3v2.3 header with +// the given payload size encoded as a syncsafe integer. +// +//nolint:mnd // byte offsets from the ID3v2 spec. +func buildID3v2Header(payloadSize int) []byte { + header := []byte{ + 'I', 'D', '3', // signature + 3, 0, // version 2.3.0 + 0, // flags + 0, 0, 0, 0, // size (syncsafe, filled below) + } + + header[6] = byte((payloadSize >> 21) & 0x7F) + header[7] = byte((payloadSize >> 14) & 0x7F) + header[8] = byte((payloadSize >> 7) & 0x7F) + header[9] = byte(payloadSize & 0x7F) + + return header +} diff --git a/backend/metadata/genre.go b/backend/metadata/genre.go new file mode 100644 index 0000000..b1d86bb --- /dev/null +++ b/backend/metadata/genre.go @@ -0,0 +1,48 @@ +// Package metadata provides audio file metadata extraction utilities. +package metadata + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// genreSeparators defines the characters treated as genre delimiters. +const genreSeparators = ",;" + +// ParseGenres splits a raw genre string on commas and semicolons, +// trims whitespace, normalizes each entry to title case, removes +// duplicates, and returns the unique genre names. An empty or +// whitespace-only input returns nil. +func ParseGenres(raw string) []string { + parts := strings.FieldsFunc( + raw, func(r rune) bool { + return strings.ContainsRune(genreSeparators, r) + }, + ) + + caser := cases.Title(language.English) + seen := make(map[string]struct{}, len(parts)) + + var genres []string + + for _, p := range parts { + name := strings.TrimSpace(p) + if name == "" { + continue + } + + name = caser.String(name) + + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + + genres = append(genres, name) + } + + return genres +} diff --git a/backend/metadata/genre_test.go b/backend/metadata/genre_test.go new file mode 100644 index 0000000..dd502b6 --- /dev/null +++ b/backend/metadata/genre_test.go @@ -0,0 +1,109 @@ +package metadata + +import ( + "testing" +) + +func TestParseGenres(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []string + }{ + { + name: "single genre", + raw: "Rock", + want: []string{"Rock"}, + }, + { + name: "semicolon separated", + raw: "Rock; Electronic", + want: []string{"Rock", "Electronic"}, + }, + { + name: "comma separated", + raw: "Rock, Jazz", + want: []string{"Rock", "Jazz"}, + }, + { + name: "mixed separators", + raw: "Rock; Pop, Jazz", + want: []string{"Rock", "Pop", "Jazz"}, + }, + { + name: "case normalization deduplicates", + raw: "rock,ROCK,Rock", + want: []string{"Rock"}, + }, + { + name: "whitespace and empty segments", + raw: " Pop ; ; Jazz , ", + want: []string{"Pop", "Jazz"}, + }, + { + name: "empty string", + raw: "", + want: nil, + }, + { + name: "only separators", + raw: ";;,,;,", + want: nil, + }, + { + name: "whitespace only", + raw: " ", + want: nil, + }, + { + name: "title case multi-word genre", + raw: "hip hop; drum and bass", + want: []string{"Hip Hop", "Drum And Bass"}, + }, + { + name: "preserves already correct casing", + raw: "Post-Punk", + want: []string{"Post-Punk"}, + }, + { + name: "duplicate after title case", + raw: "electronic; Electronic; ELECTRONIC", + want: []string{"Electronic"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ParseGenres(tt.raw) + if !slicesEqual(got, tt.want) { + t.Errorf( + "ParseGenres(%q) = %v, want %v", + tt.raw, got, tt.want, + ) + } + }) + } +} + +// slicesEqual reports whether two string slices are equal. +func slicesEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/backend/metadata/metadata.go b/backend/metadata/metadata.go index 35e7c55..175b0d5 100644 --- a/backend/metadata/metadata.go +++ b/backend/metadata/metadata.go @@ -2,9 +2,28 @@ package metadata import ( "fmt" + "io" "os" + "time" ) +// ExtractionTiming holds sub-operation durations from a single +// ExtractAllMetadata call so callers can build per-format aggregates. +type ExtractionTiming struct { + TagExtraction time.Duration + DurationExtraction time.Duration +} + +// AudioProperties holds technical properties of an audio file that +// are extracted from its stream headers during scanning. +type AudioProperties struct { + SampleRate int // Sample rate in Hz (e.g. 44100, 96000). + BitDepth int // Bits per sample (e.g. 16, 24). + Channels int // Number of audio channels (1=mono, 2=stereo). + Bitrate int // Bitrate in kbps. + FileSize int64 // File size in bytes. +} + // AudioFileExtension represents a supported audio file extension. type AudioFileExtension string @@ -50,3 +69,85 @@ func GetTrackLengthMillis(path string) (int64, error) { return lengthMillis, nil } + +// ExtractAllMetadata opens the file once and extracts tags, duration, +// and audio properties (sample rate, bit depth, channels, bitrate, +// file size). If skipDuration is true, only tags are extracted and +// the remaining outputs are zero-valued. +// The returned ExtractionTiming records how long each sub-operation took. +func ExtractAllMetadata( + path string, + skipDuration bool, +) (*TrackMetadata, int64, *AudioProperties, *ExtractionTiming, error) { + timing := &ExtractionTiming{} + props := &AudioProperties{} + + f, err := os.Open(path) + if err != nil { + return nil, 0, props, timing, fmt.Errorf( + "could not open file: %w", err, + ) + } + + defer func() { _ = f.Close() }() + + // Capture file size. + fi, err := f.Stat() + if err != nil { + return nil, 0, props, timing, fmt.Errorf( + "could not stat file: %w", err, + ) + } + + props.FileSize = fi.Size() + + // Extract tags first (reads only headers, fast). + tagStart := time.Now() + + tags, err := ExtractTagsFromReader(f) + + timing.TagExtraction = time.Since(tagStart) + + if err != nil { + return nil, 0, props, timing, fmt.Errorf( + "could not extract tags from %s: %w", path, err, + ) + } + + if skipDuration { + return tags, 0, props, timing, nil + } + + // Seek back to the beginning for duration extraction. + if _, err := f.Seek(0, io.SeekStart); err != nil { + return tags, 0, props, timing, fmt.Errorf( + "could not seek file for duration: %w", err, + ) + } + + durStart := time.Now() + + lengthMillis, audioProps, err := getTrackDuration(f) + + timing.DurationExtraction = time.Since(durStart) + + if err != nil { + return tags, 0, props, timing, fmt.Errorf( + "error getting duration for %s: %w", path, err, + ) + } + + // Merge stream properties into the result, keeping the + // file size we already captured. + audioProps.FileSize = props.FileSize + + // Compute bitrate from file size and duration when the + // format parser did not provide one (lossless formats). + if audioProps.Bitrate == 0 && lengthMillis > 0 { + audioProps.Bitrate = int( + props.FileSize * 8 / lengthMillis, + ) + } + + return tags, lengthMillis, audioProps, timing, nil +} diff --git a/backend/metadata/mp3duration.go b/backend/metadata/mp3duration.go new file mode 100644 index 0000000..be33da0 --- /dev/null +++ b/backend/metadata/mp3duration.go @@ -0,0 +1,402 @@ +package metadata + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "os" +) + +// errNoSyncWord is returned when no valid MP3 frame sync word +// is found within the search window. +var errNoSyncWord = errors.New("could not find MP3 sync word") + +// maxSyncSearchBytes limits how far we scan for the first sync word +// after skipping any ID3v2 tags. 512 KB accommodates files with +// large embedded artwork or multiple prepended ID3v2 tags. +const maxSyncSearchBytes = 512 * 1024 + +// maxID3v2Tags limits how many consecutive ID3v2 tags we skip. +// Some files contain multiple prepended tags from different tagging +// tools. +const maxID3v2Tags = 5 + +// MPEG version constants. +const ( + mpegVersion1 = 3 // 0b11 + mpegVersion2 = 2 // 0b10 + mpegVersion2_5 = 0 // 0b00 (unofficial extension) +) + +// bitrateTable maps [versionIndex][bitrateIndex] to kbps. +// versionIndex 0 = MPEG1, 1 = MPEG2/2.5. +// bitrateIndex 0 and 15 are invalid. +// +//nolint:mnd // lookup table values are from the MPEG spec. +var bitrateTable = [2][16]int{ + // MPEG1 Layer 3 + {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, + // MPEG2/2.5 Layer 3 + {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, +} + +// sampleRateTable maps [versionIndex][sampleRateIndex] to Hz. +// versionIndex: 0 = MPEG1, 1 = MPEG2, 2 = MPEG2.5. +// +//nolint:mnd // lookup table values are from the MPEG spec. +var sampleRateTable = [3][4]int{ + {44100, 48000, 32000, 0}, // MPEG1 + {22050, 24000, 16000, 0}, // MPEG2 + {11025, 12000, 8000, 0}, // MPEG2.5 +} + +// samplesPerFrame returns the number of PCM samples per MP3 frame +// for the given MPEG version (Layer 3 only). +// +//nolint:mnd // constants from the MPEG spec. +func samplesPerFrame(version int) int { + if version == mpegVersion1 { + return 1152 + } + + return 576 // MPEG2 / MPEG2.5 +} + +// mp3BitDepth is the effective bit depth for decoded MP3 audio. +// The MPEG standard decodes to 16-bit PCM. +const mp3BitDepth = 16 + +// getMP3Duration computes the duration of an MP3 file in +// milliseconds by reading only the first frame's header and any +// Xing/VBRI VBR header it contains. For CBR files (no VBR header) +// it falls back to fileSize / bitrate. It also returns audio +// properties extracted from the frame header. +// +// The file position is undefined after this call. +func getMP3Duration( + f *os.File, +) (int64, *AudioProperties, error) { + // 1. Skip all leading ID3v2 tags. Some files have multiple + // consecutive tags from different tagging tools. + audioStart, err := skipID3v2(f) + if err != nil { + return 0, nil, fmt.Errorf( + "skipping ID3v2: %w", err, + ) + } + + audioStart, err = skipAdditionalID3v2(f, audioStart) + if err != nil { + return 0, nil, fmt.Errorf( + "skipping additional ID3v2 tags: %w", err, + ) + } + + // 2. Find and parse the first MP3 frame header. + hdr, frameOffset, err := findFrameHeader(f, audioStart) + if err != nil { + return 0, nil, err + } + + // Build audio properties from the frame header. + channels := 2 + if hdr.channelMode == 3 { //nolint:mnd // 3 = mono + channels = 1 + } + + props := &AudioProperties{ + SampleRate: hdr.sampleRate, + BitDepth: mp3BitDepth, + Channels: channels, + Bitrate: hdr.bitrateKbps, + } + + // 3. Attempt to read a VBR header (Xing/Info or VBRI) from + // inside the first frame. + vbrFrames, found, err := readVBRHeader( + f, hdr, frameOffset, + ) + if err != nil { + return 0, nil, err + } + + if found && vbrFrames > 0 { + spf := samplesPerFrame(hdr.version) + durationMS := int64(vbrFrames) * + int64(spf) * 1000 / int64(hdr.sampleRate) + + return durationMS, props, nil + } + + // 4. CBR fallback: duration = audioBytes * 8 / bitrate. + fi, err := f.Stat() + if err != nil { + return 0, nil, fmt.Errorf( + "stat file for CBR duration: %w", err, + ) + } + + audioBytes := fi.Size() - audioStart + durationMS := audioBytes * 8 * 1000 / + (int64(hdr.bitrateKbps) * 1000) + + return durationMS, props, nil +} + +// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio +// frame header. +type mpegFrameHeader struct { + version int // mpegVersion1, mpegVersion2, mpegVersion2_5 + bitrateKbps int + sampleRate int + channelMode int // 0-3; 3 = mono + padding int // 0 or 1 +} + +// skipID3v2 checks for an ID3v2 tag at the start of f and returns +// the byte offset where audio data begins. +// +//nolint:mnd // byte offsets from the ID3v2 spec. +func skipID3v2(f *os.File) (int64, error) { + var buf [10]byte + + if _, err := f.ReadAt(buf[:], 0); err != nil { + return 0, fmt.Errorf("reading ID3v2 header: %w", err) + } + + if string(buf[:3]) != "ID3" { + return 0, nil // no ID3v2 tag + } + + // Syncsafe integer: 4 bytes, each using 7 bits. + size := int64(buf[6])<<21 | + int64(buf[7])<<14 | + int64(buf[8])<<7 | + int64(buf[9]) + + return 10 + size, nil +} + +// skipAdditionalID3v2 looks for further ID3v2 tags starting at +// offset and advances past each one found. This handles files +// where multiple tagging tools have each prepended their own ID3v2 +// header. +// +//nolint:mnd // byte offsets from the ID3v2 spec. +func skipAdditionalID3v2( + f *os.File, + offset int64, +) (int64, error) { + var buf [10]byte + + for range maxID3v2Tags { + if _, err := f.ReadAt(buf[:], offset); err != nil { + // EOF or short read means no more tags. + return offset, nil //nolint:nilerr + } + + if string(buf[:3]) != "ID3" { + return offset, nil + } + + size := int64(buf[6])<<21 | + int64(buf[7])<<14 | + int64(buf[8])<<7 | + int64(buf[9]) + + offset += 10 + size + } + + return offset, nil +} + +// findFrameHeader scans from startOffset for the first valid MP3 +// sync word and returns the parsed header plus the file offset +// where the frame begins. +// +//nolint:mnd,cyclop // bit manipulation from the MPEG spec. +func findFrameHeader( + f *os.File, + startOffset int64, +) (mpegFrameHeader, int64, error) { + if _, err := f.Seek(startOffset, io.SeekStart); err != nil { + return mpegFrameHeader{}, 0, fmt.Errorf( + "seeking to audio start: %w", err, + ) + } + + // Read a chunk large enough to contain the first frame. + buf := make([]byte, maxSyncSearchBytes) + + n, err := io.ReadAtLeast(f, buf, 4) + if err != nil { + return mpegFrameHeader{}, 0, fmt.Errorf( + "reading audio data: %w", err, + ) + } + + buf = buf[:n] + + for i := 0; i <= len(buf)-4; i++ { + // Sync word: 11 set bits (0xFF followed by 0xE0 mask). + if buf[i] != 0xFF || buf[i+1]&0xE0 != 0xE0 { + continue + } + + hdr, ok := parseFrameHeader(buf[i : i+4]) + if !ok { + continue + } + + return hdr, startOffset + int64(i), nil + } + + return mpegFrameHeader{}, 0, errNoSyncWord +} + +// parseFrameHeader decodes a 4-byte MPEG audio frame header. +// Returns false if the header contains invalid field combinations. +// +//nolint:mnd,cyclop // bit manipulation from the MPEG spec. +func parseFrameHeader(b []byte) (mpegFrameHeader, bool) { + version := int((b[1] >> 3) & 0x03) + layer := int((b[1] >> 1) & 0x03) + + // We only handle Layer 3. + if layer != 1 { // Layer encoding: 1 = Layer 3 + return mpegFrameHeader{}, false + } + + // Determine version index for the bitrate table. + var bitrateIdx int + + switch version { + case mpegVersion1: + bitrateIdx = 0 + case mpegVersion2, mpegVersion2_5: + bitrateIdx = 1 + default: + return mpegFrameHeader{}, false // reserved + } + + brIndex := int((b[2] >> 4) & 0x0F) + bitrate := bitrateTable[bitrateIdx][brIndex] + + if bitrate == 0 { + return mpegFrameHeader{}, false + } + + // Sample rate. + var srVersionIdx int + + switch version { + case mpegVersion1: + srVersionIdx = 0 + case mpegVersion2: + srVersionIdx = 1 + case mpegVersion2_5: + srVersionIdx = 2 + } + + srIndex := int((b[2] >> 2) & 0x03) + sampleRate := sampleRateTable[srVersionIdx][srIndex] + + if sampleRate == 0 { + return mpegFrameHeader{}, false + } + + padding := int((b[2] >> 1) & 0x01) + channelMode := int((b[3] >> 6) & 0x03) + + return mpegFrameHeader{ + version: version, + bitrateKbps: bitrate, + sampleRate: sampleRate, + channelMode: channelMode, + padding: padding, + }, true +} + +// readVBRHeader tries to read a Xing/Info or VBRI header from the +// first frame at frameOffset. Returns the total frame count and +// whether a VBR header was found. +// +//nolint:mnd // byte offsets from Xing/VBRI specs. +func readVBRHeader( + f *os.File, + hdr mpegFrameHeader, + frameOffset int64, +) (uint32, bool, error) { + // Xing/Info header offset depends on version and channel mode. + var sideInfoSize int + + switch { + case hdr.version == mpegVersion1 && hdr.channelMode != 3: + sideInfoSize = 32 + case hdr.version == mpegVersion1 && hdr.channelMode == 3: + sideInfoSize = 17 + case hdr.channelMode != 3: + sideInfoSize = 17 + default: + sideInfoSize = 9 + } + + // The Xing header sits right after the 4-byte frame header + + // side information. + xingOffset := frameOffset + 4 + int64(sideInfoSize) + + // Read enough bytes for Xing header (magic + flags + frames). + var xingBuf [12]byte + + if _, err := f.ReadAt(xingBuf[:], xingOffset); err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + + return 0, false, fmt.Errorf( + "reading Xing header: %w", err, + ) + } + + magic := string(xingBuf[:4]) + if magic == "Xing" || magic == "Info" { + flags := binary.BigEndian.Uint32(xingBuf[4:8]) + + // Bit 0 of flags indicates the frames field is present. + if flags&0x01 != 0 { + frames := binary.BigEndian.Uint32(xingBuf[8:12]) + + return frames, true, nil + } + + // Xing header present but no frame count — fall through + // to CBR fallback. + return 0, true, nil + } + + // VBRI header is always at a fixed offset of 36 bytes from + // the frame start (regardless of version/channel mode). + vbriOffset := frameOffset + 36 + + var vbriBuf [26]byte + + if _, err := f.ReadAt(vbriBuf[:], vbriOffset); err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + + return 0, false, fmt.Errorf( + "reading VBRI header: %w", err, + ) + } + + if string(vbriBuf[:4]) == "VBRI" { + // Total frames at offset 14 from VBRI magic. + frames := binary.BigEndian.Uint32(vbriBuf[14:18]) + + return frames, true, nil + } + + return 0, false, nil +} diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go new file mode 100644 index 0000000..b780e63 --- /dev/null +++ b/backend/metadata/mp3duration_test.go @@ -0,0 +1,277 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + +// testMP3Files returns the paths to all .mp3 files in the test_data +// directory. It skips the test if none are found. +func testMP3Files(t *testing.T) []string { + t.Helper() + + root := filepath.Join("..", "..", "test_data") + + if _, err := os.Stat(root); os.IsNotExist(err) { + t.Skip("test_data directory not present, skipping") + } + + var files []string + + err := filepath.Walk(root, func( + path string, info os.FileInfo, err error, + ) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(path) == ".mp3" { + files = append(files, path) + } + + return nil + }) + if err != nil { + t.Fatalf("walking test_data: %v", err) + } + + if len(files) == 0 { + t.Skip("no .mp3 test fixtures found in test_data/") + } + + return files +} + +// TestGetMP3Duration_MatchesBeepDecode verifies that the fast +// header-only parser produces a duration within 1 second of the +// full decode via beep, for every test MP3 file. +func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) { + for _, path := range testMP3Files(t) { + t.Run(filepath.Base(path), func(t *testing.T) { + // Reference value: full beep decode. + refMS, err := GetTrackLengthMillis(path) + if err != nil { + t.Fatalf( + "beep decode failed: %v", err, + ) + } + + // Fast path. + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + fastMS, _, err := getMP3Duration(f) + if err != nil { + t.Fatalf( + "getMP3Duration failed: %v", err, + ) + } + + diffMS := refMS - fastMS + if diffMS < 0 { + diffMS = -diffMS + } + + // Allow up to 1 second of difference to account + // for rounding and the slight inaccuracy of the + // CBR fallback for VBR-without-Xing files. + const toleranceMS = 1000 + + t.Logf( + "beep=%dms fast=%dms diff=%dms", + refMS, fastMS, diffMS, + ) + + if diffMS > toleranceMS { + t.Errorf( + "duration mismatch: beep=%dms fast=%dms "+ + "(diff %dms exceeds %dms tolerance)", + refMS, fastMS, diffMS, toleranceMS, + ) + } + }) + } +} + +// TestGetMP3Duration_BasicParsing exercises the parser on a single +// file and verifies a positive duration is returned. +func TestGetMP3Duration_BasicParsing(t *testing.T) { + files := testMP3Files(t) + + f, err := os.Open(files[0]) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + ms, _, err := getMP3Duration(f) + if err != nil { + t.Fatalf("getMP3Duration: %v", err) + } + + if ms <= 0 { + t.Errorf("expected positive duration, got %d", ms) + } +} + +// TestGetMP3Duration_WithMultipleID3v2 creates a temporary MP3 file +// with two consecutive ID3v2 tags prepended and verifies that +// getMP3Duration correctly skips both and finds the audio. +func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) { + files := testMP3Files(t) + src := files[0] + + srcData, err := os.ReadFile(src) + if err != nil { + t.Fatalf("reading source: %v", err) + } + + // Get reference duration from the original file. + origF, err := os.Open(src) + if err != nil { + t.Fatalf("open original: %v", err) + } + + defer func() { _ = origF.Close() }() + + origMS, _, err := getMP3Duration(origF) + if err != nil { + t.Fatalf("getMP3Duration on original: %v", err) + } + + // Build a file with two ID3v2 tags: 1 KB + 2 KB of padding. + //nolint:mnd // synthetic tag construction. + tag1Size := 1024 + tag2Size := 2048 + + tag1 := buildID3v2Header(tag1Size) + tag2 := buildID3v2Header(tag2Size) + + out := make( + []byte, + 0, + len(tag1)+tag1Size+len(tag2)+tag2Size+len(srcData), + ) + out = append(out, tag1...) + out = append(out, make([]byte, tag1Size)...) + out = append(out, tag2...) + out = append(out, make([]byte, tag2Size)...) + out = append(out, srcData...) + + tmpDir := t.TempDir() + tmpPath := filepath.Join(tmpDir, "multi_id3v2.mp3") + + if err := os.WriteFile( + tmpPath, out, 0o644, + ); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + tmpF, err := os.Open(tmpPath) + if err != nil { + t.Fatalf("open temp: %v", err) + } + + defer func() { _ = tmpF.Close() }() + + wrappedMS, _, err := getMP3Duration(tmpF) + if err != nil { + t.Fatalf( + "getMP3Duration on multi-ID3v2 file: %v", err, + ) + } + + diffMS := origMS - wrappedMS + if diffMS < 0 { + diffMS = -diffMS + } + + // The CBR calculation uses file size, so the prepended tags + // will cause a slight overestimate. Allow generous tolerance. + const toleranceMS = 5000 + + t.Logf( + "original=%dms wrapped=%dms diff=%dms", + origMS, wrappedMS, diffMS, + ) + + if diffMS > toleranceMS { + t.Errorf( + "duration mismatch: original=%dms "+ + "wrapped=%dms (diff %dms "+ + "exceeds %dms tolerance)", + origMS, wrappedMS, diffMS, toleranceMS, + ) + } +} + +// TestSkipAdditionalID3v2 verifies that skipAdditionalID3v2 handles +// files with no additional tags, one additional tag, and multiple +// additional tags. +func TestSkipAdditionalID3v2(t *testing.T) { + // Build a file: [ID3v2(100)] [ID3v2(200)] [ID3v2(50)] [data] + //nolint:mnd // synthetic tag sizes for test. + sizes := []int{100, 200, 50} + + var buf []byte + + for _, sz := range sizes { + buf = append(buf, buildID3v2Header(sz)...) + buf = append(buf, make([]byte, sz)...) + } + + buf = append(buf, []byte("audio data here")...) + + tmpDir := t.TempDir() + tmpPath := filepath.Join(tmpDir, "multi_id3.bin") + + if err := os.WriteFile( + tmpPath, buf, 0o644, + ); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + f, err := os.Open(tmpPath) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + // skipID3v2 handles the first tag. + firstEnd, err := skipID3v2(f) + if err != nil { + t.Fatalf("skipID3v2: %v", err) + } + + //nolint:mnd // expected offset after first tag. + expectedFirst := int64(10 + 100) + if firstEnd != expectedFirst { + t.Fatalf( + "first tag end: got %d, want %d", + firstEnd, expectedFirst, + ) + } + + // skipAdditionalID3v2 handles the remaining tags. + finalOffset, err := skipAdditionalID3v2(f, firstEnd) + if err != nil { + t.Fatalf("skipAdditionalID3v2: %v", err) + } + + // Expected: 10+100 + 10+200 + 10+50 = 380 + //nolint:mnd // expected offset after all tags. + expectedAll := int64(10 + 100 + 10 + 200 + 10 + 50) + if finalOffset != expectedAll { + t.Errorf( + "final offset: got %d, want %d", + finalOffset, expectedAll, + ) + } +} diff --git a/backend/models/art.go b/backend/models/art.go deleted file mode 100644 index ae2e05d..0000000 --- a/backend/models/art.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package models defines domain types for music data. -package models - -// Art holds album artwork data. -type Art struct{} diff --git a/backend/models/files.go b/backend/models/files.go deleted file mode 100644 index 19e4ae2..0000000 --- a/backend/models/files.go +++ /dev/null @@ -1,13 +0,0 @@ -package models - -import "time" - -// AudioFileType identifies the format of an audio file. -type AudioFileType int - -// AudioFile represents a music file with its metadata. -type AudioFile struct { - Path string - Type AudioFileType - Length time.Duration -} diff --git a/backend/models/music.go b/backend/models/music.go deleted file mode 100644 index 0e97dff..0000000 --- a/backend/models/music.go +++ /dev/null @@ -1,21 +0,0 @@ -package models - -// Album represents a music album with its tracks and metadata. -type Album struct { - Name string - Tracks []Track - MusicBrainzReleaseID string - CoverArt Art -} - -// Track represents a single music track. -type Track struct { - Name string - MusicBrainzRecordingID string -} - -// Artist represents a music artist. -type Artist struct { - Name string - MusicBrainzArtistID string -} diff --git a/backend/player/buffered_streamer.go b/backend/player/buffered_streamer.go new file mode 100644 index 0000000..6d711fd --- /dev/null +++ b/backend/player/buffered_streamer.go @@ -0,0 +1,189 @@ +package player + +import ( + "sync" + "time" + + "github.com/gopxl/beep/v2" +) + +// BufferedStreamer wraps a beep.Streamer with a goroutine-driven +// read-ahead ring buffer. It decouples the source streamer's I/O +// timing from the speaker callback's real-time deadline, preventing +// audible glitches caused by disk stalls, GC pauses, or CPU +// scheduling delays. +// +// The read-ahead goroutine continuously fills the ring buffer from +// the source. The speaker callback drains the ring buffer without +// ever touching the source directly. If the ring buffer is +// temporarily empty (read-ahead hasn't caught up), Stream returns +// silence rather than blocking or signaling end-of-stream. +type BufferedStreamer struct { + mu sync.Mutex + source beep.Streamer + ring [][2]float64 + readPos int + writPos int + count int + done bool + err error + closed chan struct{} +} + +// NewBufferedStreamer creates a BufferedStreamer that pre-fills +// bufferSize samples from source via a background goroutine. +// A typical bufferSize is 2× the sample rate (~2 seconds of audio). +func NewBufferedStreamer( + source beep.Streamer, + bufferSize int, +) *BufferedStreamer { + bs := &BufferedStreamer{ + source: source, + ring: make([][2]float64, bufferSize), + closed: make(chan struct{}), + } + + go bs.readAhead() + + return bs +} + +// readAhead continuously reads from the source into the ring buffer +// until the source is drained, an error occurs, or Close is called. +func (bs *BufferedStreamer) readAhead() { + // Temporary buffer for reading from source outside the lock. + // 512 samples per chunk keeps the critical section short. + const chunkSize = 512 + + tmp := make([][2]float64, chunkSize) + + for { + // Check if closed. + select { + case <-bs.closed: + return + default: + } + + bs.mu.Lock() + space := len(bs.ring) - bs.count + + if space == 0 { + // Buffer full — release lock and wait briefly. + bs.mu.Unlock() + + select { + case <-bs.closed: + return + case <-time.After(1 * time.Millisecond): + } + + continue + } + + // Determine how many samples to request. + toRead := space + if toRead > chunkSize { + toRead = chunkSize + } + + bs.mu.Unlock() + + // Read from source WITHOUT holding the lock so disk I/O + // does not block the speaker goroutine. + n, ok := bs.source.Stream(tmp[:toRead]) + + if n > 0 { + bs.mu.Lock() + + for i := range n { + bs.ring[bs.writPos] = tmp[i] + bs.writPos = (bs.writPos + 1) % len(bs.ring) + } + + bs.count += n + bs.mu.Unlock() + } + + if !ok { + bs.mu.Lock() + bs.done = true + + if srcErr := bs.source.Err(); srcErr != nil { + bs.err = srcErr + } + + bs.mu.Unlock() + + return + } + + // If source returned 0 samples but is still ok, yield + // briefly to avoid busy-spinning. + if n == 0 { + select { + case <-bs.closed: + return + case <-time.After(1 * time.Millisecond): + } + } + } +} + +// Stream copies samples from the ring buffer into the provided +// slice. If the buffer is temporarily empty but the source is not +// yet drained, it fills the output with silence and returns +// (len(samples), true) to avoid speaker underrun. +func (bs *BufferedStreamer) Stream( + samples [][2]float64, +) (int, bool) { + bs.mu.Lock() + defer bs.mu.Unlock() + + if bs.count == 0 && bs.done { + return 0, false + } + + if bs.count == 0 { + // Buffer temporarily empty — fill with silence. + for i := range samples { + samples[i] = [2]float64{} + } + + return len(samples), true + } + + // Copy available samples from ring buffer. + n := len(samples) + if n > bs.count { + n = bs.count + } + + for i := range n { + samples[i] = bs.ring[bs.readPos] + bs.readPos = (bs.readPos + 1) % len(bs.ring) + } + + bs.count -= n + + return n, true +} + +// Err returns any error encountered by the source streamer. +func (bs *BufferedStreamer) Err() error { + bs.mu.Lock() + defer bs.mu.Unlock() + + return bs.err +} + +// Close signals the read-ahead goroutine to stop. It is safe to +// call multiple times. +func (bs *BufferedStreamer) Close() { + select { + case <-bs.closed: + // Already closed. + default: + close(bs.closed) + } +} diff --git a/backend/player/buffered_streamer_test.go b/backend/player/buffered_streamer_test.go new file mode 100644 index 0000000..2eb4074 --- /dev/null +++ b/backend/player/buffered_streamer_test.go @@ -0,0 +1,294 @@ +package player + +import ( + "runtime" + "testing" + "time" + + "github.com/gopxl/beep/v2" +) + +// slowStreamer wraps a beep.Streamer and introduces a delay before +// each Stream call, simulating slow disk I/O. +type slowStreamer struct { + inner beep.Streamer + delay time.Duration +} + +func (s *slowStreamer) Stream(samples [][2]float64) (int, bool) { + time.Sleep(s.delay) + + return s.inner.Stream(samples) +} + +func (s *slowStreamer) Err() error { return s.inner.Err() } + +// finiteStreamer produces exactly N samples with incrementing values +// starting at 1.0 (so sample 0 → 1.0, sample 1 → 2.0, etc.) and +// then signals end-of-stream. Values start at 1 so they are +// distinguishable from silence (zero). +func finiteStreamer(n int) beep.Streamer { + pos := 0 + + return beep.StreamerFunc(func(samples [][2]float64) (int, bool) { + if pos >= n { + return 0, false + } + + filled := 0 + + for i := range samples { + if pos >= n { + break + } + + val := float64(pos + 1) // +1 so first sample is 1.0 + samples[i] = [2]float64{val, val} + pos++ + filled++ + } + + return filled, true + }) +} + +func TestBufferedStreamer_BasicStream(t *testing.T) { + const total = 1000 + src := finiteStreamer(total) + bs := NewBufferedStreamer(src, 2048) + + defer bs.Close() + + var collected [][2]float64 + + buf := make([][2]float64, 256) + + for { + n, ok := bs.Stream(buf) + + for i := range n { + // Skip silence frames (buffer not yet filled). + if buf[i][0] == 0 && buf[i][1] == 0 && len(collected) == 0 { + continue + } + + collected = append(collected, buf[i]) + } + + if !ok { + break + } + + // Safety valve: if we've collected enough samples plus + // extra from potential silence padding, break. + if len(collected) >= total { + // Drain remaining. + for { + n, ok = bs.Stream(buf) + if !ok { + break + } + + for i := range n { + if buf[i][0] != 0 || buf[i][1] != 0 { + collected = append(collected, buf[i]) + } + } + } + + break + } + } + + if len(collected) != total { + t.Fatalf( + "expected %d samples, got %d", total, len(collected), + ) + } + + // Verify ordering (values start at 1.0). + for i, s := range collected { + expected := float64(i + 1) + if s[0] != expected || s[1] != expected { + t.Fatalf( + "sample %d: expected [%f %f], got [%f %f]", + i, expected, expected, s[0], s[1], + ) + } + } +} + +func TestBufferedStreamer_SmallReads(t *testing.T) { + const total = 200 + src := finiteStreamer(total) + bs := NewBufferedStreamer(src, 512) + + defer bs.Close() + + // Give read-ahead time to fill. + time.Sleep(50 * time.Millisecond) + + var collected [][2]float64 + + buf := make([][2]float64, 1) // Read one sample at a time. + + for { + n, ok := bs.Stream(buf) + + for i := range n { + if buf[i][0] == 0 && buf[i][1] == 0 && len(collected) == 0 { + continue + } + + collected = append(collected, buf[i]) + } + + if !ok { + break + } + + if len(collected) >= total { + // Drain. + for { + n, ok = bs.Stream(buf) + if !ok { + break + } + + for i := range n { + if buf[i][0] != 0 || buf[i][1] != 0 { + collected = append(collected, buf[i]) + } + } + } + + break + } + } + + if len(collected) != total { + t.Fatalf( + "expected %d samples, got %d", total, len(collected), + ) + } + + for i, s := range collected { + expected := float64(i + 1) + if s[0] != expected || s[1] != expected { + t.Fatalf( + "sample %d: expected [%f %f], got [%f %f]", + i, expected, expected, s[0], s[1], + ) + } + } +} + +func TestBufferedStreamer_SourceDrained(t *testing.T) { + const total = 100 + src := finiteStreamer(total) + bs := NewBufferedStreamer(src, 256) + + defer bs.Close() + + // Wait for read-ahead to completely drain the source. + time.Sleep(50 * time.Millisecond) + + // Read all samples out. + consumed := 0 + buf := make([][2]float64, 32) + hitEOF := false + + for range 1000 { // Safety limit. + n, ok := bs.Stream(buf) + + for i := range n { + if buf[i][0] != 0 || buf[i][1] != 0 { + consumed++ + } + } + + if !ok { + hitEOF = true + + break + } + } + + if !hitEOF { + t.Fatal("expected stream to return ok=false after source drained") + } + + if consumed != total { + t.Fatalf("expected %d non-zero samples, got %d", total, consumed) + } +} + +func TestBufferedStreamer_EmptyBufferReturnsSilence(t *testing.T) { + // Use a slow source that sleeps 50ms per call. + src := &slowStreamer{ + inner: finiteStreamer(100), + delay: 50 * time.Millisecond, + } + bs := NewBufferedStreamer(src, 1024) + + defer bs.Close() + + // Immediately call Stream before read-ahead has had time to + // fill anything. The buffer should be empty. + buf := make([][2]float64, 64) + n, ok := bs.Stream(buf) + + if !ok { + t.Fatal("expected ok=true when buffer is empty but source not drained") + } + + if n != len(buf) { + t.Fatalf("expected %d samples (silence), got %d", len(buf), n) + } + + // All returned samples should be silence (zeros). + for i := range n { + if buf[i][0] != 0 || buf[i][1] != 0 { + t.Fatalf( + "sample %d should be silence, got [%f %f]", + i, buf[i][0], buf[i][1], + ) + } + } +} + +func TestBufferedStreamer_Close(t *testing.T) { + // Use a source that never drains. + infinite := beep.StreamerFunc(func(samples [][2]float64) (int, bool) { + for i := range samples { + samples[i] = [2]float64{1.0, 1.0} + } + + return len(samples), true + }) + + goroutinesBefore := runtime.NumGoroutine() + bs := NewBufferedStreamer(infinite, 4096) + + // Let read-ahead goroutine start. + time.Sleep(10 * time.Millisecond) + + bs.Close() + + // Wait for goroutine to exit. + time.Sleep(50 * time.Millisecond) + + goroutinesAfter := runtime.NumGoroutine() + + // The goroutine count should not have increased. Allow ±1 for + // runtime fluctuations. + if goroutinesAfter > goroutinesBefore+1 { + t.Fatalf( + "goroutine leak: before=%d after=%d", + goroutinesBefore, goroutinesAfter, + ) + } + + // Calling Close again should not panic. + bs.Close() +} diff --git a/backend/player/player.go b/backend/player/player.go index d491d11..8ee39aa 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -9,22 +9,36 @@ import ( "math" "os" "path/filepath" + "sync" "time" - "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/gopxl/beep/v2" + "github.com/gopxl/beep/v2/effects" + "github.com/gopxl/beep/v2/generators" + "github.com/gopxl/beep/v2/speaker" "github.com/wailsapp/wails/v2/pkg/runtime" + "yellowjacket/backend/coverart" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" + "yellowjacket/backend/mediacontrols" "yellowjacket/backend/metadata" + "yellowjacket/backend/profiling" ) // Player handles audio playback and state management. +// +// Lock ordering: always acquire p.mu BEFORE speaker.Lock(). +// The beep playback-finished callback dispatches to a new goroutine +// so it never holds p.mu while the speaker lock is held. type Player struct { + // mu protects all mutable fields below from concurrent access. + // It must be held by every public method and released before + // calling the playbackFinishedHandler (which re-enters the player + // via the queue). + mu sync.Mutex + ctx context.Context logger *slog.Logger db *database.DB @@ -34,10 +48,20 @@ type Player struct { baseStreamer beep.Streamer seeker beep.StreamSeeker resampled beep.Streamer + buffered *BufferedStreamer control *beep.Ctrl volume *effects.Volume speakerStreamer beep.Streamer playbackFinishedHandler func() + trackChangeID uint64 + mediaControls mediacontrols.Handler + + // trackLengthMs holds the authoritative track duration in + // milliseconds, sourced from the database (which uses the + // custom header parser). The go-mp3 decoder's Len() can be + // inflated for files with multiple ID3v2 tags, so this value + // is preferred for display and position calculations. + trackLengthMs int64 } // State represents the current playback state. @@ -50,6 +74,26 @@ const ( Stopped State = "stopped" ) +// TrackInfo contains metadata and playback state for the currently +// loaded track. It is emitted as the payload of the TrackChanged +// event and serialized as camelCase JSON to match the frontend +// TrackInfo interface in player-store.ts. +type TrackInfo struct { + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + State State `json:"state"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + CoverArt string `json:"coverArt"` + CoverArtSmall string `json:"coverArtSmall"` + CoverArtMedium string `json:"coverArtMedium"` + CoverArtLarge string `json:"coverArtLarge"` + TrackLength int `json:"trackLength"` + SeekPosition int `json:"seekPosition"` + TrackChangeID uint64 `json:"trackChangeId"` +} + // Sentinel errors for player operations. var ( errNoControlStreamer = errors.New("no control streamer") @@ -60,10 +104,10 @@ var ( var speakerSampleRate = beep.SampleRate(44100) -// 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, +// NewPlayer creates a player. Call InitSpeaker separately to +// initialize the audio output device. +func NewPlayer(logger *slog.Logger, db *database.DB) *Player { + return &Player{ logger: logger, db: db, state: Stopped, @@ -72,86 +116,65 @@ func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Play SampleRate: speakerSampleRate, }, } - - // 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. +// InitSpeaker initializes the audio output device. This is +// separated from NewPlayer so the player struct can be created +// before wails.Run (for binding registration) while deferring +// hardware initialization to OnStartup. +func (p *Player) InitSpeaker() error { + defer profiling.TimeOp(p.logger, "player.InitSpeaker")() + + // TODO: allow user to change buffer size and speaker sample rate. + // Speaker buffer is 200ms (~8820 samples at 44100 Hz), providing + // secondary protection against underruns behind the read-ahead + // BufferedStreamer. + err := speaker.Init( + p.format.SampleRate, + p.format.SampleRate.N(time.Second/5), + ) + if err != nil { + return fmt.Errorf( + "failed to initialize speaker: %w", err, + ) + } + + return nil +} + +// SetPlaybackFinishedHandler sets a callback invoked when a track +// finishes naturally. This allows the queue to drive auto-advance +// without circular imports. func (p *Player) SetPlaybackFinishedHandler(handler func()) { + p.mu.Lock() + defer p.mu.Unlock() + p.playbackFinishedHandler = handler } -// SetContext sets the Wails context, registers event handlers, and restores persisted state. +// SetMediaControls provides an OS media controls handler. When set, +// the player pushes metadata, playback state, volume, and seek +// notifications to the OS media overlay. +func (p *Player) SetMediaControls(h mediacontrols.Handler) { + p.mu.Lock() + defer p.mu.Unlock() + + p.mediaControls = h +} + +// SetContext sets the Wails runtime context and restores persisted +// state. func (p *Player) SetContext(ctx context.Context) { + p.mu.Lock() + defer p.mu.Unlock() + p.ctx = ctx - p.registerEventHandlers() - p.RestoreState() + p.restoreStateLocked() } -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") - - if err := p.Play(); err != nil { - p.logger.Error("failed to play", "err", err) - } - }) - runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) { - p.logger.Info("Received RequestPauseEvent") - - if err := p.Pause(); err != nil { - p.logger.Error("failed to pause", "err", err) - } - }) - 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() - }) -} +// --------------------------------------------------------------- +// Emit helpers (must be called with p.mu held) +// --------------------------------------------------------------- // emitPlaybackStateChanged emits a playback state change event. func (p *Player) emitPlaybackStateChanged(state State) { @@ -161,12 +184,22 @@ func (p *Player) emitPlaybackStateChanged(state State) { return } - p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state) + p.logger.Info( + "Emitting PlaybackStateChangedEvent", "state", state, + ) + runtime.EventsEmit( p.ctx, events.PlaybackStateChanged, map[string]string{"state": string(state)}, ) + + if p.mediaControls != nil { + p.mediaControls.UpdatePlaybackState( + stateToMediaControls(state), + p.currentPositionSecondsLocked(), + ) + } } func (p *Player) emitPlaybackFinished() { @@ -188,8 +221,18 @@ func (p *Player) emitVolumeChanged() { } volume := int(p.getUserVolume()) - p.logger.Info("Emitting VolumeChangedEvent", "volume", volume) + p.logger.Info( + "Emitting VolumeChangedEvent", "volume", volume, + ) + runtime.EventsEmit(p.ctx, events.VolumeChanged, volume) + + if p.mediaControls != nil { + // MPRIS volume is 0.0–1.0 linear. + p.mediaControls.UpdateVolume( + float64(volume) / float64(MaxUserVol), + ) + } } func (p *Player) emitTrackChanged() { @@ -199,42 +242,49 @@ func (p *Player) emitTrackChanged() { return } - trackLengthSecs, err := p.TrackLengthInSeconds() + trackInfo := p.getCurrentTrackInfoLocked() + + trackLengthSecs, err := p.trackLengthLocked() 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), - } + trackInfo.TrackLength = trackLengthSecs + + // Compute current seek position in display seconds. + trackInfo.SeekPosition = p.displayPositionSecsLocked() + + // Increment track change ID so the frontend can detect changes + // even when the same file plays consecutively. + p.trackChangeID++ + trackInfo.TrackChangeID = p.trackChangeID + + runtime.EventsEmit( + p.ctx, events.TrackChanged, trackInfo, + ) + + p.logger.Info( + "Emitting TrackChangedEvent with track info", + "trackInfo", trackInfo, + ) + + if p.mediaControls != nil { + p.mediaControls.UpdateMetadata( + p.buildMediaMetadata( + trackInfo, trackLengthSecs, + ), + ) } - - // 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. +// 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.mu.Lock() + defer p.mu.Unlock() + p.emitVolumeChanged() if p.currentFile != nil { @@ -243,17 +293,33 @@ func (p *Player) EmitCurrentState() { } } -func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.SampleRate) error { +// --------------------------------------------------------------- +// Streamer management (must be called with p.mu held) +// --------------------------------------------------------------- + +func (p *Player) updateStreamers( + newBaseStreamer beep.StreamSeeker, + sr beep.SampleRate, +) error { // set base streamer p.baseStreamer = newBaseStreamer p.seeker = newBaseStreamer // resample file stream to match speaker // TODO: variable resample quality - p.resampled = beep.Resample(4, sr, speakerSampleRate, p.baseStreamer) + p.resampled = beep.Resample( + 4, sr, speakerSampleRate, p.baseStreamer, + ) + + // Buffer resampled audio to decouple disk I/O from speaker + // timing. 2 seconds of read-ahead at speaker sample rate + // absorbs I/O stalls and GC pauses without audible glitches. + p.buffered = NewBufferedStreamer( + p.resampled, int(speakerSampleRate)*2, + ) // wrap in ctrl streamer to allow play/pause - p.control = &beep.Ctrl{Streamer: p.resampled} + p.control = &beep.Ctrl{Streamer: p.buffered} // Preserve existing volume settings across track changes. prevVolume := 0.0 @@ -278,45 +344,96 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp 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. +// startPaused registers the current streamer chain with the speaker +// in a paused state. Must be called with p.mu held. 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() - } - }))) + // The beep.Callback runs with the speaker mutex held, so we + // dispatch to a goroutine that can safely acquire p.mu. + speaker.Play(beep.Seq( + p.speakerStreamer, + beep.Callback(func() { + go p.onPlaybackFinished() + }), + )) p.state = Paused } +// onPlaybackFinished handles the natural end of a track. It is +// called on a new goroutine from the beep callback (which holds +// the speaker lock) so that it can safely acquire p.mu. +func (p *Player) onPlaybackFinished() { + p.mu.Lock() + p.state = Stopped + handler := p.playbackFinishedHandler + mc := p.mediaControls + p.mu.Unlock() + + // Emit Wails events outside the lock — these are non-blocking + // calls that don't need player state. + p.emitPlaybackFinished() + + if p.ctx != nil { + runtime.EventsEmit( + p.ctx, + events.PlaybackStateChanged, + map[string]string{"state": string(Stopped)}, + ) + } + + // Notify media controls outside the lock. The track just + // ended so position is 0. + if mc != nil { + mc.UpdatePlaybackState( + mediacontrols.StateStopped, 0, + ) + } + + p.logger.Info("Playback finished naturally") + + // Notify queue for auto-advance. Called without p.mu held + // because it re-enters the player via LoadFile/Play. + if handler != nil { + handler() + } +} + +// --------------------------------------------------------------- +// LoadFile +// --------------------------------------------------------------- + // LoadFile opens and decodes an audio file for playback. func (p *Player) LoadFile(filePath string) error { - // opening file + p.mu.Lock() + defer p.mu.Unlock() + + return p.loadFileLocked(filePath) +} + +func (p *Player) loadFileLocked(filePath string) error { + defer profiling.TimeOp(p.logger, "player.LoadFile")() + f, err := os.Open(filePath) if err != nil { p.logger.Error("Failed to open file") - return fmt.Errorf("failed to open file %w", err) + return fmt.Errorf("failed to open file: %w", err) } streamer, format, err := metadata.DecodeFile(f) if err != nil { - p.logger.Error("failed to decode audio file", "path", filePath, "err", 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 { @@ -326,26 +443,43 @@ func (p *Player) LoadFile(filePath string) error { p.state = Stopped speaker.Unlock() + // Stop the read-ahead goroutine for the previous track. + if p.buffered != nil { + p.buffered.Close() + } + if p.currentFile != nil { if closeErr := p.currentFile.Close(); closeErr != nil { - p.logger.Warn("failed to close previous audio file", "err", closeErr) + p.logger.Warn( + "failed to close previous audio file", + "err", closeErr, + ) } } p.currentFile = f - if err := p.updateStreamers(streamer, format.SampleRate); err != nil { + if err := p.updateStreamers( + streamer, format.SampleRate, + ); err != nil { return fmt.Errorf("failed to update streamers: %w", err) } p.startPaused() p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() - p.logger.Info("File loaded, state set to paused", "file", filePath) + p.saveState() + p.logger.Info( + "File loaded, state set to paused", "file", filePath, + ) return nil } +// --------------------------------------------------------------- +// Play / Pause +// --------------------------------------------------------------- + func (p *Player) validateReadyToPlay() error { if p.control == nil { return errNoControlStreamer @@ -364,6 +498,9 @@ func (p *Player) validateReadyToPlay() error { // Play starts or resumes audio playback. func (p *Player) Play() error { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.validateReadyToPlay(); err != nil { return err } @@ -374,26 +511,34 @@ func (p *Player) Play() error { return nil } - // Track finished naturally — seek to the beginning and re-register - // a paused stream with the speaker so the unpause below starts it. + // 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) + return fmt.Errorf( + "failed to seek to beginning: %w", err, + ) } - if err := p.updateStreamers(p.seeker, p.format.SampleRate); err != nil { - return fmt.Errorf("failed to update streamers for replay: %w", err) + if err := p.updateStreamers( + p.seeker, p.format.SampleRate, + ); err != nil { + return fmt.Errorf( + "failed to update streamers for replay: %w", err, + ) } p.startPaused() p.logger.Info("Rebuilt streamers for replay") } - // Unpause — works for both resume-from-pause and replay-from-stopped. + // Unpause — works for both resume-from-pause and + // replay-from-stopped. speaker.Lock() p.control.Paused = false speaker.Unlock() @@ -407,6 +552,9 @@ func (p *Player) Play() error { // Pause pauses the current playback. func (p *Player) Pause() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.control == nil { return errNoAudioStream } @@ -425,6 +573,7 @@ func (p *Player) Pause() error { p.state = Paused p.logger.Info("Paused playback") p.emitPlaybackStateChanged(p.state) + p.saveState() } else { p.logger.Info("Already paused or not playing") } @@ -432,23 +581,108 @@ func (p *Player) Pause() error { return nil } -// SetVolume sets the playback volume (0-100). -func (p *Player) SetVolume(desiredVolume UserVolume) error { - speaker.Lock() - // clamp value between 1 and 100 - volume := clampVolume(desiredVolume) +// IsPlaying reports whether the player is currently playing audio. +func (p *Player) IsPlaying() bool { + p.mu.Lock() + defer p.mu.Unlock() - // Apply the volume settings + return p.state == Playing +} + +// --------------------------------------------------------------- +// UnloadTrack +// --------------------------------------------------------------- + +// UnloadTrack tears down the current track, releasing the file and +// streamer chain. The player returns to the initial "no track +// loaded" state and emits events so the frontend clears its +// current-track display. +func (p *Player) UnloadTrack() { + p.mu.Lock() + defer p.mu.Unlock() + + // Stop audio output. + if p.control != nil { + speaker.Lock() + p.control.Paused = true + speaker.Unlock() + } + + // Close the open audio file. + if p.currentFile != nil { + if err := p.currentFile.Close(); err != nil { + p.logger.Warn( + "Failed to close audio file during unload", + "err", err, + ) + } + + p.currentFile = nil + } + + // Stop the read-ahead goroutine before releasing the chain. + if p.buffered != nil { + p.buffered.Close() + } + + // Release streamer chain. Volume is intentionally kept so the + // user's volume setting persists across tracks. + p.baseStreamer = nil + p.seeker = nil + p.resampled = nil + p.buffered = nil + p.control = nil + p.speakerStreamer = nil + p.trackLengthMs = 0 + + p.state = Stopped + + // Notify frontend that there is no longer a current track. + p.emitPlaybackStateChanged(p.state) + runtime.EventsEmit(p.ctx, events.TrackChanged, nil) + + if p.mediaControls != nil { + p.mediaControls.UpdateMetadata(mediacontrols.Metadata{}) + } + + p.saveState() + + p.logger.Info("Track unloaded") +} + +// --------------------------------------------------------------- +// Volume +// --------------------------------------------------------------- + +// SetVolume sets the playback volume (0-100), emits a +// VolumeChanged event, and persists the new level. +func (p *Player) SetVolume(desiredVolume UserVolume) { + p.mu.Lock() + defer p.mu.Unlock() + + p.setVolumeLocked(desiredVolume) + p.emitVolumeChanged() + p.saveState() +} + +func (p *Player) setVolumeLocked(desiredVolume UserVolume) { + speaker.Lock() + + volume := clampVolume(desiredVolume) p.volume.Volume = float64(volume.ToVolume()) p.volume.Silent = volume == MinUserVol - speaker.Unlock() - return nil + speaker.Unlock() } // ChangeVolume adjusts the volume by a relative amount. func (p *Player) ChangeVolume(deltaVolume int) error { - return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume)) + p.mu.Lock() + defer p.mu.Unlock() + + p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume)) + + return nil } func (p *Player) getUserVolume() UserVolume { @@ -457,32 +691,47 @@ func (p *Player) getUserVolume() UserVolume { // MuteToggle toggles the mute state. func (p *Player) MuteToggle() error { + p.mu.Lock() + defer p.mu.Unlock() + p.volume.Silent = !p.volume.Silent + p.saveState() return nil } -// CurrentPositionSeconds returns the current playback position in seconds. +// --------------------------------------------------------------- +// Position / Seek +// --------------------------------------------------------------- + +// CurrentPositionSeconds returns the current playback position in +// display seconds. func (p *Player) CurrentPositionSeconds() (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.seeker == nil { return 0, errNoAudioFileLoaded } - speaker.Lock() - pos := p.seeker.Position() / int(p.format.SampleRate) - speaker.Unlock() - - return pos, nil + return p.displayPositionSecsLocked(), nil } -// CurrentPosition returns the playback position as a percentage (0-100). +// CurrentPosition returns the playback position as a percentage +// (0-100). func (p *Player) CurrentPosition() (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.seeker == nil { return 0, errNoAudioFileLoaded } speaker.Lock() - pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len())) + pos := math.Round( + 100.0 * float64(p.seeker.Position()) / + float64(p.seeker.Len()), + ) speaker.Unlock() return int(pos), nil @@ -490,29 +739,38 @@ func (p *Player) CurrentPosition() (int, error) { // Seek jumps to a specific position in seconds. func (p *Player) Seek(targetSeconds int) error { + p.mu.Lock() + defer p.mu.Unlock() + + return p.seekLocked(targetSeconds) +} + +func (p *Player) seekLocked(targetSeconds int) error { if p.seeker == nil { runtime.EventsEmit(p.ctx, events.SeekFailed) return errNoAudioFileLoaded } - lengthSecs, err := p.TrackLengthInSeconds() + lengthSecs, err := p.trackLengthLocked() 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())), + math.Round( + (float64(targetSeconds) / float64(lengthSecs)) * + float64(p.seeker.Len()), + ), ) + p.logger.Debug( "attempting to seek", - "target-seconds", - targetSeconds, - "song-length", - lengthSecs, - "samples", - samples, + "target-seconds", targetSeconds, + "song-length", lengthSecs, + "samples", samples, ) if seekErr := p.seeker.Seek(samples); seekErr != nil { @@ -523,64 +781,95 @@ func (p *Player) Seek(targetSeconds int) error { speaker.Unlock() + if p.mediaControls != nil { + p.mediaControls.NotifySeek(targetSeconds) + } + 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 +// --------------------------------------------------------------- +// Track info +// --------------------------------------------------------------- + +// GetCurrentTrackInfo returns information about the currently +// loaded track. +func (p *Player) GetCurrentTrackInfo() TrackInfo { + p.mu.Lock() + defer p.mu.Unlock() + + return p.getCurrentTrackInfoLocked() +} + +func (p *Player) getCurrentTrackInfoLocked() TrackInfo { + info := TrackInfo{ + State: p.state, } - fileName := filepath.Base(p.currentFile.Name()) - filePath := p.currentFile.Name() + if p.currentFile == nil { + return info + } - // Default values - title := fileName - artist := "" - album := "" - coverArt := "" + info.FileName = filepath.Base(p.currentFile.Name()) + info.FilePath = p.currentFile.Name() + info.Title = info.FileName // default title is the filename - // Try to get metadata from database + // Try to get metadata from database. if p.db != nil { - meta, err := p.db.Queries.GetTrackMetadataByPath(p.ctx, filePath) + meta, err := p.db.Queries.GetTrackMetadataByPath( + p.ctx, info.FilePath, + ) if err == nil { if meta.Title != "" { - title = meta.Title + info.Title = meta.Title } - artist = meta.Artist - album = meta.Album + info.Artist = meta.Artist + info.Album = meta.Album + p.trackLengthMs = meta.LengthMilliseconds if meta.CoverArtPath != "" { - coverArt = "/covers/" + filepath.Base(meta.CoverArtPath) + urls := coverart.ResolveURLs(meta.CoverArtPath) + info.CoverArt = urls.Original + info.CoverArtSmall = urls.Small + info.CoverArtMedium = urls.Medium + info.CoverArtLarge = urls.Large } } else { - p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err) + p.logger.Debug( + "Could not get track metadata from database", + "path", info.FilePath, "err", err, + ) } } - return map[string]interface{}{ - "fileName": fileName, - "filePath": filePath, - "state": string(p.state), - "title": title, - "artist": artist, - "album": album, - "coverArt": coverArt, - }, nil + return info } // TrackLengthInSeconds returns the duration of the current track. func (p *Player) TrackLengthInSeconds() (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + + return p.trackLengthLocked() +} + +func (p *Player) trackLengthLocked() (int, error) { + // Prefer the database duration — the custom header parser + // handles multiple ID3v2 tags correctly, whereas go-mp3's + // Len() can be inflated by phantom frames. + if p.trackLengthMs > 0 { + return int(p.trackLengthMs / 1000), nil + } + + return p.seekerLengthSecsLocked() +} + +// seekerLengthSecsLocked returns the track length in seconds as +// reported by the beep decoder. This may differ from the +// database duration for MP3 files with multiple ID3v2 tags. +// It is used internally for seek sample calculations. +func (p *Player) seekerLengthSecsLocked() (int, error) { if p.seeker == nil { return 0, errNoAudioFileLoaded } @@ -592,15 +881,115 @@ func (p *Player) TrackLengthInSeconds() (int, error) { return length, nil } +// displayPositionSecsLocked converts the current seeker position to +// display seconds. When the DB duration is available, the position +// is scaled from the (potentially inflated) seeker time scale to the +// correct display time scale. Must be called with p.mu held. +func (p *Player) displayPositionSecsLocked() int { + if p.seeker == nil { + return 0 + } + + speaker.Lock() + pos := p.seeker.Position() + total := p.seeker.Len() + speaker.Unlock() + + if total == 0 { + return 0 + } + + displayLength, err := p.trackLengthLocked() + if err != nil { + return pos / int(p.format.SampleRate) + } + + return int( + math.Round( + float64(pos) / float64(total) * + float64(displayLength), + ), + ) +} + +// --------------------------------------------------------------- +// Media controls helpers +// --------------------------------------------------------------- + +// stateToMediaControls maps the player's State type to the +// mediacontrols PlaybackState. +func stateToMediaControls(s State) mediacontrols.PlaybackState { + switch s { + case Playing: + return mediacontrols.StatePlaying + case Paused: + return mediacontrols.StatePaused + default: + return mediacontrols.StateStopped + } +} + +// currentPositionSecondsLocked returns the playback position in +// display seconds. Must be called with p.mu held. +func (p *Player) currentPositionSecondsLocked() int { + return p.displayPositionSecsLocked() +} + +// buildMediaMetadata constructs a mediacontrols.Metadata from a +// TrackInfo and duration. It resolves the cover art filesystem path +// from the database for use by MPRIS (which needs file:// URIs). +// Must be called with p.mu held. +func (p *Player) buildMediaMetadata( + info TrackInfo, + durationSec int, +) mediacontrols.Metadata { + meta := mediacontrols.Metadata{ + Title: info.Title, + Artist: info.Artist, + Album: info.Album, + DurationSec: durationSec, + } + + // Resolve cover art filesystem path. The database stores the + // full path; ResolveURLs converts it to relative HTTP paths + // for the frontend, but MPRIS needs the actual file path. + if p.db != nil && info.FilePath != "" { + dbMeta, err := p.db.Queries.GetTrackMetadataByPath( + p.ctx, info.FilePath, + ) + if err == nil && dbMeta.CoverArtPath != "" { + meta.ArtFilePath = dbMeta.CoverArtPath + } + } + + return meta +} + +// --------------------------------------------------------------- +// State persistence +// --------------------------------------------------------------- + // SaveState persists the current player state to the database. +// This is called during shutdown to capture the final state. func (p *Player) SaveState() { + p.mu.Lock() + defer p.mu.Unlock() + + p.saveState() +} + +// saveState is the internal helper that writes the current player +// state to the database. Must be called with p.mu held. +func (p *Player) saveState() { if p.db == nil { - p.logger.Warn("No database available, cannot save player state") + p.logger.Warn( + "No database available, cannot save player state", + ) return } - volume := int64(MaxUserVol) + volume := int64(DefaultUserVol) muted := false if p.volume != nil { @@ -613,22 +1002,21 @@ func (p *Player) SaveState() { trackPath = p.currentFile.Name() } - positionSeconds := int64(0) + positionSeconds := int64(p.displayPositionSecsLocked()) - 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, - }) + 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) + p.logger.Error( + "Failed to save player state", "err", err, + ) return } @@ -641,25 +1029,42 @@ func (p *Player) SaveState() { ) } +// --------------------------------------------------------------- +// State restoration +// --------------------------------------------------------------- + // RestoreState loads the persisted player state from the database. func (p *Player) RestoreState() { + p.mu.Lock() + defer p.mu.Unlock() + + p.restoreStateLocked() +} + +func (p *Player) restoreStateLocked() { + defer profiling.TimeOp(p.logger, "player.RestoreState")() + if p.db == nil { - p.logger.Warn("No database available, cannot restore player state") + 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) + 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. + // 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, @@ -668,11 +1073,7 @@ func (p *Player) RestoreState() { } vol := clampVolume(UserVolume(state.Volume)) - - err = p.SetVolume(vol) - if err != nil { - p.logger.Error("Failed to restore volume", "err", err) - } + p.setVolumeLocked(vol) if state.Muted { p.volume.Silent = true @@ -681,7 +1082,9 @@ func (p *Player) RestoreState() { // 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", + p.logger.Warn( + "Last track file no longer exists, "+ + "skipping restore", "path", state.LastTrackPath, "err", statErr, ) @@ -689,18 +1092,22 @@ func (p *Player) RestoreState() { return } - err = p.LoadFile(state.LastTrackPath) + err = p.loadFileLocked(state.LastTrackPath) if err != nil { - p.logger.Error("Failed to restore last track", "path", state.LastTrackPath, "err", err) + 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)) + err = p.seekLocked(int(state.LastPositionSeconds)) if err != nil { - p.logger.Error("Failed to restore playback position", + p.logger.Error( + "Failed to restore playback position", "seconds", state.LastPositionSeconds, "err", err, ) diff --git a/backend/player/player_test.go b/backend/player/player_test.go index eb54a9e..0ce63fc 100644 --- a/backend/player/player_test.go +++ b/backend/player/player_test.go @@ -1,7 +1,6 @@ package player import ( - "context" "log/slog" "os" "testing" @@ -15,8 +14,8 @@ var testQueue = []string{ func TestPlayer(t *testing.T) { // This is an integration test that requires: - // 1. A Wails runtime context (SetContext calls runtime.EventsOn) - // 2. An audio output device (speaker.Init) + // 1. A Wails runtime context (SetContext restores persisted state) + // 2. An audio output device (InitSpeaker) // // Skip unless the caller explicitly opts in via YELLOWJACKET_INTEGRATION=1. if os.Getenv("YELLOWJACKET_INTEGRATION") == "" { @@ -27,25 +26,24 @@ func TestPlayer(t *testing.T) { t.Logf("Starting test") - p, err := NewPlayer(context.Background(), slog.Default(), nil) - if err != nil { - t.Fatalf("could not create player: %s", err.Error()) + p := NewPlayer(slog.Default(), nil) + + if err := p.InitSpeaker(); err != nil { + t.Fatalf("could not initialize speaker: %s", err.Error()) } - // SetContext registers Wails event handlers; only works with a real Wails context. + // SetContext restores persisted state; only works with a real Wails context. p.SetContext(t.Context()) t.Logf("initializing player") for _, track := range testQueue { t.Logf("loading file: %s", track) - err = p.LoadFile(track) - if err != nil { + if err := p.LoadFile(track); err != nil { t.Fatalf("could not load file %s: %s", track, err.Error()) } - err = p.Play() - if err != nil { + if err := p.Play(); err != nil { t.Fatalf("could not play file %s: %s", track, err.Error()) } } diff --git a/backend/player/volume.go b/backend/player/volume.go index 80c271c..5f2a002 100644 --- a/backend/player/volume.go +++ b/backend/player/volume.go @@ -8,13 +8,14 @@ type Volume float64 // User volume range bounds. const ( - MinUserVol UserVolume = 0 - MaxUserVol UserVolume = 100 + MinUserVol UserVolume = 0 + MaxUserVol UserVolume = 100 + DefaultUserVol UserVolume = 50 ) // Internal volume range bounds. const ( - MinVol Volume = -4 + MinVol Volume = -5 MaxVol Volume = 0 ) diff --git a/backend/player/volume_test.go b/backend/player/volume_test.go new file mode 100644 index 0000000..b887a28 --- /dev/null +++ b/backend/player/volume_test.go @@ -0,0 +1,204 @@ +package player + +import ( + "math" + "testing" + + "yellowjacket/backend/mediacontrols" +) + +func TestUserVolume_ToVolume(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input UserVolume + expected Volume + }{ + {"min (0)", MinUserVol, MinVol}, + {"max (100)", MaxUserVol, MaxVol}, + {"default (50)", DefaultUserVol, -2.5}, + {"quarter (25)", 25, -3.75}, + {"three-quarter (75)", 75, -1.25}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.input.ToVolume() + if math.Abs(float64(got)-float64(tt.expected)) > 0.001 { + t.Errorf("UserVolume(%d).ToVolume() = %f, want %f", tt.input, got, tt.expected) + } + }) + } +} + +func TestVolume_ToUserVolume(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input Volume + expected UserVolume + }{ + {"min (-5.0)", MinVol, MinUserVol}, + {"max (0.0)", MaxVol, MaxUserVol}, + {"midpoint (-2.5)", -2.5, 50}, + {"quarter (-3.75)", -3.75, 25}, + {"three-quarter (-1.25)", -1.25, 75}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.input.ToUserVolume() + if got != tt.expected { + t.Errorf("Volume(%f).ToUserVolume() = %d, want %d", tt.input, got, tt.expected) + } + }) + } +} + +func TestUserVolume_ToVolume_OutOfRange(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input UserVolume + }{ + {"negative (-1)", -1}, + {"over max (101)", 101}, + {"way over (200)", 200}, + {"far negative (-50)", -50}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.input.ToVolume() + // Out-of-range returns zero-value Volume (0.0). + if got != 0.0 { + t.Errorf("UserVolume(%d).ToVolume() = %f, want 0.0 (zero-value)", tt.input, got) + } + }) + } +} + +func TestVolume_ToUserVolume_OutOfRange(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input Volume + }{ + {"below min (-6.0)", -6.0}, + {"above max (1.0)", 1.0}, + {"far below (-10.0)", -10.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.input.ToUserVolume() + // Out-of-range returns zero-value UserVolume (0). + if got != 0 { + t.Errorf("Volume(%f).ToUserVolume() = %d, want 0 (zero-value)", tt.input, got) + } + }) + } +} + +func TestUserVolume_ToVolume_Roundtrip(t *testing.T) { + t.Parallel() + + // The conversion uses float64 intermediates and int truncation + // (not rounding), so some values lose 1 unit in the roundtrip. + // This characterization test verifies the actual behavior: + // the result is always within ±1 of the original, and boundary + // values (0, 50, 100) are exact. + for i := UserVolume(0); i <= 100; i++ { + vol := i.ToVolume() + roundtripped := vol.ToUserVolume() + + diff := int(roundtripped) - int(i) + if diff < -1 || diff > 1 { + t.Errorf( + "Roundtrip UserVolume(%d) -> Volume(%f) -> UserVolume(%d): "+ + "drift %d exceeds ±1", + i, vol, roundtripped, diff, + ) + } + } + + // Verify key boundary values are exact. + exactCases := []UserVolume{MinUserVol, DefaultUserVol, MaxUserVol} + for _, uv := range exactCases { + vol := uv.ToVolume() + roundtripped := vol.ToUserVolume() + + if roundtripped != uv { + t.Errorf( + "Exact roundtrip UserVolume(%d) -> Volume(%f) -> "+ + "UserVolume(%d): want exact match", + uv, vol, roundtripped, + ) + } + } +} + +func TestClampVolume(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input UserVolume + expected UserVolume + }{ + {"far below min", -10, MinUserVol}, + {"at min", 0, 0}, + {"middle", 50, 50}, + {"at max", 100, 100}, + {"above max", 150, MaxUserVol}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := clampVolume(tt.input) + if got != tt.expected { + t.Errorf("clampVolume(%d) = %d, want %d", tt.input, got, tt.expected) + } + }) + } +} + +func TestStateToMediaControls(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input State + expected mediacontrols.PlaybackState + }{ + {"playing", Playing, mediacontrols.StatePlaying}, + {"paused", Paused, mediacontrols.StatePaused}, + {"stopped", Stopped, mediacontrols.StateStopped}, + {"unknown", State("unknown"), mediacontrols.StateStopped}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := stateToMediaControls(tt.input) + if got != tt.expected { + t.Errorf("stateToMediaControls(%q) = %d, want %d", tt.input, got, tt.expected) + } + }) + } +} diff --git a/backend/playlist/favorites.go b/backend/playlist/favorites.go new file mode 100644 index 0000000..66c3a9b --- /dev/null +++ b/backend/playlist/favorites.go @@ -0,0 +1,357 @@ +package playlist + +import ( + "errors" + "fmt" + "time" + + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/events" + "yellowjacket/backend/favorites" +) + +var errNoDefaultPlaylist = errors.New( + "no default playlist configured", +) + +// FavoritesConfigProvider is a narrow interface for reading and +// writing the default-playlist configuration. +type FavoritesConfigProvider interface { + GetFavoritesPlaylistID() int64 + SetFavoritesPlaylistID(id int64) error + GetFavoritesIconStyle() string +} + +// EnsureDefaultPlaylist verifies the configured default playlist +// exists in the database. If the playlist is missing or no ID +// has been configured yet, a new playlist named "Favorites" is +// created and the config is updated. +func (s *Service) EnsureDefaultPlaylist() { + if s.favoritesConf == nil { + s.logger.Warn( + "No favorites config provider, skipping", + ) + + return + } + + id := s.favoritesConf.GetFavoritesPlaylistID() + + // Check whether the playlist still exists. + if id > 0 { + _, err := s.db.Queries.GetPlaylist( + s.db.Ctx, id, + ) + if err == nil { + return // Playlist exists, nothing to do. + } + + s.logger.Warn( + "Default playlist not found, recreating", + "configuredId", id, + ) + } + + // Create a fresh default playlist. + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, favorites.DefaultPlaylistName, + ) + if err != nil { + s.logger.Error( + "Failed to create default playlist", + "err", err, + ) + + return + } + + s.savePlaylistFile(created.ID, created.Name) + + if setErr := s.favoritesConf.SetFavoritesPlaylistID( + created.ID, + ); setErr != nil { + s.logger.Error( + "Failed to save default playlist ID", + "err", setErr, + ) + } + + s.logger.Info( + "Default playlist created", + "id", created.ID, + "name", created.Name, + ) + + s.emitEvent(events.PlaylistCreated, Summary{ + ID: created.ID, + Name: created.Name, + CreatedAt: created.CreatedAt.Format(time.RFC3339), + UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + }) +} + +// GetDefaultPlaylistTrackPaths returns the file paths of all +// tracks in the default playlist. +func (s *Service) GetDefaultPlaylistTrackPaths() ( + []string, + error, +) { + id := s.defaultPlaylistID() + if id == 0 { + return []string{}, nil + } + + paths, err := s.db.Queries.GetPlaylistTrackFilePaths( + s.db.Ctx, id, + ) + if err != nil { + s.logger.Error( + "Failed to get default playlist paths", + "playlistId", id, + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get default playlist paths: %w", + err, + ) + } + + if paths == nil { + paths = []string{} + } + + return paths, nil +} + +// GetDefaultPlaylistInfo returns the ID and name of the default +// playlist for display in the frontend. +func (s *Service) GetDefaultPlaylistInfo() ( + Summary, + error, +) { + id := s.defaultPlaylistID() + if id == 0 { + return Summary{}, nil + } + + pl, err := s.db.Queries.GetPlaylist(s.db.Ctx, id) + if err != nil { + return Summary{}, fmt.Errorf( + "failed to get default playlist: %w", err, + ) + } + + return Summary{ + ID: pl.ID, + Name: pl.Name, + CreatedAt: pl.CreatedAt.Format(time.RFC3339), + UpdatedAt: pl.UpdatedAt.Format(time.RFC3339), + }, nil +} + +// ToggleDefaultPlaylistTrack adds or removes a single track +// from the default playlist. Returns true if the track is now +// in the playlist (was added), false if it was removed. +func (s *Service) ToggleDefaultPlaylistTrack( + filePath string, +) (bool, error) { + id := s.defaultPlaylistID() + if id == 0 { + return false, errNoDefaultPlaylist + } + + inPlaylist, err := s.db.Queries.IsTrackInPlaylist( + s.db.Ctx, + sqlcgen.IsTrackInPlaylistParams{ + PlaylistID: id, + FilePath: filePath, + }, + ) + if err != nil { + return false, fmt.Errorf( + "failed to check playlist membership: %w", + err, + ) + } + + if inPlaylist != 0 { + // Remove. + if rmErr := s.db.Queries.RemovePlaylistTrackByPath( + s.db.Ctx, + sqlcgen.RemovePlaylistTrackByPathParams{ + PlaylistID: id, + FilePath: filePath, + }, + ); rmErr != nil { + return false, fmt.Errorf( + "failed to remove track: %w", rmErr, + ) + } + + s.savePlaylistFileByID(id) + s.emitEvent( + events.DefaultPlaylistChanged, + map[string]any{ + "filePath": filePath, + "added": false, + }, + ) + s.emitEvent(events.PlaylistTracksChanged, id) + + return false, nil + } + + // Add. + nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, id, + ) + if posErr != nil { + return false, fmt.Errorf( + "failed to get next position: %w", posErr, + ) + } + + if addErr := s.addSingleTrack( + id, filePath, nextPos, + ); addErr != nil { + return false, fmt.Errorf( + "failed to add track: %w", addErr, + ) + } + + s.savePlaylistFileByID(id) + s.emitEvent( + events.DefaultPlaylistChanged, + map[string]any{ + "filePath": filePath, + "added": true, + }, + ) + s.emitEvent(events.PlaylistTracksChanged, id) + + return true, nil +} + +// AddToDefaultPlaylist adds multiple tracks to the default +// playlist, skipping any that are already present. +func (s *Service) AddToDefaultPlaylist( + filePaths []string, +) error { + id := s.defaultPlaylistID() + if id == 0 { + return errNoDefaultPlaylist + } + + nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, id, + ) + if err != nil { + return fmt.Errorf( + "failed to get next position: %w", err, + ) + } + + var added int + + for _, fp := range filePaths { + inPlaylist, chkErr := s.db.Queries.IsTrackInPlaylist( + s.db.Ctx, + sqlcgen.IsTrackInPlaylistParams{ + PlaylistID: id, + FilePath: fp, + }, + ) + if chkErr != nil { + s.logger.Warn( + "Could not check playlist membership", + "filePath", fp, + "err", chkErr, + ) + + continue + } + + if inPlaylist != 0 { + continue + } + + if addErr := s.addSingleTrack( + id, fp, nextPos+int64(added), + ); addErr != nil { + s.logger.Warn( + "Could not add track to default playlist", + "filePath", fp, + "err", addErr, + ) + + continue + } + + added++ + } + + if added > 0 { + s.savePlaylistFileByID(id) + s.emitEvent( + events.DefaultPlaylistChanged, nil, + ) + s.emitEvent(events.PlaylistTracksChanged, id) + } + + return nil +} + +// RemoveFromDefaultPlaylist removes multiple tracks from the +// default playlist. +func (s *Service) RemoveFromDefaultPlaylist( + filePaths []string, +) error { + id := s.defaultPlaylistID() + if id == 0 { + return errNoDefaultPlaylist + } + + var removed int + + for _, fp := range filePaths { + rmErr := s.db.Queries.RemovePlaylistTrackByPath( + s.db.Ctx, + sqlcgen.RemovePlaylistTrackByPathParams{ + PlaylistID: id, + FilePath: fp, + }, + ) + if rmErr != nil { + s.logger.Warn( + "Could not remove track from default playlist", + "filePath", fp, + "err", rmErr, + ) + + continue + } + + removed++ + } + + if removed > 0 { + s.savePlaylistFileByID(id) + s.emitEvent( + events.DefaultPlaylistChanged, nil, + ) + s.emitEvent(events.PlaylistTracksChanged, id) + } + + return nil +} + +// defaultPlaylistID returns the configured default playlist ID, +// or 0 if not configured. +func (s *Service) defaultPlaylistID() int64 { + if s.favoritesConf == nil { + return 0 + } + + return s.favoritesConf.GetFavoritesPlaylistID() +} diff --git a/backend/playlist/m3u.go b/backend/playlist/m3u.go new file mode 100644 index 0000000..52b66ac --- /dev/null +++ b/backend/playlist/m3u.go @@ -0,0 +1,502 @@ +package playlist + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const ( + m3uHeader = "#EXTM3U" + m3uPlaylist = "#PLAYLIST:" + m3uExtInf = "#EXTINF:" + m3uExtension = ".m3u8" +) + +var ( + errEmptyM3UFile = errors.New("M3U file is empty") + errPlaylistDirNil = errors.New("playlists directory path is empty") +) + +// unsafeChars matches characters that are not safe for filenames. +// Uses Unicode letter/digit classes so accented characters are kept. +var unsafeChars = regexp.MustCompile(`[^\p{L}\p{N}\-. ]+`) + +// m3uEntry represents a single track entry parsed from an M3U8 file. +type m3uEntry struct { + // RelativePath is the path relative to the library root. + RelativePath string + // DurationSec is the track duration in seconds (from #EXTINF). + DurationSec int + // DisplayTitle is the display title (from #EXTINF). + DisplayTitle string +} + +// parsedPlaylist is the result of parsing an M3U8 file. +type parsedPlaylist struct { + Name string + Entries []m3uEntry +} + +// writeM3U8 writes a playlist to an M3U8 file at the given directory. +// The file is named "{id}-{sanitized-name}.m3u8". +func writeM3U8( + dirPath string, + playlistID int64, + name string, + entries []m3uEntry, +) error { + if dirPath == "" { + return errPlaylistDirNil + } + + filePath := playlistFilePath(dirPath, playlistID, name) + + // Remove any old file for this ID with a different name. + if err := removeOldPlaylistFile( + dirPath, playlistID, filePath, + ); err != nil { + return fmt.Errorf( + "could not remove old playlist file: %w", err, + ) + } + + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf( + "could not create M3U8 file %q: %w", + filePath, err, + ) + } + + defer func() { _ = file.Close() }() + + w := bufio.NewWriter(file) + + // Write header. + _, _ = fmt.Fprintln(w, m3uHeader) + _, _ = fmt.Fprintf( + w, "%s%s\n", m3uPlaylist, name, + ) + + // Write entries. + for _, entry := range entries { + _, _ = fmt.Fprintf( + w, "%s%d,%s\n", + m3uExtInf, + entry.DurationSec, + entry.DisplayTitle, + ) + _, _ = fmt.Fprintln(w, entry.RelativePath) + } + + if err := w.Flush(); err != nil { + return fmt.Errorf( + "could not flush M3U8 file %q: %w", + filePath, err, + ) + } + + return nil +} + +// parseM3U8 reads and parses an M3U8 (or M3U) file. +func parseM3U8(filePath string) (parsedPlaylist, error) { + file, err := os.Open(filePath) + if err != nil { + return parsedPlaylist{}, fmt.Errorf( + "could not open M3U file %q: %w", filePath, err, + ) + } + + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + + var result parsedPlaylist + + headerSeen := false + pendingDuration := 0 + pendingTitle := "" + hasPendingExtInf := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Check header. If the first non-empty line is not + // #EXTM3U, treat the file as a simple M3U (just + // path lines) and fall through to process normally. + if !headerSeen { + headerSeen = true + + if line == m3uHeader { + continue + } + } + + // Playlist name directive. + if strings.HasPrefix(line, m3uPlaylist) { + result.Name = strings.TrimPrefix(line, m3uPlaylist) + + continue + } + + // EXTINF line. + if strings.HasPrefix(line, m3uExtInf) { + dur, title := parseExtInf(line) + pendingDuration = dur + pendingTitle = title + hasPendingExtInf = true + + continue + } + + // Skip other comment lines. + if strings.HasPrefix(line, "#") { + continue + } + + // This is a track path line. + entry := m3uEntry{ + RelativePath: line, + } + + if hasPendingExtInf { + entry.DurationSec = pendingDuration + entry.DisplayTitle = pendingTitle + hasPendingExtInf = false + pendingDuration = 0 + pendingTitle = "" + } + + result.Entries = append(result.Entries, entry) + } + + if err := scanner.Err(); err != nil { + return parsedPlaylist{}, fmt.Errorf( + "error reading M3U file %q: %w", filePath, err, + ) + } + + if !headerSeen { + return parsedPlaylist{}, errEmptyM3UFile + } + + // Derive name from filename if not set via #PLAYLIST directive. + if result.Name == "" { + base := filepath.Base(filePath) + result.Name = strings.TrimSuffix( + base, filepath.Ext(base), + ) + + // Strip ID prefix if present (e.g., "1-my-playlist"). + if idx := strings.Index(result.Name, "-"); idx > 0 { + prefix := result.Name[:idx] + if _, err := strconv.ParseInt( + prefix, 10, 64, + ); err == nil { + result.Name = result.Name[idx+1:] + } + } + } + + return result, nil +} + +// parseExtInf parses an #EXTINF line and returns duration and title. +// Format: #EXTINF:duration,display title. +func parseExtInf(line string) (int, string) { + data := strings.TrimPrefix(line, m3uExtInf) + + commaIdx := strings.Index(data, ",") + if commaIdx < 0 { + dur, _ := strconv.Atoi(strings.TrimSpace(data)) + + return dur, "" + } + + durStr := strings.TrimSpace(data[:commaIdx]) + title := strings.TrimSpace(data[commaIdx+1:]) + + dur, _ := strconv.Atoi(durStr) + + return dur, title +} + +// playlistFilePath returns the full path for a playlist M3U8 file. +func playlistFilePath( + dirPath string, + id int64, + name string, +) string { + sanitized := sanitizeFilename(name) + + return filepath.Join( + dirPath, + fmt.Sprintf("%d-%s%s", id, sanitized, m3uExtension), + ) +} + +// sanitizeFilename converts a playlist name to a safe filename. +func sanitizeFilename(name string) string { + // Lowercase. + s := strings.ToLower(name) + + // Replace spaces and underscores with hyphens. + s = strings.ReplaceAll(s, " ", "-") + s = strings.ReplaceAll(s, "_", "-") + + // Remove unsafe characters. + s = unsafeChars.ReplaceAllString(s, "") + + // Collapse multiple hyphens. + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + + // Trim leading/trailing hyphens and dots. + s = strings.Trim(s, "-.") + + // Ensure non-empty. + if s == "" { + s = "playlist" + } + + // Truncate to a reasonable length. + const maxLen = 100 + + if runeCount := len([]rune(s)); runeCount > maxLen { + runes := []rune(s) + s = string(runes[:maxLen]) + } + + return s +} + +// findPlaylistFile finds the existing M3U8 file for a given playlist +// ID by globbing for "{id}-*.m3u8". +func findPlaylistFile( + dirPath string, + id int64, +) (string, error) { + pattern := filepath.Join( + dirPath, + fmt.Sprintf("%d-*%s", id, m3uExtension), + ) + + matches, err := filepath.Glob(pattern) + if err != nil { + return "", fmt.Errorf( + "could not glob for playlist file: %w", err, + ) + } + + // Filter matches to ensure the extracted ID matches the + // target. The glob pattern "1-*.m3u8" also matches + // "10-foo.m3u8", "11-bar.m3u8", etc. + for _, m := range matches { + if extractPlaylistID(m) == id { + return m, nil + } + } + + return "", nil +} + +// removeOldPlaylistFile removes an old playlist file for the given +// ID if it exists and differs from the expected path. +func removeOldPlaylistFile( + dirPath string, + id int64, + expectedPath string, +) error { + existing, err := findPlaylistFile(dirPath, id) + if err != nil { + return err + } + + if existing == "" || existing == expectedPath { + return nil + } + + if err := os.Remove(existing); err != nil && !os.IsNotExist(err) { + return fmt.Errorf( + "could not remove old playlist file %q: %w", + existing, err, + ) + } + + return nil +} + +// toAbsolutePath converts a relative path to an absolute path using +// the library root. If the path is already absolute, it is returned +// as-is. +func toAbsolutePath(relativePath, libraryRoot string) string { + if filepath.IsAbs(relativePath) { + return relativePath + } + + return filepath.Join(libraryRoot, relativePath) +} + +// toRelativePath converts an absolute path to a relative path based +// on the library root. If the path cannot be made relative, it is +// returned as-is. +func toRelativePath(absolutePath, libraryRoot string) string { + if libraryRoot == "" { + return absolutePath + } + + rel, err := filepath.Rel(libraryRoot, absolutePath) + if err != nil { + return absolutePath + } + + // If the relative path escapes the library root (starts with + // ".."), keep the absolute path. + if strings.HasPrefix(rel, "..") { + return absolutePath + } + + return rel +} + +// isValidM3UExtension checks whether a file extension is a +// recognized M3U variant. +func isValidM3UExtension(ext string) bool { + lower := strings.ToLower(ext) + + return lower == ".m3u" || lower == ".m3u8" +} + +// listPlaylistFiles returns all M3U8 files in the playlists +// directory. +func listPlaylistFiles(dirPath string) ([]string, error) { + pattern := filepath.Join(dirPath, "*"+m3uExtension) + + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf( + "could not list playlist files: %w", err, + ) + } + + return matches, nil +} + +// extractPlaylistID extracts the playlist DB ID from an M3U8 +// filename. The expected format is "{id}-{name}.m3u8". Returns 0 if +// the ID cannot be extracted. +func extractPlaylistID(filePath string) int64 { + base := filepath.Base(filePath) + name := strings.TrimSuffix(base, filepath.Ext(base)) + + idx := strings.Index(name, "-") + if idx <= 0 { + return 0 + } + + id, err := strconv.ParseInt(name[:idx], 10, 64) + if err != nil { + return 0 + } + + return id +} + +// removeM3UEntries removes entries from a slice whose resolved +// absolute paths appear in the target set. +func removeM3UEntries( + entries []m3uEntry, + targetAbsPaths map[string]struct{}, + libraryRoot string, +) []m3uEntry { + result := make([]m3uEntry, 0, len(entries)) + + for _, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + if _, remove := targetAbsPaths[absPath]; remove { + continue + } + + result = append(result, e) + } + + return result +} + +// replaceM3UEntryPaths replaces the relative paths of entries +// whose resolved absolute paths match keys in the replacements +// map. Values are new relative paths. +func replaceM3UEntryPaths( + entries []m3uEntry, + replacements map[string]string, + libraryRoot string, +) []m3uEntry { + result := make([]m3uEntry, len(entries)) + + for i, e := range entries { + result[i] = e + + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + + if newRel, ok := replacements[absPath]; ok { + result[i].RelativePath = newRel + } + } + + return result +} + +// findM3UEntry finds the M3U entry whose resolved absolute path +// matches the given target path. Returns the entry and its index, +// or -1 if not found. +func findM3UEntry( + entries []m3uEntry, + targetAbsPath string, + libraryRoot string, +) (m3uEntry, int) { + for i, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + if absPath == targetAbsPath { + return e, i + } + } + + return m3uEntry{}, -1 +} + +// displayTitle builds an EXTINF display title from artist and title. +func displayTitle(artist, title string) string { + artist = strings.TrimSpace(artist) + title = strings.TrimSpace(title) + + if artist == "" && title == "" { + return "Unknown" + } + + if artist == "" { + return title + } + + if title == "" { + return artist + } + + return artist + " - " + title +} diff --git a/backend/playlist/m3u_test.go b/backend/playlist/m3u_test.go new file mode 100644 index 0000000..f9c10f6 --- /dev/null +++ b/backend/playlist/m3u_test.go @@ -0,0 +1,862 @@ +package playlist + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSanitizeFilename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple name", + input: "My Playlist", + expected: "my-playlist", + }, + { + name: "special characters", + input: "Rock & Roll: Best Of!", + expected: "rock-roll-best-of", + }, + { + name: "unicode characters", + input: "Música Favorita", + expected: "música-favorita", + }, + { + name: "empty string", + input: "", + expected: "playlist", + }, + { + name: "only special characters", + input: "!!!@@@###", + expected: "playlist", + }, + { + name: "underscores become hyphens", + input: "my_cool_playlist", + expected: "my-cool-playlist", + }, + { + name: "multiple spaces collapse", + input: "my big playlist", + expected: "my-big-playlist", + }, + { + name: "leading and trailing hyphens trimmed", + input: " --hello-- ", + expected: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := sanitizeFilename(tt.input) + if result != tt.expected { + t.Errorf( + "sanitizeFilename(%q) = %q, want %q", + tt.input, result, tt.expected, + ) + } + }) + } +} + +func TestPlaylistFilePath(t *testing.T) { + t.Parallel() + + result := playlistFilePath("/data/playlists", 42, "My Favorites") + expected := filepath.Join( + "/data/playlists", "42-my-favorites.m3u8", + ) + + if result != expected { + t.Errorf( + "playlistFilePath() = %q, want %q", + result, expected, + ) + } +} + +func TestWriteAndParseM3U8(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + entries := []m3uEntry{ + { + RelativePath: "Artist/Album/01 - Song.flac", + DurationSec: 243, + DisplayTitle: "Artist - Song", + }, + { + RelativePath: "Other/Track.mp3", + DurationSec: 180, + DisplayTitle: "Other - Track", + }, + } + + err := writeM3U8(dir, 1, "Test Playlist", entries) + if err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + + // Verify file exists. + expectedPath := filepath.Join(dir, "1-test-playlist.m3u8") + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected file %q to exist: %v", expectedPath, err) + } + + // Parse it back. + parsed, err := parseM3U8(expectedPath) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if parsed.Name != "Test Playlist" { + t.Errorf( + "parsed.Name = %q, want %q", + parsed.Name, "Test Playlist", + ) + } + + if len(parsed.Entries) != len(entries) { + t.Fatalf( + "parsed %d entries, want %d", + len(parsed.Entries), len(entries), + ) + } + + for i, entry := range parsed.Entries { + if entry.RelativePath != entries[i].RelativePath { + t.Errorf( + "entry[%d].RelativePath = %q, want %q", + i, entry.RelativePath, + entries[i].RelativePath, + ) + } + + if entry.DurationSec != entries[i].DurationSec { + t.Errorf( + "entry[%d].DurationSec = %d, want %d", + i, entry.DurationSec, + entries[i].DurationSec, + ) + } + + if entry.DisplayTitle != entries[i].DisplayTitle { + t.Errorf( + "entry[%d].DisplayTitle = %q, want %q", + i, entry.DisplayTitle, + entries[i].DisplayTitle, + ) + } + } +} + +func TestWriteM3U8EmptyPlaylist(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := writeM3U8(dir, 5, "Empty", nil) + if err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + + parsed, err := parseM3U8( + filepath.Join(dir, "5-empty.m3u8"), + ) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if parsed.Name != "Empty" { + t.Errorf("parsed.Name = %q, want %q", parsed.Name, "Empty") + } + + if len(parsed.Entries) != 0 { + t.Errorf( + "parsed %d entries, want 0", + len(parsed.Entries), + ) + } +} + +func TestWriteM3U8EmptyDir(t *testing.T) { + t.Parallel() + + err := writeM3U8("", 1, "test", nil) + if err == nil { + t.Fatal("expected error for empty dir path") + } +} + +func TestParseM3U8EmptyFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + emptyFile := filepath.Join(dir, "empty.m3u8") + + err := os.WriteFile( + emptyFile, + []byte(""), + 0o644, + ) + if err != nil { + t.Fatalf("could not write test file: %v", err) + } + + _, err = parseM3U8(emptyFile) + if err == nil { + t.Fatal("expected error for empty M3U file") + } +} + +func TestParseM3U8NonExistentFile(t *testing.T) { + t.Parallel() + + _, err := parseM3U8("/nonexistent/file.m3u8") + if err == nil { + t.Fatal("expected error for non-existent file") + } +} + +func TestToRelativePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + absPath string + libraryRoot string + expected string + }{ + { + name: "normal relative", + absPath: "/music/Artist/Album/song.flac", + libraryRoot: "/music", + expected: "Artist/Album/song.flac", + }, + { + name: "path outside library root", + absPath: "/other/song.flac", + libraryRoot: "/music", + expected: "/other/song.flac", + }, + { + name: "empty library root", + absPath: "/music/song.flac", + libraryRoot: "", + expected: "/music/song.flac", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := toRelativePath( + tt.absPath, tt.libraryRoot, + ) + if result != tt.expected { + t.Errorf( + "toRelativePath(%q, %q) = %q, want %q", + tt.absPath, tt.libraryRoot, + result, tt.expected, + ) + } + }) + } +} + +func TestToAbsolutePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + relPath string + libraryRoot string + expected string + }{ + { + name: "relative path", + relPath: "Artist/Album/song.flac", + libraryRoot: "/music", + expected: "/music/Artist/Album/song.flac", + }, + { + name: "already absolute", + relPath: "/music/song.flac", + libraryRoot: "/other", + expected: "/music/song.flac", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := toAbsolutePath( + tt.relPath, tt.libraryRoot, + ) + if result != tt.expected { + t.Errorf( + "toAbsolutePath(%q, %q) = %q, want %q", + tt.relPath, tt.libraryRoot, + result, tt.expected, + ) + } + }) + } +} + +func TestDisplayTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + artist string + title string + expected string + }{ + { + name: "both present", + artist: "Artist", + title: "Title", + expected: "Artist - Title", + }, + { + name: "artist only", + artist: "Artist", + title: "", + expected: "Artist", + }, + { + name: "title only", + artist: "", + title: "Title", + expected: "Title", + }, + { + name: "neither present", + artist: "", + title: "", + expected: "Unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := displayTitle(tt.artist, tt.title) + if result != tt.expected { + t.Errorf( + "displayTitle(%q, %q) = %q, want %q", + tt.artist, tt.title, + result, tt.expected, + ) + } + }) + } +} + +func TestExtractPlaylistID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + expected int64 + }{ + { + name: "normal ID-prefixed filename", + filePath: "/data/playlists/42-my-favorites.m3u8", + expected: 42, + }, + { + name: "no ID prefix", + filePath: "/data/playlists/my-favorites.m3u8", + expected: 0, + }, + { + name: "ID only", + filePath: "/data/playlists/1-.m3u8", + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := extractPlaylistID(tt.filePath) + if result != tt.expected { + t.Errorf( + "extractPlaylistID(%q) = %d, want %d", + tt.filePath, result, tt.expected, + ) + } + }) + } +} + +func TestFindPlaylistFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create a playlist file. + err := writeM3U8(dir, 7, "Test", nil) + if err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + + // Find it. + found, err := findPlaylistFile(dir, 7) + if err != nil { + t.Fatalf("findPlaylistFile() error = %v", err) + } + + if found == "" { + t.Fatal("expected to find playlist file") + } + + // Try to find a non-existent ID. + found, err = findPlaylistFile(dir, 999) + if err != nil { + t.Fatalf("findPlaylistFile() error = %v", err) + } + + if found != "" { + t.Errorf("expected empty string, got %q", found) + } +} + +func TestIsValidM3UExtension(t *testing.T) { + t.Parallel() + + tests := []struct { + ext string + expected bool + }{ + {".m3u", true}, + {".m3u8", true}, + {".M3U", true}, + {".M3U8", true}, + {".mp3", false}, + {".txt", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.ext, func(t *testing.T) { + t.Parallel() + + result := isValidM3UExtension(tt.ext) + if result != tt.expected { + t.Errorf( + "isValidM3UExtension(%q) = %v, want %v", + tt.ext, result, tt.expected, + ) + } + }) + } +} + +func TestRemoveOldPlaylistFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create an initial playlist file. + err := writeM3U8(dir, 3, "Old Name", nil) + if err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + + oldPath := filepath.Join(dir, "3-old-name.m3u8") + if _, err := os.Stat(oldPath); err != nil { + t.Fatalf("old file should exist: %v", err) + } + + // Write with a new name — should remove the old file. + err = writeM3U8(dir, 3, "New Name", nil) + if err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + + // Old file should be gone. + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Error("old file should have been removed") + } + + // New file should exist. + newPath := filepath.Join(dir, "3-new-name.m3u8") + if _, err := os.Stat(newPath); err != nil { + t.Errorf("new file should exist: %v", err) + } +} + +func TestListPlaylistFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create some playlist files. + for i := int64(1); i <= 3; i++ { + if err := writeM3U8( + dir, i, "playlist", nil, + ); err != nil { + t.Fatalf("writeM3U8() error = %v", err) + } + } + + // Also create a non-m3u8 file that should be ignored. + err := os.WriteFile( + filepath.Join(dir, "notes.txt"), + []byte("test"), + 0o644, + ) + if err != nil { + t.Fatalf("could not create decoy file: %v", err) + } + + files, err := listPlaylistFiles(dir) + if err != nil { + t.Fatalf("listPlaylistFiles() error = %v", err) + } + + if len(files) != 3 { + t.Errorf("found %d files, want 3", len(files)) + } +} + +func TestParseExtInf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + expectedDur int + expectedName string + }{ + { + name: "standard EXTINF", + line: "#EXTINF:243,Artist - Title", + expectedDur: 243, + expectedName: "Artist - Title", + }, + { + name: "duration only", + line: "#EXTINF:180", + expectedDur: 180, + expectedName: "", + }, + { + name: "zero duration", + line: "#EXTINF:0,Some Title", + expectedDur: 0, + expectedName: "Some Title", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dur, title := parseExtInf(tt.line) + if dur != tt.expectedDur { + t.Errorf( + "duration = %d, want %d", + dur, tt.expectedDur, + ) + } + + if title != tt.expectedName { + t.Errorf( + "title = %q, want %q", + title, tt.expectedName, + ) + } + }) + } +} + +func TestFindPlaylistFileOverlappingIDs(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create playlists with IDs 1 and 10 — the glob + // pattern "1-*.m3u8" must not match "10-longer.m3u8". + if err := writeM3U8(dir, 1, "Short", nil); err != nil { + t.Fatalf("writeM3U8(1) error = %v", err) + } + + if err := writeM3U8(dir, 10, "Longer", nil); err != nil { + t.Fatalf("writeM3U8(10) error = %v", err) + } + + found, err := findPlaylistFile(dir, 1) + if err != nil { + t.Fatalf("findPlaylistFile(1) error = %v", err) + } + + if got := extractPlaylistID(found); got != 1 { + t.Errorf( + "findPlaylistFile(1) returned ID %d, want 1", + got, + ) + } + + found, err = findPlaylistFile(dir, 10) + if err != nil { + t.Fatalf("findPlaylistFile(10) error = %v", err) + } + + if got := extractPlaylistID(found); got != 10 { + t.Errorf( + "findPlaylistFile(10) returned ID %d, want 10", + got, + ) + } +} + +func TestParseM3U8SimpleFormat(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + simpleFile := filepath.Join(dir, "simple.m3u") + + // Write a simple M3U with no #EXTM3U header — just paths. + content := "Artist/Album/01 - Song.flac\nOther/Track.mp3\n" + + if err := os.WriteFile( + simpleFile, []byte(content), 0o644, + ); err != nil { + t.Fatalf("could not write test file: %v", err) + } + + parsed, err := parseM3U8(simpleFile) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if len(parsed.Entries) != 2 { + t.Fatalf( + "parsed %d entries, want 2", + len(parsed.Entries), + ) + } + + if parsed.Entries[0].RelativePath != + "Artist/Album/01 - Song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + parsed.Entries[0].RelativePath, + "Artist/Album/01 - Song.flac", + ) + } + + if parsed.Entries[1].RelativePath != + "Other/Track.mp3" { + t.Errorf( + "entry[1].RelativePath = %q, want %q", + parsed.Entries[1].RelativePath, + "Other/Track.mp3", + ) + } +} + +func TestParseM3U8SimpleFormatWithComments(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + simpleFile := filepath.Join(dir, "commented.m3u") + + // Simple M3U with comment lines (no #EXTM3U header). + content := "# Generated by SomeApp\n" + + "Artist/Song.flac\n" + + "# Another comment\n" + + "Other/Track.mp3\n" + + if err := os.WriteFile( + simpleFile, []byte(content), 0o644, + ); err != nil { + t.Fatalf("could not write test file: %v", err) + } + + parsed, err := parseM3U8(simpleFile) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if len(parsed.Entries) != 2 { + t.Fatalf( + "parsed %d entries, want 2", + len(parsed.Entries), + ) + } + + if parsed.Entries[0].RelativePath != + "Artist/Song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + parsed.Entries[0].RelativePath, + "Artist/Song.flac", + ) + } +} + +func TestRemoveM3UEntries(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Artist/Song1.flac"}, + {RelativePath: "Artist/Song2.flac"}, + {RelativePath: "Artist/Song3.flac"}, + } + + targets := map[string]struct{}{ + "/music/Artist/Song2.flac": {}, + } + + result := removeM3UEntries(entries, targets, "/music") + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + if result[0].RelativePath != "Artist/Song1.flac" { + t.Errorf( + "entry[0] = %q, want %q", + result[0].RelativePath, + "Artist/Song1.flac", + ) + } + + if result[1].RelativePath != "Artist/Song3.flac" { + t.Errorf( + "entry[1] = %q, want %q", + result[1].RelativePath, + "Artist/Song3.flac", + ) + } +} + +func TestRemoveM3UEntriesAll(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Song.flac"}, + } + + targets := map[string]struct{}{ + "/music/Song.flac": {}, + } + + result := removeM3UEntries(entries, targets, "/music") + + if len(result) != 0 { + t.Errorf("expected 0 entries, got %d", len(result)) + } +} + +func TestReplaceM3UEntryPaths(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + { + RelativePath: "old/path/song.flac", + DurationSec: 180, + DisplayTitle: "Song", + }, + { + RelativePath: "other/track.mp3", + DurationSec: 240, + DisplayTitle: "Track", + }, + } + + replacements := map[string]string{ + "/music/old/path/song.flac": "new/path/song.flac", + } + + result := replaceM3UEntryPaths( + entries, replacements, "/music", + ) + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + if result[0].RelativePath != "new/path/song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + result[0].RelativePath, + "new/path/song.flac", + ) + } + + // Duration and title should be preserved. + if result[0].DurationSec != 180 { + t.Errorf( + "entry[0].DurationSec = %d, want 180", + result[0].DurationSec, + ) + } + + // Unchanged entry should remain the same. + if result[1].RelativePath != "other/track.mp3" { + t.Errorf( + "entry[1].RelativePath = %q, want %q", + result[1].RelativePath, + "other/track.mp3", + ) + } +} + +func TestFindM3UEntry(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Artist/Song1.flac"}, + {RelativePath: "Artist/Song2.flac"}, + {RelativePath: "Artist/Song3.flac"}, + } + + entry, idx := findM3UEntry( + entries, "/music/Artist/Song2.flac", "/music", + ) + + if idx != 1 { + t.Errorf("expected index 1, got %d", idx) + } + + if entry.RelativePath != "Artist/Song2.flac" { + t.Errorf( + "entry.RelativePath = %q, want %q", + entry.RelativePath, + "Artist/Song2.flac", + ) + } + + // Not found. + _, idx = findM3UEntry( + entries, "/music/Artist/Missing.flac", "/music", + ) + + if idx != -1 { + t.Errorf("expected index -1, got %d", idx) + } +} diff --git a/backend/playlist/match.go b/backend/playlist/match.go new file mode 100644 index 0000000..e6f61f4 --- /dev/null +++ b/backend/playlist/match.go @@ -0,0 +1,379 @@ +// Package playlist provides playlist management functionality. +package playlist + +import ( + "math" + "path/filepath" + "regexp" + "strings" + "unicode/utf8" +) + +// Scoring weights for candidate matching. +const ( + weightFilename = 0.50 + weightTitle = 0.30 + weightDuration = 0.10 + weightPathDirs = 0.10 + autoMatchMinimum = 0.85 +) + +// maxCandidates is the default limit for search results. +const maxCandidates = 20 + +// maxLibrarySearchResults is the limit for manual library search. +const maxLibrarySearchResults = 50 + +// durationToleranceClose is the duration difference in seconds +// considered a near-exact match. +const durationToleranceClose = 1 + +// durationToleranceMedium is the medium tolerance threshold. +const durationToleranceMedium = 5 + +// durationToleranceFar is the maximum tolerance before scoring +// drops to zero. +const durationToleranceFar = 15 + +// separatorPattern splits file paths and names on common +// separators: slashes, hyphens, underscores, spaces, dots. +var separatorPattern = regexp.MustCompile( + `[/\\\-_. ]+`, +) + +// trackNumberPattern matches leading track numbers like +// "01", "1", "01.", "01 -", etc. +var trackNumberPattern = regexp.MustCompile( + `^\d{1,3}[.\-\s]*$`, +) + +// phantomProfile pre-computes all derived data for a phantom +// track so that scoring multiple candidates avoids redundant +// string processing. +type phantomProfile struct { + baseLower string // lowercase basename + baseStem string // basename without extension + baseWords []string // significant words from stem + dirWords []string // significant words from dir path + displayLow string // lowercase display title + parsedArt string // parsed artist from display title + parsedTitle string // parsed title from display title + titleWords []string // significant words from display title + durationSec int // phantom duration in seconds +} + +// newPhantomProfile builds a phantomProfile from raw phantom +// data, performing all string splits and normalisation once. +func newPhantomProfile( + phantomPath string, + displayTitle string, + durationSec int, +) phantomProfile { + baseLower := strings.ToLower( + filepath.Base(phantomPath), + ) + baseStem := stripExtension(baseLower) + displayLow := strings.ToLower( + strings.TrimSpace(displayTitle), + ) + parsedArt, parsedTitle := parseDisplayTitle(displayLow) + + return phantomProfile{ + baseLower: baseLower, + baseStem: baseStem, + baseWords: significantWords(baseStem), + dirWords: pathDirWords(phantomPath), + displayLow: displayLow, + parsedArt: parsedArt, + parsedTitle: parsedTitle, + titleWords: significantWords(displayLow), + durationSec: durationSec, + } +} + +// scoreCandidate computes a match confidence (0.0-1.0) between +// a phantom track and a candidate library track. +func scoreCandidate( + pp phantomProfile, + candidatePath string, + candidateTitle string, + candidateArtist string, + candidateDurationMs int64, +) float64 { + fnScore := scoreFilename(pp, candidatePath) + titleScore := scoreTitleArtist( + pp, candidateTitle, candidateArtist, + ) + durScore := scoreDuration( + pp.durationSec, candidateDurationMs, + ) + dirScore := scorePathDirs(pp, candidatePath) + + // If duration is unknown, redistribute its weight to + // filename. + fnWeight := weightFilename + durWeight := weightDuration + + if pp.durationSec == 0 { + fnWeight += durWeight + durWeight = 0 + } + + return fnScore*fnWeight + + titleScore*weightTitle + + durScore*durWeight + + dirScore*weightPathDirs +} + +// scoreFilename compares the basenames of two file paths. +func scoreFilename( + pp phantomProfile, candidatePath string, +) float64 { + cBase := strings.ToLower( + filepath.Base(candidatePath), + ) + + // Exact basename match. + if pp.baseLower == cBase { + return 1.0 + } + + // Match ignoring extension. + cStem := stripExtension(cBase) + + if pp.baseStem == cStem { + return 0.8 + } + + // Check if all significant words from phantom stem appear + // in candidate stem. + cWords := significantWords(cStem) + + if len(pp.baseWords) == 0 { + return 0.0 + } + + return keywordOverlap(pp.baseWords, cWords) +} + +// scoreTitleArtist compares the phantom's EXTINF display title +// against the candidate's DB title and artist fields. +func scoreTitleArtist( + pp phantomProfile, + candidateTitle, candidateArtist string, +) float64 { + if pp.displayLow == "" { + return 0.0 + } + + candidateTitle = strings.ToLower( + strings.TrimSpace(candidateTitle), + ) + candidateArtist = strings.ToLower( + strings.TrimSpace(candidateArtist), + ) + + // Exact title match. + if pp.parsedTitle != "" && + pp.parsedTitle == candidateTitle { + if pp.parsedArt != "" && + pp.parsedArt == candidateArtist { + return 1.0 + } + + return 0.8 + } + + // Keyword overlap between display title and combined + // candidate metadata. + combined := candidateTitle + " " + candidateArtist + cWords := significantWords(combined) + + if len(pp.titleWords) == 0 { + return 0.0 + } + + return keywordOverlap(pp.titleWords, cWords) +} + +// scoreDuration computes a score based on duration proximity. +func scoreDuration( + phantomSec int, candidateMs int64, +) float64 { + if phantomSec == 0 || candidateMs == 0 { + return 0.0 + } + + diff := math.Abs( + float64(phantomSec) - float64(candidateMs)/1000.0, + ) + + switch { + case diff <= float64(durationToleranceClose): + return 1.0 + case diff <= float64(durationToleranceMedium): + return 0.8 + case diff <= float64(durationToleranceFar): + return 0.5 + default: + return 0.0 + } +} + +// scorePathDirs compares the directory components of two paths. +func scorePathDirs( + pp phantomProfile, candidatePath string, +) float64 { + if len(pp.dirWords) == 0 { + return 0.0 + } + + cDirs := pathDirWords(candidatePath) + + return keywordOverlap(pp.dirWords, cDirs) +} + +// parseDisplayTitle splits an EXTINF display title on " - " into +// (artist, title). If no separator is found, returns ("", full). +func parseDisplayTitle(dt string) (artist, title string) { + idx := strings.Index(dt, " - ") + if idx < 0 { + return "", dt + } + + return strings.TrimSpace(dt[:idx]), + strings.TrimSpace(dt[idx+3:]) +} + +// extractKeywords extracts meaningful search keywords from a file +// path by splitting on separators, removing track numbers, common +// noise words, and the file extension. +func extractKeywords(filePath string) []string { + // Remove extension. + stem := stripExtension(filePath) + + // Split on separators. + parts := separatorPattern.Split(stem, -1) + + var keywords []string + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Skip pure track numbers. + if trackNumberPattern.MatchString(p) { + continue + } + + // Skip very short tokens. + if len(p) < 2 { + continue + } + + keywords = append(keywords, strings.ToLower(p)) + } + + return dedupStrings(keywords) +} + +// significantWords extracts meaningful lowercase words from a +// string, filtering out noise. +func significantWords(s string) []string { + parts := separatorPattern.Split(s, -1) + + var words []string + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Skip pure track numbers. + if trackNumberPattern.MatchString(p) { + continue + } + + // Skip single characters. + if countRunes(p) < 2 { + continue + } + + words = append(words, strings.ToLower(p)) + } + + return words +} + +// pathDirWords extracts lowercase words from the directory +// portion of a path (excluding the filename). +func pathDirWords(filePath string) []string { + dir := filepath.Dir(filePath) + if dir == "." || dir == "/" { + return nil + } + + return significantWords(dir) +} + +// keywordOverlap calculates the proportion of source words that +// appear in target words (Jaccard-like, asymmetric). +func keywordOverlap(source, target []string) float64 { + if len(source) == 0 { + return 0.0 + } + + targetSet := make(map[string]struct{}, len(target)) + + for _, w := range target { + targetSet[w] = struct{}{} + } + + var matches int + + for _, w := range source { + if _, ok := targetSet[w]; ok { + matches++ + } + } + + return float64(matches) / float64(len(source)) +} + +// stripExtension removes the file extension from a path or +// filename. +func stripExtension(s string) string { + ext := filepath.Ext(s) + if ext == "" { + return s + } + + return s[:len(s)-len(ext)] +} + +// dedupStrings removes duplicate strings, preserving order. +func dedupStrings(ss []string) []string { + seen := make(map[string]struct{}, len(ss)) + + var result []string + + for _, s := range ss { + if _, ok := seen[s]; ok { + continue + } + + seen[s] = struct{}{} + + result = append(result, s) + } + + return result +} + +// countRunes returns the number of runes in a string. +func countRunes(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/backend/playlist/match_test.go b/backend/playlist/match_test.go new file mode 100644 index 0000000..2471019 --- /dev/null +++ b/backend/playlist/match_test.go @@ -0,0 +1,411 @@ +package playlist + +import ( + "math" + "testing" +) + +func TestScoreCandidateExactFilename(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/old/path/Artist/Album/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/new/path/Artist/Album/01 - Song.flac", + "Song", + "Artist", + 243000, + ) + + if score < 0.9 { + t.Errorf("expected score >= 0.9, got %f", score) + } +} + +func TestScoreCandidateNoMatch(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/music/Artist/Album/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/music/Completely/Different/track.mp3", + "Other Title", + "Other Artist", + 180000, + ) + + if score > 0.3 { + t.Errorf("expected score <= 0.3, got %f", score) + } +} + +func TestScoreCandidateSameFilenameNewDir(t *testing.T) { + t.Parallel() + + // Common case: file moved to a different directory. + pp := newPhantomProfile( + "/music/Old Dir/Artist/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/music/New Dir/Artist/01 - Song.flac", + "Song", + "Artist", + 243000, + ) + + if score < 0.8 { + t.Errorf( + "expected score >= 0.8 for same filename, got %f", + score, + ) + } +} + +func TestScoreCandidateDurationOnly(t *testing.T) { + t.Parallel() + + // Very close duration, but different filenames. + score := scoreDuration(243, 243500) + if score < 0.8 { + t.Errorf( + "expected duration score >= 0.8 for ~0.5s diff, got %f", + score, + ) + } + + // Exact match. + score = scoreDuration(180, 180000) + if score != 1.0 { + t.Errorf( + "expected 1.0 for exact match, got %f", + score, + ) + } + + // Far apart. + score = scoreDuration(100, 200000) + if score != 0.0 { + t.Errorf( + "expected 0.0 for 100s diff, got %f", + score, + ) + } + + // Unknown duration. + score = scoreDuration(0, 180000) + if score != 0.0 { + t.Errorf( + "expected 0.0 for unknown, got %f", + score, + ) + } +} + +func TestScoreFilename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + phantom string + cand string + minScore float64 + maxScore float64 + }{ + { + name: "exact match", + phantom: "/a/b/song.flac", + cand: "/c/d/song.flac", + minScore: 1.0, + maxScore: 1.0, + }, + { + name: "same stem different ext", + phantom: "/a/song.flac", + cand: "/b/song.mp3", + minScore: 0.7, + maxScore: 0.9, + }, + { + name: "completely different", + phantom: "/a/song.flac", + cand: "/b/other.mp3", + minScore: 0.0, + maxScore: 0.2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile(tt.phantom, "", 0) + score := scoreFilename(pp, tt.cand) + + if score < tt.minScore || score > tt.maxScore { + t.Errorf( + "scoreFilename(%q, %q) = %f, want [%f, %f]", + tt.phantom, tt.cand, + score, tt.minScore, tt.maxScore, + ) + } + }) + } +} + +func TestScoreTitleArtist(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + display string + title string + artist string + minScore float64 + }{ + { + name: "exact match", + display: "Pink Floyd - Comfortably Numb", + title: "Comfortably Numb", + artist: "Pink Floyd", + minScore: 0.9, + }, + { + name: "title only match", + display: "Comfortably Numb", + title: "Comfortably Numb", + artist: "Pink Floyd", + minScore: 0.7, + }, + { + name: "no match", + display: "Something Else", + title: "Completely Different", + artist: "Other Artist", + minScore: 0.0, + }, + { + name: "empty display title", + display: "", + title: "Any Title", + artist: "Any Artist", + minScore: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/dummy/path.flac", tt.display, 0, + ) + score := scoreTitleArtist( + pp, tt.title, tt.artist, + ) + + if score < tt.minScore { + t.Errorf( + "scoreTitleArtist(%q, %q, %q) = %f, want >= %f", + tt.display, tt.title, tt.artist, + score, tt.minScore, + ) + } + }) + } +} + +func TestExtractKeywords(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + expected []string + }{ + { + name: "typical music path", + path: "/music/Pink Floyd/The Wall/03 - Another Brick in the Wall.flac", + expected: []string{ + "music", "pink", "floyd", "the", + "wall", "another", "brick", "in", + }, + }, + { + name: "simple filename", + path: "song.mp3", + expected: []string{"song"}, + }, + { + name: "track number stripped", + path: "01 - Song Title.flac", + expected: []string{"song", "title"}, + }, + { + name: "empty path", + path: "", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := extractKeywords(tt.path) + if !stringSliceEqual(result, tt.expected) { + t.Errorf( + "extractKeywords(%q) = %v, want %v", + tt.path, result, tt.expected, + ) + } + }) + } +} + +func TestParseDisplayTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + artist string + title string + }{ + { + input: "Artist - Title", + artist: "Artist", + title: "Title", + }, + { + input: "Just a Title", + artist: "", + title: "Just a Title", + }, + { + input: "", + artist: "", + title: "", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + artist, title := parseDisplayTitle(tt.input) + if artist != tt.artist || title != tt.title { + t.Errorf( + "parseDisplayTitle(%q) = (%q, %q), want (%q, %q)", + tt.input, artist, title, + tt.artist, tt.title, + ) + } + }) + } +} + +func TestKeywordOverlap(t *testing.T) { + t.Parallel() + + // Full overlap. + score := keywordOverlap( + []string{"a", "b", "c"}, + []string{"a", "b", "c", "d"}, + ) + + if score != 1.0 { + t.Errorf("expected 1.0, got %f", score) + } + + // Partial overlap. + score = keywordOverlap( + []string{"a", "b", "c"}, + []string{"a", "d", "e"}, + ) + + expected := 1.0 / 3.0 + if math.Abs(score-expected) > 0.01 { + t.Errorf("expected ~%f, got %f", expected, score) + } + + // No overlap. + score = keywordOverlap( + []string{"a", "b"}, + []string{"c", "d"}, + ) + + if score != 0.0 { + t.Errorf("expected 0.0, got %f", score) + } + + // Empty source. + score = keywordOverlap(nil, []string{"a"}) + if score != 0.0 { + t.Errorf("expected 0.0 for empty source, got %f", score) + } +} + +func TestSortCandidatesByScore(t *testing.T) { + t.Parallel() + + candidates := []CandidateTrack{ + {FilePath: "a", Score: 0.3}, + {FilePath: "b", Score: 0.9}, + {FilePath: "c", Score: 0.6}, + } + + sortCandidatesByScore(candidates) + + if candidates[0].FilePath != "b" { + t.Errorf( + "expected first candidate to be 'b', got %q", + candidates[0].FilePath, + ) + } + + if candidates[1].FilePath != "c" { + t.Errorf( + "expected second candidate to be 'c', got %q", + candidates[1].FilePath, + ) + } + + if candidates[2].FilePath != "a" { + t.Errorf( + "expected third candidate to be 'a', got %q", + candidates[2].FilePath, + ) + } +} + +// stringSliceEqual compares two string slices. +func stringSliceEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go new file mode 100644 index 0000000..889b265 --- /dev/null +++ b/backend/playlist/playlist.go @@ -0,0 +1,1947 @@ +// Package playlist provides playlist management functionality. +package playlist + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/coverart" + "yellowjacket/backend/database" + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/events" + "yellowjacket/backend/system" +) + +var ( + errEmptyName = errors.New("playlist name cannot be empty") + errEmptyFilePath = errors.New("file path cannot be empty") + errNoFilePaths = errors.New("no file paths provided") + errUnsupportedFileType = errors.New("unsupported file type") +) + +// playlistsDirName is the subdirectory within the user data +// directory where M3U8 playlist files are stored. +const playlistsDirName = "playlists" + +// LibraryDirProvider is a narrow interface for obtaining the +// configured library directory path. +type LibraryDirProvider interface { + GetLibraryDirectory() string +} + +// Summary is a lightweight representation of a playlist for the +// picker UI. +type Summary struct { + ID int64 `json:"ID"` + Name string `json:"Name"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` +} + +// Track represents a track within a playlist, including its +// metadata. +type Track struct { + ID int64 `json:"ID"` + Position int64 `json:"Position"` + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + CoverArtPath string `json:"CoverArtPath"` + CoverArtSmall string `json:"CoverArtSmall"` + CoverArtMedium string `json:"CoverArtMedium"` + CoverArtLarge string `json:"CoverArtLarge"` + Duration string `json:"Duration"` + Phantom bool `json:"Phantom"` +} + +// WithTracks contains a playlist summary and all its tracks. +type WithTracks struct { + Summary Summary `json:"Summary"` + Tracks []Track `json:"Tracks"` +} + +// CandidateTrack represents a potential library match for a +// phantom track. +type CandidateTrack struct { + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + Duration string `json:"Duration"` + Score float64 `json:"Score"` +} + +// PhantomMatch represents a high-confidence pairing of a phantom +// track to a library track. +type PhantomMatch struct { + PhantomPath string `json:"PhantomPath"` + PhantomTitle string `json:"PhantomTitle"` + Candidate CandidateTrack `json:"Candidate"` +} + +// PhantomSearchResult contains auto-matched pairs and remaining +// unmatched phantom paths for a batch search operation. +type PhantomSearchResult struct { + AutoMatched []PhantomMatch `json:"AutoMatched"` + Unmatched []string `json:"Unmatched"` +} + +// DuplicateTrackInfo holds metadata for a track that already +// exists in a playlist. +type DuplicateTrackInfo struct { + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + Duration string `json:"Duration"` +} + +// DuplicateCheckResult contains the outcome of checking for +// duplicate tracks in a playlist. +type DuplicateCheckResult struct { + Duplicates []DuplicateTrackInfo `json:"Duplicates"` + Unique []string `json:"Unique"` +} + +// Service manages playlist operations. +type Service struct { + // mu protects ctx and favoritesConf from concurrent access + // during initialization. + mu sync.Mutex + ctx context.Context + logger *slog.Logger + db *database.DB + libraryDir LibraryDirProvider + favoritesConf FavoritesConfigProvider +} + +// NewService creates a new playlist service. +func NewService( + logger *slog.Logger, + db *database.DB, + libraryDir LibraryDirProvider, +) *Service { + return &Service{ + logger: logger.WithGroup("playlist"), + db: db, + libraryDir: libraryDir, + } +} + +// SetFavoritesConfig sets the provider used to read and write +// the default-playlist configuration. +func (s *Service) SetFavoritesConfig( + provider FavoritesConfigProvider, +) { + s.mu.Lock() + defer s.mu.Unlock() + + s.favoritesConf = provider +} + +// SetContext sets the Wails runtime context and runs the +// one-time startup migration to bootstrap M3U8 files for +// existing playlists. +func (s *Service) SetContext(ctx context.Context) { + s.mu.Lock() + s.ctx = ctx + s.mu.Unlock() + + s.migrateExistingPlaylists() +} + +// GetAllPlaylists returns all playlists ordered by most recently +// updated. +func (s *Service) GetAllPlaylists() ([]Summary, error) { + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Error( + "Failed to get playlists", "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlists: %w", err, + ) + } + + summaries := make([]Summary, 0, len(playlists)) + + for _, p := range playlists { + summaries = append(summaries, Summary{ + ID: p.ID, + Name: p.Name, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + }) + } + + return summaries, nil +} + +// GetAllPlaylistsWithTracks returns all playlists with their +// tracks in a single call, merging phantom tracks from M3U8 files. +func (s *Service) GetAllPlaylistsWithTracks() ( + []WithTracks, + error, +) { + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Error( + "Failed to get playlists", "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlists: %w", err, + ) + } + + rows, err := s.db.Queries.GetAllPlaylistTracksWithMetadata( + s.db.Ctx, + ) + if err != nil { + s.logger.Error( + "Failed to get all playlist tracks", + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get all playlist tracks: %w", + err, + ) + } + + // Group DB tracks by playlist ID, keyed by absolute file path. + dbTracksByPlaylist := make( + map[int64]map[string]Track, + ) + + for _, row := range rows { + track := trackFromRow( + row.ID, + row.Position, + row.FilePath, + row.Title, + row.Artist, + row.Album, + row.LengthMilliseconds, + row.CoverArtPath, + ) + + if dbTracksByPlaylist[row.PlaylistID] == nil { + dbTracksByPlaylist[row.PlaylistID] = make( + map[string]Track, + ) + } + + dbTracksByPlaylist[row.PlaylistID][row.FilePath] = track + } + + result := make([]WithTracks, 0, len(playlists)) + + for _, p := range playlists { + tracks := s.mergeTracksForPlaylist( + p.ID, + p.Name, + dbTracksByPlaylist[p.ID], + ) + + result = append(result, WithTracks{ + Summary: Summary{ + ID: p.ID, + Name: p.Name, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + }, + Tracks: tracks, + }) + } + + return result, nil +} + +// GetPlaylistTracks returns all tracks in a playlist with full +// metadata, merging phantom tracks from the M3U8 file. +func (s *Service) GetPlaylistTracks( + playlistID int64, +) ([]Track, error) { + rows, err := s.db.Queries.GetPlaylistTracksWithMetadata( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get playlist tracks", + "playlistId", playlistID, + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlist tracks: %w", + err, + ) + } + + // Build a map of DB tracks keyed by absolute file path. + dbTracks := make(map[string]Track, len(rows)) + + for _, row := range rows { + track := trackFromRow( + row.ID, + row.Position, + row.FilePath, + row.Title, + row.Artist, + row.Album, + row.LengthMilliseconds, + row.CoverArtPath, + ) + + dbTracks[row.FilePath] = track + } + + // Get playlist name for M3U file lookup. + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get playlist", + "playlistId", playlistID, + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlist: %w", err, + ) + } + + return s.mergeTracksForPlaylist( + playlistID, playlist.Name, dbTracks, + ), nil +} + +// mergeTracksForPlaylist merges DB tracks with M3U8 entries, +// producing phantom tracks for unresolved paths. +func (s *Service) mergeTracksForPlaylist( + playlistID int64, + _ string, + dbTracks map[string]Track, +) []Track { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for merge", + "err", err, + ) + + return dbTracksToSlice(dbTracks) + } + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return dbTracksToSlice(dbTracks) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "Could not parse M3U8 for merge", + "playlistId", playlistID, + "path", m3uPath, + "err", err, + ) + + return dbTracksToSlice(dbTracks) + } + + libraryRoot := s.getLibraryRoot() + tracks := make([]Track, 0, len(parsed.Entries)) + + for i, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + if dbTrack, ok := dbTracks[absPath]; ok { + dbTrack.Position = int64(i) + tracks = append(tracks, dbTrack) + + continue + } + + // Phantom track — file not resolved in DB. + tracks = append(tracks, Track{ + Position: int64(i), + FilePath: absPath, + Title: entry.DisplayTitle, + Phantom: true, + }) + } + + return tracks +} + +// dbTracksToSlice converts a map of tracks to an ordered slice. +func dbTracksToSlice(m map[string]Track) []Track { + if len(m) == 0 { + return []Track{} + } + + tracks := make([]Track, 0, len(m)) + + for _, t := range m { + tracks = append(tracks, t) + } + + return tracks +} + +// trackFromRow converts raw query row fields into a Track. +func trackFromRow( + id, position int64, + filePath, title, artist, album string, + lengthMilliseconds int64, + coverArtPath string, +) Track { + track := Track{ + ID: id, + Position: position, + FilePath: filePath, + Title: title, + Artist: artist, + Album: album, + Duration: strconv.FormatInt(lengthMilliseconds, 10), + } + + if coverArtPath != "" { + urls := coverart.ResolveURLs(coverArtPath) + track.CoverArtPath = urls.Original + track.CoverArtSmall = urls.Small + track.CoverArtMedium = urls.Medium + track.CoverArtLarge = urls.Large + } + + return track +} + +// CreatePlaylist creates a new empty playlist with the given name. +func (s *Service) CreatePlaylist( + name string, +) (Summary, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return Summary{}, errEmptyName + } + + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, trimmed, + ) + if err != nil { + s.logger.Error( + "Failed to create playlist", + "name", trimmed, "err", err, + ) + + return Summary{}, fmt.Errorf( + "failed to create playlist: %w", err, + ) + } + + s.logger.Info( + "Playlist created", + "id", created.ID, "name", created.Name, + ) + + s.savePlaylistFile(created.ID, created.Name) + s.emitEvent(events.PlaylistCreated, Summary{ + ID: created.ID, + Name: created.Name, + CreatedAt: created.CreatedAt.Format(time.RFC3339), + UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + }) + + return Summary{ + ID: created.ID, + Name: created.Name, + CreatedAt: created.CreatedAt.Format(time.RFC3339), + UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + }, nil +} + +// AddTracksToPlaylist adds one or more tracks to an existing +// playlist. +func (s *Service) AddTracksToPlaylist( + playlistID int64, + filePaths []string, +) error { + if len(filePaths) == 0 { + return errNoFilePaths + } + + nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get next position", + "playlistId", playlistID, + "err", err, + ) + + return fmt.Errorf( + "failed to get next track position: %w", err, + ) + } + + for i, fp := range filePaths { + if err := s.addSingleTrack( + playlistID, fp, nextPos+int64(i), + ); err != nil { + return err + } + } + + s.logger.Info( + "Tracks added to playlist", + "playlistId", playlistID, + "count", len(filePaths), + ) + + s.savePlaylistFileByID(playlistID) + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// FindDuplicateTracksInPlaylist checks which of the given file +// paths already exist in the specified playlist. Returns metadata +// for each duplicate and a list of non-duplicate file paths. +func (s *Service) FindDuplicateTracksInPlaylist( + playlistID int64, + filePaths []string, +) (DuplicateCheckResult, error) { + rows, err := s.db.Queries.GetPlaylistTracksWithMetadata( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get playlist tracks for duplicate check", + "playlistId", playlistID, + "err", err, + ) + + return DuplicateCheckResult{}, fmt.Errorf( + "failed to get playlist tracks: %w", err, + ) + } + + existingPaths := make( + map[string]sqlcgen.GetPlaylistTracksWithMetadataRow, + len(rows), + ) + + for _, row := range rows { + existingPaths[row.FilePath] = row + } + + var duplicates []DuplicateTrackInfo + + var unique []string + + for _, fp := range filePaths { + if row, exists := existingPaths[fp]; exists { + duplicates = append(duplicates, DuplicateTrackInfo{ + FilePath: fp, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, 10, + ), + }) + } else { + unique = append(unique, fp) + } + } + + return DuplicateCheckResult{ + Duplicates: duplicates, + Unique: unique, + }, nil +} + +// CreatePlaylistWithTracks creates a new playlist and populates +// it with tracks. +func (s *Service) CreatePlaylistWithTracks( + name string, + filePaths []string, +) (Summary, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return Summary{}, errEmptyName + } + + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, trimmed, + ) + if err != nil { + s.logger.Error( + "Failed to create playlist", + "name", trimmed, "err", err, + ) + + return Summary{}, fmt.Errorf( + "failed to create playlist: %w", err, + ) + } + + if len(filePaths) > 0 { + nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, + created.ID, + ) + if posErr != nil { + return Summary{}, fmt.Errorf( + "failed to get next track position: %w", + posErr, + ) + } + + for i, fp := range filePaths { + if err := s.addSingleTrack( + created.ID, fp, nextPos+int64(i), + ); err != nil { + return Summary{}, fmt.Errorf( + "playlist created but failed to add tracks: %w", + err, + ) + } + } + } + + summary := Summary{ + ID: created.ID, + Name: created.Name, + CreatedAt: created.CreatedAt.Format(time.RFC3339), + UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + } + + s.logger.Info( + "Playlist created with tracks", + "id", created.ID, + "name", created.Name, + "trackCount", len(filePaths), + ) + + s.savePlaylistFile(created.ID, created.Name) + s.emitEvent(events.PlaylistCreated, summary) + + return summary, nil +} + +// RemoveTracksFromPlaylist removes multiple tracks from a playlist +// by their playlist_track IDs. +func (s *Service) RemoveTracksFromPlaylist( + playlistID int64, + trackIDs []int64, +) error { + if len(trackIDs) == 0 { + return nil + } + + for _, id := range trackIDs { + if err := s.db.Queries.RemovePlaylistTrack( + s.db.Ctx, + id, + ); err != nil { + s.logger.Error( + "Failed to remove playlist track", + "playlistId", playlistID, + "trackId", id, + "err", err, + ) + + return fmt.Errorf( + "failed to remove track %d from playlist: %w", + id, + err, + ) + } + } + + s.logger.Info( + "Tracks removed from playlist", + "playlistId", playlistID, + "count", len(trackIDs), + ) + + s.savePlaylistFileByID(playlistID) + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// DeletePlaylist deletes a playlist and its M3U8 file. +// If the deleted playlist was the default, a new default +// playlist is automatically created. +func (s *Service) DeletePlaylist(playlistID int64) error { + if err := s.db.Queries.DeletePlaylist( + s.db.Ctx, playlistID, + ); err != nil { + s.logger.Error( + "Failed to delete playlist", + "playlistId", playlistID, + "err", err, + ) + + return fmt.Errorf( + "failed to delete playlist: %w", err, + ) + } + + s.deletePlaylistFile(playlistID) + + s.logger.Info( + "Playlist deleted", "playlistId", playlistID, + ) + + s.emitEvent(events.PlaylistDeleted, playlistID) + + // Recreate the default playlist if we just deleted it. + if s.defaultPlaylistID() == playlistID { + s.EnsureDefaultPlaylist() + } + + return nil +} + +// RenamePlaylist renames a playlist and updates its M3U8 file. +func (s *Service) RenamePlaylist( + playlistID int64, + newName string, +) error { + trimmed := strings.TrimSpace(newName) + if trimmed == "" { + return errEmptyName + } + + if err := s.db.Queries.UpdatePlaylistName( + s.db.Ctx, + sqlcgen.UpdatePlaylistNameParams{ + Name: trimmed, + ID: playlistID, + }, + ); err != nil { + s.logger.Error( + "Failed to rename playlist", + "playlistId", playlistID, + "newName", trimmed, + "err", err, + ) + + return fmt.Errorf( + "failed to rename playlist: %w", err, + ) + } + + // Re-save the M3U8 file with the new name (handles rename + // of the file on disk). + s.savePlaylistFile(playlistID, trimmed) + + s.logger.Info( + "Playlist renamed", + "playlistId", playlistID, + "newName", trimmed, + ) + + s.emitEvent(events.PlaylistRenamed, Summary{ + ID: playlistID, + Name: trimmed, + }) + + return nil +} + +// uniquePlaylistName returns a name that doesn't collide with existing +// playlists. If "Chill Vibes" exists, returns "Chill Vibes (1)". +// If that also exists, returns "Chill Vibes (2)", etc. +func (s *Service) uniquePlaylistName(name string) string { + count, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, name) + if err != nil || count == 0 { + return name + } + + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s (%d)", name, i) + + c, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, candidate) + if err != nil || c == 0 { + return candidate + } + } +} + +// ImportPlaylist imports a playlist from an external M3U/M3U8 +// file. It creates a new playlist in the DB, resolves tracks +// against the library, and saves an M3U8 file. +func (s *Service) ImportPlaylist( + filePath string, +) (Summary, error) { + if strings.TrimSpace(filePath) == "" { + return Summary{}, errEmptyFilePath + } + + ext := filepath.Ext(filePath) + if !isValidM3UExtension(ext) { + return Summary{}, fmt.Errorf( + "%w: %q, expected .m3u or .m3u8", + errUnsupportedFileType, ext, + ) + } + + parsed, err := parseM3U8(filePath) + if err != nil { + return Summary{}, fmt.Errorf( + "could not parse playlist file: %w", err, + ) + } + + playlistName := parsed.Name + if playlistName == "" { + base := filepath.Base(filePath) + playlistName = strings.TrimSuffix( + base, filepath.Ext(base), + ) + } + + playlistName = s.uniquePlaylistName(playlistName) + + // Create playlist in DB. + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, playlistName, + ) + if err != nil { + return Summary{}, fmt.Errorf( + "could not create playlist for import: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + var ( + resolved int + unresolved int + position int + ) + + for _, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if lookupErr != nil { + // Track not in library — will appear as phantom. + unresolved++ + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: created.ID, + AudioFileID: audioFile.ID, + Position: int64(position), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not add imported track", + "playlistId", created.ID, + "path", absPath, + "err", addErr, + ) + + continue + } + + position++ + resolved++ + } + + // Save the M3U8 file with entries (preserves unresolved + // paths for phantom display). + s.saveImportedPlaylistFile( + created.ID, playlistName, parsed.Entries, libraryRoot, + ) + + s.logger.Info( + "Playlist imported", + "id", created.ID, + "name", playlistName, + "resolved", resolved, + "unresolved", unresolved, + ) + + summary := Summary{ + ID: created.ID, + Name: playlistName, + CreatedAt: created.CreatedAt.Format(time.RFC3339), + UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + } + + s.emitEvent(events.PlaylistCreated, summary) + + return summary, nil +} + +// ImportPlaylists imports multiple playlists from external M3U/M3U8 +// files. Each file is imported sequentially using ImportPlaylist. +// Errors from individual imports are collected; partial success is +// possible. Returns the summaries of successfully imported playlists +// and the first error encountered (if any). +func (s *Service) ImportPlaylists( + filePaths []string, +) ([]Summary, error) { + if len(filePaths) == 0 { + return nil, errNoFilePaths + } + + summaries := make([]Summary, 0, len(filePaths)) + + var firstErr error + + for _, fp := range filePaths { + summary, err := s.ImportPlaylist(fp) + if err != nil { + s.logger.Warn( + "Failed to import playlist file", + "path", fp, + "err", err, + ) + + if firstErr == nil { + firstErr = fmt.Errorf( + "import %q failed: %w", fp, err, + ) + } + + continue + } + + summaries = append(summaries, summary) + } + + return summaries, firstErr +} + +// RestoreAllPlaylists restores playlist tracks from M3U8 files. +// This is called after a full library rescan to repopulate +// playlist_tracks from the surviving M3U8 files. +func (s *Service) RestoreAllPlaylists() { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for restore", + "err", err, + ) + + return + } + + files, err := listPlaylistFiles(dir) + if err != nil { + s.logger.Warn( + "Could not list playlist files", + "err", err, + ) + + return + } + + if len(files) == 0 { + return + } + + libraryRoot := s.getLibraryRoot() + + var totalRestored, totalUnresolved int + + for _, file := range files { + playlistID := extractPlaylistID(file) + if playlistID == 0 { + s.logger.Warn( + "Could not extract playlist ID from filename", + "file", file, + ) + + continue + } + + restored, unresolved := s.restoreSinglePlaylist( + playlistID, file, libraryRoot, + ) + + totalRestored += restored + totalUnresolved += unresolved + } + + s.logger.Info( + "All playlists restored from M3U8 files", + "totalRestored", totalRestored, + "totalUnresolved", totalUnresolved, + ) + + s.emitEvent(events.PlaylistsRestored, nil) +} + +// restoreSinglePlaylist restores tracks for a single playlist +// from its M3U8 file. +func (s *Service) restoreSinglePlaylist( + playlistID int64, + m3uPath string, + libraryRoot string, +) (restored, unresolved int) { + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "Could not parse M3U8 for restore", + "playlistId", playlistID, + "path", m3uPath, + "err", err, + ) + + return 0, 0 + } + + // Verify the playlist exists in the DB. + _, err = s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Warn( + "Playlist not found in DB during restore", + "playlistId", playlistID, + "err", err, + ) + + return 0, 0 + } + + var position int + + for _, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if lookupErr != nil { + unresolved++ + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: audioFile.ID, + Position: int64(position), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not restore track", + "playlistId", playlistID, + "path", absPath, + "err", addErr, + ) + + continue + } + + position++ + restored++ + } + + s.logger.Info( + "Playlist restored", + "playlistId", playlistID, + "restored", restored, + "unresolved", unresolved, + ) + + return restored, unresolved +} + +// addSingleTrack looks up the audio file by path and inserts it +// into the playlist. +func (s *Service) addSingleTrack( + playlistID int64, + filePath string, + position int64, +) error { + if strings.TrimSpace(filePath) == "" { + return errEmptyFilePath + } + + audioFile, err := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, filePath, + ) + if err != nil { + s.logger.Error( + "Failed to find audio file", + "filePath", filePath, + "err", err, + ) + + return fmt.Errorf( + "failed to find audio file %q: %w", + filePath, err, + ) + } + + _, err = s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: audioFile.ID, + Position: position, + }, + ) + if err != nil { + s.logger.Error( + "Failed to add track to playlist", + "playlistId", playlistID, + "audioFileId", audioFile.ID, + "err", err, + ) + + return fmt.Errorf( + "failed to add track to playlist: %w", err, + ) + } + + return nil +} + +// --- M3U8 file management helpers --- + +// playlistsDir returns the path to the playlists directory, +// creating it if needed. +func (s *Service) playlistsDir() (string, error) { + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return "", fmt.Errorf( + "could not get user data directory: %w", err, + ) + } + + dir := filepath.Join(dataDir, playlistsDirName) + + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return "", fmt.Errorf( + "could not create playlists directory: %w", err, + ) + } + + return dir, nil +} + +// getLibraryRoot returns the configured library directory path. +func (s *Service) getLibraryRoot() string { + if s.libraryDir == nil { + return "" + } + + return s.libraryDir.GetLibraryDirectory() +} + +// savePlaylistFile saves the current state of a playlist to its +// M3U8 file. +func (s *Service) savePlaylistFile( + playlistID int64, + name string, +) { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for save", + "err", err, + ) + + return + } + + entries := s.buildM3UEntries(playlistID) + + if err := writeM3U8( + dir, playlistID, name, entries, + ); err != nil { + s.logger.Warn( + "Could not save playlist M3U8 file", + "playlistId", playlistID, + "err", err, + ) + } +} + +// savePlaylistFileByID looks up the playlist name and saves. +func (s *Service) savePlaylistFileByID(playlistID int64) { + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Warn( + "Could not get playlist for save", + "playlistId", playlistID, + "err", err, + ) + + return + } + + s.savePlaylistFile(playlistID, playlist.Name) +} + +// saveImportedPlaylistFile saves an M3U8 file for an imported +// playlist, preserving the original entries (including +// unresolved paths). +func (s *Service) saveImportedPlaylistFile( + playlistID int64, + name string, + entries []m3uEntry, + libraryRoot string, +) { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for import save", + "err", err, + ) + + return + } + + // Convert any absolute paths in entries to relative. + converted := make([]m3uEntry, len(entries)) + + for i, entry := range entries { + converted[i] = m3uEntry{ + RelativePath: toRelativePath( + toAbsolutePath( + entry.RelativePath, libraryRoot, + ), + libraryRoot, + ), + DurationSec: entry.DurationSec, + DisplayTitle: entry.DisplayTitle, + } + } + + if err := writeM3U8( + dir, playlistID, name, converted, + ); err != nil { + s.logger.Warn( + "Could not save imported playlist M3U8 file", + "playlistId", playlistID, + "err", err, + ) + } +} + +// buildM3UEntries builds M3U entries from the current DB state +// of a playlist. +func (s *Service) buildM3UEntries( + playlistID int64, +) []m3uEntry { + rows, err := s.db.Queries.GetPlaylistTracksWithMetadata( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Warn( + "Could not get tracks for M3U build", + "playlistId", playlistID, + "err", err, + ) + + return nil + } + + libraryRoot := s.getLibraryRoot() + entries := make([]m3uEntry, 0, len(rows)) + + for _, row := range rows { + durationSec := int( + row.LengthMilliseconds / 1000, + ) + + entries = append(entries, m3uEntry{ + RelativePath: toRelativePath( + row.FilePath, libraryRoot, + ), + DurationSec: durationSec, + DisplayTitle: displayTitle( + row.Artist, row.Title, + ), + }) + } + + return entries +} + +// deletePlaylistFile removes the M3U8 file for a playlist. +func (s *Service) deletePlaylistFile(playlistID int64) { + dir, err := s.playlistsDir() + if err != nil { + return + } + + existing, err := findPlaylistFile(dir, playlistID) + if err != nil || existing == "" { + return + } + + if err := os.Remove(existing); err != nil && + !os.IsNotExist(err) { + s.logger.Warn( + "Could not delete playlist file", + "playlistId", playlistID, + "path", existing, + "err", err, + ) + } +} + +// emitEvent emits a Wails event if the context is available. +func (s *Service) emitEvent( + eventName string, + data any, +) { + if s.ctx == nil { + return + } + + runtime.EventsEmit(s.ctx, eventName, data) +} + +// migrateExistingPlaylists generates M3U8 files for any +// existing DB playlists that don't already have one. This runs +// once at startup to bootstrap the file-based backup for users +// who already have playlists. +func (s *Service) migrateExistingPlaylists() { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for migration", + "err", err, + ) + + return + } + + existingFiles, err := listPlaylistFiles(dir) + if err != nil { + s.logger.Warn( + "Could not list existing playlist files", + "err", err, + ) + + return + } + + // Build a set of IDs that already have files. + existingIDs := make(map[int64]struct{}) + + for _, file := range existingFiles { + id := extractPlaylistID(file) + if id > 0 { + existingIDs[id] = struct{}{} + } + } + + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Warn( + "Could not get playlists for migration", + "err", err, + ) + + return + } + + var migrated int + + for _, p := range playlists { + if _, exists := existingIDs[p.ID]; exists { + continue + } + + s.savePlaylistFile(p.ID, p.Name) + + migrated++ + } + + if migrated > 0 { + s.logger.Info( + "Migrated existing playlists to M3U8 files", + "count", migrated, + ) + } +} + +// ================================================================= +// Phantom track resolution +// ================================================================= + +// FindPhantomMatches searches the library for matches for the +// given phantom file paths. High-confidence matches are returned +// as auto-matched pairs; the rest remain in the unmatched list. +func (s *Service) FindPhantomMatches( + playlistID int64, + phantomPaths []string, +) (PhantomSearchResult, error) { + if len(phantomPaths) == 0 { + return PhantomSearchResult{}, nil + } + + dir, err := s.playlistsDir() + if err != nil { + return PhantomSearchResult{}, fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + // Load M3U8 entries for display title / duration data. + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil { + return PhantomSearchResult{}, fmt.Errorf( + "could not find playlist file: %w", err, + ) + } + + var entries []m3uEntry + + if m3uPath != "" { + parsed, parseErr := parseM3U8(m3uPath) + if parseErr == nil { + entries = parsed.Entries + } + } + + // Build a lookup from absolute path to M3U entry. + entryByPath := make(map[string]m3uEntry, len(entries)) + + for _, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + entryByPath[absPath] = e + } + + // Track which candidates have been claimed by auto-match + // so we don't assign the same candidate to two phantoms. + claimed := make(map[string]struct{}) + + var result PhantomSearchResult + + for _, phantomPath := range phantomPaths { + entry := entryByPath[phantomPath] + candidates := s.searchCandidates( + phantomPath, entry, + ) + + matched := false + + for _, c := range candidates { + if _, taken := claimed[c.FilePath]; taken { + continue + } + + if c.Score >= autoMatchMinimum { + result.AutoMatched = append( + result.AutoMatched, + PhantomMatch{ + PhantomPath: phantomPath, + PhantomTitle: entry.DisplayTitle, + Candidate: c, + }, + ) + + claimed[c.FilePath] = struct{}{} + matched = true + + break + } + } + + if !matched { + result.Unmatched = append( + result.Unmatched, phantomPath, + ) + } + } + + return result, nil +} + +// GetPhantomCandidates returns scored candidate matches for a +// single phantom track. +func (s *Service) GetPhantomCandidates( + playlistID int64, + phantomPath string, +) ([]CandidateTrack, error) { + dir, err := s.playlistsDir() + if err != nil { + return nil, fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + // Find the M3U entry for this phantom. + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil { + return nil, fmt.Errorf( + "could not find playlist file: %w", err, + ) + } + + var entry m3uEntry + + if m3uPath != "" { + parsed, parseErr := parseM3U8(m3uPath) + if parseErr == nil { + entry, _ = findM3UEntry( + parsed.Entries, phantomPath, libraryRoot, + ) + } + } + + return s.searchCandidates( + phantomPath, entry, + ), nil +} + +// SearchLibrary searches the entire library by a free-text query +// for manual phantom resolution. +func (s *Service) SearchLibrary( + query string, +) ([]CandidateTrack, error) { + trimmed := strings.TrimSpace(query) + if trimmed == "" { + return []CandidateTrack{}, nil + } + + rows, err := s.db.SearchFTS( + trimmed, maxLibrarySearchResults, + ) + if err != nil { + return nil, fmt.Errorf( + "library search failed: %w", err, + ) + } + + candidates := make([]CandidateTrack, 0, len(rows)) + + for _, row := range rows { + candidates = append(candidates, CandidateTrack{ + FilePath: row.FilePath, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, 10, + ), + }) + } + + return candidates, nil +} + +// ResolvePhantomTracks replaces phantom entries in a playlist +// with real library tracks. The matches map keys are phantom +// absolute paths and values are resolved absolute paths. +func (s *Service) ResolvePhantomTracks( + playlistID int64, + matches map[string]string, +) error { + if len(matches) == 0 { + return nil + } + + dir, err := s.playlistsDir() + if err != nil { + return fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return fmt.Errorf( + "could not find M3U8 file for playlist %d: %w", + playlistID, err, + ) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + return fmt.Errorf( + "could not parse M3U8: %w", err, + ) + } + + // Get next available DB position. + nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, playlistID, + ) + if err != nil { + return fmt.Errorf( + "could not get next position: %w", err, + ) + } + + // Build M3U path replacements and insert DB rows. + pathReplacements := make( + map[string]string, len(matches), + ) + + var resolved int + + for phantomAbs, resolvedAbs := range matches { + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, resolvedAbs, + ) + if lookupErr != nil { + s.logger.Warn( + "Resolved path not found in library", + "phantomPath", phantomAbs, + "resolvedPath", resolvedAbs, + "err", lookupErr, + ) + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: audioFile.ID, + Position: nextPos + int64(resolved), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not add resolved track", + "playlistId", playlistID, + "path", resolvedAbs, + "err", addErr, + ) + + continue + } + + newRel := toRelativePath(resolvedAbs, libraryRoot) + pathReplacements[phantomAbs] = newRel + resolved++ + } + + // Rewrite the M3U8 with updated paths. + if resolved > 0 { + updated := replaceM3UEntryPaths( + parsed.Entries, pathReplacements, libraryRoot, + ) + + playlist, nameErr := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if nameErr != nil { + return fmt.Errorf( + "could not get playlist name: %w", nameErr, + ) + } + + if writeErr := writeM3U8( + dir, playlistID, playlist.Name, updated, + ); writeErr != nil { + return fmt.Errorf( + "could not rewrite M3U8: %w", writeErr, + ) + } + } + + s.logger.Info( + "Phantom tracks resolved", + "playlistId", playlistID, + "resolved", resolved, + "requested", len(matches), + ) + + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// RemovePhantomTracks removes phantom entries from a playlist's +// M3U8 file. Since phantom tracks have no DB rows, only the +// M3U8 file is modified. +func (s *Service) RemovePhantomTracks( + playlistID int64, + phantomPaths []string, +) error { + if len(phantomPaths) == 0 { + return nil + } + + dir, err := s.playlistsDir() + if err != nil { + return fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return fmt.Errorf( + "could not find M3U8 file for playlist %d: %w", + playlistID, err, + ) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + return fmt.Errorf( + "could not parse M3U8: %w", err, + ) + } + + targetSet := make( + map[string]struct{}, len(phantomPaths), + ) + + for _, p := range phantomPaths { + targetSet[p] = struct{}{} + } + + updated := removeM3UEntries( + parsed.Entries, targetSet, libraryRoot, + ) + + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + return fmt.Errorf( + "could not get playlist name: %w", err, + ) + } + + if err := writeM3U8( + dir, playlistID, playlist.Name, updated, + ); err != nil { + return fmt.Errorf( + "could not rewrite M3U8: %w", err, + ) + } + + s.logger.Info( + "Phantom tracks removed", + "playlistId", playlistID, + "removed", len(phantomPaths), + ) + + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// searchCandidates finds and scores candidate library tracks +// for a single phantom track. +func (s *Service) searchCandidates( + phantomPath string, + entry m3uEntry, +) []CandidateTrack { + basename := filepath.Base(phantomPath) + seen := make(map[string]struct{}) + + var combined []database.SearchRow + + // 1. Exact basename match via indexed column. + bnRows, err := s.db.Queries.SearchAudioFilesByBasename( + s.db.Ctx, + sqlcgen.SearchAudioFilesByBasenameParams{ + Basename: basename, + Limit: int64(maxCandidates), + }, + ) + if err != nil { + s.logger.Warn( + "Basename search failed", + "basename", basename, + "err", err, + ) + } + + for _, r := range bnRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, database.SearchRow{ + FilePath: r.FilePath, + LengthMilliseconds: r.LengthMilliseconds, + Title: r.Title, + Artist: r.Artist, + Album: r.Album, + }) + } + + // 2. FTS5 filename-token search for fuzzy basename + // matches (e.g. different extension). + ftsFileRows, err := s.db.SearchFTSByFilename( + basename, maxCandidates, + ) + if err != nil { + s.logger.Warn( + "FTS filename search failed", + "basename", basename, + "err", err, + ) + } + + for _, r := range ftsFileRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, r) + } + + // 3. FTS5 keyword search from path + display title. + keywords := extractKeywords(phantomPath) + + if entry.DisplayTitle != "" { + titleKeywords := extractKeywords( + entry.DisplayTitle, + ) + keywords = append(keywords, titleKeywords...) + keywords = dedupStrings(keywords) + } + + if len(keywords) > 0 { + kwQuery := strings.Join(keywords, " ") + + kwRows, kwErr := s.db.SearchFTS( + kwQuery, maxCandidates, + ) + if kwErr != nil { + s.logger.Warn( + "FTS keyword search failed", + "keywords", keywords, + "err", kwErr, + ) + } + + for _, r := range kwRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, r) + } + } + + // Score each candidate. + pp := newPhantomProfile( + phantomPath, entry.DisplayTitle, + entry.DurationSec, + ) + + candidates := make( + []CandidateTrack, 0, len(combined), + ) + + for _, row := range combined { + score := scoreCandidate( + pp, + row.FilePath, + row.Title, + row.Artist, + row.LengthMilliseconds, + ) + + candidates = append(candidates, CandidateTrack{ + FilePath: row.FilePath, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, 10, + ), + Score: score, + }) + } + + // Sort by score descending. + sortCandidatesByScore(candidates) + + if len(candidates) > maxCandidates { + candidates = candidates[:maxCandidates] + } + + return candidates +} + +// sortCandidatesByScore sorts candidates by score descending. +func sortCandidatesByScore(candidates []CandidateTrack) { + slices.SortFunc( + candidates, + func(a, b CandidateTrack) int { + if a.Score > b.Score { + return -1 + } + + if a.Score < b.Score { + return 1 + } + + return 0 + }, + ) +} diff --git a/backend/profiling/doc.go b/backend/profiling/doc.go new file mode 100644 index 0000000..c2164b8 --- /dev/null +++ b/backend/profiling/doc.go @@ -0,0 +1,56 @@ +// Package profiling provides dev-only performance profiling via pprof and runtime/trace. +// +// In dev builds (build tag "dev"), Start launches an HTTP server on localhost:6060 +// exposing the standard pprof endpoints and a /debug/trace endpoint for capturing +// execution traces. It also enables block and mutex profiling at reasonable sampling +// rates. +// +// In production builds, all exported functions are no-ops and the pprof/trace +// imports are excluded from the binary entirely. +// +// # Quick start +// +// Run the app in dev mode (pprof starts automatically): +// +// make dev +// +// Then, in a separate terminal, use the interactive profiling helper: +// +// ./scripts/profile.sh +// +// The script provides a menu-driven interface that opens results in your +// browser as flame graphs. No pprof knowledge required. You can also +// invoke it directly: +// +// ./scripts/profile.sh cpu # CPU profile +// ./scripts/profile.sh heap # Heap (memory) profile +// ./scripts/profile.sh allocs # Allocation profile +// ./scripts/profile.sh goroutine # Goroutine dump +// ./scripts/profile.sh block # Block (sync) profile +// ./scripts/profile.sh mutex # Mutex contention profile +// ./scripts/profile.sh trace # Execution trace +// ./scripts/profile.sh health # Quick runtime health check +// +// # Manual usage +// +// If you prefer the CLI directly: +// +// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # CPU +// go tool pprof http://localhost:6060/debug/pprof/heap # Memory +// go tool pprof http://localhost:6060/debug/pprof/goroutine # Goroutines +// curl -o trace.out http://localhost:6060/debug/trace?seconds=5 # Trace +// go tool trace trace.out +// +// # Programmatic usage +// +// stop := profiling.Start(logger) +// defer stop() +// +// # Operation timing +// +// Use TimeOp to log the duration of any operation in dev builds: +// +// defer profiling.TimeOp(logger, "player.LoadFile")() +// +// In production builds TimeOp is a no-op with zero overhead. +package profiling diff --git a/backend/profiling/profiling.go b/backend/profiling/profiling.go new file mode 100644 index 0000000..15c5408 --- /dev/null +++ b/backend/profiling/profiling.go @@ -0,0 +1,159 @@ +//go:build dev + +package profiling + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/http/pprof" + "runtime" + "runtime/trace" + "strconv" + "time" +) + +const ( + // pprofAddr is the address the pprof HTTP server listens on. + pprofAddr = "localhost:6060" + + // defaultTraceSecs is the default trace capture duration. + defaultTraceSecs = 5 + + // blockProfileRate controls the fraction of goroutine blocking + // events reported. 1 = every event (most detailed, slight overhead). + blockProfileRate = 1 + + // mutexProfileFraction controls the fraction of mutex contention + // events reported. 5 = 1/5 of events. + mutexProfileFraction = 5 + + // serverShutdownTimeout is the maximum time to wait for the + // pprof server to drain connections on shutdown. + serverShutdownTimeout = 5 * time.Second +) + +// Start launches the pprof HTTP server and enables block/mutex profiling. +// It returns a stop function that gracefully shuts down the server. +func Start(logger *slog.Logger) func() { + plog := logger.WithGroup("profiling") + + // Enable block and mutex profiling so /debug/pprof/block and + // /debug/pprof/mutex return useful data. + runtime.SetBlockProfileRate(blockProfileRate) + runtime.SetMutexProfileFraction(mutexProfileFraction) + + mux := http.NewServeMux() + + // Register the standard pprof handlers. + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + + // Custom endpoint: capture a runtime/trace for a configurable + // duration and stream it back. Usage: + // curl -o trace.out http://localhost:6060/debug/trace?seconds=5 + // go tool trace trace.out + mux.HandleFunc("/debug/trace", traceHandler(plog)) + + srv := &http.Server{ + Addr: pprofAddr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + // Use a listener so we can log the actual bound address. + ln, err := net.Listen("tcp", pprofAddr) + if err != nil { + plog.Error( + "Failed to start pprof server", + "addr", pprofAddr, "err", err, + ) + + return func() {} + } + + plog.Info( + fmt.Sprintf( + "pprof server listening on http://%s/debug/pprof/", + ln.Addr().String(), + ), + ) + + go func() { + if serveErr := srv.Serve(ln); serveErr != nil && + !errors.Is(serveErr, http.ErrServerClosed) { + plog.Error("pprof server error", "err", serveErr) + } + }() + + return func() { + plog.Info("Shutting down pprof server") + + ctx, cancel := context.WithTimeout( + context.Background(), serverShutdownTimeout, + ) + defer cancel() + + if shutErr := srv.Shutdown(ctx); shutErr != nil { + plog.Error( + "pprof server shutdown error", + "err", shutErr, + ) + } + + // Disable block/mutex profiling. + runtime.SetBlockProfileRate(0) + runtime.SetMutexProfileFraction(0) + } +} + +// traceHandler returns an HTTP handler that captures a runtime/trace +// for the requested number of seconds (default 5). +func traceHandler(logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + secs := defaultTraceSecs + + if s := r.URL.Query().Get("seconds"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + secs = v + } + } + + logger.Info( + "Starting trace capture", + "seconds", secs, + ) + + w.Header().Set( + "Content-Type", "application/octet-stream", + ) + w.Header().Set( + "Content-Disposition", + "attachment; filename=trace.out", + ) + + if err := trace.Start(w); err != nil { + http.Error( + w, + fmt.Sprintf("trace already in progress: %v", err), + http.StatusConflict, + ) + + return + } + + time.Sleep(time.Duration(secs) * time.Second) + trace.Stop() + + logger.Info( + "Trace capture complete", + "seconds", secs, + ) + } +} diff --git a/backend/profiling/profiling_prod.go b/backend/profiling/profiling_prod.go new file mode 100644 index 0000000..9302971 --- /dev/null +++ b/backend/profiling/profiling_prod.go @@ -0,0 +1,11 @@ +//go:build !dev + +package profiling + +import "log/slog" + +// Start is a no-op in production builds. The pprof and runtime/trace +// imports are excluded entirely, adding zero overhead to the binary. +func Start(_ *slog.Logger) func() { + return func() {} +} diff --git a/backend/profiling/timing.go b/backend/profiling/timing.go new file mode 100644 index 0000000..faa750b --- /dev/null +++ b/backend/profiling/timing.go @@ -0,0 +1,29 @@ +//go:build dev + +package profiling + +import ( + "log/slog" + "time" +) + +// TimeOp starts a timer and returns a function that, when called, logs the +// elapsed duration. Intended for use with defer: +// +// defer profiling.TimeOp(logger, "database.Init")() +// +// The extra () is required — defer evaluates the outer call immediately +// (capturing the start time) and defers the returned closure. +func TimeOp(logger *slog.Logger, operation string) func() { + start := time.Now() + + logger.Debug("operation started", "op", operation) + + return func() { + logger.Info( + "operation completed", + "op", operation, + "duration", time.Since(start), + ) + } +} diff --git a/backend/profiling/timing_prod.go b/backend/profiling/timing_prod.go new file mode 100644 index 0000000..ca4241f --- /dev/null +++ b/backend/profiling/timing_prod.go @@ -0,0 +1,13 @@ +//go:build !dev + +package profiling + +import "log/slog" + +func noop() {} + +// TimeOp is a no-op in production builds. The compiler will inline +// and eliminate this entirely. +func TimeOp(_ *slog.Logger, _ string) func() { + return noop +} diff --git a/backend/queue/emit.go b/backend/queue/emit.go new file mode 100644 index 0000000..e936246 --- /dev/null +++ b/backend/queue/emit.go @@ -0,0 +1,82 @@ +package queue + +import ( + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/events" +) + +// emitQueueChanged emits the full queue state to the frontend. +func (q *Queue) emitQueueChanged() { + if q.ctx == nil { + return + } + + state := State{ + Tracks: q.tracks, + CurrentIndex: q.currentIndex, + ShuffleMode: q.shuffleMode, + RepeatMode: q.repeatMode, + SourcePlaylistID: q.sourcePlaylistID, + } + + // Ensure tracks is never nil in JSON. + if state.Tracks == nil { + state.Tracks = []Track{} + } + + runtime.EventsEmit(q.ctx, events.QueueChanged, state) +} + +// emitIndexChanged emits only the current index to the frontend. +func (q *Queue) emitIndexChanged() { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueIndexChanged, + IndexChanged{CurrentIndex: q.currentIndex}, + ) +} + +// emitModeChanged emits only the shuffle/repeat mode to the frontend. +func (q *Queue) emitModeChanged() { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueModeChanged, + ModeChanged{ + ShuffleMode: q.shuffleMode, + RepeatMode: q.repeatMode, + }, + ) +} + +// emitTracksModified emits a delta update for track list changes. +func (q *Queue) emitTracksModified( + action string, + tracks []Track, + index int, + positions []int, +) { + if q.ctx == nil { + return + } + + runtime.EventsEmit( + q.ctx, + events.QueueTracksModified, + TracksModified{ + Action: action, + Tracks: tracks, + Index: index, + Positions: positions, + CurrentIndex: q.currentIndex, + }, + ) +} diff --git a/backend/queue/handlers.go b/backend/queue/handlers.go new file mode 100644 index 0000000..ae933aa --- /dev/null +++ b/backend/queue/handlers.go @@ -0,0 +1,40 @@ +package queue + +// OnPlaybackFinished is called when a track finishes playing naturally. +// This drives the auto-advance behavior. +func (q *Queue) OnPlaybackFinished() { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.tracks) == 0 { + return + } + + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + if q.playCurrentTrack() { + q.emitIndexChanged() + } + + return + } + + nextIdx := q.nextIndex() + if nextIdx == -1 { + // Queue exhausted — this is the extension point for a future fallback playlist. + q.onQueueExhausted() + + return + } + + prevIndex := q.currentIndex + q.currentIndex = nextIdx + + if !q.playCurrentTrack() { + q.currentIndex = prevIndex + + return + } + + q.emitIndexChanged() +} diff --git a/backend/queue/navigation.go b/backend/queue/navigation.go new file mode 100644 index 0000000..203e010 --- /dev/null +++ b/backend/queue/navigation.go @@ -0,0 +1,132 @@ +package queue + +import "math/rand/v2" + +// nextIndex returns the next track index respecting shuffle and repeat modes. +// Returns -1 if there is no next track (queue exhausted). +func (q *Queue) nextIndex() int { + if len(q.tracks) == 0 { + return -1 + } + + if q.shuffleMode && len(q.shuffleOrder) > 0 { + return q.nextShuffledIndex() + } + + next := q.currentIndex + 1 + if next >= len(q.tracks) { + if q.repeatMode == RepeatAll { + return 0 + } + + return -1 + } + + return next +} + +// previousIndex returns the previous track index respecting shuffle and repeat. +// Returns -1 if there is no previous track. +func (q *Queue) previousIndex() int { + if len(q.tracks) == 0 { + return -1 + } + + if q.shuffleMode && len(q.shuffleOrder) > 0 { + return q.previousShuffledIndex() + } + + prev := q.currentIndex - 1 + if prev < 0 { + if q.repeatMode == RepeatAll { + return len(q.tracks) - 1 + } + + return -1 + } + + return prev +} + +// nextShuffledIndex finds the next index in the shuffle order. +func (q *Queue) nextShuffledIndex() int { + shufflePos := q.currentShufflePosition() + if shufflePos == -1 { + // Current track not found in shuffle order — shouldn't happen. + return -1 + } + + nextShufflePos := shufflePos + 1 + if nextShufflePos >= len(q.shuffleOrder) { + if q.repeatMode == RepeatAll { + return q.shuffleOrder[0] + } + + return -1 + } + + return q.shuffleOrder[nextShufflePos] +} + +// previousShuffledIndex finds the previous index in the shuffle order. +func (q *Queue) previousShuffledIndex() int { + shufflePos := q.currentShufflePosition() + if shufflePos == -1 { + return -1 + } + + prevShufflePos := shufflePos - 1 + if prevShufflePos < 0 { + if q.repeatMode == RepeatAll { + return q.shuffleOrder[len(q.shuffleOrder)-1] + } + + return -1 + } + + return q.shuffleOrder[prevShufflePos] +} + +// currentShufflePosition finds where the current track index is in the shuffle order. +func (q *Queue) currentShufflePosition() int { + for i, idx := range q.shuffleOrder { + if idx == q.currentIndex { + return i + } + } + + return -1 +} + +// generateShuffleOrder creates a Fisher-Yates shuffled index order, +// placing the current track at position 0 so it doesn't replay immediately. +func (q *Queue) generateShuffleOrder() { + n := len(q.tracks) + if n == 0 { + q.shuffleOrder = nil + + return + } + + order := make([]int, n) + for i := range order { + order[i] = i + } + + // Fisher-Yates shuffle. + for i := n - 1; i > 0; i-- { + j := rand.IntN(i + 1) + order[i], order[j] = order[j], order[i] + } + + // Move the current track to position 0 so it doesn't replay immediately. + for i, idx := range order { + if idx == q.currentIndex { + order[0], order[i] = order[i], order[0] + + break + } + } + + q.shuffleOrder = order +} diff --git a/backend/queue/navigation_test.go b/backend/queue/navigation_test.go new file mode 100644 index 0000000..adcb6c0 --- /dev/null +++ b/backend/queue/navigation_test.go @@ -0,0 +1,210 @@ +package queue + +import ( + "log/slog" + "testing" +) + +// newTestQueueDirect creates a Queue with direct field manipulation +// (no DB needed) for pure navigation logic tests. +func newTestQueueDirect(tracks int, currentIndex int) *Queue { + q := &Queue{ + logger: slog.Default(), + repeatMode: RepeatOff, + } + + q.tracks = make([]Track, tracks) + for i := range tracks { + q.tracks[i] = Track{FilePath: "/test/track.mp3", Position: int64(i)} + } + + q.currentIndex = currentIndex + + return q +} + +func TestNextIndex_NormalMode_AdvancesToNextTrack(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 2) + + got := q.nextIndex() + if got != 3 { + t.Errorf("nextIndex: got %d, want 3", got) + } +} + +func TestNextIndex_NormalMode_EndOfQueue_RepeatOff(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 4) + + got := q.nextIndex() + if got != -1 { + t.Errorf("nextIndex at end (repeatOff): got %d, want -1", got) + } +} + +func TestNextIndex_NormalMode_EndOfQueue_RepeatAll(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 4) + q.repeatMode = RepeatAll + + got := q.nextIndex() + if got != 0 { + t.Errorf("nextIndex at end (repeatAll): got %d, want 0", got) + } +} + +func TestNextIndex_RepeatOne(t *testing.T) { + t.Parallel() + + // Note: RepeatOne is handled in the Next() method, not nextIndex(). + // nextIndex() with RepeatOne still advances normally — the repeat-one + // logic replays the current track before calling nextIndex(). + // This test verifies nextIndex advances in the RepeatOne case. + q := newTestQueueDirect(5, 2) + q.repeatMode = RepeatOne + + got := q.nextIndex() + // nextIndex itself doesn't handle RepeatOne — it just advances. + if got != 3 { + t.Errorf("nextIndex (repeatOne): got %d, want 3", got) + } +} + +func TestPreviousIndex_NormalMode_GoesBack(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 3) + + got := q.previousIndex() + if got != 2 { + t.Errorf("previousIndex: got %d, want 2", got) + } +} + +func TestPreviousIndex_AtStart_RepeatOff(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 0) + + got := q.previousIndex() + if got != -1 { + t.Errorf("previousIndex at start (repeatOff): got %d, want -1", got) + } +} + +func TestPreviousIndex_AtStart_RepeatAll(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 0) + q.repeatMode = RepeatAll + + got := q.previousIndex() + if got != 4 { + t.Errorf("previousIndex at start (repeatAll): got %d, want 4", got) + } +} + +func TestGenerateShuffleOrder_Properties(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + trackCount int + currentIdx int + }{ + {"single track", 1, 0}, + {"five tracks", 5, 2}, + {"twenty tracks", 20, 10}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(tc.trackCount, tc.currentIdx) + q.generateShuffleOrder() + + // Property 1: length matches track count. + if got := len(q.shuffleOrder); got != tc.trackCount { + t.Errorf("shuffleOrder length: got %d, want %d", got, tc.trackCount) + } + + // Property 2: current track is at shuffleOrder[0]. + if q.shuffleOrder[0] != tc.currentIdx { + t.Errorf( + "shuffleOrder[0]: got %d, want %d (currentIndex)", + q.shuffleOrder[0], tc.currentIdx, + ) + } + + // Property 3: all indices present (no duplicates, no missing). + seen := make(map[int]bool, tc.trackCount) + + for _, idx := range q.shuffleOrder { + if idx < 0 || idx >= tc.trackCount { + t.Errorf("shuffleOrder contains out-of-range index: %d", idx) + } + + if seen[idx] { + t.Errorf("shuffleOrder contains duplicate index: %d", idx) + } + + seen[idx] = true + } + + if len(seen) != tc.trackCount { + t.Errorf( + "unique indices in shuffleOrder: got %d, want %d", + len(seen), tc.trackCount, + ) + } + }) + } +} + +func TestNextIndex_ShuffleMode(t *testing.T) { + t.Parallel() + + q := newTestQueueDirect(5, 2) + q.shuffleMode = true + // Set a known shuffle order: [2, 4, 0, 3, 1] + // Current index is 2, which is at shuffleOrder[0]. + q.shuffleOrder = []int{2, 4, 0, 3, 1} + + // Next in shuffle order should be shuffleOrder[1] = 4. + got := q.nextIndex() + if got != 4 { + t.Errorf("nextIndex (shuffle): got %d, want 4", got) + } + + // Advance to index 4 and get next. + q.currentIndex = 4 + got = q.nextIndex() + + if got != 0 { + t.Errorf("nextIndex (shuffle, pos 2): got %d, want 0", got) + } + + // At the end of shuffle order with RepeatOff. + q.currentIndex = 1 // last in shuffleOrder + got = q.nextIndex() + + if got != -1 { + t.Errorf("nextIndex (shuffle, end, repeatOff): got %d, want -1", got) + } + + // At the end of shuffle order with RepeatAll. + q.repeatMode = RepeatAll + got = q.nextIndex() + + if got != 2 { + t.Errorf( + "nextIndex (shuffle, end, repeatAll): got %d, want 2 "+ + "(wraps to shuffleOrder[0])", got, + ) + } +} diff --git a/backend/queue/persistence.go b/backend/queue/persistence.go new file mode 100644 index 0000000..09abeb9 --- /dev/null +++ b/backend/queue/persistence.go @@ -0,0 +1,474 @@ +package queue + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/profiling" +) + +// persistAddTrack inserts a single track at the end of the queue. +// No position shifting is needed because this is always an append. +// The caller must hold q.mu. +func (q *Queue) persistAddTrack(track Track) { + _, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: track.Position, + }) + if err != nil { + q.logger.Error("Failed to persist added track", "err", err) + } +} + +// persistAddTracks inserts multiple tracks at the end of the queue +// atomically in a transaction. No position shifting is needed because +// these are always appends. +// The caller must hold q.mu. +func (q *Queue) persistAddTracks(tracks []Track) { + if len(tracks) == 0 { + return + } + + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + txQueries := q.db.Queries.WithTx(tx) + + for _, track := range tracks { + _, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: track.Position, + }) + if insertErr != nil { + q.logger.Error("Failed to insert track", "err", insertErr) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// persistInsertTracks inserts multiple tracks at a given position, +// shifting existing tracks to make room. Uses a transaction for atomicity. +// The caller must hold q.mu. +func (q *Queue) persistInsertTracks(tracks []Track, insertPos int) { + if len(tracks) == 0 { + return + } + + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + // SAFETY: Multi-row position shift by variable N unsupported by sqlc + // (ShiftQueuePositionsUp only shifts by 1). Bind variables match args; + // no string interpolation. + _, err = tx.ExecContext( + q.db.Ctx, + "UPDATE queue_tracks SET position = position + ? WHERE position >= ?", + len(tracks), insertPos, + ) + if err != nil { + q.logger.Error("Failed to shift positions up", "err", err) + + return + } + + txQueries := q.db.Queries.WithTx(tx) + + for i, track := range tracks { + _, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: int64(insertPos + i), + }) + if insertErr != nil { + q.logger.Error("Failed to insert track", "err", insertErr) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// persistRemoveTrack deletes a single track at the given position and +// shifts subsequent positions down to close the gap. +// The caller must hold q.mu. +func (q *Queue) persistRemoveTrack(position int) { + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + txQueries := q.db.Queries.WithTx(tx) + + if removeErr := txQueries.RemoveQueueTrackByPosition( + q.db.Ctx, int64(position), + ); removeErr != nil { + q.logger.Error("Failed to remove track by position", "err", removeErr) + + return + } + + if shiftErr := txQueries.ShiftQueuePositionsDown( + q.db.Ctx, int64(position), + ); shiftErr != nil { + q.logger.Error("Failed to shift positions down", "err", shiftErr) + + return + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of +// file paths using a single query per chunk (instead of 2 queries per track). +// Returns a map keyed by file path. This is safe to call without holding q.mu. +func (q *Queue) lookupTrackMetaBatch( + filePaths []string, +) map[string]trackMeta { + result := make(map[string]trackMeta, len(filePaths)) + + // Deduplicate paths to avoid redundant work. + unique := make([]string, 0, len(filePaths)) + seen := make(map[string]bool, len(filePaths)) + + for _, fp := range filePaths { + if !seen[fp] { + seen[fp] = true + + unique = append(unique, fp) + } + } + + // Process in chunks to stay under the SQLite bind variable limit. + for i := 0; i < len(unique); i += maxSQLiteVars { + end := i + maxSQLiteVars + if end > len(unique) { + end = len(unique) + } + + chunk := unique[i:end] + q.lookupChunk(chunk, result) + } + + return result +} + +// lookupChunk executes a single batch query for a chunk of file paths +// using the sqlc-generated LookupTrackMetaByPaths query against the +// track_metadata VIEW. +func (q *Queue) lookupChunk( + paths []string, + result map[string]trackMeta, +) { + if len(paths) == 0 { + return + } + + rows, err := q.db.Queries.LookupTrackMetaByPaths(q.db.Ctx, paths) + if err != nil { + q.logger.Error("Batch metadata lookup failed", "err", err) + + return + } + + for _, row := range rows { + result[row.FilePath] = trackMeta{ + AudioFileID: row.ID, + FilePath: row.FilePath, + Title: row.Title, + Artist: row.ArtistName, + } + } +} + +// persistTracks writes the current queue tracks to the database atomically +// using a transaction with batched multi-row inserts. +func (q *Queue) persistTracks() { + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + // Clear existing tracks. + txQueries := q.db.Queries.WithTx(tx) + + if clearErr := txQueries.ClearQueueTracks(q.db.Ctx); clearErr != nil { + q.logger.Error("Failed to clear queue tracks", "err", clearErr) + + return + } + + // Batch insert tracks. Each row needs 2 bind vars (audio_file_id, position). + const varsPerRow = 2 + + batchSize := maxSQLiteVars / varsPerRow + + for i := 0; i < len(q.tracks); i += batchSize { + end := i + batchSize + if end > len(q.tracks) { + end = len(q.tracks) + } + + batch := q.tracks[i:end] + + if insertErr := q.insertTrackBatch(tx, batch); insertErr != nil { + q.logger.Error( + "Failed to batch insert queue tracks", + "err", insertErr, + ) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// insertTrackBatch inserts a batch of tracks in a single multi-row INSERT. +func (q *Queue) insertTrackBatch(tx *sql.Tx, batch []Track) error { + if len(batch) == 0 { + return nil + } + + valuePlaceholders := make([]string, len(batch)) + args := make([]any, 0, len(batch)*2) + + for i, track := range batch { + valuePlaceholders[i] = "(?, ?)" + + args = append(args, track.AudioFileID, track.Position) + } + + // SAFETY: Multi-row INSERT with variable row count unsupported by sqlc. Placeholder count matches args length; no string interpolation. + query := "INSERT INTO queue_tracks (audio_file_id, position) VALUES " + + strings.Join(valuePlaceholders, ",") + + _, err := tx.ExecContext(q.db.Ctx, query, args...) + if err != nil { + return fmt.Errorf("batch insert failed: %w", err) + } + + return nil +} + +// persistState writes the queue metadata to the database. +func (q *Queue) persistState() { + var shuffleOrderJSON sql.NullString + + if len(q.shuffleOrder) > 0 { + data, err := json.Marshal(q.shuffleOrder) + if err != nil { + q.logger.Error( + "Failed to marshal shuffle order", + "err", err, + ) + } else { + shuffleOrderJSON = sql.NullString{ + String: string(data), + Valid: true, + } + } + } + + sourcePlaylistID := sql.NullInt64{} + if q.sourcePlaylistID > 0 { + sourcePlaylistID = sql.NullInt64{ + Int64: q.sourcePlaylistID, + Valid: true, + } + } + + err := q.db.Queries.UpdateQueueState( + q.db.Ctx, + sqlcgen.UpdateQueueStateParams{ + SourcePlaylistID: sourcePlaylistID, + CurrentPosition: int64(q.currentIndex), + ShuffleMode: q.shuffleMode, + RepeatMode: string(q.repeatMode), + ShuffleOrder: shuffleOrderJSON, + }, + ) + if err != nil { + q.logger.Error("Failed to persist queue state", "err", err) + } +} + +// SaveState persists the queue state to the database. +func (q *Queue) SaveState() { + q.mu.Lock() + defer q.mu.Unlock() + + q.persistTracks() + q.persistState() + q.logger.Info("Queue state saved", + "trackCount", len(q.tracks), + "currentIndex", q.currentIndex, + "shuffleMode", q.shuffleMode, + "repeatMode", q.repeatMode, + ) +} + +// RestoreState loads the queue state from the database. +func (q *Queue) RestoreState() { + defer profiling.TimeOp(q.logger, "queue.RestoreState")() + + q.mu.Lock() + defer q.mu.Unlock() + + // Restore queue metadata. + state, err := q.db.Queries.GetQueueState(q.db.Ctx) + if err != nil { + q.logger.Error("Failed to load queue state", "err", err) + + return + } + + q.currentIndex = int(state.CurrentPosition) + q.shuffleMode = state.ShuffleMode + q.repeatMode = RepeatMode(state.RepeatMode) + + if state.SourcePlaylistID.Valid { + q.sourcePlaylistID = state.SourcePlaylistID.Int64 + } + + // Restore shuffle order. + if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" { + var order []int + + if err := json.Unmarshal( + []byte(state.ShuffleOrder.String), &order, + ); err != nil { + q.logger.Warn("Failed to parse shuffle order", "err", err) + } else { + q.shuffleOrder = order + } + } + + // Restore queue tracks. + rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx) + if err != nil { + q.logger.Error("Failed to load queue tracks", "err", err) + + return + } + + q.tracks = make([]Track, 0, len(rows)) + + for _, row := range rows { + q.tracks = append(q.tracks, Track{ + ID: row.ID, + AudioFileID: row.AudioFileID, + FilePath: row.FilePath, + Position: row.Position, + Title: row.Title, + Artist: row.Artist, + }) + } + + // Clamp current index. A value of -1 is valid and means "no current + // track" (e.g. the queue was exhausted before shutdown). Only clamp + // when the index exceeds the restored track count. + if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { + q.currentIndex = len(q.tracks) - 1 + } + + q.logger.Info("Queue state restored", + "trackCount", len(q.tracks), + "currentIndex", q.currentIndex, + "shuffleMode", q.shuffleMode, + "repeatMode", q.repeatMode, + ) +} diff --git a/backend/queue/persistence_test.go b/backend/queue/persistence_test.go new file mode 100644 index 0000000..483c4a4 --- /dev/null +++ b/backend/queue/persistence_test.go @@ -0,0 +1,207 @@ +package queue + +import ( + "log/slog" + "testing" +) + +func TestSaveState_RestoreState_Roundtrip(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 2, false) + + // Change modes so we test all fields. + q.CycleRepeat() // off -> all + q.ToggleShuffle() + + q.SaveState() + + // Create a new Queue with the same DB. + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + s1 := q.GetState() + s2 := q2.GetState() + + // Tracks length. + if len(s2.Tracks) != len(s1.Tracks) { + t.Fatalf("tracks length: got %d, want %d", len(s2.Tracks), len(s1.Tracks)) + } + + // Each track's FilePath, Title, Artist. + for i := range s1.Tracks { + if s2.Tracks[i].FilePath != s1.Tracks[i].FilePath { + t.Errorf( + "track[%d] FilePath: got %q, want %q", + i, s2.Tracks[i].FilePath, s1.Tracks[i].FilePath, + ) + } + + if s2.Tracks[i].Title != s1.Tracks[i].Title { + t.Errorf("track[%d] Title: got %q, want %q", i, s2.Tracks[i].Title, s1.Tracks[i].Title) + } + + if s2.Tracks[i].Artist != s1.Tracks[i].Artist { + t.Errorf( + "track[%d] Artist: got %q, want %q", + i, s2.Tracks[i].Artist, s1.Tracks[i].Artist, + ) + } + } + + // CurrentIndex. + if s2.CurrentIndex != s1.CurrentIndex { + t.Errorf("currentIndex: got %d, want %d", s2.CurrentIndex, s1.CurrentIndex) + } + + // ShuffleMode. + if s2.ShuffleMode != s1.ShuffleMode { + t.Errorf("shuffleMode: got %v, want %v", s2.ShuffleMode, s1.ShuffleMode) + } + + // RepeatMode. + if s2.RepeatMode != s1.RepeatMode { + t.Errorf("repeatMode: got %q, want %q", s2.RepeatMode, s1.RepeatMode) + } + + // ShuffleOrder. + q.mu.Lock() + q2.mu.Lock() + + if len(q2.shuffleOrder) != len(q.shuffleOrder) { + t.Errorf("shuffleOrder length: got %d, want %d", len(q2.shuffleOrder), len(q.shuffleOrder)) + } else { + for i := range q.shuffleOrder { + if q2.shuffleOrder[i] != q.shuffleOrder[i] { + t.Errorf( + "shuffleOrder[%d]: got %d, want %d", + i, q2.shuffleOrder[i], q.shuffleOrder[i], + ) + } + } + } + + q2.mu.Unlock() + q.mu.Unlock() +} + +func TestSaveState_RestoreState_EmptyQueue(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + + // Save empty state (no SetQueue called). + q.SaveState() + + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + state := q2.GetState() + if len(state.Tracks) != 0 { + t.Errorf("tracks after restore empty: got %d, want 0", len(state.Tracks)) + } +} + +func TestSaveState_RestoreState_SingleTrack(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 1) + + q.SetQueue(paths, 0, false) + q.SaveState() + + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + state := q2.GetState() + if len(state.Tracks) != 1 { + t.Fatalf("tracks: got %d, want 1", len(state.Tracks)) + } + + if state.Tracks[0].FilePath != paths[0] { + t.Errorf("track FilePath: got %q, want %q", state.Tracks[0].FilePath, paths[0]) + } + + if state.CurrentIndex != 0 { + t.Errorf("currentIndex: got %d, want 0", state.CurrentIndex) + } +} + +func TestSaveState_RestoreState_PreservesTrackOrder(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 10) + + q.SetQueue(paths, 0, false) + q.SaveState() + + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + state := q2.GetState() + if len(state.Tracks) != 10 { + t.Fatalf("tracks: got %d, want 10", len(state.Tracks)) + } + + for i, track := range state.Tracks { + if track.FilePath != paths[i] { + t.Errorf("track[%d] order: got %q, want %q", i, track.FilePath, paths[i]) + } + } +} + +func TestRestoreState_NoSavedState(t *testing.T) { + t.Parallel() + + _, db := setupTestQueue(t) + + // RestoreState on fresh DB with no prior SaveState — should not panic. + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + state := q2.GetState() + if len(state.Tracks) != 0 { + t.Errorf("tracks after restore (no save): got %d, want 0", len(state.Tracks)) + } +} + +func TestSaveState_OverwritesPreviousState(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 8) + + // First save: 5 tracks. + q.SetQueue(paths[:5], 0, false) + q.SaveState() + + // Second save: 3 different tracks. + q.SetQueue(paths[5:8], 0, false) + q.SaveState() + + q2 := NewQueue(slog.Default(), db) + q2.SetPlayer(&mockTrackLoader{}) + q2.RestoreState() + + state := q2.GetState() + if len(state.Tracks) != 3 { + t.Fatalf("tracks after overwrite: got %d, want 3", len(state.Tracks)) + } + + // Verify the 3 tracks are from the second save, not the first. + for i, track := range state.Tracks { + if track.FilePath != paths[5+i] { + t.Errorf("track[%d]: got %q, want %q", i, track.FilePath, paths[5+i]) + } + } +} diff --git a/backend/queue/queue.go b/backend/queue/queue.go index cb6bc74..a84f96a 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -3,18 +3,13 @@ package queue import ( "context" - "database/sql" - "encoding/json" - "errors" "log/slog" - "math/rand/v2" + "slices" "sync" - - "github.com/wailsapp/wails/v2/pkg/runtime" + "sync/atomic" "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" - "yellowjacket/backend/events" + "yellowjacket/backend/profiling" ) // RepeatMode represents the queue repeat behavior. @@ -31,11 +26,40 @@ const ( // "Previous" restarts the current track instead of going to the prior one. const PreviousRestartThreshold = 3 +// maxSQLiteVars is the maximum number of bind variables SQLite supports +// per statement. We use a conservative limit for batching. +const maxSQLiteVars = 900 + +// initialBatchSize is the number of tracks resolved eagerly in the first +// phase of SetQueue so the queue panel is populated immediately. +const initialBatchSize = 50 + +// trackMeta holds the result of a batch metadata lookup. +type trackMeta struct { + AudioFileID int64 + FilePath string + Title string + Artist string +} + +// toTrack converts metadata lookup results into a queue Track. +func (m trackMeta) toTrack(position int64) Track { + return Track{ + AudioFileID: m.AudioFileID, + FilePath: m.FilePath, + Position: position, + Title: m.Title, + Artist: m.Artist, + } +} + // TrackLoader is the interface the queue uses to tell the player to load a file. type TrackLoader interface { LoadFile(filePath string) error Play() error + IsPlaying() bool CurrentPositionSeconds() (int, error) + UnloadTrack() } // Track represents a track in the queue with its metadata. @@ -57,6 +81,26 @@ type State struct { SourcePlaylistID int64 `json:"sourcePlaylistId"` } +// IndexChanged is the payload for the QueueIndexChanged event. +type IndexChanged struct { + CurrentIndex int `json:"currentIndex"` +} + +// ModeChanged is the payload for the QueueModeChanged event. +type ModeChanged struct { + ShuffleMode bool `json:"shuffleMode"` + RepeatMode RepeatMode `json:"repeatMode"` +} + +// TracksModified is the payload for the QueueTracksModified event. +type TracksModified struct { + Action string `json:"action"` + Tracks []Track `json:"tracks,omitempty"` + Index int `json:"index"` + Positions []int `json:"positions,omitempty"` + CurrentIndex int `json:"currentIndex"` +} + // Queue manages an ordered list of tracks for playback. type Queue struct { ctx context.Context @@ -71,6 +115,10 @@ type Queue struct { repeatMode RepeatMode shuffleOrder []int sourcePlaylistID int64 + + // setQueueGen is incremented each time SetQueue is called. Background + // goroutines check this to detect if they have been superseded. + setQueueGen atomic.Int64 } // NewQueue creates a new queue manager. @@ -82,10 +130,12 @@ func NewQueue(logger *slog.Logger, db *database.DB) *Queue { } } -// SetContext sets the Wails runtime context and registers event handlers. +// SetContext sets the Wails runtime context for event emission. func (q *Queue) SetContext(ctx context.Context) { + q.mu.Lock() + defer q.mu.Unlock() + q.ctx = ctx - q.registerEventHandlers() } // SetPlayer provides the queue with a reference to the player for auto-advance. @@ -93,394 +143,293 @@ func (q *Queue) SetPlayer(player TrackLoader) { q.player = player } -// OnPlaybackFinished is called when a track finishes playing naturally. -// This drives the auto-advance behavior. -func (q *Queue) OnPlaybackFinished() { - q.mu.Lock() - defer q.mu.Unlock() - - if len(q.tracks) == 0 { - return - } - - // Repeat One: replay the current track. - if q.repeatMode == RepeatOne { - q.playCurrentTrack() - - return - } - - nextIdx := q.nextIndex() - if nextIdx == -1 { - // Queue exhausted — this is the extension point for a future fallback playlist. - q.onQueueExhausted() - - return - } - - q.currentIndex = nextIdx - q.playCurrentTrack() -} - -// registerEventHandlers sets up Wails event listeners for queue commands. -func (q *Queue) registerEventHandlers() { - if q.ctx == nil { - q.logger.Error("Context is nil, cannot register event handlers") - - return - } - - runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) { - q.logger.Info("Received RequestNext") - q.Next() - }) - - runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) { - q.logger.Info("Received RequestPrevious") - q.Previous() - }) - - runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) { - q.logger.Info("Received RequestSetQueue") - q.handleSetQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) { - q.logger.Info("Received RequestAddToQueue") - q.handleAddToQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) { - q.logger.Info("Received RequestPlayNext") - q.handlePlayNext(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestRemoveFromQueue, func(data ...any) { - q.logger.Info("Received RequestRemoveFromQueue") - q.handleRemoveFromQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestToggleShuffle, func(_ ...any) { - q.logger.Info("Received RequestToggleShuffle") - q.ToggleShuffle() - }) - - runtime.EventsOn(q.ctx, events.RequestCycleRepeat, func(_ ...any) { - q.logger.Info("Received RequestCycleRepeat") - q.CycleRepeat() - }) - - runtime.EventsOn(q.ctx, events.RequestAddTracksToQueue, func(data ...any) { - q.logger.Info("Received RequestAddTracksToQueue") - q.handleAddTracksToQueue(data...) - }) - - runtime.EventsOn(q.ctx, events.RequestPlayTracksNext, func(data ...any) { - q.logger.Info("Received RequestPlayTracksNext") - q.handlePlayTracksNext(data...) - }) -} - -// handleSetQueue processes the RequestSetQueue event payload. -// Expects data[0] = []interface{} of file path strings, data[1] = float64 start index. -func (q *Queue) handleSetQueue(data ...any) { - if len(data) < 2 { - q.logger.Error("RequestSetQueue: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error("RequestSetQueue: invalid filePaths type") - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - startIndex := 0 - - if si, ok := data[1].(float64); ok { - startIndex = int(si) - } - - q.SetQueue(filePaths, startIndex) -} - -// handleAddToQueue processes the RequestAddToQueue event payload. -// Expects data[0] = string file path. -func (q *Queue) handleAddToQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestAddToQueue: missing data") - - return - } - - filePath, ok := data[0].(string) - if !ok { - q.logger.Error("RequestAddToQueue: invalid filePath type", "got", data[0]) - - return - } - - q.AddTrack(filePath) -} - -// handlePlayNext processes the RequestPlayNext event payload. -// Expects data[0] = string file path. -func (q *Queue) handlePlayNext(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestPlayNext: missing data") - - return - } - - filePath, ok := data[0].(string) - if !ok { - q.logger.Error("RequestPlayNext: invalid filePath type", "got", data[0]) - - return - } - - q.InsertNext(filePath) -} - -// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload. -// Expects data[0] = float64 position. -func (q *Queue) handleRemoveFromQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestRemoveFromQueue: missing data") - - return - } - - position, ok := data[0].(float64) - if !ok { - q.logger.Error("RequestRemoveFromQueue: invalid position type", "got", data[0]) - - return - } - - q.RemoveTrack(int(position)) -} - -// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload. -// Expects data[0] = []interface{} of file path strings. -func (q *Queue) handleAddTracksToQueue(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestAddTracksToQueue: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error("RequestAddTracksToQueue: invalid filePaths type", "got", data[0]) - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - q.AddTracks(filePaths) -} - -// handlePlayTracksNext processes the RequestPlayTracksNext event payload. -// Expects data[0] = []interface{} of file path strings. -func (q *Queue) handlePlayTracksNext(data ...any) { - if len(data) < 1 { - q.logger.Error("RequestPlayTracksNext: missing data") - - return - } - - filePathsRaw, ok := data[0].([]interface{}) - if !ok { - q.logger.Error("RequestPlayTracksNext: invalid filePaths type", "got", data[0]) - - return - } - - filePaths := make([]string, 0, len(filePathsRaw)) - - for _, fp := range filePathsRaw { - if s, ok := fp.(string); ok { - filePaths = append(filePaths, s) - } - } - - q.InsertNextTracks(filePaths) -} - // SetQueue replaces the entire queue with new tracks and starts playing. -func (q *Queue) SetQueue(filePaths []string, startIndex int) { +// When shuffleStart is true and shuffle mode is active, a random first +// track is chosen instead of the one at startIndex. This is intended for +// "Play All" type actions where no specific track was selected. +// It uses a two-phase approach: the first batch of tracks (up to +// initialBatchSize) is resolved immediately so playback begins and the +// queue panel is populated without delay. The remaining tracks are then +// resolved in the background. A generation counter ensures stale +// background work is discarded if SetQueue is called again. +func (q *Queue) SetQueue( + filePaths []string, + startIndex int, + shuffleStart bool, +) { + defer profiling.TimeOp(q.logger, "queue.SetQueue")() + + gen := q.setQueueGen.Add(1) + + if startIndex < 0 || startIndex >= len(filePaths) { + startIndex = 0 + } + + // Phase 1: resolve an initial window of tracks centered on startIndex + // so the queue panel is populated around the playing track immediately. + windowStart := max(0, startIndex-initialBatchSize/2) + windowEnd := min(len(filePaths), windowStart+initialBatchSize) + windowStart = max(0, windowEnd-initialBatchSize) + + initialPaths := filePaths[windowStart:windowEnd] + + batchMeta := q.lookupTrackMetaBatch(initialPaths) + q.mu.Lock() - defer q.mu.Unlock() - // Look up audio file IDs and metadata for all paths. - tracks := make([]Track, 0, len(filePaths)) - - for i, fp := range filePaths { - af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp) - if err != nil { - q.logger.Warn("Could not find audio file in database", "path", fp, "err", err) + // Build the initial tracks slice preserving original order. + tracks := make([]Track, 0, len(initialPaths)) + for i, fp := range initialPaths { + m, ok := batchMeta[fp] + if !ok { continue } - track := Track{ - AudioFileID: af.ID, - FilePath: fp, - Position: int64(i), - } + tracks = append(tracks, m.toTrack(int64(i))) + } - // Try to get metadata. - meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp) - if metaErr == nil { - track.Title = meta.Title - track.Artist = meta.Artist - } + if len(tracks) == 0 { + q.logger.Warn("No tracks found in initial batch") + q.mu.Unlock() - tracks = append(tracks, track) + return } q.tracks = tracks q.sourcePlaylistID = 0 + q.shuffleOrder = nil - if startIndex >= 0 && startIndex < len(q.tracks) { - q.currentIndex = startIndex - } else { - q.currentIndex = 0 + // Find the start track within the initial batch. + q.currentIndex = 0 + startPath := filePaths[startIndex] + + for i, t := range q.tracks { + if t.FilePath == startPath { + q.currentIndex = i + + break + } } - // Regenerate shuffle order if shuffle is on. - if q.shuffleMode { + // When the caller signals that shuffle should pick the first track + // (e.g. "Play All" rather than a specific track click) and shuffle + // mode is active, generate a shuffle order and start from its first + // element — a random track. + if shuffleStart && q.shuffleMode && len(q.tracks) > 1 { + q.currentIndex = -1 q.generateShuffleOrder() + q.currentIndex = q.shuffleOrder[0] } - // Persist to DB. - q.persistTracks() - q.persistState() - - // Start playing the selected track. + // Start playing immediately. q.playCurrentTrack() q.emitQueueChanged() + + // Record the path actually playing so Phase 2 can find it after the + // full track list is rebuilt. + playingPath := q.tracks[q.currentIndex].FilePath + + q.mu.Unlock() + + // Phase 2: if there are more tracks beyond the initial batch, + // resolve them in the background. If everything fits in the initial + // batch we can persist and finish synchronously. + if len(filePaths) <= initialBatchSize { + q.mu.Lock() + + if shuffleStart && q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistTracks() + q.persistState() + + q.mu.Unlock() + + return + } + + go q.resolveRemainingTracks(gen, filePaths, playingPath, batchMeta) +} + +// resolveRemainingTracks runs in a goroutine to batch-resolve all tracks +// for a SetQueue call. It checks the generation counter before applying +// results to avoid overwriting a newer SetQueue call. playingPath is the +// file path of the track that is currently playing so the correct +// currentIndex can be located in the rebuilt track list. +// phase1Meta contains metadata already resolved in Phase 1; those paths +// are skipped to avoid redundant database lookups. +func (q *Queue) resolveRemainingTracks( + gen int64, + filePaths []string, + playingPath string, + phase1Meta map[string]trackMeta, +) { + // Exclude paths already resolved in Phase 1. + var unresolvedPaths []string + + for _, fp := range filePaths { + if _, alreadyResolved := phase1Meta[fp]; !alreadyResolved { + unresolvedPaths = append(unresolvedPaths, fp) + } + } + + // Only look up paths that Phase 1 didn't cover. + allMeta := q.lookupTrackMetaBatch(unresolvedPaths) + + // Merge Phase 1 results into the lookup. + for k, v := range phase1Meta { + allMeta[k] = v + } + + // Check if we have been superseded before acquiring the mutex. + if q.setQueueGen.Load() != gen { + return + } + + q.mu.Lock() + defer q.mu.Unlock() + + // Double-check under the lock. + if q.setQueueGen.Load() != gen { + return + } + + tracks := make([]Track, 0, len(filePaths)) + + for i, fp := range filePaths { + meta, found := allMeta[fp] + if !found { + q.logger.Warn( + "Could not find audio file in database", + "path", fp, + ) + + continue + } + + tracks = append(tracks, meta.toTrack(int64(i))) + } + + q.tracks = tracks + + // Recalculate currentIndex: find the track that is actually playing. + // This may differ from the original startIndex when shuffleStart was + // used to pick a random first track. + q.currentIndex = 0 + + for i, t := range q.tracks { + if t.FilePath == playingPath { + q.currentIndex = i + + break + } + } + + q.commitMutation(false) + q.emitQueueChanged() } // AddTrack appends a track to the end of the queue. -// If the queue was empty, it starts playing the added track immediately. +// If the queue was empty, it loads the added track in a paused state. func (q *Queue) AddTrack(filePath string) { + meta := q.lookupTrackMetaBatch([]string{filePath}) + q.mu.Lock() defer q.mu.Unlock() - af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath) - if err != nil { - q.logger.Error("Could not find audio file", "path", filePath, "err", err) + m, ok := meta[filePath] + if !ok { + q.logger.Error( + "Could not find audio file", + "path", filePath, + ) return } wasEmpty := len(q.tracks) == 0 - track := Track{ - AudioFileID: af.ID, - FilePath: filePath, - Position: int64(len(q.tracks)), - } - - // Try to get metadata. - meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath) - if metaErr == nil { - track.Title = meta.Title - track.Artist = meta.Artist - } + track := m.toTrack(int64(len(q.tracks))) q.tracks = append(q.tracks, track) - // Persist. - _, insertErr := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ - AudioFileID: af.ID, - Position: track.Position, - }) - if insertErr != nil { - q.logger.Error("Failed to persist queue track", "err", insertErr) - } - - // Update shuffle order if shuffle is on. - if q.shuffleMode { - q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1) - } - - // Auto-play if this is the first track added to an empty queue. + // Load (paused) if this is the first track added to an empty queue. if wasEmpty { q.currentIndex = 0 - q.playCurrentTrack() - } - - q.emitQueueChanged() -} - -// AddTracks appends multiple tracks to the end of the queue. -// If the queue was empty, it starts playing the first added track immediately. -func (q *Queue) AddTracks(filePaths []string) { - q.mu.Lock() - defer q.mu.Unlock() - - wasEmpty := len(q.tracks) == 0 - - for _, fp := range filePaths { - af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp) - if err != nil { - q.logger.Warn("Could not find audio file", "path", fp, "err", err) - - continue - } - - track := Track{ - AudioFileID: af.ID, - FilePath: fp, - Position: int64(len(q.tracks)), - } - - meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp) - if metaErr == nil { - track.Title = meta.Title - track.Artist = meta.Artist - } - - q.tracks = append(q.tracks, track) + q.loadCurrentTrack() } if q.shuffleMode { q.generateShuffleOrder() } - q.persistTracks() + q.persistAddTrack(track) q.persistState() + q.emitTracksModified( + "add", + []Track{track}, + len(q.tracks)-1, + nil, + ) +} - if wasEmpty && len(q.tracks) > 0 { - q.currentIndex = 0 - q.playCurrentTrack() +// AddTracks appends multiple tracks to the end of the queue. +// If the queue was empty, it loads the first added track in a paused state. +func (q *Queue) AddTracks(filePaths []string) { + allMeta := q.lookupTrackMetaBatch(filePaths) + + q.mu.Lock() + defer q.mu.Unlock() + + wasEmpty := len(q.tracks) == 0 + insertIndex := len(q.tracks) + + var newTracks []Track + + for _, fp := range filePaths { + m, ok := allMeta[fp] + if !ok { + q.logger.Warn( + "Could not find audio file", + "path", fp, + ) + + continue + } + + track := m.toTrack(int64(len(q.tracks))) + q.tracks = append(q.tracks, track) + + newTracks = append(newTracks, track) } - q.emitQueueChanged() + // Load (paused) if this is the first track added to an empty queue. + if wasEmpty && len(q.tracks) > 0 { + q.currentIndex = 0 + q.loadCurrentTrack() + } + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistAddTracks(newTracks) + q.persistState() + q.emitTracksModified( + "add", + newTracks, + insertIndex, + nil, + ) } // InsertNextTracks inserts multiple tracks as a contiguous block after the current track. +// If the queue was empty, it loads the first inserted track in a paused state. func (q *Queue) InsertNextTracks(filePaths []string) { + allMeta := q.lookupTrackMetaBatch(filePaths) + q.mu.Lock() defer q.mu.Unlock() @@ -494,36 +443,29 @@ func (q *Queue) InsertNextTracks(filePaths []string) { var newTracks []Track for _, fp := range filePaths { - af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp) - if err != nil { - q.logger.Warn("Could not find audio file", "path", fp, "err", err) + m, ok := allMeta[fp] + if !ok { + q.logger.Warn( + "Could not find audio file", + "path", fp, + ) continue } - track := Track{ - AudioFileID: af.ID, - FilePath: fp, - } - - meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp) - if metaErr == nil { - track.Title = meta.Title - track.Artist = meta.Artist - } - - newTracks = append(newTracks, track) + newTracks = append(newTracks, m.toTrack(0)) } if len(newTracks) == 0 { return } - // Insert the block into the slice at insertPos. - tail := make([]Track, len(q.tracks[insertPos:])) - copy(tail, q.tracks[insertPos:]) - q.tracks = append(q.tracks[:insertPos], newTracks...) - q.tracks = append(q.tracks, tail...) + q.tracks = slices.Insert(q.tracks, insertPos, newTracks...) + + if wasEmpty { + q.currentIndex = 0 + q.loadCurrentTrack() + } q.reindexPositions() @@ -531,62 +473,274 @@ func (q *Queue) InsertNextTracks(filePaths []string) { q.generateShuffleOrder() } - q.persistTracks() + q.persistInsertTracks(newTracks, insertPos) q.persistState() - - if wasEmpty { - q.currentIndex = 0 - q.playCurrentTrack() - } - - q.emitQueueChanged() + q.emitTracksModified( + "insert", + newTracks, + insertPos, + nil, + ) } // InsertNext inserts a track right after the currently playing track. +// If the queue was empty, it loads the inserted track in a paused state. func (q *Queue) InsertNext(filePath string) { + meta := q.lookupTrackMetaBatch([]string{filePath}) + q.mu.Lock() defer q.mu.Unlock() - af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath) - if err != nil { - q.logger.Error("Could not find audio file", "path", filePath, "err", err) + m, ok := meta[filePath] + if !ok { + q.logger.Error( + "Could not find audio file", + "path", filePath, + ) return } + wasEmpty := len(q.tracks) == 0 + insertPos := q.currentIndex + 1 if insertPos > len(q.tracks) { insertPos = len(q.tracks) } - track := Track{ - AudioFileID: af.ID, - FilePath: filePath, - Position: int64(insertPos), + track := m.toTrack(int64(insertPos)) + q.tracks = slices.Insert(q.tracks, insertPos, track) + + // Load (paused) if this is the first track added to an empty queue. + if wasEmpty { + q.currentIndex = 0 + q.loadCurrentTrack() } - // Try to get metadata. - meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath) - if metaErr == nil { - track.Title = meta.Title - track.Artist = meta.Artist - } - - // Insert into slice. - q.tracks = append(q.tracks, Track{}) - copy(q.tracks[insertPos+1:], q.tracks[insertPos:]) - q.tracks[insertPos] = track - - // Reindex positions. q.reindexPositions() - // Regenerate shuffle order if needed. if q.shuffleMode { q.generateShuffleOrder() } - q.persistTracks() - q.emitQueueChanged() + q.persistInsertTracks([]Track{track}, insertPos) + q.persistState() + q.emitTracksModified( + "insert", + []Track{track}, + insertPos, + nil, + ) +} + +// InsertTracksAt inserts multiple tracks at the given index. +// If the queue was empty, it loads the first inserted track in a paused state. +func (q *Queue) InsertTracksAt(filePaths []string, index int) { + allMeta := q.lookupTrackMetaBatch(filePaths) + + q.mu.Lock() + defer q.mu.Unlock() + + wasEmpty := len(q.tracks) == 0 + + // Clamp index to valid range. + if index < 0 { + index = 0 + } + + if index > len(q.tracks) { + index = len(q.tracks) + } + + var newTracks []Track + + for _, fp := range filePaths { + m, ok := allMeta[fp] + if !ok { + q.logger.Warn( + "Could not find audio file", + "path", fp, + ) + + continue + } + + newTracks = append(newTracks, m.toTrack(0)) + } + + if len(newTracks) == 0 { + return + } + + q.tracks = slices.Insert(q.tracks, index, newTracks...) + + // Shift currentIndex if insertion is at or before it. + if q.currentIndex >= 0 && index <= q.currentIndex { + q.currentIndex += len(newTracks) + } + + if wasEmpty { + q.currentIndex = 0 + q.loadCurrentTrack() + } + + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistInsertTracks(newTracks, index) + q.persistState() + q.emitTracksModified( + "insert", + newTracks, + index, + nil, + ) +} + +// MoveQueueTracks moves tracks at the given indices to a new position +// as a contiguous block. The toIndex is the target position in the +// original (pre-move) array. +func (q *Queue) MoveQueueTracks( + fromIndices []int, + toIndex int, +) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(fromIndices) == 0 || len(q.tracks) == 0 { + return + } + + // De-duplicate and sort source indices. + seen := make(map[int]bool, len(fromIndices)) + + var sorted []int + + for _, idx := range fromIndices { + if idx >= 0 && idx < len(q.tracks) && !seen[idx] { + seen[idx] = true + + sorted = append(sorted, idx) + } + } + + if len(sorted) == 0 { + return + } + + slices.Sort(sorted) + + // Clamp toIndex. + if toIndex < 0 { + toIndex = 0 + } + + if toIndex > len(q.tracks) { + toIndex = len(q.tracks) + } + + // Check if this is a no-op: all source indices are contiguous + // and already start at the target position. + isContiguous := true + + for i := 1; i < len(sorted); i++ { + if sorted[i] != sorted[i-1]+1 { + isContiguous = false + + break + } + } + + lastSorted := sorted[len(sorted)-1] + + if isContiguous && + (sorted[0] == toIndex || lastSorted+1 == toIndex) { + return + } + + // Find where currentIndex ends up after the move. + currentTrackIdx := q.currentIndex + + // Extract the tracks to move. + moving := make([]Track, len(sorted)) + for i, idx := range sorted { + moving[i] = q.tracks[idx] + } + + // Build a new slice without the moved tracks. + remaining := make([]Track, 0, len(q.tracks)-len(sorted)) + removeSet := make(map[int]bool, len(sorted)) + + for _, idx := range sorted { + removeSet[idx] = true + } + + for i, t := range q.tracks { + if !removeSet[i] { + remaining = append(remaining, t) + } + } + + // Calculate adjusted insertion index in the remaining slice. + adjustedIdx := toIndex + + for _, idx := range sorted { + if idx < toIndex { + adjustedIdx-- + } + } + + if adjustedIdx < 0 { + adjustedIdx = 0 + } + + if adjustedIdx > len(remaining) { + adjustedIdx = len(remaining) + } + + // Insert the moved block at the adjusted position. + q.tracks = slices.Insert(remaining, adjustedIdx, moving...) + + // Track currentIndex through the move. + if currentTrackIdx >= 0 { + if removeSet[currentTrackIdx] { + // The current track was moved — find its new position. + for ri, orig := range sorted { + if orig == currentTrackIdx { + q.currentIndex = adjustedIdx + ri + + break + } + } + } else { + // The current track was not moved. Find its position + // in 'remaining', then account for the insertion. + posInRemaining := currentTrackIdx + + for _, idx := range sorted { + if idx < currentTrackIdx { + posInRemaining-- + } + } + + if adjustedIdx <= posInRemaining { + q.currentIndex = posInRemaining + len(sorted) + } else { + q.currentIndex = posInRemaining + } + } + } + + q.commitMutation(true) + q.emitTracksModified( + "move", + moving, + toIndex, + sorted, + ) } // RemoveTrack removes a track at the given position from the queue. @@ -595,20 +749,97 @@ func (q *Queue) RemoveTrack(position int) { defer q.mu.Unlock() if position < 0 || position >= len(q.tracks) { - q.logger.Warn("RemoveTrack: position out of range", "position", position) + q.logger.Warn( + "RemoveTrack: position out of range", + "position", position, + ) return } + removingCurrent := q.currentIndex >= 0 && + position == q.currentIndex + q.tracks = append(q.tracks[:position], q.tracks[position+1:]...) - // Adjust current index if needed. - if position < q.currentIndex { + // Adjust current index if needed. A currentIndex of -1 means no track + // is loaded, so only shift when a valid track is selected. + if q.currentIndex >= 0 && position < q.currentIndex { q.currentIndex-- - } else if position == q.currentIndex && q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { + } else if position == q.currentIndex && + q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { q.currentIndex = len(q.tracks) - 1 } + q.persistRemoveTrack(position) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistState() + q.emitTracksModified( + "remove", + nil, + 0, + []int{position}, + ) + + if removingCurrent { + q.handleCurrentTrackRemoved() + } +} + +// RemoveTracks removes multiple tracks at the given positions from the queue. +// Positions are deduplicated, validated, and removed in descending order so +// that indices remain stable during removal. +func (q *Queue) RemoveTracks(positions []int) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(positions) == 0 { + return + } + + // Deduplicate and filter out-of-range positions. + seen := make(map[int]bool, len(positions)) + + valid := make([]int, 0, len(positions)) + + for _, p := range positions { + if p < 0 || p >= len(q.tracks) || seen[p] { + continue + } + + seen[p] = true + + valid = append(valid, p) + } + + if len(valid) == 0 { + return + } + + removedCurrent := q.currentIndex >= 0 && seen[q.currentIndex] + + // Sort ascending so we can iterate in reverse for descending removal. + slices.Sort(valid) + + // Remove in descending order to keep earlier indices stable. + for i := len(valid) - 1; i >= 0; i-- { + pos := valid[i] + q.tracks = append(q.tracks[:pos], q.tracks[pos+1:]...) + + if q.currentIndex >= 0 && pos < q.currentIndex { + q.currentIndex-- + } else if pos == q.currentIndex && + q.currentIndex >= len(q.tracks) && + len(q.tracks) > 0 { + q.currentIndex = len(q.tracks) - 1 + } + } + q.reindexPositions() if q.shuffleMode { @@ -617,10 +848,26 @@ func (q *Queue) RemoveTrack(position int) { q.persistTracks() q.persistState() - q.emitQueueChanged() + q.emitTracksModified( + "remove", + nil, + 0, + valid, + ) + + q.logger.Info( + "Removed tracks from queue", + "count", len(valid), + ) + + if removedCurrent { + q.handleCurrentTrackRemoved() + } } -// Next advances to the next track. +// Next advances to the next track. If the player was paused, the next +// track is loaded but not played. In RepeatOne mode, the current track +// is replayed instead of advancing. func (q *Queue) Next() { q.mu.Lock() defer q.mu.Unlock() @@ -629,6 +876,17 @@ func (q *Queue) Next() { return } + wasPlaying := q.player != nil && q.player.IsPlaying() + + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + if q.playOrLoadCurrentTrack(wasPlaying) { + q.emitIndexChanged() + } + + return + } + nextIdx := q.nextIndex() if nextIdx == -1 { q.onQueueExhausted() @@ -636,17 +894,37 @@ func (q *Queue) Next() { return } + prevIndex := q.currentIndex q.currentIndex = nextIdx - q.playCurrentTrack() - q.emitQueueChanged() + + if !q.playOrLoadCurrentTrack(wasPlaying) { + q.currentIndex = prevIndex + + return + } + + q.emitIndexChanged() } // Previous goes to the previous track (or restarts current if >3s in). +// If the player was paused, the track is loaded but not played. +// In RepeatOne mode, the current track is replayed instead of navigating. func (q *Queue) Previous() { q.mu.Lock() defer q.mu.Unlock() - if len(q.tracks) == 0 { + if len(q.tracks) == 0 || q.currentIndex < 0 { + return + } + + wasPlaying := q.player != nil && q.player.IsPlaying() + + // Repeat One: replay the current track. + if q.repeatMode == RepeatOne { + if q.playOrLoadCurrentTrack(wasPlaying) { + q.emitIndexChanged() + } + return } @@ -654,8 +932,9 @@ func (q *Queue) Previous() { if q.player != nil { posSecs, err := q.player.CurrentPositionSeconds() if err == nil && posSecs > PreviousRestartThreshold { - q.playCurrentTrack() - q.emitQueueChanged() + if q.playOrLoadCurrentTrack(wasPlaying) { + q.emitIndexChanged() + } return } @@ -664,15 +943,120 @@ func (q *Queue) Previous() { prevIdx := q.previousIndex() if prevIdx == -1 { // At the beginning — just restart the current track. - q.playCurrentTrack() - q.emitQueueChanged() + if q.playOrLoadCurrentTrack(wasPlaying) { + q.emitIndexChanged() + } return } + prevCurrentIndex := q.currentIndex q.currentIndex = prevIdx - q.playCurrentTrack() - q.emitQueueChanged() + + if !q.playOrLoadCurrentTrack(wasPlaying) { + q.currentIndex = prevCurrentIndex + + return + } + + q.emitIndexChanged() +} + +// Play handles a play request by either resuming the current track or +// starting playback from the beginning of the queue. When a track is +// already active (currentIndex != -1) the player is told to resume; +// otherwise playback starts from the first track (or a random one when +// shuffle is enabled). +func (q *Queue) Play() { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.tracks) == 0 { + return + } + + // A track is already active — ask the player to resume. + if q.currentIndex != -1 { + if q.player == nil { + q.logger.Error( + "No player set, cannot resume", + ) + + return + } + + if err := q.player.Play(); err != nil { + q.logger.Warn( + "Resume requested but player not ready", + "err", err, + ) + } + + return + } + + // No active track — start from the beginning. + q.playFromStart() +} + +// playFromStart restarts playback from the beginning of the queue. +// If shuffle is enabled, a new shuffle order is generated and playback +// starts from a random track. This is a no-op when a track is already +// active (currentIndex != -1) or the queue is empty. +// The caller must hold q.mu. +func (q *Queue) playFromStart() { + if q.currentIndex != -1 { + return + } + + if len(q.tracks) == 0 { + return + } + + if q.shuffleMode { + q.generateShuffleOrder() + q.currentIndex = q.shuffleOrder[0] + } else { + q.currentIndex = 0 + } + + if !q.playCurrentTrack() { + q.currentIndex = -1 + + return + } + + q.emitIndexChanged() +} + +// PlayIndex jumps to and plays the track at the given index. +func (q *Queue) PlayIndex(index int) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.tracks) == 0 { + return + } + + if index < 0 || index >= len(q.tracks) { + q.logger.Warn( + "PlayIndex: index out of range", + "index", index, "trackCount", len(q.tracks), + ) + + return + } + + prevIndex := q.currentIndex + q.currentIndex = index + + if !q.playCurrentTrack() { + q.currentIndex = prevIndex + + return + } + + q.emitIndexChanged() } // ToggleShuffle toggles shuffle mode on/off. @@ -689,10 +1073,10 @@ func (q *Queue) ToggleShuffle() { } q.persistState() - q.emitQueueChanged() + q.emitModeChanged() } -// CycleRepeat cycles through repeat modes: off → all → one → off. +// CycleRepeat cycles through repeat modes: off -> all -> one -> off. func (q *Queue) CycleRepeat() { q.mu.Lock() defer q.mu.Unlock() @@ -707,7 +1091,7 @@ func (q *Queue) CycleRepeat() { } q.persistState() - q.emitQueueChanged() + q.emitModeChanged() } // GetState returns the current queue state for the frontend. @@ -727,6 +1111,28 @@ func (q *Queue) GetState() State { } } +// Clear removes all tracks from the queue, stops playback, and +// resets the queue state. It persists the cleared state and +// notifies the frontend. +func (q *Queue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + + q.logger.Info("Clearing queue") + + q.tracks = nil + q.currentIndex = -1 + q.shuffleOrder = nil + q.sourcePlaylistID = 0 + + if q.player != nil { + q.player.UnloadTrack() + } + + q.commitMutation(false) + q.emitQueueChanged() +} + // EmitCurrentState emits the current queue state to the frontend. // This is called after the frontend DOM is ready. func (q *Queue) EmitCurrentState() { @@ -736,222 +1142,26 @@ func (q *Queue) EmitCurrentState() { q.emitQueueChanged() } -// SaveState persists the queue state to the database. -func (q *Queue) SaveState() { - q.mu.Lock() - defer q.mu.Unlock() +// playOrLoadCurrentTrack loads the current track and optionally starts +// playback. When autoPlay is true it behaves like playCurrentTrack; +// when false it only loads the file (leaving the player paused). +// Returns true if the file was loaded (and optionally played) successfully. +func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) bool { + if autoPlay { + return q.playCurrentTrack() + } - q.persistTracks() - q.persistState() - q.logger.Info("Queue state saved", - "trackCount", len(q.tracks), - "currentIndex", q.currentIndex, - "shuffleMode", q.shuffleMode, - "repeatMode", q.repeatMode, - ) + return q.loadCurrentTrack() } -// RestoreState loads the queue state from the database. -func (q *Queue) RestoreState() { - q.mu.Lock() - defer q.mu.Unlock() - - // Restore queue metadata. - state, err := q.db.Queries.GetQueueState(q.db.Ctx) - if err != nil { - q.logger.Error("Failed to load queue state", "err", err) - - return - } - - q.currentIndex = int(state.CurrentPosition) - q.shuffleMode = state.ShuffleMode - q.repeatMode = RepeatMode(state.RepeatMode) - - if state.SourcePlaylistID.Valid { - q.sourcePlaylistID = state.SourcePlaylistID.Int64 - } - - // Restore shuffle order. - if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" { - var order []int - - if err := json.Unmarshal([]byte(state.ShuffleOrder.String), &order); err != nil { - q.logger.Warn("Failed to parse shuffle order", "err", err) - } else { - q.shuffleOrder = order - } - } - - // Restore queue tracks. - rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx) - if err != nil { - q.logger.Error("Failed to load queue tracks", "err", err) - - return - } - - q.tracks = make([]Track, 0, len(rows)) - - for _, row := range rows { - q.tracks = append(q.tracks, Track{ - ID: row.ID, - AudioFileID: row.AudioFileID, - FilePath: row.FilePath, - Position: row.Position, - Title: row.Title, - Artist: row.Artist, - }) - } - - // Clamp current index. - if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 { - q.currentIndex = len(q.tracks) - 1 - } - - q.logger.Info("Queue state restored", - "trackCount", len(q.tracks), - "currentIndex", q.currentIndex, - "shuffleMode", q.shuffleMode, - "repeatMode", q.repeatMode, - ) -} - -// nextIndex returns the next track index respecting shuffle and repeat modes. -// Returns -1 if there is no next track (queue exhausted). -func (q *Queue) nextIndex() int { - if len(q.tracks) == 0 { - return -1 - } - - if q.shuffleMode && len(q.shuffleOrder) > 0 { - return q.nextShuffledIndex() - } - - next := q.currentIndex + 1 - if next >= len(q.tracks) { - if q.repeatMode == RepeatAll { - return 0 - } - - return -1 - } - - return next -} - -// previousIndex returns the previous track index respecting shuffle and repeat. -// Returns -1 if there is no previous track. -func (q *Queue) previousIndex() int { - if len(q.tracks) == 0 { - return -1 - } - - if q.shuffleMode && len(q.shuffleOrder) > 0 { - return q.previousShuffledIndex() - } - - prev := q.currentIndex - 1 - if prev < 0 { - if q.repeatMode == RepeatAll { - return len(q.tracks) - 1 - } - - return -1 - } - - return prev -} - -// nextShuffledIndex finds the next index in the shuffle order. -func (q *Queue) nextShuffledIndex() int { - shufflePos := q.currentShufflePosition() - if shufflePos == -1 { - // Current track not found in shuffle order — shouldn't happen. - return -1 - } - - nextShufflePos := shufflePos + 1 - if nextShufflePos >= len(q.shuffleOrder) { - if q.repeatMode == RepeatAll { - return q.shuffleOrder[0] - } - - return -1 - } - - return q.shuffleOrder[nextShufflePos] -} - -// previousShuffledIndex finds the previous index in the shuffle order. -func (q *Queue) previousShuffledIndex() int { - shufflePos := q.currentShufflePosition() - if shufflePos == -1 { - return -1 - } - - prevShufflePos := shufflePos - 1 - if prevShufflePos < 0 { - if q.repeatMode == RepeatAll { - return q.shuffleOrder[len(q.shuffleOrder)-1] - } - - return -1 - } - - return q.shuffleOrder[prevShufflePos] -} - -// currentShufflePosition finds where the current track index is in the shuffle order. -func (q *Queue) currentShufflePosition() int { - for i, idx := range q.shuffleOrder { - if idx == q.currentIndex { - return i - } - } - - return -1 -} - -// generateShuffleOrder creates a Fisher-Yates shuffled index order, -// placing the current track at position 0 so it doesn't replay immediately. -func (q *Queue) generateShuffleOrder() { - n := len(q.tracks) - if n == 0 { - q.shuffleOrder = nil - - return - } - - order := make([]int, n) - for i := range order { - order[i] = i - } - - // Fisher-Yates shuffle. - for i := n - 1; i > 0; i-- { - j := rand.IntN(i + 1) - order[i], order[j] = order[j], order[i] - } - - // Move the current track to position 0 so it doesn't replay immediately. - for i, idx := range order { - if idx == q.currentIndex { - order[0], order[i] = order[i], order[0] - - break - } - } - - q.shuffleOrder = order -} - -// playCurrentTrack tells the player to load and play the current track. -func (q *Queue) playCurrentTrack() { +// loadCurrentTrack tells the player to load the current track without +// starting playback. It persists the updated queue state. Returns true +// if the file was loaded successfully. +func (q *Queue) loadCurrentTrack() bool { if q.player == nil { - q.logger.Error("No player set, cannot play track") + q.logger.Error("No player set, cannot load track") - return + return false } if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) { @@ -961,36 +1171,79 @@ func (q *Queue) playCurrentTrack() { "trackCount", len(q.tracks), ) - return + return false } track := q.tracks[q.currentIndex] q.logger.Info( - "Playing track from queue", + "Loading track from queue", "filePath", track.FilePath, "position", q.currentIndex, ) err := q.player.LoadFile(track.FilePath) if err != nil { - q.logger.Error("Failed to load file from queue", "filePath", track.FilePath, "err", err) + q.logger.Error( + "Failed to load file from queue", + "filePath", track.FilePath, "err", err, + ) + + return false + } + + q.persistState() + + return true +} + +// playCurrentTrack tells the player to load and play the current track. +// Returns true if the file was loaded and playback started successfully. +func (q *Queue) playCurrentTrack() bool { + if !q.loadCurrentTrack() { + return false + } + + err := q.player.Play() + if err != nil { + track := q.tracks[q.currentIndex] + q.logger.Error( + "Failed to play file from queue", + "filePath", track.FilePath, "err", err, + ) + + return false + } + + return true +} + +// handleCurrentTrackRemoved handles the case where the currently loaded +// track was removed from the queue. If tracks remain it loads the track +// now at currentIndex (paused); otherwise it exhausts the queue. +func (q *Queue) handleCurrentTrackRemoved() { + if len(q.tracks) == 0 { + q.onQueueExhausted() return } - err = q.player.Play() - if err != nil { - q.logger.Error("Failed to play file from queue", "filePath", track.FilePath, "err", err) - } - - q.persistState() + q.loadCurrentTrack() } // onQueueExhausted is called when there are no more tracks to play. -// This is the extension point for a future fallback playlist feature. +// It unloads the current track, resets the index to -1 (no current track), +// and notifies the frontend. func (q *Queue) onQueueExhausted() { - // Future: load fallback playlist here. - q.logger.Info("Queue exhausted, stopping playback") + q.logger.Info("Queue exhausted, unloading track") + + q.currentIndex = -1 + + if q.player != nil { + q.player.UnloadTrack() + } + + q.emitIndexChanged() + q.persistState() } // reindexPositions updates the Position field of all tracks to match slice index. @@ -1000,80 +1253,18 @@ func (q *Queue) reindexPositions() { } } -// persistTracks writes the current queue tracks to the database. -func (q *Queue) persistTracks() { - err := q.db.Queries.ClearQueueTracks(q.db.Ctx) - if err != nil { - q.logger.Error("Failed to clear queue tracks", "err", err) - - return +// commitMutation persists the current queue state after a mutation. +// When reindex is true, track positions are renumbered first. +// The caller must hold q.mu. +func (q *Queue) commitMutation(reindex bool) { + if reindex { + q.reindexPositions() } - for _, track := range q.tracks { - _, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ - AudioFileID: track.AudioFileID, - Position: track.Position, - }) - if err != nil { - q.logger.Error("Failed to insert queue track", "err", err) - } + if q.shuffleMode { + q.generateShuffleOrder() } + + q.persistTracks() + q.persistState() } - -// persistState writes the queue metadata to the database. -func (q *Queue) persistState() { - var shuffleOrderJSON sql.NullString - - if len(q.shuffleOrder) > 0 { - data, err := json.Marshal(q.shuffleOrder) - if err != nil { - q.logger.Error("Failed to marshal shuffle order", "err", err) - } else { - shuffleOrderJSON = sql.NullString{String: string(data), Valid: true} - } - } - - sourcePlaylistID := sql.NullInt64{} - if q.sourcePlaylistID > 0 { - sourcePlaylistID = sql.NullInt64{Int64: q.sourcePlaylistID, Valid: true} - } - - err := q.db.Queries.UpdateQueueState(q.db.Ctx, sqlcgen.UpdateQueueStateParams{ - SourcePlaylistID: sourcePlaylistID, - CurrentPosition: int64(q.currentIndex), - ShuffleMode: q.shuffleMode, - RepeatMode: string(q.repeatMode), - ShuffleOrder: shuffleOrderJSON, - }) - if err != nil { - q.logger.Error("Failed to persist queue state", "err", err) - } -} - -// emitQueueChanged emits the full queue state to the frontend. -func (q *Queue) emitQueueChanged() { - if q.ctx == nil { - return - } - - state := State{ - Tracks: q.tracks, - CurrentIndex: q.currentIndex, - ShuffleMode: q.shuffleMode, - RepeatMode: q.repeatMode, - SourcePlaylistID: q.sourcePlaylistID, - } - - // Ensure tracks is never nil in JSON. - if state.Tracks == nil { - state.Tracks = []Track{} - } - - runtime.EventsEmit(q.ctx, events.QueueChanged, state) -} - -// Sentinel errors. -var ( - ErrEmptyQueue = errors.New("queue is empty") - ErrNoPlayer = errors.New("no player set") -) diff --git a/backend/queue/queue_test.go b/backend/queue/queue_test.go new file mode 100644 index 0000000..7b7b7a0 --- /dev/null +++ b/backend/queue/queue_test.go @@ -0,0 +1,400 @@ +package queue + +import ( + "fmt" + "log/slog" + "testing" + + "yellowjacket/backend/database" +) + +// mockTrackLoader satisfies the TrackLoader interface for tests. +// All methods are no-ops. +type mockTrackLoader struct { + loadedFile string +} + +func (m *mockTrackLoader) LoadFile(filePath string) error { + m.loadedFile = filePath + + return nil +} + +func (m *mockTrackLoader) Play() error { return nil } +func (m *mockTrackLoader) IsPlaying() bool { return false } +func (m *mockTrackLoader) UnloadTrack() {} + +func (m *mockTrackLoader) CurrentPositionSeconds() (int, error) { + return 0, nil +} + +// setupTestQueue creates an isolated Queue backed by an in-memory DB. +func setupTestQueue(t *testing.T) (*Queue, *database.DB) { + t.Helper() + + db := database.NewTestDB(t) + q := NewQueue(slog.Default(), db) + q.SetPlayer(&mockTrackLoader{}) + + return q, db +} + +// seedAudioFiles inserts `count` audio_file rows (with FK chain) and +// returns the file paths as a string slice. +func seedAudioFiles(t *testing.T, db *database.DB, count int) []string { + t.Helper() + + // Shared artist credit. + _, err := db.ExecContext( + "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + paths := make([]string, count) + + for i := range count { + recID := i + 1 + afID := i + 1 + fp := fmt.Sprintf("/test/track%d.mp3", i+1) + paths[i] = fp + + _, err := db.ExecContext( + "INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)", + recID, fmt.Sprintf("Track %d", i+1), + ) + if err != nil { + t.Fatalf("insert recording %d: %v", recID, err) + } + + _, err = db.ExecContext( + "INSERT OR IGNORE INTO audio_files (id, file_path, "+ + "length_milliseconds, file_type_id, recording_id) "+ + "VALUES (?, ?, 180000, 0, ?)", + afID, fp, recID, + ) + if err != nil { + t.Fatalf("insert audio_file %d: %v", afID, err) + } + } + + return paths +} + +func TestSetQueue_PopulatesTracks(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + state := q.GetState() + if got := len(state.Tracks); got != 5 { + t.Errorf("track count: got %d, want 5", got) + } + + if state.CurrentIndex != 0 { + t.Errorf("currentIndex: got %d, want 0", state.CurrentIndex) + } +} + +func TestSetQueue_WithStartIndex(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 2, false) + + state := q.GetState() + if state.CurrentIndex != 2 { + t.Errorf("currentIndex: got %d, want 2", state.CurrentIndex) + } +} + +func TestSetQueue_WithShuffleStart(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + // Enable shuffle mode first. + q.ToggleShuffle() + + q.SetQueue(paths, 0, true) + + state := q.GetState() + if !state.ShuffleMode { + t.Error("shuffleMode: got false, want true") + } + + q.mu.Lock() + soLen := len(q.shuffleOrder) + q.mu.Unlock() + + if soLen != 5 { + t.Errorf("shuffleOrder length: got %d, want 5", soLen) + } +} + +func TestAddTrack_AppendsToQueue(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 4) + + q.SetQueue(paths[:3], 0, false) + q.AddTrack(paths[3]) + + state := q.GetState() + if got := len(state.Tracks); got != 4 { + t.Errorf("track count: got %d, want 4", got) + } + + lastTrack := state.Tracks[len(state.Tracks)-1] + if lastTrack.FilePath != paths[3] { + t.Errorf("last track path: got %q, want %q", lastTrack.FilePath, paths[3]) + } +} + +func TestInsertTracksAt_BeforeCurrentIndex(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 7) + + q.SetQueue(paths[:5], 2, false) + + // Insert 2 tracks at index 1 (before currentIndex=2). + q.InsertTracksAt(paths[5:7], 1) + + state := q.GetState() + // currentIndex should shift by 2 (the number of inserted tracks). + if state.CurrentIndex != 4 { + t.Errorf("currentIndex after insert before: got %d, want 4", state.CurrentIndex) + } + + if got := len(state.Tracks); got != 7 { + t.Errorf("track count: got %d, want 7", got) + } +} + +func TestInsertTracksAt_AfterCurrentIndex(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 7) + + q.SetQueue(paths[:5], 2, false) + + // Insert 2 tracks at index 3 (after currentIndex=2). + q.InsertTracksAt(paths[5:7], 3) + + state := q.GetState() + // currentIndex should remain 2. + if state.CurrentIndex != 2 { + t.Errorf("currentIndex after insert after: got %d, want 2", state.CurrentIndex) + } +} + +func TestMoveQueueTracks_ForwardMove(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + // Move track at index 1 to index 3. + q.MoveQueueTracks([]int{1}, 3) + + state := q.GetState() + // After moving index 1 forward: the track originally at index 1 + // should now be at index 2 (adjustedIdx = 3-1 = 2). + if state.Tracks[2].FilePath != paths[1] { + t.Errorf("moved track: got %q at index 2, want %q", state.Tracks[2].FilePath, paths[1]) + } +} + +func TestMoveQueueTracks_BackwardMove(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + // Move track at index 3 to index 1. + q.MoveQueueTracks([]int{3}, 1) + + state := q.GetState() + // Track originally at index 3 should now be at index 1. + if state.Tracks[1].FilePath != paths[3] { + t.Errorf("moved track: got %q at index 1, want %q", state.Tracks[1].FilePath, paths[3]) + } +} + +func TestMoveQueueTracks_MoveCurrentTrack(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 2, false) + + // Move the current track (index 2) to index 4. + q.MoveQueueTracks([]int{2}, 4) + + state := q.GetState() + // The current track should follow to its new position. + currentPath := state.Tracks[state.CurrentIndex].FilePath + if currentPath != paths[2] { + t.Errorf("current track after move: got %q, want %q", currentPath, paths[2]) + } +} + +func TestRemoveTrack_RemovesCorrectTrack(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + q.RemoveTrack(2) + + state := q.GetState() + if got := len(state.Tracks); got != 4 { + t.Errorf("track count: got %d, want 4", got) + } + + // Verify the removed track (paths[2]) is not present. + for _, track := range state.Tracks { + if track.FilePath == paths[2] { + t.Errorf("removed track %q still present in queue", paths[2]) + } + } +} + +func TestRemoveTrack_RemoveCurrentTrack(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 2, false) + + q.RemoveTrack(2) + + state := q.GetState() + if got := len(state.Tracks); got != 4 { + t.Errorf("track count: got %d, want 4", got) + } + + // After removing currentIndex=2, index should be clamped to valid range. + if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Tracks) { + t.Errorf( + "currentIndex out of range: got %d, track count %d", + state.CurrentIndex, len(state.Tracks), + ) + } +} + +func TestClear_EmptiesQueue(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + q.Clear() + + state := q.GetState() + if got := len(state.Tracks); got != 0 { + t.Errorf("track count after clear: got %d, want 0", got) + } + + if state.CurrentIndex != -1 { + t.Errorf("currentIndex after clear: got %d, want -1", state.CurrentIndex) + } +} + +func TestToggleShuffle_TogglesMode(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + paths := seedAudioFiles(t, db, 5) + + q.SetQueue(paths, 0, false) + + // Toggle on. + q.ToggleShuffle() + state := q.GetState() + + if !state.ShuffleMode { + t.Error("shuffleMode after first toggle: got false, want true") + } + + q.mu.Lock() + soLen := len(q.shuffleOrder) + q.mu.Unlock() + + if soLen != 5 { + t.Errorf("shuffleOrder length after toggle on: got %d, want 5", soLen) + } + + // Toggle off. + q.ToggleShuffle() + state = q.GetState() + + if state.ShuffleMode { + t.Error("shuffleMode after second toggle: got true, want false") + } + + q.mu.Lock() + soLen = len(q.shuffleOrder) + q.mu.Unlock() + + if soLen != 0 { + t.Errorf("shuffleOrder length after toggle off: got %d, want 0", soLen) + } +} + +func TestCycleRepeat_CyclesThroughModes(t *testing.T) { + t.Parallel() + + q, db := setupTestQueue(t) + _ = seedAudioFiles(t, db, 1) + + // Default is RepeatOff. + state := q.GetState() + if state.RepeatMode != RepeatOff { + t.Errorf("initial repeatMode: got %q, want %q", state.RepeatMode, RepeatOff) + } + + // off -> all + q.CycleRepeat() + state = q.GetState() + + if state.RepeatMode != RepeatAll { + t.Errorf("after first cycle: got %q, want %q", state.RepeatMode, RepeatAll) + } + + // all -> one + q.CycleRepeat() + state = q.GetState() + + if state.RepeatMode != RepeatOne { + t.Errorf("after second cycle: got %q, want %q", state.RepeatMode, RepeatOne) + } + + // one -> off + q.CycleRepeat() + state = q.GetState() + + if state.RepeatMode != RepeatOff { + t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff) + } +} diff --git a/backend/system/disktype_linux.go b/backend/system/disktype_linux.go new file mode 100644 index 0000000..8639ae7 --- /dev/null +++ b/backend/system/disktype_linux.go @@ -0,0 +1,96 @@ +//go:build linux + +package system + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +var errNoBlockDevice = errors.New( + "no matching block device found", +) + +// IsRotationalDisk reports whether the block device backing the +// given path is a rotational (spinning) disk. Detection uses the +// Linux sysfs interface at /sys/block/