diff --git a/backend/app.go b/backend/app.go index fd3da81..99dc3ed 100644 --- a/backend/app.go +++ b/backend/app.go @@ -39,6 +39,7 @@ type YellowJacketApp struct { mediaControls mediacontrols.Handler appContext context.Context appConfig *config.Config + startupErr error } // NewYellowJacketApp creates and initializes the application. @@ -131,8 +132,6 @@ func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig { return yj.appConfig.Window } -var startupErr error - // OnStartup initializes components that require the Wails runtime context. func (yj *YellowJacketApp) OnStartup(ctx context.Context) { defer profiling.TimeOp(yj.logger, "app.OnStartup")() @@ -151,8 +150,8 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // Initialize speaker hardware (player struct created in // NewYellowJacketApp for Wails binding registration). if err := yj.player.InitSpeaker(); err != nil { - startupErr = errors.Join( - startupErr, + yj.startupErr = errors.Join( + yj.startupErr, fmt.Errorf("could not initialize speaker: %w", err), ) } @@ -249,8 +248,8 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) { // listeners, index.ts calls Player.EmitCurrentState() and // Queue.EmitCurrentState() via Wails bindings. func (yj *YellowJacketApp) OnDomReady(ctx context.Context) { - if startupErr != nil { - yj.logger.Error("startup error", "err", startupErr.Error()) + if yj.startupErr != nil { + yj.logger.Error("startup error", "err", yj.startupErr.Error()) wailsruntime.Quit(ctx) } } diff --git a/backend/config/config.go b/backend/config/config.go index b182aa3..45bab72 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -149,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) } diff --git a/backend/database/database.go b/backend/database/database.go index 50bfac6..c916830 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -220,6 +220,48 @@ func runMigrations( } } + // 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") + } + return nil } 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 +}